一行代码解决!iOS 二进制重排启动优化

阅读数:202 2019 年 10 月 31 日 13:34

一行代码解决!iOS二进制重排启动优化

随着产品给的的需求越来越多,堆叠的功能也越来越复杂,整个 App 应用大小也越来越大,而越来越多的功能也导致了越来越多的体验和性能问题;而其中这最能直观影响用户的就是启动速度。
传统的启动优化是基于减少不必要代码,懒加载,划分任务优先级,利用多线程来做的,此类相关优化的策略已经很普遍了,主要是从减少主线程任务的角度来出发,很难再做出大的提升。今天,我们从另一个角度去思考启动优化–内存加载机制。本方案在我们应用上可以将启动速度平均优化 10% 左右,且无需改动一行代码。

关于启动

当用户打开你的 App 时,到底发生了什么?

一行代码解决!iOS二进制重排启动优化

你可能会说,开始加载二进制,dyld 初始化,objc 初始化,执行 load 函数执行 c++ 构造函数,最后进入 main 函数,然后执行 App 初始化逻辑,最终首页出现,启动完成。但在这之前呢?在这之后呢?在这所有的过程中都需要干什么呢?

答案是:需要执行代码,需要 pc 寄存器不停的跳转,完成函数的调用和上下文切换,从而实现具体的逻辑。

当 dyld 初始化 App 时,会把程序的二进制 mmap 到内存里,当需要使用具体内存时,再去触发物理内存加载(懒加载),然后访问。而当程序不停的执行和初始化时,就会涉及到 pc 寄存器不停的跳来跳去,寻址,取指令,译码,执行。这其中最为耗时的就是取指令,因为取指需要不断的涉及到内存的缺页访问,即 page fault。page fault 一般流程如下:

一行代码解决!iOS二进制重排启动优化

page fault:当待访问的 VA 虚拟地址不存在对应的物理内存地址时,MMU 将会触发 page fault 中断来加载对应的物理页,建立起虚拟内存和物理内存的映射关系。page fault 一般耗时有多少呢?如下图:

一行代码解决!iOS二进制重排启动优化

在较差的情况下,page fault 居然耗时可以轻松达到 1ms;而即便在较正常的情况下,一次 page fault 大概也要耗时 0.3~0.6ms 左右;那么 App 启动期间到底大概需要发生多少次 page fault 呢?比如在我们应用中的数据如下:(XCode 中 File Backed Page in 就是 page fault)

一行代码解决!iOS二进制重排启动优化

一次正常的 cold launch,需要触发至少 2000 多次 page fault,总 page fault 耗时居然达到了 300 多 ms 如果我们能将这 300 多 ms 尽量优化,那就会是一次非常好的启动优化了。

备注:本文所有测试数据基于 XCode 11beta5, iOS12.1 , iphone6s

二进制重排

讲完了背景和介绍,我们接下来看看到底怎么解决 page fault 过多的问题。

1. page fault 的危害

前面我们说了频繁发生 page fault 的问题在于我们的二进制需要不停的执行指令,如果当需要执行的代码文件偏移过于随机时,则会导致 pc 寄存器不停发生切换,从而不停的触发页内存加载。那么,当应用的 page fault 频率过高时会有什么问题:

  • 增加指令执行的耗时(取指慢)
  • 增大 disk thrashing 的风险

以上二者会导致我们指令的执行时间慢,从而导致主线程不停被阻塞而最终影响启动速度。另外在 iOS 上 A7,A8-based 处理器的物理页大小为 4kb,而 A9 之后的处理器物理页大小为 16kb。如果我们能利用好物理页的限制,让我们所有的待执行的关键指令和代码都紧凑的排列在相邻的物理页内,那么我们就能尽可能的减少 page fault 的次数,也能极大降低 disk thrashing 的概率。因此二进制重排的概念就出来了。

