如何在iPhone中容纳一百万颗星星



这样繁琐的事情(如繁星点点的天空)和如此繁琐的事情(如优化iOS应用程序的内存消耗)可能会结合在一起:值得尝试将繁星点点的天空推入AR应用程序中,有关这种消耗的问题将立即出现。

在许多其他情况下,使内存使用最小化将很有用。 因此,有关一个小项目示例的文字显示了一些优化方法,这些方法在完全不同的iOS应用程序(不仅是iOS-)中很有用。

该帖子是根据Mobius 2018 Piter会议的Conrad Filer报告的笔录编写的。 我们附加其视频,然后附加第一人称的文本版本:



很高兴欢迎大家! 我的名字叫Conrad Filer,以“一个iPhone中的百万颗星”的壮观名字,我们将讨论如何最小化iOS应用程序占用的内存大小。 色彩丰富,示例中。

为什么要优化?


通常是什么促使我们进行优化,我们到底想实现什么? 我们不希望这样:



我们不希望用户等待。 也就是说,第一个原因是减少启动时间

另一个原因是提高质量



我们可以谈谈图像,声音甚至AI的质量。 “优化的AI”意味着您可以实现更多目标-例如,计算游戏的前进次数。

第三个原因非常重要: 节省电池电量 。 优化有助于减少电池电量。 这是一个有趣的比较,尽管来自Android世界。 这里比较了Vulkan和OpenGL ES:



第二个是针对移动平台的优化。 观察电池电量消耗的速度,您可以看到,对于类似的图像,OpenGL ES比Vulkan花费了更多的资源。

什么样的优化可以帮助您? 例如,在基于回合的游戏中,当用户考虑自己的举动时,可以将FPS降低为零。 如果您拥有3D引擎,那么在用户只是看着屏幕的同时关闭所有功能是完全明智的。

另外,有时没有优化的方法,您将无法实现一项或另一项高级功能:根本无法使用它。

没有狂热


在谈到优化时,人们不得不回想起Donald Knuth的论点:“例如,在97%的情况下,我们应该忘记效率低下:过早的优化是万恶之源。 尽管我们不应该在这3%的临界值中放弃我们的能力。”

在97%的情况下,我们不应该关心效率,而首先要关注如何使我们的代码易于理解,安全和可测试。 我们仍在为移动设备而不是飞船进行开发。 我们工作的公司不应为我们编写的代码提供支持。 此外,开发人员的工作时间是有成本的,如果您将其花费在优化不必要的事情上,那么您将花费公司的钱。 好吧,事实上,优化的代码往往更难以理解,您可以从我今天将向您展示的示例中看到。

通常,根据需要有意义地确定优先级并进行优化。

方法


在进行优化时,我们通常会监视性能(读取:处理器负载)或所使用的内存量。 通常,这两个选项会发生冲突,您将需要在两者之间找到平衡。

对于处理器,我们可以减少操作所需的处理器周期数。 如您所知,更少的处理器周期为我们提供了更少的加载时间,更少的电池消耗,提供更好质量的能力等。

对于iOS开发人员,Xcode Instruments提供了方便的Time Profiler工具。 它使您可以跟踪应用程序的不同部分花费的CPU周期数。 该报告不是关于工具的,因此我现在不再赘述,WWDC上有一个很好的视频。

您可以选择另一个目标-为了内存而优化。 我们将尝试确保启动时我们的应用程序适合最小数量的RAM单元。 请记住,最庞大的应用程序是在清理操作系统时强制执行的强制关闭的第一个候选对象。 因此,这会影响您的应用程序在后台停留多长时间。

同样重要的是,不同设备的RAM资源也应不同。 举例来说,如果您决定为Apple Watch开发,则内存不足,这也使您可以进行优化。

最后,有时少量的内存也使程序运行非常快。 我举一个例子。 以下是各种大小(以字节为单位)的结构:



Element8包含8个字节,Element16-16,依此类推。



我们将创建数组,每种结构类型一个。 所有数组的维数相同-10,000个元素。 每个结构都包含不同数量的字段(增加); 域n是第一个域,因此存在于所有结构中。

现在让我们尝试以下操作:对于每个数组,我们将计算其所有字段n的总和。 也就是说,每次我们将求和相同数量的元素(10,000件)。 唯一的区别是,对于每个总和,变量n将从不同大小的结构中提取。 我们对求和是否需要相同的时间感兴趣。

