基于 Mach-O 符号重排减少缺页中断次数来提升 iOS App 启动速度的可行性分析

4,510

背景

最近字节跳动技术团队放出了一篇文章:抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%,提到通过重排 Mach-O中的二进制,减少启动流程中的缺页中断次数,为 App 节约了 200ms 左右的启动时间(根据抖音目前的启动速度估算),本着严谨的态度,本文将对这种技术方案的可行性和价值进行分析和验证。

原理

应用程序启动时,系统会为应用分配虚拟内存,随后将 Mach-O 载入,在调用符号时,如果 __TEXT, __text 段还未分配物理内存,会引起缺页中断,接下来内核才会为符号所在的页实际分配物理内存,这类硬缺页错误较为耗时。再加上 iOS 的调页策略不包含预加载,即缺页时仅调入当前页,并不会根据局部性原理主动调入后续页面,如果启动链路依赖的符号分散在多个页上,将会引发非常多次的缺页中断,通过将这些符号重排,使他们尽可能集中在一些连续的区域,就能使得调入页时尽可能调入更多启动链路的符号,减少缺页次数,提高启动速度。

关于内核处理缺页中断的验证

耗时验证

笔者首先重启了设备,随后使用 Instruments 的 Systam Trace 分析了某中型 App 的冷启动过程,发现缺页次数多达 1.8W 次,单次的耗时在微秒到毫秒级范围内,第一次缺页发生在对 AliPaySDK 的初始化过程,启动链路缺页的总耗时超过了 300ms,抛开动态库加载等不可避免的缺页外,这其中还有不少的优化空间。

iOS调页策略验证

笔者首先想通过阅读 darwin-xnu 的源码分析其对 Page Fault 的处理过程,奈何功力不足,内核函数读起来十分吃力,因此找了另一条路:通过构造一些分散在不同页的符号并调用他们,然后分析缺页中断报告。

为了构造分散在不同页的符号,笔者使用汇编写了 4 个强制 16K 对齐的函数,使他们分散在 4 个页上,汇编代码如下:

.section __TEXT,__text,regular,pure_instructions
.global _m0, _m1, _m2, _m3