disk thrashing : thrashing occurs when a computer’s virtual memory resources are overused, leading to a constant state of paging and page faults, inhibiting most application-level processing.[1] This causes the performance of the computer to degrade or collapse.

2. 重排的目的

重排的本质就是为了解决上面 2 个问题, 频繁 page fault 和 disk thrashing。原理就是将所有启动期间先后执行的函数代码,紧凑的排列在顺序的二进制中,使得 pc 寄存器的指令跳转幅度大幅降低。让单个物理页能尽可能的加载更多的当前或下一条待执行的函数。

3. 怎么重排

要使得函数符号按特定顺序排列在二进制中,XCode 早就提供了支持,具体的苹果也一直身体力行,比如 objc 的源码就采用了二进制重排优化,如下图:

一行代码解决!iOS二进制重排启动优化

我们只要在编译设置里指定一个 order file 即可;而 order file 的内容如下:

一行代码解决!iOS二进制重排启动优化

将所有符号按顺序排列,以换行符分隔,编译器就会按照 order file 指定的符号顺序来排列二进制代码段。由此就能达到重排优化了。

4. 怎么做

由上,我们已经较为清晰的知道了二进制重排的意义了,那么我们需要怎么做呢?针对应用中的 objc,c,c++ 代码和符号我们要怎么知道他们的执行顺序并监控呢?即只要我们能通过某种手段 trace 到所有启动阶段执行的函数符号,然后把这些函数符号按顺序排列好,组成 order file 交给编译器即可。实现如下:

  • objc 方法

对于 objc 的方法,我们只要 hook 掉所有的 objcmsgSend,以及 objcmsgSendSuper2 来建立监控即可。代码大概如下:

复制代码
.text .align 2 .global _pgoobjcmsgSend _pgoobjcmsgSend:
// push stp q6, q7, [sp, #-32]! stp q4, q5, [sp, #-32]! stp q2, q3, [sp, #-32]! stp q0, q1, [sp, #-32]! stp x8, lr, [sp, #-16]! stp x6, x7, [sp, #-16]! stp x4, x5, [sp, #-16]! stp x2, x3, [sp, #-16]! stp x0, x1, [sp, #-16]!
//call stub 函数监控 bl pgoastub_msgSend mov x9, x0
// pop ldp x0, x1, [sp], #16 ldp x2, x3, [sp], #16 ldp x4, x5, [sp], #16 ldp x6, x7, [sp], #16 ldp x8, lr, [sp], #16 ldp q0, q1, [sp], #32 ldp q2, q3, [sp], #32 ldp q4, q5, [sp], #32 ldp q6, q7, [sp], #32
// Call original objc_msgSend. br x9 ret
  • block 方法

