写点什么

淘票票 iOS 应用启动阶段性能优化

  • 2020-05-11
  • 本文字数:6386 字

    阅读完需:约 21 分钟

淘票票iOS应用启动阶段性能优化

应用的启动性能,作为和用户体验直接关联的重要指标,一直是各大技术团队花时间花精力去钻研优化的部分。由于在应用启动阶段,iOS 系统和应用本身会做很多事情,包括 binary 加载、二方库启动、框架加载、界面渲染等等,这些事情涉及到 iOS 开发的方方面面。所以,一个应用的启动性能如何,能够直接体现技术团队的水准。


淘票票团队经过启动优化专项治理,将启动时间降低了 28.2%。本文将分享在应用点击到应用完成加载这个阶段中,我们的技术优化策略。

一、应用点击到应用完成加载

一般情况下,我们将启动阶段大概的分为四个:


1)Pre-Main:加载 binary、静态库等;


2)Main:main.m 中 main 方法执行;


3)ApplicationDidFinish:iOS 系统回调,通知应用以完成启动加载,可以开始界面绘制;


4)界面绘制完成:首界面完全展现在用户设备屏幕上。


这里的阶段范围,涉及到我们这次分享的主要是 Pre-Main 阶段。


考虑是对 Pre-Main 阶段进行优化,实际上这里不太涉及对于代码的处理,因为在这个阶段我们写的代码还没有被真正的运行起来。这个阶段主要的工作都是 iOS 系统在做,系统会将 binary 从安装到本地的.app 文件夹中找出来,通过签名验证 binary 身份,之后将 binary 加载到虚拟内存中。


这部分的优化,我们更多地需要去理解系统行为,在编译这个层级就做好优化准备。


说到理解系统行为,能够去看的东西很多也很少。iOS 操作系统本身是很复杂的,虽然其是一个闭源系统,但 iOS 底层使用的是 Unix,我们可以通过类 Unix 系统,也就是开源的 Linux 的很多系统行为来推测 Unix 的行为,并推测 iOS 的系统行为。但是 iOS 提供的相关文档和接口很少,使得就算是我们理解了一个行为,可能也没有办法将对应处理应用到 iOS 应用上。所以如何在众多可能性中找到那些我们可以做的事情,就比较重要了。


通过一系列的摸索,和调研其他团队的优化经验,启动阶段的 Page Fault 优化进入了我们的视野。

二、PageFault

PageFault 是什么?回答这个问题,我们先要回顾一下现代操作系统的内存处理机制。


  1. 早期的内存机制


在计算机发展早期,操作系统对于内存处理机制都是有多少内存用多少内存,系统本身的内存地址和操作系统中应用的内存地址在物理内存上都是一一对应的。当你写了一个软件并运行,其中一个变量的地址打印出来是 0x000FF1,那么这个变量的实际储存地址就是物理内存上的 0x000FF1 这个位置。这种原始的内存处理方式,配合 C 语言中自由度极高的指针,带来了很多可能。程序员想要把其他应用在内存中的片段给引用到自己的软件中运行,直接将对应内容地址加载即可。实际上当年很多应用都是这么做的,通过这种手段避免了重复加载运行库的内容。


当然,这么做除了容易导致崩溃之外,还存在另一个比较严重的问题:在计算机蛮荒的时代,内存作为计算机中重要的高 I/O 性能硬件,价格是非常高的。如果操作系统和应用需要将所有运行内容都加载进内存,那么一个 128MB 的软件就会需要 128MB 的物理内存,考虑到操作系统自身的内存占用还会需要更多内存。


为了解决这么一个昂贵的硬件问题,操作系统的内存 Paging 机制被建立了起来。


  1. 虚拟内存


操作系统开发者发现,当一个应用加载进内存之后,并不是所有的内存内容都会随时被应用使用到。包括操作系统本身在内的应用,被完全加载进内存之后,也不是所有内容都会在同一时间被完全用上。那么是不是可以将部分暂时不用的内存内容,从内存移到硬盘上,等到需要的时候再从硬盘中读取呢?事实证明这个想发是可行的。


如果今天你手动安装过 Linux 系统,在安装时需要给系统设定一部分的 Swap 硬盘区域。在 Windows 系统中如果打开系统设置,也能找到虚拟内存的对应调整选项。这里的 Swap 和虚拟内存,就是 Paging 机制下系统在硬盘上建立的暂存内存数据的位置。