.p2align 14
_m0:
sub sp, sp, #48
stp x29, x30, [sp, #-32]
str x2, [sp, #-48]
mov x2, 0
bl _m1
add x2, x2, x0
bl _m2
add x2, x2, x0
bl _m3
add x2, x2, x0
mov x0, x2
ldp x29, x30, [sp, #-32]
ldr x2, [sp, #-48]
add sp, sp, #48
ret

.p2align 14
_m1:
mov x0, #1
ret

.p2align 14
_m2:
mov x0, #2
ret

.p2align 14
_m3:
mov x0, #3
ret

代码的功能很简单,m0 将 m1 ~ m3 的调用结果累加并返回,mx 将返回 x,关键点在于声明 .p2align 14 来强制按照 16K 对齐,随后我们可以看到二进制中这些符号之间的间隔恰好为 16K,即恰好分散在了 4 个页上:

# Symbols:
# Address	Size    	File  Name
0x100008000	0x00004000	[  4] _m0
0x10000C000	0x00004000	[  4] _m1
0x100010000	0x00004000	[  4] _m2
0x100014000	0x00000008	[  4] _m3

随后在 main.m 中调用 m0,这会引起 m0 -> m1 -> m2 -> m3 的串行调用,分析结果如下:

image.png

其中高亮的行是非系统库引起的第一次缺页中断,地址为 0x100ac8000,减去程序的基址 0xac8000,可以得到符号的地址是 0x100008000,通过 MachOView 分析 Symbol Table 这就是符号 m0 的地址:

image.png

接下来两次,减去基址分别发生在 0x10000c000 和 0x100010000,毫不意外的,他们恰好是 m1 和 m2 的地址:

image.png

image.png

这说明内核在处理缺页中断时并没有预载入相邻的页,程序的运行过程是由缺页中断驱动的,且耗时在数十到数百微秒级,甚至能到毫秒级。以某个中型 App 为例,整个 __TEXT,__text 段共计 41M,包含了约 2563 个页,以最极端情况,如果启动链路不幸每个页都要雨露均沾一下,就会发生 2000 多次缺页中断,如果按照每次 100us 计算,这将消耗 200 ms 以上。

尝试重排

根据 Apple 官方文档,其中提到了对重排符号的设置方法:

The path to a file which alters the order in which functions and data are laid out. For each section in the output file, any symbol in that section that are specified in the order file is moved to the start of its section and laid out in the same order as in the order file. Order files are text files with one symbol name per line. Lines starting with a # are comments. A symbol name may be optionally preceded with its object file leafname and a colon (e.g. foo.o:_foo). This is useful for static functions/data that occur in multiple files. A symbol name may also be optionally preceded with the architecture (e.g. ppc:_foo or ppc:foo.o:_foo). This enables you to have one order file that works for multiple architectures. Literal c-strings may be ordered by quoting the string in the order file (e.g. “Hello, world”). Generally you should not specify an order file in Debug or Development configurations, as this will make the linked binary less readable to the debugger. Use them only in Release or Deployment configurations.

简言之,即创建一个文本文件,每行一个符号,将 order_file 的路径配置到 Xcode 的 Build Settings 中的 Order File 配置项,随后链接器就会按照 order_file 中的顺序来排列符号了。注意这个地方有个坑,如果符号都是用汇编写的,例如上文中的 m0 ~ m3,order_file 是不会生效的

因此笔者在 main.m 中定义了两个 C 函数来实验重排:

int m4() {
    return 4;
}

int m5() {
    return 5;
}

他们在 Mach-O 中的默认分布如下:

# Symbols:
# Address	Size    	File  Name
0x100010018	0x00000008	[  2] _m4
0x100010020	0x00000008	[  2] _m5

下面我们在 order_file 中写入排序规则:

_m5
_m4

再次 Build,查看 linkmap 结果:

# Symbols:
# Address	Size    	File  Name
0x100004000	0x00000008	[  2] _m5
0x100004008	0x00000008	[  2] _m4
0x100004010	0x00000208	[  2] _main
0x100004218	0x00000088	[  3] -[AppDelegate application:didFinishLaunchingWithOptions:]

可以看到 _m5, _m4 被插入到了 __TEXT, __text 段的最前端,且按照 order_file 的顺序进行了排序。

分析启动链路上的符号

这一块儿主要有三个思路,静态分析、堆栈采样和 Hook。

静态分析

静态分析即从__TEXT, __text 段的 _main 方法开始,通过 capstone 等反汇编库将机器码转成汇编代码分析控制流,收集启动链路上的符号,这里比较有挑战的是一些间接寻址的计算,如果简单分析只能覆盖到大多数符号。

堆栈采样

这种方式即在启动流程中以某个采样频率获取当前调用栈,将这些函数和方法记录为启动链路的符号,这种方式听起来比较靠谱,由于尚未实践,不知道准确度和效果如何。

Hook

  1. 最容易想到是 Hook objc_msgSend 来 cover 住启动链路上所有的 OC 方法,对于 C/C++ 函数结合静态分析方案即可;

  2. 小伙伴提到在越狱机上 Hook XNU 的 vm_fault 等函数,从源头处分析引起缺页的符号,理论上讲通过 vm_map 等结构体分析虚拟内存的值可确定哪些是由目标进程引起的缺页,从而进一步定位到引起缺页的段偏移量,进而找到符号,这里有个疑问是进入 vm_fault 时的偏移量可能是页的起始地址,这时可能已经丢失了符号地址,只能定位到是哪一页,无法定位到具体符号,笔者是个内核小白,对这种方案也只是猜测,望大佬们指点。

总结

经过一些实验性的实践和分析,二进制重排对启动速度的影响似乎是不可小觑的,而且由于链接器天然支持了符号重排选项,降低了手动调整二进制文件带来的极大风险,但不知道会不会引入其他的坑。

参考资料

  1. 抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%
  2. 由「抖音二进制文件重排」想到的 - everettjf
  3. Xcode Build Settings Reference