结果如下:



该图显示了求和时间对数组中使用的结构大小的依赖性。 事实证明,从更大的结构获得场n的时间更长,因此求和操作花费的时间更长。
你们中的许多人已经了解了为什么会这样。

处理器具有L1,L2缓存(有时甚至还有L3和L4)。 处理器直接,快速地访问这种类型的内存。



存在缓存以加快数据重用。 假设我们正在处理数组。 如果处理器所需的阵列已存在于任何高速缓存中,则处理器早先已需要它。 那时,他从主内存中请求它们,将它们放置在缓存中,并对它们执行了所有必要的操作,之后这些数据仍然处于说谎状态(它们没有时间擦除其他数据)。



L1,L2高速缓存的大小不是很大。 处理器工作所需的阵列可能更大。 为了完全在这样的阵列上执行操作,我们将必须将其分部分卸载到高速缓存中,并一步一步地对这些部分进行操作。 由于对主内存的不断请求,处理我们的数组将花费更长的时间。

对数据结构进行编程时,请尽量记住高速缓存。 通过减小数据结构的大小,可能会实现其成功的缓存容量,并加快将来对其执行的操作。 与主内存的交互一直以来都是,而且很可能仍将是提高生产力的重要因素,即使您为现代高性能设备编写Swift时也是如此。

CPU与RAM:延迟初始化


尽管在某些情况下,当减少了使用的内存时,程序会开始更快地运行,但是在某些情况下,这两个指标相反。 我将以延迟初始化的概念为例。

假设我们有一个makeHeavyObject()方法,该方法返回一些大对象。 此方法将初始化lazilyCalculated变量。



lazy修饰符将lazilyCalculated变量设置为延迟初始化。 这意味着仅在执行期间第一次对其进行调用时,才会为其分配值。 然后,makeHeavyObject()方法将起作用,并且将结果对象分配给lazilyCalculated变量。

这里有什么好处? 从初始化的那一刻起(尽管稍后会执行),我们在内存中有一个对象。 它的值已计数,可以立即使用-发出一个请求。 另一件事是,我们的对象很大,并且从初始化的那一刻起,它将占据内存中绝大部分的单元格。

您可以采用另一种方式-根本不存储该字段的值:



有了与lazilyCalculated字段的每个链接,makeHeavyObject()方法将再次执行。 该值将返回到查询点,而不会被放置在内存中。 如您所见,存储变量是可选的。

还有什么更昂贵的方法-在内存中存储一​​个大对象,却又不花CPU时间,或者每次我们需要我们的字段时都调用一个方法,同时又节省了内存? 您应该手头上有现成的价值还是要即时计算呢? 无论您在哪里进行计算,无论是在远程服务器上还是在本地计算机上,无论您要使用何种缓存,这种困境都经常出现。 在这种特殊情况下,您必须根据系统限制做出决定。

优化周期





无论您进行什么优化,通常,您的工作都将基于相同的算法进行。 首先,检查代码,配置文件/度量(使用适当的工具在Xcode中),以尝试确定其瓶颈。 本质上,按照执行方法需要的时间来排列方法。 然后查看最上面的几行,以确定要优化的内容。

选择一个对象,您便可以为自己设置任务(或者,科学地说,是提出一个假设):通过应用这些或其他优化方法,可以使选定的代码段更快地工作。

接下来,您尝试进行优化。 每次修改后,您都要查看性能指标,评估修改的效果,成功进行了多少改进。

就像在科研工作中一样:推测,实验,结果分析。 您会一遍又一遍地经历这个动作周期。 实践表明,以这种方式构造的工作确实使您可以逐个消除botneks。

单元测试





简要介绍一下单元测试:我们有一些正在测试的功能,一些输入数据输入和输出数据输出; 接收输入作为输入,我们的函数应始终返回输出,并且我们的任何优化都不应违反此属性。

单元测试可帮助我们跟踪故障。 如果响应输入,我们的函数停止返回输出,那么我们直接或间接地更改了函数的旧工作方式。

如果您没有在代码中编写大量的单元测试,甚至不要尝试开始优化。 您应该能够进行回归测试。 如果您在GitHub上查看我在示例应用程序中的提交(我将继续研究),您会发现我的一些优化带来了bug。

现在,对于有趣的部分,让我们继续前进。

百万星