自此,应用程序真正能访问到的内存地址,和物理内存中的地址,被完全的分离开了。一个 128MB 的内存在合理的运用下可以配合 1GB 以上的硬盘(虚拟内存)完成 binary 大小高达 1GB 程序的运行。


  1. Page-In,Page-Out


当一个应用开始运行时,操作系统会加载应用到内存中,这个过程我们称之为 Page-In。当操作系统加载一段内容之后,突然发现物理内存不够继续加载更多内容了,这时候就需要释放一部分内存,也就是把之前的一部分内存里内容移动到虚拟内存上,这个过程我们称之为 Page-Out。


由于 Page-Out 是一个将内存内容写到硬盘上的高 I/O 消耗操作,所以操作系统会尽量防止这种操作,也就是在 Binary 加载时尽量将足够多的内容加载到内存中。当然系统是不知道 Binary 里面哪些是需要使用,哪些是不需要使用的,所以系统实际上是尽量保证 Binary 头部的内容被加载到内存中,而剩下没有办法加载进去的部分,则放在硬盘的虚拟内存中。


  1. PageFault


现在我们应用的 Binary 被加载完了,其中一部分在内存里,一部分在虚拟内存里,当然我们应用自己是不知道那些在内存里那些在虚拟内存里的,对于我们来说这都是一样的。然后我们应用开始运行,这时我们向操作系统请求内存里的内容,如果这个内容刚好在内存里,一切顺利;如果这个内容实际山并不是在内存里,而是在虚拟内存里,操作系统就需要把请求的内容从硬盘加载到内存中。就算是在现在,内存的 I/O 速度和硬盘 I/O 速度依旧存在数量级的差距,所以把内存中的内容移动到虚拟内存再移动回去,必然会产生不小的 I/O 开销。针对这种应用访问了在虚拟内存内容的情况,我们称之为 PageFault。


进入现代之后,随着人们对电脑的使用量增加,大众对于操作系统稳定性、安全性的要求越来越高,所以在 Paging 机制的基础上,内存鉴权机制、ASLR(进程地址空间布局随机化)、签名验证机制都被加了进来。在 iOS 上,发生 PageFault 的时候,由于系统从虚拟内存中拿出内容放到内存中时,会再次进行签名验证防止出现非法代码注入,所以 PageFault 的开销就比想象中更大了。


那么启动阶段的 PageFault 问题如何解决呢?


  1. PageFault 解决


这里我们做一个设想:我们假设启动阶段并不会用到全部 Binary 内容,如果我们能够获取足够的内存空间,把所有启动阶段的内容都放到内存里,那么是不是可以做到在启动阶段完全没有 PageFault 了呢?


这个设想是美好的,但实际上我们运行的设备可能本身就没有足够的内存,所以我们只能通过优化来尽量实现这个目标,尽可能的减少 PageFault。


那么,应该如何优化我们的应用 Binary 使得 PageFault 尽量减少呢?这里就要用到我们接下来介绍的这种古老的优化技术了。

三、二进制排序

就像我前面说的,PageFault 这个问题,在早期操作系统开始使用 Paging 时就已经存在了,而那个年代的前辈开发者们已经在探索优化这个问题的方法。为了实现我们前面的设想,编译器开发者们很早就在编译器中提供了二进制排序这项技术。


如果你学习过编译器原理,了解过编译器的 Binary 编译过程,你会记得在编译的最后阶段,Linker 会将之前生成的 Mach-O 文件合并成我们最终可以运行的 Binary。


默认情况下,Linker 会按照 Mach-O 文件顺序将里面的方法一个个写入到 Binary 中。不过在这个阶段,无论是使用 llvm 还是 gcc,我们都可以通过提供一个 Order File 来引导 Linker 在生成 Binary 时,将方法按我们提供的照顺序排列在 Binary 中。


通过这种操作,我们可以尽量把启动阶段需要使用到的方法写在 Binary 前面,就像我之前说的那样,由于操作系统并不知道我们 Binary 中那些部分是需要一直在内存中的,所以系统会优先保证 Binary 头部内容是存在内存中的,如果我们应用启动只访问了这些写在 Binary 头部的方法,那么就不会发生 PageFault 了。


现在我们优化的手段有了,那么我们怎么知道哪些方法是需要优化的呢?当然我们可以手动去编辑 Order File,但我们淘票票这个级别的应用,启动阶段可能涉及到的方法实在是太多了,光是我们自己的类中能够确定的就有好几十个,更不用提没有源代码的二方库和各种系统库了。


所以如何生成一个合理的 Order File 呢?

