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

发布于: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

阅读数:1455 发布于:2019 年 10 月 31 日 13:34

更多 文化 & 方法、iOS、腾讯云 相关课程,可下载【 极客时间 】App 免费领取 >

评论

发布
暂无评论
  • 进程空间管理:项目组还可以自行布置会议室

    一个进程要运行起来需要什么样的内存结构?

    2019 年 5 月 17 日

  • App 启动速度怎么做优化与监控?

    今天这篇文章,我从App启动后会做哪些事儿说起,和你分享了如何把App的启动速度优化到极致。

    2019 年 3 月 14 日

  • Android 开发周报:美团热更新方案 Robust 开源、Apk 编译速度优化详解

    离Google I/O 2017开发者大会还有两个月左右的时间,已经有了关于Andorid 8.0的新特性传闻,本期周报继续为大家带来多方面的技术干货和开源项目,有粘性果冻动画绘制、美团热更新方案Robust、性能优化、美团网络优化等等。欢迎阅读。

    2017 年 3 月 22 日

  • HotSpot 虚拟机的 intrinsic

    HotSpot虚拟机将对标注了`@HotSpotIntrinsicCandidate`注解的方法的调用,替换为直接使用基于特定CPU指令的高效实现。

    2018 年 9 月 10 日

  • 苹果 WWDC2014 门票开卖 - iOS 移动开发周报

    本期iOS移动开发周报带来如下内容:苹果WWDC2014门票开卖, ARC下dealloc过程,修改OSX和iOS程序内容的内存修改器等。

    2014 年 4 月 10 日

  • iOS 通知中心扩展制作入门 - iOS 移动开发周报

    本期iOS移动开发周报带来如下内容:iOS 通知中心扩展制作入门,iOS APP可执行文件的组成,objc非主流代码技巧等。

    2014 年 8 月 10 日

  • 链接器:符号是怎么绑定到地址上的?

    了解程序运行阶段的动态库链接原理,会让你更多地了解程序在启动时做的事情,同时还能够对你有一些启发。

    2019 年 3 月 21 日

  • 阿里无线 11.11:手机淘宝 521 性能优化项目揭秘

    又是一年双十一,亿万用户都会在这一天打开手机淘宝,高兴地在会场页面不断浏览,面对琳琅满目的商品图片,抢着添加购物车,下单付款。为了让用户更顺畅更方便地实现这一切,做到“如丝般顺滑”,双十一前夕手机淘宝成立了“521”(我爱你)性能优化项目,在日常优化基础之上进行三个方面的专项优化攻关,分别是1)H5页面的一秒法则;2)启动时间和页面帧率提升20%;3)Android内存占用降低50%。优化过程中遇到的困难,思考后找寻的方案,实施后提取的经验都会在下面详细地介绍给读者。

    2015 年 12 月 14 日

  • 怎样在 1 秒内启动 Linux

    尽可能快的启动系统,对于自动化设备是非常重要的。系统能够在用户无法感知的时间内启动,也就意味着在不需要工作时,可以完全切断电源,而不是挂起进入休眠状态。本文基于Atmel AT91系列片上系统和NAND闪存,经过一系列的优化,将Linux系统启动时间,从最初的11秒,降低到最终的656毫秒。

    2015 年 12 月 2 日

  • OpenJDK HotSpot 或将在 Java 9 带来预先编译技术(AOT)

    OpenJDK HotSpot或将在Java 9初期版本引入预先编译技术(AOT)。InfoQ会持续关注2016年9月份提交的相关提案。

    2016 年 10 月 10 日