同理也是 hook block 来做到的,block 的内存结构如下:struct Block_layout { void *isa; volatile int32_t flags; // contains ref count int32_t reserved; BlockInvokeFunction invoke; struct Block_descriptor_1 *descriptor; // imported variables };通过 hook block 的 retain,copy 等操作,交换其原始 invoke 函数并保存,从而达到了 hook。另外由于个别原因,个别 block 无法被 hook,我们采取了其它 workround 绕过了这些场景,提高了 block hook 的准确性。

  • load 方法

load 方法我们也是采取 hook 的方式,但利用了一个 DATA 段的 R/W 特性,即通过插桩 stub 函数,然后在 stub 函数里回调我们的 hook 代码即可。当然也可以采取简单粗暴的静态扫描的方式,但就没那么能保证顺序了。

  • c++ 构造函数

c++ 构造函数即:全局 c++ 变量或 constructor 修饰的函数会在 main 函数之前执行,同理这些函数列表也是存在 DATA 段内的,我们也可以利用插桩 stub 函数,再在 stub 函数里回调我们的 hook 代码即可。

  • initialize

同样利用 objc_stub 函数在发消息前先判定 cls 是否已经 initialize,已经 initialize 则忽略,否则记录对应函数符号。后续会改为采取插桩的方式。

  • 其它

以上我们通过 hook 或插桩的方式解决的 90% 以上 objc 程序的问题,但是如果里面还涉及到 c,c++ 等代码的执行那就暂时行不通了。此时我们需要借助于静态分析。c,c++ 代码函数调用的本质是 bl 指令,所以我们是通过递归扫描 bl 指令来完成的,大概如下:

复制代码
define PGOAINSBL (0x94000000)
define PGOAINSBL_FLAG (0xfc000000)
static void pgoascansubroutiner(intptrt func,int depth) { if(depth <= 0) return ; pgoainssst p = (pgoainssst)func; int i=0; while(i<2048) { intptrt vpc = (intptrt)p; int value = (int)*p; if((value & PGOAINSBLFLAG) == PGOAINSBL) { ... ... // 此处省略关键代码 if(pgoavamainvalid(va)) { pgoaaddfunc(va); pgoascansubroutiner(va, depth-1); } } else if((value & PGOAINSRET_FLAG) == PGOAINSRET) { //ret return ; } i++; p++; } }

这个方式能解决绝大部分问题,但是缺陷在于会有较大概率的误扫描,即可能存在部分死代码或极低概率才走的代码而被优化,反而浪费的部分重排空间。

5. 优化效果

采用了上述优化方案后,我们应用的 cold launch 启动速度大概提升了 10%,page fault 次数减少了 15% 左右。

一行代码解决!iOS二进制重排启动优化

一键接入

看完上文是不是觉得怎么搞个二进制重排优化那么复杂呢?需要搞那么多,没那么人力精力来优化啊,没关系,可以用我们提供的 sdk。sdk 支持一行代码接入后,运行一次 App 即能把需要重排的符号给输出来。然后只要在 XCode BuildSetting 里设置 order file 路径即可。

复制代码
if defined(arm64) || defined(aarch64)
bool debug = false;
ifdef DEBUG
debug = true;
endif
pgoa_logall(debug,nil);
endif

然后将生成的 order_symbol.txt 文件导入到工程配置 order file 后重新编 Release 包后即可。

结语

1. 对比与展望

对比其他已有公开的方案,我们的方案有什么特点:

  • 支持 sdk 一键接入
  • 支持动态 hook c++ constructor
  • 支持 initialize 方法 hook 和插桩
  • 支持所有 block 的 hook
  • 支持所有的 bl 函数调用 (c/c++ 代码)

但通过分析我们也发现目前方案仍然存在一些问题,例如:

  • 静态扫描会导致部分符号顺序略微有出入
  • 常量,全局变量的随机访问导致频繁触发 page fault
  • 本机翻译符号效率较低
  • 考虑从更底层的方式去 trace

后续我们会尽快完善已知问题并可提供给外部使用,并持续优化部分已知问题。

2. 再谈 PGO

本方案本质也是一种 PGO(Performance Guided Optimization)。PGO 的目的就是根据 profile 调优的数据来倾向性的去做优化。其实苹果本身也提供了 PGO 的方式,但苹果本身的方案放在我们这些采用 CI 工具构建的大型 app 上部署和使用起来较为麻烦,且不利于我们自己去发现分析问题。比如通过自行完善 PGO,我们可以做到了解所有启动代码的顺序和时序,有更好的数据来帮助我们分析启动过程。而且能完美适应当前的 CI 构建工具,不需要做额外的适配和改变。

3. 参考

About the App Launch Sequence
About the Virtual Memory System
Thrashing (computerscience)
Optimizing App Launch
Improving iOS Startup Performance with Binary Layout Optimizations
objc4
Hook objc_msgSend – 从 0.5 到 1
hook C++ static initializers

本文转载自公众号云加社区(ID:QcloudCommunity)。

原文链接:

https://mp.weixin.qq.com/s/JEFqg0kKROyfaYJFHeudFw

评论

发布