四、Order File 生成

  1. 抖音的方案


首先我们最先想到的,是抖音这边分享[1]。抖音分享的方法分为两部分,一部分是针对 Objc 方法的,一部分是针对 C++ 方法的。


Objc 的方法处理是相对简单的,由于 Objc 方法都会通过 objc_msgSend 进行执行,所以我们只需要引入 FishHook 将 objc_msgSend 给 Hook 掉,记录下启动阶段每一个方法的地址,之后将地址和 Binary 的 Link Map 进行匹配即可。


C++ 的方法处理抖音这边做的不是很好,他们的做法是通过分析 Linker Map 拿到 C++ 方法对应地址,然后在启动过程中将内存不断 dump 出来,一个个看里面包含了哪些方法。这个做法非常繁琐,操作成本很高。


当然,抖音也针对 Block 进行了 Hook 处理,一样是通过 FishHook Hook 掉了 block 函数方法,拿到 block 地址,之后通过 Linker Map 反查。


总得来说,抖音的方案整体实现是基于 C 的 Hook 配合基于内存地址反查的手工操作。


  1. Facebook 的方案


抖音之后,我们了解了 Facebook 分享的方法[2]。


根据 Facebook 在 llvm 邮件组里面的公开邮件[3],他们一开始使用的是 dtrace 这种工具来跟踪记录启动阶段的方法,然而面对不同情况下的应用启动流程,他们很难将多个 dtrace 结果生成一份 Order File。所以他们开始通过改造 llvm,通过参数使 llvm 在编译中加入插桩,运行时通过插桩来记录启动阶段执行的方法,最后进行汇总。


使用 Facebook 的这种方法,首先需要保证整个项目都是通过源代码的进行编译的,如果你的项目不是通过源代码编译的,而是引用了一些二方库,由于没有编译过程,所以 llvm 插桩也就不会插入,最终生成的 Order File 也就不会包含对应二方库的方法。


那么有没有更好的方案呢?


在研究 Facebook 的方法中间,通过 llvm 邮件组的公开邮件,我们发现了一个更加官方的解决方案。

五、Profile-Guided Optimizations

Profile-Guided Optimizations,简称 PGO,是 llvm 中一项相对来说比较古老的技术,最早的介绍可以追溯到 2013 年的一份 PPT[4]中。


从 PPT 中我们可以了解到,PGO 可能是一个源于 Code Coverage 的项目。llvm 的工程师在处理完代码覆盖率之后,想到了可以通过代码覆盖率检查的方法将启动阶段中使用到的方法都梳理出来,之后生成类似于 Order File 的文件,引导编译过程中 Linker 阶段处理。


但不同于 Order File 只记录了方法和方法顺序,PGO 生成的 Profile 文件还会记录对应方法的被调用次数和引用次数,帮助 Linker 做最后的顺序处理。


下面是一个模拟的 PGO 文件,可以看到这里不仅记录了方法,还记录了方法中关联的方法,和对应的引用次数。


MVAAA.m:__64-[MVAAA bbb]_block_invoke:   Hash: 0x000000a49844645a   Counters: 3   Function count: 0MVCCC.m:CGSizeMake:   Hash: 0x0000000000000018   Counters: 1   Function count: 7
复制代码


PGO 在 llvm 中的实现,主要是通过在 llvm IR 中进行插桩,之后让应用运行生成 profile 文件,后面编译时这份 profile 文件会被使用在编译的 link 环节中,Linker 会解析这份文件并按照策略对程序方法进行排序。


由于相比 Order File,PGO 文件还会参考引用次数等变量,使得最终优化结果上,PGO 的预期结果会高于 Order File。


当然,PGO 最大的好处还是在于苹果在 Xcode 中提供了方便的支持。[5]


通过苹果提供的工具,我们可以很方便的生成 profile 文件,并在编译时将 profile 文件添加到编译环节,获得启动性能提升。


配置完 Profile 之后,通过 Instrument 测量两次应用启动结果,可以看到 File Backed Page In 有了非常明显的减少,数值下降了 23% 左右。之后我们在新版中配置了 Profile 文件,通过线上数据统计,仅通过 PGO 这项优化,就带来了 18% 左右的启动时间下降,效果显著。


那么,这么做就是极致了么?


必然不是,就像我之前提到的 Facebook 方案一样,PGO 整套流程的方法获取,也是依赖于 llvm IR 插针,这导致面对非源代码编译的组件,二方库、三方库,PGO 对他们的无能为力。


