抖音研發實踐:基於二進制文件重排的解決方案 APP啟動速度提升超15%
背景
啟動是App給用戶的第一印象,對用戶體驗至關重要。抖音的業務迭代迅速,如果放任不管,啟動速度會一點點劣化。為此抖音iOS客戶端團隊做了大量優化工作,除了傳統的修改業務代碼方式,我們還做了些開拓性的探索,發現修改代碼在二進制文件的布局可以提高啟動性能,方案落地后在抖音上啟動速度提高了約15%。
本文從原理出發,介紹了我們是如何通過靜態掃描和運行時trace找到啟動時候調用的函數,然后修改編譯參數完成二進制文件的重新排布。
原理
Page Fault
進程如果能直接訪問物理內存無疑是很不安全的,所以操作系統在物理內存的上又建立了一層虛擬內存。為了提高效率和方便管理,又對虛擬內存和物理內存又進行分頁(Page)。當進程訪問一個虛擬內存Page而對應的物理內存卻不存在時,會觸發一次缺頁中斷(Page Fault),分配物理內存,有需要的話會從磁盤mmap讀人數據。
通過App Store渠道分發的App,Page Fault還會進行簽名驗證,所以一次Page Fault的耗時比想象的要多:
分配物理內存 磁盤IO 驗簽
重排
編譯器在生成二進制代碼的時候,默認按照鏈接的Object File(.o)順序寫文件,按照Object File內部的函數順序寫函數。
靜態庫文件.a就是一組.o文件的ar包,可以用
ar -t
查看.a包含的所有.o。
簡化問題:假設我們只有兩個page:page1/page2,其中綠色的method1和method3啟動時候需要調用,為了執行對應的代碼,系統必須進行兩個Page Fault。
但如果我們把method1和method3排布到一起,那么只需要一個Page Fault即可,這就是二進制文件重排的核心原理
我們的經驗是優化一個Page Fault,啟動速度提升0.6~0.8ms。
核心問題
為了完成重排,有以下幾個問題要解決:
-
重排效果怎么樣 - 獲取啟動階段的page fault次數
-
重排成功了沒 - 拿到當前二進制的函數布局
-
如何重排 - 讓鏈接器按照指定順序生成Mach-O
-
重排的內容 - 獲取啟動時候用到的函數
System Trace
日常開發中性能分析是用最多的工具無疑是Time Profiler,但Time Profiler是基於采樣的,並且只能統計線程實際在運行的時間,而發生Page Fault的時候線程是被blocked,所以我們需要用一個不常用但功能卻很強大的工具:System Trace。
選中主線程,在VM Activity中的File Backed Page In次數就是Page Fault次數,並且雙擊還能按時序看到引起Page Fault的堆棧:
signpost
現在我們在Instrument中已經能拿到某個時間段的Page In次數,那么如何和啟動映射起來呢?
我們的答案是:os_signpost
。
os_signpost
是iOS 12開始引入的一組API,可以在Instruments繪制一個時間段,代碼也很簡單:
1os_log_t logger = os_log_create("com.bytedance.tiktok", "performance");
2os_signpost_id_t signPostId = os_signpost_id_make_with_pointer(logger,sign);
3//標記時間段開始
4os_signpost_interval_begin(logger, signPostId, "Launch","%{public}s", "");
5//標記結束
6os_signpost_interval_end(logger, signPostId, "Launch");
通常可以把啟動分為四個階段處理:
load -> C++靜態初始化 -> didFinishLaunch -> UISetup
有多少個Mach-O,就會有多少個Load和C++靜態初始化階段,用signpost相關API對對應階段打點,方便跟蹤每個階段的優化效果。
Linkmap
Linkmap是iOS編譯過程的中間產物,記錄了二進制文件的布局,需要在Xcode的Build Settings里開啟Write Link Map File:
比如以下是一個單頁面Demo項目的linkmap。
linkmap主要包括三大部分:
-
Object Files 生成二進制用到的link單元的路徑和文件編號
-
Sections 記錄Mach-O每個Segment/section的地址范圍
-
Symbols 按順序記錄每個符號的地址范圍
ld
Xcode使用的鏈接器件是ld,ld有一個不常用的參數-order_file
,通過man ld
可以看到詳細文檔:
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 file is moved to the start of its section and laid out in the same order as in the order file file.
可以看到,order_file中的符號會按照順序排列在對應section的開始,完美的滿足了我們的需求。
Xcode的GUI也提供了order_file選項:
如果order_file中的符號實際不存在會怎么樣呢?
ld會忽略這些符號,如果提供了link選項-order_file_statistics
,會以warning的形式把這些沒找到的符號打印在日志里。
獲得符號
還剩下最后一個,也是最核心的一個問題,獲取啟動時候用到的函數符號。
我們首先排除了解析Instruments(Time Profiler/System Trace) trace文件方案,因為他們都是基於特定場景采樣的,大多數符號獲取不到。最后選擇了靜態掃描+運行時Trace結合的解決方案。
Load
Objective C的符號名是+-[Class_name(category_name) method:name:]
,其中+
表示類方法,-
表示實例方法。
剛剛提到linkmap里記錄了所有的符號名,所以只要掃一遍linkmap的__TEXT,__text
,正則匹配("^\+\[.*\ load\]$"
)既可以拿到所有的load方法符號。
C++靜態初始化
C++並不像Objective C方法那樣,大部分方法調用編譯后都是objc_msgSend
,也就沒有一個入口函數去運行時hook。
但是可以用-finstrument-functions
在編譯期插樁“hook”,但由於抖音的很多依賴由其他團隊提供靜態庫,這套方案需要修改依賴的構建過程。二進制文件重排在沒有業界經驗可供參考,不確定收益的情況下,選擇了並不完美但成本最低的靜態掃描方案。
__DATA,__mod_init_func
,這個section存儲了包含C++靜態初始化方法的文件,獲得文件號
[ 5]
。
1//__mod_init_func
20x100008060 0x00000008 [ 5] ltmp7
3//[ 5]對應的文件
4[ 5] .../Build/Products/Debug-iphoneos/libStaticLibrary.a(StaticLibrary.o)
2. 通過文件號,解壓出.o。
1➜ lipo libStaticLibrary.a -thin arm64 -output arm64.a
2➜ ar -x arm64.a StaticLibrary.o
3. 通過.o,獲得靜態初始化的符號名_demo_constructor
。
1➜ objdump -r -section=__mod_init_func StaticLibrary.o
2
3StaticLibrary.o: file format Mach-O arm64
4
5RELOCATION RECORDS FOR [__mod_init_func]:
60000000000000000 ARM64_RELOC_UNSIGNED _demo_constructor
4. 通過符號名,文件號,在linkmap中找到符號在二進制中的范圍:
10x100004A30 0x0000001C [ 5] _demo_constructor
5. 通過起始地址,對代碼進行反匯編:
1➜ objdump -d --start-address=0x100004A30 --stop-address=0x100004A4B demo_arm64
2
3_demo_constructor:
4100004a30: fd 7b bf a9 stp x29, x30, [sp, #-16]!
5100004a34: fd 03 00 91 mov x29, sp
6100004a38: 20 0c 80 52 mov w0, #97
7100004a3c: da 06 00 94 bl #7016
8100004a40: 40 0c 80 52 mov w0, #98
9100004a44: fd 7b c1 a8 ldp x29, x30, [sp], #16
10100004a48: d7 06 00 14 b #7004
6. 通過掃描bl
指令掃描子程序調用,子程序在二進制的開始地址為:100004a3c +1b68(對應十進制的7016)。
1100004a3c: da 06 00 94 bl #7016
7. 通過開始地址,可以找到符號名和結束地址,然后重復5~7,遞歸的找到所有的子程序調用的函數符號。
小坑
STL里會針對string生成初始化函數,這樣會導致多個.o里存在同名的符號,例如:
1__ZNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEC1IDnEEPKc
類似這樣的重復符號的情況在C++里有很多,所以C/C++符號在order_file里要帶着所在的.o信息:
1//order_file.txt
2libDemoLibrary.a(object.o):__GLOBAL__sub_I_demo_file.cpp
局限性
branch系列匯編指令除了bl/b,還有br/blr,即通過寄存器的間接子程序調用,靜態掃描無法覆蓋到這種情況。
Local符號
在做C++靜態初始化掃描的時候,發現掃描出了很多類似l002的符號。經過一番調研,發現是依賴方輸出靜態庫的時候裁剪了local符號。導致__GLOBAL__sub_I_demo_file.cpp
變成了l002。
需要靜態庫出包的時候保留local符號,CI腳本不要執行strip -x
,同時Xcode對應target的Strip Style修改為Debugging symbol:
Objective C方法
絕大部分Objective C的方法在編譯后會走objc_msgSend
,所以通過fishhook(https://github.com/facebook/fishhook) hook這一個C函數即可獲得Objective C符號。由於objc_msgSend
是變長參數,所以hook代碼需要用匯編來實現:
1//代碼參考InspectiveC
2__attribute__((__naked__))
3static void hook_Objc_msgSend() {
4 save()
5 __asm volatile ("mov x2, lr\n");
6 __asm volatile ("mov x3, x4\n");
7 call(blr, &before_objc_msgSend)
8 load()
9 call(blr, orig_objc_msgSend)
10 save()
11 call(blr, &after_objc_msgSend)
12 __asm volatile ("mov lr, x0\n");
13 load()
14 ret()
15}
子程序調用時候要保存和恢復參數寄存器,所以save和load分別對x0~x9, q0~q9入棧/出棧。call則通過寄存器來間接調用函數:
1#define save() \
2__asm volatile ( \
3"stp q6, q7, [sp, #-32]!\n"\
4...
5
6#define load() \
7__asm volatile ( \
8"ldp x0, x1, [sp], #16\n" \
9...
10
11#define call(b, value) \
12__asm volatile ("stp x8, x9, [sp, #-16]!\n"); \
13__asm volatile ("mov x12, %0\n" :: "r"(value)); \
14__asm volatile ("ldp x8, x9, [sp], #16\n"); \
15__asm volatile (#b " x12\n");
在before_objc_msgSend
中用棧保存lr,在after_objc_msgSend
恢復lr。由於要生成trace文件,為了降低文件的大小,直接寫入的是函數地址,且只有當前可執行文件的Mach-O(app和動態庫)代碼段才會寫入:
iOS中,由於ALSR(https://en.wikipedia.org/wiki/Address_space_layout_randomization)的存在,在寫入之前需要先減去偏移量slide:
1IMP imp = (IMP)class_getMethodImplementation(object_getClass(self), _cmd);
2unsigned long imppos = (unsigned long)imp;
3unsigned long addr = immpos - macho_slide
獲取一個二進制的__text
段地址范圍:
1unsigned long size = 0;
2unsigned long start = (unsigned long)getsectiondata(mhp, "__TEXT", "__text", &size);
3unsigned long end = start + size;
獲取到函數地址后,反查linkmap既可找到方法的符號名。
Block
block是一種特殊的單元,block在編譯后的函數體是一個C函數,在調用的時候直接通過指針調用,並不走objc_msgSend,所以需要單獨hook。
通過Block的源碼可以看到block的內存布局如下:
1struct Block_layout {
2 void *isa;
3 int32_t flags; // contains ref count
4 int32_t reserved;
5 void *invoke;
6 struct Block_descriptor1 *descriptor;
7};
8struct Block_descriptor1 {
9 uintptr_t reserved;
10 uintptr_t size;
11};
其中invoke就是函數的指針,hook思路是將invoke替換為自定義實現,然后在reserved保存為原始實現。
1//參考 https://github.com/youngsoft/YSBlockHook
2if (layout->descriptor != NULL && layout->descriptor->reserved == NULL)
3{
4 if (layout->invoke != (void *)hook_block_envoke)
5 {
6 layout->descriptor->reserved = layout->invoke;
7 layout->invoke = (void *)hook_block_envoke;
8 }
9}
由於block對應的函數簽名不一樣,所以這里仍然采用匯編來實現hook_block_envoke
:
1__attribute__((__naked__))
2static void hook_block_envoke() {
3 save()
4 __asm volatile ("mov x1, lr\n");
5 call(blr, &before_block_hook);
6 __asm volatile ("mov lr, x0\n");
7 load()
8 //調用原始的invoke,即resvered存儲的地址
9 __asm volatile ("ldr x12, [x0, #24]\n");
10 __asm volatile ("ldr x12, [x12]\n");
11 __asm volatile ("br x12\n");
12}
在before_block_hook
中獲得函數地址(同樣要減去slide)。
1intptr_t before_block_hook(id block,intptr_t lr)
2{
3 Block_layout * layout = (Block_layout *)block;
4 //layout->descriptor->reserved即block的函數地址
5 return lr;
6}
同樣,通過函數地址反查linkmap既可找到block符號。
瓶頸
基於靜態掃描+運行時trace的方案仍然存在少量瓶頸:
-
initialize hook不到
-
部分block hook不到
-
C++通過寄存器的間接函數調用靜態掃描不出來
目前的重排方案能夠覆蓋到80%~90%的符號,未來我們會嘗試編譯期插樁等方案來進行100%的符號覆蓋,讓重排達到最優效果。
整體流程
總結
目前,在缺少業界經驗參考的情況下,我們成功驗證了二進制文件重排方案在iOS APP開發中的可行性和穩定性。基於二進制文件重排,我們在針對抖音的iOS客戶端上的優化工作中,獲得了約15%的啟動速度提升。
抽象來看,APP開發中大家會遇到這樣一個通用的問題,即在某些情況下,APP運行需要進行大量的Page Fault,這會影響代碼執行速度。而二進制文件重排方案,目前看來是解決這一通用問題比較好的方案。
未來我們會進行更多的嘗試,讓二進制文件重排在更多的業務場景落地。
手淘架構組最新實踐 | iOS基於靜態庫插樁的⼆進制重排啟動優化
出品|阿里巴巴新零售淘系技術部
本文知識點提煉:1、APP 啟動時 PageFault 的性能分析 2、靜態庫插樁重排方案的技術原理
背景
近期抖音和 Facebook 分享了自己通過二進制重排優化啟動時間的方案,手淘 iOS 架構團隊也對二進制重排進行了研究,由於手淘工程模塊已經二進制化,因此實現了一套基於靜態庫插樁的重排方案。
▐ APP 啟動 和 PageFault
當我們向操作系統申請內存時,操作系統並不是直接分配給我們物理內存,而是只標記當前進程擁有該段內存,當真正使用這段內存時才會分配。這種延遲分配物理內存的方式就通過 page fault 機制來實現的。當我們訪問一個內存地址時,如果該地址非法,或者我們對其沒有訪問權限,或者該地址對應的物理內存還未分配, cpu 都會生成一個 page fault ,進而執行操作系統的 page fault handler 。如果是因為還未分配物理內存,操作系統會立即分配物理內存給當前進程,然后重試產生這個 page fault 的內存訪問指令。

分配虛擬內存 讀取 Page Fault 分配物理內存 讀取
App 在啟動時,需要執行各種函數,我們需要讀取 TEXT 段代碼到物理內存中,這個過程會發生缺⻚中斷,由於啟動時所需要執行的代碼分布在 TEXT 段的各個部分,會讀取很多⻚面,導致啟動時 Page Fault 數量非常多。與直接訪問物理內存不同, page fault 過程大部分是由軟件完成的,消耗時間比較久,所以是影響啟動性能的一個關鍵指標。
例如下圖中,手淘啟動時首先的調用的幾個方法 會分布在虛擬內存的各個⻚面中, 執行這些方法時,需要從讀取到物理內容中,就會產生多次 page fault 。
如果能將啟動階段需要的讀取代碼集中排布,將這些方法全都放到相鄰的區域中,我們讀取這些方法可能就只需要極少的 page fault 次數。可以減少不必要的 page fault 時間。達到優化啟動時間的效果。
重排前后的函數在頁面的布局對比:
重排方案
▐ 如何獲取方法的執行順序
為了生成 order_file , 我們需要確定應用啟動時方法的執行順序。之前抖音和 facebook 都分享過自己的方案,在實際操作的過程中,我們發現抖音和 facebook 的方案並不適用於手淘。
抖音通過靜態掃描和運行時 Trace 等方法確定 order_file,該方案無法覆蓋 initialize、block 和 C++ 通過寄存器的間接函數調用靜態掃描不出來調用。
facebook 分享過通過 llvm 插樁的確定 order_file 的方案,需要使用源碼重新打包。由於手淘幾乎全是已經編譯好的二進制模塊,在手淘使用該方案不現實。
只能想其他辦法...
手淘之前已經做過 pod 預編譯,我和師兄念紀想到了是否可以通過在匯編層面對 pod 編譯后的靜態庫進行插樁。在啟動時,插樁后的方法都會調用記錄方法,從而獲得啟動方法的執行順序。在參考了離青對匯編插樁的研究后,確定了靜態庫插樁的實現方案。
▐ 靜態庫插樁
我們編譯過的靜態庫由 .o 文件組成,我們可以對 .o 中的函數代碼進行修改,在每個函數的開頭插入調用我們指定記錄函數的指令。
舉個例子:
插入前 -[MyApp window]: 的匯編代碼
-[MyApp window]:
0000000000002d88 adrp x8, #0x
0000000000002d8c ldrsw x8, [x8, #0xf18]
; 0x2f18@PAGEOFF, _OBJC_IVAR_$_MyApp._window
0000000000002d90 ldr x0, [x0, x8]
0000000000002d94 ret
插入后的 匯編代碼,可以看到 增加了跳轉到 _record_method 的指令,並且補上了 prologue 和 epilogue 。
-[MyApp window]:
0000000000002ebc stp x29, x30, [sp, #-0x10]!
0000000000002ec0 mov x29, sp
0000000000002ec4 bl _record_method
0000000000002ec8 ldp x29, x30, [sp], #0x
0000000000002ecc adrp x8, #0x
0000000000002ed0 ldrsw x8, [x8, #0xc0]
0000000000002ed4 ldr x0, [x0, x8]
0000000000002ed8 ret
▐ 生成 order file
linkmap 記錄了連接過程中的相關信息。其中包含鏈接用到的 symbol 相關的信息。通過 pc address 減去 slide 得到的地址,我們可以在 linkmap 中找到對應的 symbol .
address = pc - slide. // 因為ASLR, APP 可執行文件隨機載入的原因,需要處理一下偏移
量。
我們需要將之前記錄的地址轉換成對應的符號,為了真實還原線上的執行環境,我們只是在 app 中簡單地的記錄了 pc 地址 和 Image 的偏移量。通過解析 linkmap ,獲取函數的地址區間, 得到距離 address 最近的 symbol ,生成 order_file 。
linkmap 文件:
# Symbols:
# Address Size File Name
0x100001630 0x00000039 [ 2] -[ViewController viewDidLoad]
0x100001670 0x00000092 [ 3] _main
0x100001710 0x00000080 [ 4] -[AppDelegate application:didFinishLaunchingWithOptions:]
0x100001790 0x00000040 [ 4] -[AppDelegate applicationWillResignActive:]
0x1000017D0 0x00000040 [ 4] -[AppDelegate applicationDidEnterBackground:]
0x100001810 0x00000040 [ 4] -[AppDelegate applicationWillEnterForeground:]
0x100001850 0x00000040 [ 4] -[AppDelegate applicationDidBecomeActive:]
0x100001890 0x00000040 [ 4] -[AppDelegate applicationWillTerminate:]
▐ 更改符號的排列順序
默認情況下, ld 鏈接器會按照鏈接的順序將各個 .o 文件的數據重新布局生成可執行文件。ld 鏈接器提供 -order-file 選項操控數據排列的順序。在 Xcode 中可以通過 Order File 選項指定符號排序文件。
//Order file 內容例子:
+[xxxxx1 load]
+[xxxxx2 swizzleResumeAndSuspendMethodForClass:]
+[xxxxx3 load]
+[xxxxx4 initialize]___
+[xxxxx5 initialize]_block_invoke
+[xxxxx6 initialize]___
+[xxxxx7 initialize]_block_invoke
...
優化效果
通過精准的啟動函數重排,最后重排效果還是很可觀的,在 iPhone6 上優化了400ms 的啟動時間。
參考
感謝抖音團隊和 Facebook 團隊提供優化新思路
抖音研發實踐:基於二進制文件重排的解決方案 APP啟動速度提升超15%
Improving iOS Startup Performance with Binary Layout Optimizationshttps://atscaleconference.com/videos/performance-scale-improving-ios-startup-performance-with-binary-layout-optimizations/Linux下Page Fault的處理流程 https://cloud.tencent.com/developer/article/1459526
https://mp.weixin.qq.com/s/Drmmx5JtjG3UtTFksL6Q8Q