有一个大的(巨大的)数据库描述了一百万颗恒星。 在此之上,我创建了几个应用程序。 其中一种使用增强现实技术,可以在手机摄像头的图像上方实时绘制星星。 现在,我将在操作中进行演示:



在没有城市灯光的情况下,一个人最多可以分辨出8000个星星。 我需要大约1.8 MB的空间来存储8,000条记录。 原则上可以接受。 但是我想添加一个人可以通过望远镜看到的恒星-大约有12万颗恒星(根据所谓的Hipparcos目录,现在已经过时了)。 这已经需要27 MB。 在公共领域的现代目录中,您会发现其中的一颗约有250万颗星。 这样的数据库将已经占用大约560 MB。 如您所见,已经需要大量内存。 但是我们不只是想要一个数据库,而是想要一个基于它的应用程序,那里会有ARKit,SceneKit和其他也需要内存的东西。

怎么办
我们将优化星星。

MemoryLayout工具


您可以评估整个程序的大小。 但是对于诸如优化之类的珠宝工作,您将需要工具来估计每个单独数据结构的大小。

Swift使您可以非常简单地执行此操作-使用MemoryLayout <>对象。 您声明一个MemoryLayout <>,将您感兴趣的数据结构指定为通用类型。 现在,参考接收到的对象的属性,您可以接收有关您的结构的各种有用信息。



size属性为我们提供了该结构的一个实例所占用的字节数。
现在介绍一下stride属性。 您可能已经注意到,数组的大小通常不等于其组成元素的大小之和,而是超过数组之和。 显然,内存中的元素之间留有一些“空气”。 为了估计相邻数组中连续元素之间的距离,我们使用了stride属性。 如果将其乘以数组中元素的数量,则将得到其大小。



我们的实验结构StarData处于初始未优化状态:



这是一种数据结构,用于存储有关一颗星星的数据。 您不必深入研究每个元素的含义。 现在,更重要的是要注意这些类型:浮点变量,用于存储恒星的坐标(实际上是纬度和经度);多个Int32(用于各种ID);字符串(用于存储名称和各种分类的名称); 正确显示恒星需要一定的距离,颜色和一些其他数量。

我们请求stride属性:



目前,我们的结构重208个字节。 一百万个这样的结构将需要250 MB-如您所知,这太多了。 因此,有必要进行优化。

正确的int


在第一门编程课程中介绍了Int有各种变体的事实。 Swift中我们最熟悉的Int称为Int8。 它占用8位(1字节),可以存储-128至127(含)之间的值。 还有其他Ints:
  • Int16的大小为2个字节,取值范围为-32,768至32,767;
  • Int32的大小为4个字节,取值范围为-2,147,483,648至2,147,483,647;
  • Int64(或仅Int)的大小为8个字节,取值范围为-9,223,372,036,854,775,808至9,223,372,036,854,775,807。


那些从事Web开发并处理SQL的人可能已经在考虑这一点。 但是,是的,首先,选择最佳的Int。 在这个项目中,甚至在我想到优化之前,我就陷入了一些过早的优化(正如我刚刚告诉您的那样,不需要这样做)。

例如,让我们看一下具有ID的字段。 我们知道,我们将拥有大约一百万颗恒星-不是几万颗,而是十亿颗。 因此,对于此类字段,最好选择Int32。 然后我意识到4个字节足以容纳Float。 Double将占用8,String每个24,加全部-结果是152个字节。 如果您还记得,以前的MemoryLayout告诉我们208。为什么? 我们必须更深入地挖掘。



首先,让我们看一下Optional。 可选类型的不同之处在于,如果没有分配的值,则它们存储nil。 这样可以确保与物体交互的安全性。 但是,您知道,这种度量并非免费的:通过请求任何可选类型的size属性,您将看到这种类型总是占用一个字节。 我们为注册nil字段的能力付费。

我们不想在变量上花费额外的字节。 同时,我们真的很喜欢包含在可选中的想法。 要拿出什么? 让我们尝试实现我们的结构。

让我们选择一些对于给定字段可以合理地认为是“无效”的值,同时又适合于声明的类型。 对于getHipId(Int32),它可以是例如值“ -1”。 这将意味着我们的字段未初始化。 这是一个可选的自行车,没有nil的额外字节。

显然,通过这种技巧,我们也有潜在的漏洞。 为了保护自己免受错误的侵害,我们将为该字段创建一个吸气剂,该吸气剂将独立管理我们的新逻辑并检查字段值的有效性。