如果可以的话,我们可以找到二方库和三方库的源代码,整个项目通过完整的源代码方式进行编译,通过这种方式生成的 profile 文件就会包含对应二方库和三方库的方法名。


但是集团现在内部的状态,要实现将所有二方库的代码凑齐,是一件不太可能的事情。不仅仅是我们团队,就算是手淘团队也没有办法做到完全源代码编译,该引用的编译之后的二方库,还是得去引用编译之后的二方库。


之前说到的抖音的那种内存分析似乎可以用在这个方面,但一定要做的那么繁琐么?还是说存在更好的方法?

六、静态库插桩

在 PGO 方案中,我们无法触及的主要是各种静态的二方库和三方库,这些库已经提前打包生成了 binary 文件,所以不会再通过 llvm 的打包插桩流程。那么有没有一种方法可以将他们里面的方法全部插桩记录呢?


就在最近,集团手淘团队提出了自己的方案。[6]


根据手淘的方案,我们可以针对二方库生成的 Binary 进行汇编处理,在二方库没有签名的情况下,我们可以向二方库的方法中注入记录方法,在每个方法调用启示节点进行插桩,这样在二方库里方法被调用时,对应的插桩记录就会输出出来被我们拿到。


实际上我们并不需要输出对应方法的名字,只需要输出执行时的内存地址,然后通过内存地址的偏移量来确定对应方法在 Binary 中的位置,通过 Link Map 找到对应方法并写入 Order File。


这种方法比起抖音的不断查询内存的方式更加简单,但是在操作上需要有一定的汇编和反汇编知识,要能够修改对应二方库的 Binary 文件。


好在,我们的二方库使用版本相对稳定,通过集团提供的方法操作一次之后,生成的 Order File 并不需要在后期继续进行改动。


当然,Order File 本身相对于 PGO 生成的 profile 来说是缺少一部分信息的,Order File 毕竟只是一个方法顺序,PGO 文件本身还包含了方法调用次数和引用次数的数据。就像 llvm 开发组的 PPT 里面写的那样,提供数据越多,优化也就越有效果。


好在 Order File 可以通过添加在 Xcode 配置中和 PGO 进行混编。我们没有细致的去测试生成出来的 Binary 是否是最完美的,但从结果来看,添加上 Order File 给我们带来的提升并不明显。

七、极致的方案

也许通过结合手淘静态库插桩加上 PGO 是一个极致的方案,也许换用源代码编译出来的内容配上 PGO 才是一个最终的方案。


但更大的可能是,在现实中不存在极致的方案,存在的更多的是合理的方案。


最终能够做到哪一步,能够做到多极致,还是取决于每个项目的情况和开发组自己的取舍。


对于淘票票 iOS 组而言,通过 PGO 这种简单易用的方式进行优化,得出的结果已经足够满足我们的需求,之后的二方库相关内容,我们会通过 PGO 和 Order File 配合的形式来进行优化处理。由于我们整个项目在启动阶段使用的二方库并没有我们想象中那么多,而且由于二方库的不确定性,我们在前期优化中就将很多二方库的初始化从启动阶段移动到了后续进行,所以静态库插针这种方式给我们带来的提升并不是很明显,比起启用 PGO 来说差远了。


也许,以后会有更好的解决方案面世,比如说 llvm 可以参照手淘开发组的思想把 PGO 的插桩添加到 Mach-O 文件中,使得我们的二方库的方法顺序也可以直接生成到 PGO 文件中。针对这块我们也会持续跟踪跟进,再出现更好的方案时,尽快把方案应用到我们的项目上,把更好的体验带给我们的用户们。


引用


[1] https://mp.weixin.qq.com/s/Drmmx5JtjG3UtTFksL6Q8Q?spm=ata.13261165.0.0.6c984638unW2y9


[2] https://www.facebook.com/atscaleevents/videos/664302790740440/?spm=ata.13261165.0.0.6c 984638unW2y9


[3] http://lists.llvm.org/pipermail/llvm-dev/2019-January/129268.html


[4] https://llvm.org/devmtg/2013-11/slides/Carruth-PGO.pdf


[5] https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/xcode_profile_guided_optimization/Introduction/Introduction.html


[6] https://mp.weixin.qq.com/s/YDO0ALPQWujuLvuRWdX7dQ


作者 | 淘票票高级无线开发工程师 朔明


2020-05-11 08:002140

评论

发布
暂无评论
发现更多内容
淘票票iOS应用启动阶段性能优化_文化 & 方法_阿里巴巴文娱技术_InfoQ精选文章