App启动优化之二进制重排
如果要问2019年年底iOS最热门技术是哪些,那当然是少不了8月底抖音发布一篇关于启动优化的文章,其原理文章也说得蛮清楚的。链接如下:
抖音文章
简单总结就是
- 二进制重排优化的是pre-main之前的时间
- 因为Mach-O文件的时候是分页加载的,当用到某页数据时才会去加载(类似懒加载),当进程访问一个虚拟内存Page而对应的物理内存却不存在时(例如函数和方法),就会产生Page Fault(缺页中断)去加载该页,虽然中断时间很短,但是当page fault次数庞大时,耗时比想象的更多,而且通过App Store渠道分发的App,Page Fault还会进行签名验证。
查看pre-main耗时
可以通过给xcode设置参数来查看pre-main耗时检测
具体操作如下
Edit Scheme -- 选择一个Scheme(比如:Run)-- 选择Arguments -- Environment Variables -- 点击添加 -- 设置 name: DYLD_PRINT_STATISTICS value : 1
启动app可以看到以下打印
pre-main time: 1.3 seconds (100.0%)
dylib loading time: 161.47 milliseconds (12.2%)
rebase/binding time: 25.11 milliseconds (1.8%)
ObjC setup time: 444.37 milliseconds (33.5%)
initializer time: 691.82 milliseconds (52.2%)
从打印可以看出pre-main之前的耗时操作有如下这些:
- dylib loading :加载
可执行文件(App 的.o 文件的集合)
,加载动态链接库
; - rebase/binding :对动态链接库进行
rebase
指针调整和bind
符号绑定; - Objc setup :Objc 运行时的初始处理,包括 Objc 相关类的注册、category 注册、selector 唯一性检查等;
- initializer:包括了执行
+load()
方法、attribute((constructor))
修饰的函数的调用、创建 C++ 静态全局变量
那我们的优化方案可以从以下几方面入手:
- 减少动态库的个数,如果太多就使用合并的方式控制,这样可以节约
dylib loading
及rebase/binding
的时间 - 清理项目中未用到的类、类别、方法等,这样可以节约
Objc setup
的时间 - 对于可以不在
+load
中处理的逻辑可以放到其他的函数中去处理,比如:+initialize
;控制 C++ 全局变量的数量;这样可以节约initializer
的时间
除了以上优化手段,我们还可以借助LLVM为我们提供的优化方式,也就是今天的主题:二进制重排
查看app启动产生的Page Faults
- 打开xcode的
Instruments
,选择System Trace
工具
重排
编译器在生成二进制代码的时候,默认按照链接的Object File(.o)顺序写文件,按照Object File内部的函数顺序写函数。
(静态库文件.a就是一组.o文件的ar包,可以用ar -t查看.a包含的所有.o)
上图,假设我们只有两个page:page1/page2,其中绿色的method1和method3启动时候需要调用,为了执行对应的代码,系统必须进行两个Page Fault。
优化:如果我们把method1和method3排布到一起,那么只需要一个Page Fault即可,这就是二进制文件重排的核心原理
,如下图
Xcode相关配置
-
order file
文件路径设置
有了上面的理论知识,那么我们需要将启动时候调用的函数进行重排,让它们尽可能的分配在同一个页。比如load方法我们就将其找出来,放到一起。LLVM支持我们通过设置order来达到这个效果,如下图
至于order file的文件内容是
那些启动时候需要调用的函数和方法
,如下图
看到这里你可能会有疑问,我们如何知道启动时需要调用那些函数和方法呢?其实这也是
二进制重排最难的地方
。方法很多,抖音的文章也详细说了,我们今天采用终极方案clang全局静态插桩
方法,下面会详说。
-
Write Link Map File
设置
这个主要是用来描述可执行文件的构造部分,包括了代码段和数据段的分布情况。
一句话总结就是能查看函数、方法的内存顺序,我们重排的就是这些顺序。
具体设置如下图
设置完成后
command + b
编译一下,再去对应的路径就能看到生成的mapFile文件,直接打开(或者借助第三库界面工具Link Map
打开),如图:
从
mapFile
可以看到load
等启动时需要调用的方法和函数分散得很开,当启动执行的方法和函数很多时,那就可能分散在不同的页。待会我们重排完之后再回来对比看重排前和重排后的区别。
clang全局静态插桩
现在我们开始获取启动时候调用的所有函数和方法
clang官方文档
首先在主项目Target--Build Settings中添加编译选项
Other C Flags
增加-fsanitize-coverage=func,trace-pc-guard
,添加完之后编译时编译器会帮我们在所有函数和方法(包括block)中插入调用__sanitizer_cov_trace_pc_guard
函数的代码。如下图:
(添加完-fsanitize-coverage=func,trace-pc-guard
选项后,如果不添加下面这段代码的话,就会编译错误,原因就是找不到__sanitizer_cov_trace_pc_guard_init
和__sanitizer_cov_trace_pc_guard
)
那么我们只要在__sanitizer_cov_trace_pc_guard
函数搞事情就好,代码如下(把以下代码复制到项目,我复制到工程首页类):
#import
#import
//原子队列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定义符号结构体
typedef struct {
void *pc;
void *next;
}CKNode;
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop)
{
static uint64_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)
{
// 这个打开了就会跳过load方法
//if (!*guard) return;
//利用__builtin_return_address(0)来获得当前函数返回地址,也就是调用方的地址。
void *PC = __builtin_return_address(0);
CKNode *node = malloc(sizeof(CKNode));
*node = (CKNode){PC,NULL};
//进入
OSAtomicEnqueue(&symbolList, node, offsetof(CKNode, next));
}
//只是为了方便随便放置
-(void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];
[self getOrderFile];
}
//保存orderFile文件
-(void)getOrderFile
{
NSMutableArray * symbolNames = [NSMutableArray array];
while (YES) {
CKNode * node = OSAtomicDequeue(&symbolList, offsetof(CKNode, next));
if (node == NULL) {
break;
}
Dl_info info;
//通过dladdr来将指针解析成Dl_info结构体信息
dladdr(node->pc, &info);
NSString * name = @(info.dli_sname);
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
NSString * symbolName = isObjc ? name: [@"_" stringByAppendingString:name];
[symbolNames addObject:symbolName];
}
//逆序
NSEnumerator * emt = [symbolNames reverseObjectEnumerator];
//去重
NSMutableArray *funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
NSString * name;
while (name = [emt nextObject]) {
if (![funcs containsObject:name]) {
[funcs addObject:name];
}
}
//去掉本次方法调用的方法名
[funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
NSString * funcStr = [funcs componentsJoinedByString:@"\n"];
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"ckTest.order"];
NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
NSLog(@"%@",funcStr);
}
/** 代码相关细节总结:
1、增加-fsanitize-coverage=func,trace-pc-guard,添加完之后编译时编译器会帮我们在所有函数和方法(包括block)中插入调用`__sanitizer_cov_trace_pc_guard`函数的代码。
2、利用__builtin_return_address(0)来获得当前函数返回地址,也就是调用方的地址。
3、通过dladdr来将指针解析成Dl_info结构体信息,其中dli_sname就是符号的名称。
typedef struct dl_info {
const char *dli_fname; /* Pathname of shared object */
void *dli_fbase; /* Base address of shared object */
const char *dli_sname; /* Name of nearest symbol */
void *dli_saddr; /* Address of nearest symbol */
} Dl_info;
*/
执行以上代码最后会在app的沙盒的tmp目录下生成orderFile文件(需要改路径的可以自己修改一下代码),打开查看,如下图
有了这份orderFile文件,我们可以去xcode里设置(就是按照上面说的xcode相关配置 -> 1. order file文件路径设置
)。
设置完,再编译下,再看下link map file
文件,如图
接着我们看下重排后的Page Fault
耗时和pre-main
的时间
从上图可以看到page Fault的次数从3000+减少到2000+,时间从900+ms到400+ms
再看下pre-main
Total pre-main time: 926.43 milliseconds (100.0%)
dylib loading time: 150.69 milliseconds (16.2%)
rebase/binding time: 32.49 milliseconds (3.5%)
ObjC setup time: 88.75 milliseconds (9.5%)
initializer time: 654.27 milliseconds (70.6%)
跟之前的1.3s对比明显优化了0.4s左右(可能我用来测试的项目类比较多),效果还是挺明显的,但是这个时间每次都是有差异的,只能算大概。
注意事项说明
- 因为优化都是毫秒级别的,所以可能很难从肉眼上面看出来。
- 优化效果跟项目"大小"有关。
- 主要优化的是“冷启动”,平常我们认为把app从后台删掉,再重新启动就是冷启动,但是如果从“从后台删掉”到“重新启动”这中间时间间隔短的话,其实app的数据还是在内存里,这时候启动会很快,因为app数据还在内存里(通过
Page Fault
的次数就能证明),所以要想更好的测试重排优化的时间,建议把app从后台删掉后,多开几个其他的app后,再去启动你要测试的app,这时再去看Page Fault
耗时和pre-main
的时间。 - 我们刚才hook的只是我们工程的那些源码文件,像静态库那些已经编译好的库,我们是没有hook的,也就是说想要做到极致,也要把静态库那些重排,但是这个可能比较难,因为有可能静态库那些是别人提供的。
- 上面
page fault
的优化时间跟pre-main
实际打印的时间有差异,感觉以pre-main
实际加载的时间为准好点。 - orderFile里面列举的方法和函数名称如果在源码里找不到对应的方法和函数也是没关系的,编译器会直接忽略这些,clang还是很智能的。
- 每次时间都会有差异,只能大概估算。
- 文章很多内容都是从其他博客借鉴的,本文只是把个人觉得好的那些凑在一块,简单总结下,如有错请指正,不喜勿喷,谢谢。