这样的吸气剂从我们这里完全抽象出了发明解决方案的复杂性。
转到我们的StarData。 将所有可选类型替换为常规类型,然后查看大步显示:


事实证明,消除这些选项后,我们节省的不是9个字节(九个选项中的每个字节一个字节),而是节省了多达48个字节。令人惊喜的是,但是我想知道为什么会这样。 由于内存中的数据对齐而发生了这种情况。

数据对齐


回想一下,在Swift之前,我们是在Objective-C中编写的,它是基于C的,这种情况也可以追溯到C。

通过将任何结构放置在内存中,现代处理器不会将其元素放置在连续的流中(而不是“肩并肩”),而是将它们放置在某些网格中,这些网格由于空隙而异质地变薄。 这是数据对齐。 它使您可以简化并加快对内存中必要数据元素的访问。
数据对齐规则取决于每个变量的类型:

  • char类型的变量可以从1st,2nd,3rd,4th等开始。 个字节,因为它本身仅占用一个字节;
  • short变量占用2个字节,因此可以从2nd,4th,6th,8th等开始。 一个字节(即从每个偶数字节开始);
  • float类型的变量占用4个字节,这意味着它可以以第4、8、12、16等开头。 一个字节(即每四个字节);
  • Double和String类型的变量每个占用8个字节,因此它们可以以8th,16th,24th,32nd等开头。 个字节


MemoryLayout <>对象具有一个对齐属性,该属性返回指定类型的相应对齐规则。

我们可以运用对齐规则的知识来优化代码吗? 让我们来看一个例子。 有一个用户结构:对于firstName和lastName,我们使用常规的String,对于middleName,则使用可选的String(用户可能没有这样的名称)。 在内存中,将按以下方式放置这种结构的实例:



如您所见,由于可选的middleName占用25个字节(而不是8个24字节的倍数),因此对齐规则使您可以跳过接下来的7个字节,并在整个结构上花费80个字节。 在这里,无论您如何用字符串交换块,都不可能依靠更少的字节数。

现在是对齐失败的示例:



BadAligned结构首先声明Bool类型的isHidden(1个字节),然后声明Double类型的大小(8个字节),bool类型的isInteractable类型(1个字节),最后声明Int类型的age(也是8个字节)。按照此顺序声明,我们的变量将以这样的方式放置在内存中,即总结构将占用32个字节。

让我们尝试更改声明字段的顺序-我们将按占用的卷的升序排列它们,并查看内存中的图片如何变化。



我们的结构不占用32个字节,而是24个。节省25%。

听起来像是俄罗斯方块游戏,不是吗?对于这种低级的东西,Swift将C语言归功于其祖先。通过在大型数据结构中随机声明字段,与指定对齐规则相比,您更有可能使用更多的内存。因此,尝试记住它们并在编写代码时考虑-这并不是那么困难。

让我们再次转到我们的StarData。让我们尝试按占用量增加的顺序排列其字段。



首先是Float和Int32,然后是Double和String。没有那么复杂的俄罗斯方块!
我们收到的跨度为152个字节。也就是说,通过优化选项的实现并进行对齐,我们能够将结构的大小从208个字节减少到152个字节。

我们是否正在接近优化能力的极限?可能是。但是,您和我还没有尝试过其他方法-某些方法要复杂一个数量级,但有时可能会使您感到惊讶。

域逻辑会计


尝试着重于服务固有的细节。记住我的国际象棋示例:当屏幕上没有任何变化时改变FPS指示器的想法只是通过考虑应用程序的域逻辑进行的优化。

再次查看StarData。我们明显的“瓶颈”是String类型的字段,它们确实占用了大量空间。具体细节如下:在运行时,这些行中的大多数保持为空!只有146颗星具有“真实”名称,该名称显示在properName字段中。根据格利兹(Gliese)目录,gl_id是该恒星的ID,它拥有3801颗恒星,也远非百万。 Bayer_flamstedt-Flemstead的称号-将分配给第3064星。光谱类型SpectrumType为4307 mi。事实证明,对于大多数星星,输入的字符串变量将为空,而每个变量则占用24个字节。

我想出了以下方法。让我们获得一个关联数组作为附加结构。作为键-一个Int16类型的唯一数字标识符,作为一个值,取决于特性字符串的存在-其值或-1。

