二进制重排可以优化 Page Fault(Instruments 工具中为 File Backed Page In,以下简称 Page In)产生的耗时问题,典型的应用场景是优化 App 启动速度。
Facebook(performance-scale-improving-ios-startup-performance-with-binary-layout-optimizations)和字节(抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%)早期都分享过通过二进制重排优化启动时间的方案。之后杨萧玉也发表过一篇关于二进制重排的文章:App 二进制文件重排已经被玩坏了,文章介绍的方案是基于 Clang 内置的 SanitizerCoverage 的能力实现,很大程度的简化了二进制重排方案的落地成本,目前大多数公司应该是基于这套方案实施的。
基于 SanitizerCoverage 的方案实现开源地址:AppOrderFiles。基于这套方案有更多的细节完善空间,可以带来更多的优化效果。
插桩
SanitizerCoverage 方案核心逻辑是以下两个函数:
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
uint32_t *stop) {
static uint32_t N; // Counter for the guards.
if (start == stop || *start) return; // Initialize only once.
printf("INIT: %p %p\n", start, stop);
for (uint32_t *x = start; x < stop; x++)
*x = ++N; // Guards should start from 1.
}
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (collectFinished || !*guard) {
return;
}
// If you set *guard to 0 this code will not be called again for this edge.
// Now you can get the PC and do whatever you want:
// store it somewhere or symbolize it and print right away.
// The values of `*guard` are as you set them in
// __sanitizer_cov_trace_pc_guard_init and so you can make them consecutive
// and use them to dereference an array or a bit vector.
*guard = 0;
void *PC = __builtin_return_address(0);
PCNode *node = malloc(sizeof(PCNode));
*node = (PCNode){PC, NULL};
OSAtomicEnqueue(&queue, node, offsetof(PCNode, next));
}
__sanitizer_cov_trace_pc_guard_init 函数的参数 start 和 stop 由系统分配内存地址,其中 stop 与 start 的差值由工程中的 OC 方法、C、C++ 函数、Block 以及编译器自动生成的函数(这些类型下文简称函数)等决定,差值等于以上函数总个数 * 4 个字节。可以理解成系统开辟了一块内存空间,每隔 4 个字节存储一个函数对应的标签(也就是 __sanitizer_cov_trace_pc_guard 参数 guard)。因为每当函数被调用时,__sanitizer_cov_trace_pc_guard 就会被执行,为了避免同一个函数多次调用而重复 trace 的问题,所以设置了 guard 用来处理去重逻辑。
在 __sanitizer_cov_trace_pc_guard_init 中,只需要将每隔 4 字节的内存初始化一个值即可,按 1 自增其实没有意义(在这个 Pull requests 中也提到了),为防止内存已被写入预期之外的值,可以统一初始化为固定值:
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
uint32_t *stop) {
if (start == stop || *start) return; // Initialize only once.
printf("INIT: %p %p\n", start, stop);
for (uint32_t *x = start; x < stop; x++)
*x = 0;
}
正常来说 __sanitizer_cov_trace_pc_guard_init 会在 __sanitizer_cov_trace_pc_guard 之前调用。但在实际测试中,发现在 +load 等执行较早时,会出现 __sanitizer_cov_trace_pc_guard 被调用时 __sanitizer_cov_trace_pc_guard_init 并未被调用的情况。
因为 guard 默认初始化为 0,所以仅仅通过 if (collectFinished || !*guard) 判断,就会导致 +load 等较早时机执行的函数不能够正确 trace。在 __sanitizer_cov_trace_pc_guard 函数中对 guard 赋非 0 值解决:
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (collectFinished || *guard == 1024) {
return;
}
// If you set *guard to 0 this code will not be called again for this edge.
// Now you can get the PC and do whatever you want:
// store it somewhere or symbolize it and print right away.
// The values of `*guard` are as you set them in
// __sanitizer_cov_trace_pc_guard_init and so you can make them consecutive
// and use them to dereference an array or a bit vector.
*guard = 1024;
void *PC = __builtin_return_address(0);
PCNode *node = malloc(sizeof(PCNode));
*node = (PCNode){PC, NULL};
OSAtomicEnqueue(&queue, node, offsetof(PCNode, next));
}
符号
因为 C++ 语言的特性。在编译过程中会生成初始化函数以及静态初始化函数,这些函数在不同的文件中可能存在同名的情况,如果没有补全文件等信息,就会导致同名函数只生效一个。这个问题在字节的文章中也有说明:
同样通过 ld 命令可以看到苹果关于 -order_file 的说明,也有文件信息的补全方式:通过 : 分割文件信息和符号:
在原方案中,直接通过符号化后的字符串进行去重:
这就会导致 C++ 相关的同名函数在符号收集阶段就会丢失,可以通过函数地址的方式进行去重。
至于怎么拿到文件信息,可以通过 LinkMap 文件获取:
当有多个同名函数时,通过函数地址 Addr,判断 Addr 属于区间 [Address, Address + Size],获取到正确的 File 和 Name。
C++ Static Initializer
前面提到 C++ 会在编译期自动生成静态初始化函数,在实际测试中发现并不是所有的静态初始化函数都能够被正确插桩。静态初始化函数伴随着 Mach-O 文件的加载而执行(紧随 +load 之后执行),所以启动过程中加载的 Mach-O 文件的静态初始化函数一定会执行。
静态初始化函数生成有一定规则,分为以下两类:
// xxx 为文件名
__GLOBAL__sub_I_XXX.cpp
// index 为静态初始化变量在文件中排序索引,默认 0 不展示
___cxx_global_var_init.index
___cxx_global_var_init
___cxx_global_var_init.2
所以对于静态初始化函数,只需要通过 LinkMap 文件正则匹配的方式获取,并因其执行时间较早,将其符号统一放置到 Order 文件前面即可(+load 方法之后)。
非 text 重排
通常二进制重排关注的是 text section 的方法或函数,其实除了 text 以外多个 section 都可以重排,只是其它 section 本身并不大,启动过程占用 Page 较少,所以不太需要重排。
如果你的工程中 Swift 的占比较多,可以考虑对 __TEXT/__const
进行重排,对于 iOS 15 用户或者 iOS 16 以上 App 更新后首次启动的用户有一定的启动收益,符号的获取通过以下脚本实现:
cat Binary-arm64-LinkMap.txt | grep -v '<<dead>>|non-lazy-pointer-to-local' | grep -o '_$.*Mc$' > order_file.txt
具体可以参考文章:How To Speed Up Swift By Ordering Conformances
其它
收益验证
因为二进制重排是编译期生效,所以线上比较难通过实验观察实际收益。通过可能通过两个版本对比观察收益,比较难做到唯一变量。
一种理论可行的思路是通过动态库懒加载实现,主工程仅是一个壳通过实验控制加载进行二进制重排的其它动态库和未进行二进制重排的其它动态库。
基于 Page In 地址重排
因为二进制重排是优化 Page In 耗时,所以可以使用 Instruments 等工具直接获取到启动过程中产生 Page In 的地址。将这些地址符号化写入 Order 文件中。然后将有 Order 文件重排后的 App 继续获取启动过程中产生的 Page In 的地址,不断重复这个过程直到基本不产生新的 Page In 地址后,那么这个 Order 文件就是比较完整的。
这种方案的优势:
- 端上无需任何逻辑
- 无源码依然使用
- 符号较全面
不足之处在于重复过程比较消耗人力。
下期讲一下另外一种思路优化 Page In 耗时:『Page In 预载』。