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

| 导语 随着产品给的的需求越来越多,堆叠的功能也越来越复杂,整个App应用大小也越来越大,而越来越多的功能也导致了越来越多的体验和性能问题;而其中这最能直观影响用户的就是启动速度

传统的启动优化是基于减少不必要代码,懒加载,划分任务优先级,利用多线程来做的,此类相关优化的策略已经很普遍了,主要是从减少主线程任务的角度来出发,很难再做出大的提升。今天,我们从另一个角度去思考启动优化--内存加载机制。本方案在我们应用上可以将启动速度平均优化10%左右,且无需改动一行代码。

关于启动

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

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

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

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

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

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

一次正常的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的源码就采用了二进制重排优化,如下图:

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

将所有符号按顺序排列,以换行符分隔,编译器就会按照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%左右

一键接入

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

if defined(arm64) || defined(aarch64)bool debug = false;
ifdef DEBUGdebug = true;
endifpgoa_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](https://en.wikipedia.org/wiki/Thrashing(computerscience)

Optimizing App Launch

Improving iOS Startup Performance with Binary Layout Optimizations

objc4

Hook objc_msgSend -- 从 0.5 到 1

hook C++ static initializers

---------下方更多精彩----------

活动推荐

9月7日

上海市长宁区Hello coffee

云+社区邀您参加《AI技术原理与实践》沙龙活动

聚焦AI技术前沿实践

共话AI发展的机遇与挑战

把握现在,展望未来

点击阅读原文即可免费报名

关注云加社区,回复 3 加读者群

在看,让更多人看到!

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值