在我们的StarData中,propertyName,gl_id,bayer_flamstedt和SpectrumType对面,我们将在数组中写入与键对应的索引。如有必要,获取一个或另一个特征字符串,我们将通过索引从数组中请求值。无需手动执行此操作-我们最好实现一个方便的安全getter:



getter在这里非常重要-它向我们隐藏了我们自己实现的复杂性。数组可以注册为私有数组,现在不必知道其存在。

当然,这个解决方案有一个缺点。节省内存不但会影响处理器负载。通过这种方案,我们被迫不断访问我们的关联数组。在大多数情况下,这是徒劳的,因为大多数行将保持空白并且请求将返回“ -1”。

因此,我不得不稍微更改应用程序的概念。决定仅当用户单击星号时才向其提供有关星号的信息-只有这样,才会执行对关联数组的查询,并且接收到的数据将显示在屏幕上。

尽管使用getter进行了抽象,但我们必须承认,通过引入关联数组,我们仍然使代码变得非常复杂。这通常在优化过程中发生。因此,进行高质量的单元测试很重要-确保我们的关联数组不会在意外的时刻使我们失败。

总计:现在,“大步前进”给了我们64个字节!

这就是全部吗?不,现在我们需要再次考虑对齐规则:将Int16类型的字段重新排列为更高的字段。



现在就全部了。如您所见,使用少量本质上简单的方法,我们能够将StarData结构的大小从208个字节减少到56个字节。一百万颗星现在所占空间不是500 Mb,而是130。少四倍!

不要忘记过早优化的危险。如果您的User数据结构将用于大约20个用户,那么您不会在那赢得太多,因此这样做是没有道理的。更重要的是,在您维护代码之后,对于下一个开发人员来说很方便。以后请不要说“会议上的那个家伙说命令应该就是这样”!不要只是为了娱乐而这样做。好吧,对我来说,这样的事情是很好的娱乐,我不知道如何为您服务。

Swift编译器优化


大多数程序员都熟悉长时间(难以忍受)重组项目的痛苦。您只需对代码进行少量更改,然后坐下来等待构建完成。

但是构建过程可能会告诉您一些有关您的代码的信息。这是botnekov的一个很好的指标,您只需要对其进行调整即可工作。

我个人研究过Xcode中的编译。作为工具,我使用了以下命令:



该命令指示xCode跟踪每个函数的编译时间并将其写入culprits.txt文件。该文件的内容沿途进行排序。



使用简单的仪器,我可以观察到有趣的事情。某些方法可能只需要三行代码即可编译长达2秒钟。可能是什么原因?

例如,诸如类型编译器输出之类的东西。如果您未明确指定类型,则Swift将被迫自行检测它们。这种(我必须说,很重要的)操作需要处理器时间,因此,从编译器的角度来看,最好指出类型。只需显式地编写类型,就可以将应用程序的构建时间从5分钟减少到2(!)分钟。

但是有一个“但是”:没有类型的代码仍然更具可读性。我们已经讨论了优先事项。不要提前优化:起初,代码可读性会更昂贵。

服务器选项


到目前为止,我只提到了增强现实的应用程序。但是基于一百万颗星,我还在Swift上创建了一个服务器应用程序。您可以在GitHub上看到他的代码这是一项API服务,可让您从庞大的数据库中接收有关任何恒星的信息。我能够使用与ARkit上的应用程序相同的方法对其进行优化。在这种情况下,结果对我而言实际上是切实的:将卷减少到500 MB的水平,我有机会将其放在免费的Bluemix服务器上。结果,我的服务完全免费。

总结一下


最后,简要总结一下我今天想向您介绍的主要思想:

  • . . , , , ?
  • , unit-. , unit-. , . Unit- , .
  • . , . , : — .
  • 使用应用程序的域逻辑。最强大的优化工具是熟练使用领域逻辑的工具。了解工作的功能,您的应用程序的细节-尝试将它们考虑在内,寻找您的“个人”解决方案。
  • 内存与 中央处理器 尽最大努力保持内存和处理器利用率之间的平衡。这总是很困难,但是仍然有可能在每种情况下都找到一个最佳值。


如果您喜欢Mobius会议的这份报告,请注意,Mobius 2018 Moscow将于12月8日至9 举行,届时还将有很多有趣的事情。自11月1日起,门票价格一直在上涨,因此现在就做出决定吧!

Source: https://habr.com/ru/post/zh-CN427845/


All Articles