做者:字節跳動終端技術 —— 劉夏html
筆者來自字節跳動終端技術 AppHealth (Client Infrastructure - AppHealth) 團隊,在工做中咱們會對開源 LLVM 及 Swift 工具鏈進行維護和定製,推進各項編譯器優化在業務場景中的落地。編譯器做爲一個複雜的軟件也會有 bug,也會有各類兼容性和正確性的問題,這裏咱們分享一則開啓 clang 的 -Oz
優化選項時發現的編譯器缺陷。前端
在 Xcode 中咱們能夠對 clang 編譯器設置不一樣的優化等級,好比在 Debug 模式下默認會使用 -O0
,在 Release 模式默認使用 -Os
(兼顧執行速度和體積),可是在一些性能要求不大的場景,咱們可使用 -Oz
級別,開啓後編譯器會針對代碼體積採起更加激進的優化手段。git
公司的一個視頻組件爲了減包開啓 clang 的 -Oz
優化級別進行編譯,但在開啓後的測試中發現,視頻組件在導出視頻時出現內存暴漲而後發生 OOM 閃退,而且能夠穩定重現。經過 Instruments 及 Xcode 的 Memory Graph 功能能夠看到大量的 GLFramebuffer
被建立,而每一個 GLFramebuffer
中會持有一個 2MB 的 CVPixelBuffer
,致使佔用大量內存。github
預期中這些 GLFramebuffer
應該被複用而不是重複建立,但經過日誌發現每次獲取時都沒有可用的 buffer,因而就不斷建立新的 buffewr。在代碼邏輯中, buffer 是否能重用依賴於 -[GLFramebuffer unlock]
是否被調用,可是經過觀察發現:這些 buffer 會堆積到導出任務結束後才被 unlock
,因此咱們須要找到 unlock
被推遲的緣由。objective-c
經過閱讀代碼發現:GLFramebuffer
會被一個 SampleData
對象持有,並在 -[SampleData dealloc]
被調用時對 GLFramebuffer
進行 unlock
,當 SampleData
對象被放到 autoreleasepool
中堆積起來就會出現內存暴漲,符合前面觀察到 buffer 批量 unlock 的現象(在 autoreleasepool
批量釋放對象的時候)。算法
注意到以前不開啓 -Oz
時 SampleData
對象是不會進入 autorelasepool
的,因此沒有問題,因而接下來咱們須要找到爲何開啓 -Oz
後 SampleData
對象會被進入 autorelasepool
。markdown
在 ARC 下對象是經過諸如 objc_autoreleaseReturnValue
/ objc_autorelease
的 C 函數來觸發 autorelease
操做,咱們沒法經過符號斷點到 -[SampleData autorelease]
來確認釋放時機,除非把代碼改回 MRC,因此這裏得經過特殊的方式:架構
在工程中添加以下一個類,並在 compiler flag 設置 -fno-objc-arc
關閉 ARC:app
// 和 SampleData 同樣都是繼承自 NSObject
@interface BDRetainTracker : NSObject
@end
@implementation BDRetainTracker
- (id)autorelease {
return [super autorelease]; // 此處設置斷點
}
@end
複製代碼
在重寫的 autorelease
方法設置斷點,而後在 App 啓動後執行:ide
class_setSuperclass(SampleData.class, (Class)NSClassFromString(@"BDRetainTracker"));
複製代碼
如此一來 SampleData
被 autorelease
時會在咱們設置的斷點停下。經過這種方法結合上下文能夠發現 SampleData
被 autorelease
的時機集中在 -[CompileReaderUnit processSampleData:]
:
- (BOOL)processSampleData:(SampleData *)sampleData {
...
SampleData *videoData = [self videoReaderOutput];
...
複製代碼
若是改寫成如下形式,發現內存暴漲現象就會消失:
- (BOOL)processSampleData:(SampleData *)sampleData {
@autoreleasepool {
...
SampleData *videoData = [self videoReaderOutput];
...
}
複製代碼
這裏[self videoReaderOutput]
返回一個 autoreleased 對象是符合 ARC 的約定的,可是以前沒開啓 -Oz
時編譯器進行了優化,對象並不會進入 autoreleasepool
,方法返回後就立刻被釋放了,查看 LLVM 的相關文檔:
When returning from such a function or method, ARC retains the value at the point of evaluation of the return statement, then leaves all local scopes, and then balances out the retain while ensuring that the value lives across the call boundary. In the worst case, this may involve an
autorelease
, but callers must not assume that the value is actually in the autorelease pool.
ARC performs no extra mandatory work on the caller side, although it may elect to do something to shorten the lifetime of the returned value.
因爲 autorelase 是一個有比較大開銷的操做,因此 ARC 會盡量將其優化掉,可是從這個現象咱們能夠猜想,開啓 -Oz
後此處的編譯器對應的優化失效了,讓咱們查看 SampleData *videoData = [self videoReaderOutput]
處的彙編:
adrp x8, #0x1018b5000
ldr x1, [x8, #0x1c0] ; 加載 @selector(videoReaderOutput)
bl _OUTLINED_FUNCTION_40_100333828 ; 調用外聯函數
bl _OUTLINED_FUNCTION_0_1003336bc ; 調用外聯函數
複製代碼
其中調用的兩個 _OUTLINED_FUNCTION_
函數的內容以下:
_OUTLINED_FUNCTION_40_100333828:
mov x0, x20
b imp_stubsobjc_msgSend
_OUTLINED_FUNCTION_0_1003336bc:
mov x29, x29
b imp_stubsobjc_retainAutoreleasedReturnValue
複製代碼
因此這裏生成的代碼邏輯是符合預期的:
objc_msgSend(self, @selector(videoReaderOutput), ...)
返回一個 autoreleased 對象objc_retainAutoreleasedReturnValue
進行強引用咱們能夠對比以前開啓 -Os
生成的代碼,此處 LLVM 的 MIR outliner 生效了:
adrp x8, #0x10190d000
ldr x1, [x8, #0xf0]
mov x0, x20
bl imp_stubsobjc_msgSend
mov x29, x29
bl imp_stubsobjc_retainAutoreleasedReturnValue
複製代碼
編譯器在 -Oz
優化級別下 34 行和 56 行兩段指令由於在多處被使用,因而分別被抽離到獨立的函數進行復用,而原來的地方變成了一條函數調用的指令,數量從 4 條變成 2 條,從而達到減包的目的,這即是 LLVM 的 Machine Outliner 所作的事情,在 -Oz
下它會被默認開啓來達到更極致的代碼體積縮減(在其它優化級別下須要經過 -mllvm -enable-machine-outliner=always
來開啓),其大體原理以下:
extern int do_something(int);
int calc_1(int a, int b) {
return do_something(a * (a - b));
}
int calc_2(int a, int b) {
return do_something(a * (a + b));
}
複製代碼
這段代碼中 calc_1
/calc_2
都調用了 do_something
,儘管參數都不同,可是咱們能從彙編看到一些重複出現的指令序列(這裏用 ARMv7 架構的彙編方便演示)
calc_1(int, int):
add r1, r1, r0 ; A
mul r0, r1, r0 ; B
add r1, r1, r0 ; A
mul r0, r1, r0 ; B
b do_something(int) ; C
calc_2(int, int):
add r1, r1, r0 ; A
add r1, r1, r0 ; A
mul r0, r1, r0 ; B
b do_something(int) ; C
複製代碼
咱們給相同的指令打上相同的標籤,因此 calc_1
的指令序列是 ABABC 而 calc_2
是 AABC,編譯器經過構造一個後綴樹能夠找到它們的最長公共子串是 ABC,那麼 ABC 這一段就能夠被剝離成一個獨立的函數:
calc_1(int, int):
add r1, r1, r0 ; A
mul r0, r1, r0 ; B
b OUTLINED_FUNCTION_0
calc_2(int, int):
add r1, r1, r0 ; A
b OUTLINED_FUNCTION_0
OUTLINED_FUNCTION_0:
add r1, r1, r0 ; A
mul r0, r1, r0 ; B
b do_something(int) ; C
複製代碼
因爲在 ARC 代碼中編譯器插入的內存管理相關指令很是常見,所這些操做多數會被 outlined(讀者若是對其實現細節感興趣能夠參考這個演講)。
可是爲什麼指令被 outline 後 ARC 的優化會失效呢?留意到 mov x29, x29
這條指令,它實際上並無作任何有意義的操做(將 x29 寄存器的值又存到 x29),它只是個特殊的標記,是編譯器用於輔助運行時進行優化的手段, videoReaderOutput
的實現中返回 autorelease 對象是一個這樣的調用:
return objc_autoreleaseReturnValue(ret);
複製代碼
其運行時的實現大體以下:
// Prepare a value at +1 for return through a +0 autoreleasing convention.
id objc_autoreleaseReturnValue(id obj) {
if (prepareOptimizedReturn(ReturnAtPlus1)) return obj;
return objc_autorelease(obj);
}
// Try to prepare for optimized return with the given disposition (+0 or +1).
// Returns true if the optimized path is successful.
// Otherwise the return value must be retained and/or autoreleased as usual.
static ALWAYS_INLINE bool prepareOptimizedReturn(ReturnDisposition disposition) {
assert(getReturnDisposition() == ReturnAtPlus0);
if (callerAcceptsOptimizedReturn(__builtin_return_address(0))) {
if (disposition) setReturnDisposition(disposition);
return true;
}
return false;
}
static ALWAYS_INLINE bool callerAcceptsOptimizedReturn(const void *ra){
// fd 03 1d aa mov x29, x29
if (*(uint32_t *)ra == 0xaa1d03fd) {
return true;
}
return false;
}
static ALWAYS_INLINE void setReturnDisposition(ReturnDisposition disposition) {
tls_set_direct(RETURN_DISPOSITION_KEY, (void*)(uintptr_t)disposition);
}
複製代碼
objc_autoreleaseReturnValue
中會使用 __builtin_return_address
獲取返回地址的指令,檢查是否存在標記 mov x29 x29
,若是有,意味着我返回的這個對象會立刻被 retain,因此不必放到 autoreleasepool
中,此時運行時會在 Thread Local Storage 中記錄此處作了優化,而後回計數 +1 的對象便可。
對應地 videoReaderOutput
的調用方會使用 objc_retainAutoreleasedReturnValue
引用住對象,實現以下:
// Accept a value returned through a +0 autoreleasing convention for use at +1.
id objc_retainAutoreleasedReturnValue(id obj) {
if (acceptOptimizedReturn() == ReturnAtPlus1) return obj;
return objc_retain(obj);
}
// Try to accept an optimized return.
// Returns the disposition of the returned object (+0 or +1).
// An un-optimized return is +0.
static ALWAYS_INLINE ReturnDisposition acceptOptimizedReturn() {
ReturnDisposition disposition = getReturnDisposition();
setReturnDisposition(ReturnAtPlus0); // reset to the unoptimized state
return disposition;
}
static ALWAYS_INLINE ReturnDisposition getReturnDisposition() {
return (ReturnDisposition)(uintptr_t)tls_get_direct(RETURN_DISPOSITION_KEY);
}
複製代碼
objc_retainAutoreleasedReturnValue
看到 TLS 中的標記知道無需進行額外 retain,因而二者配合從而優化掉了一次 autorelease
和 retain
操做,但這是編譯器和運行時的優化細節,不該該假設優化必定會被髮生。正是因爲開啓 -Oz
後,machine outliner 棒打鴛鴦把 objc_msgSend
和 objc_retainAutoreleasedReturnValue
的調用指令及標記 outline 了,致使這個優化沒有觸發,對象進入 autoreleasepool
。
因此本質上這既是一個開發者的疏忽:使用佔用大內存的臨時對象後沒有及時增長 autorelasepool 將其釋放,只是 ARC 的優化將這個問題隱藏,最終在開啓 -Oz
後被暴露。
同時,這也是一個編譯器的 bug,不該該將此處代碼進行 outline 致使 ARC 的優化失效,這個 bug 直到最近纔在 LLVM 裏面被修復。
一樣是使用 ARC 的 Swift 也有相似的問題,在某些 ARC 優化(好比 -enable-copy-propagation
)沒有開啓的狀況下一些對象的生命週期可能會被延長,而後這個現象被開發者利用,在編譯器保證以外的生命週期使用該對象,一開始可能沒有問題,可是一旦這些優化因爲編譯器的升級或者代碼的改動忽然生效了,那麼以前使用對象的地方可能就會訪問到一個被釋放的對象,更多具體的例子能夠參考 WWDC 21 的 Session 10216。
字節跳動終端技術團隊(Client Infrastructure)是大前端基礎技術的全球化研發團隊(分別在北京、上海、杭州、深圳、廣州、新加坡和美國山景城設有研發團隊),負責整個字節跳動的大前端基礎設施建設,提高公司全產品線的性能、穩定性和工程效率;支持的產品包括但不限於抖音、今日頭條、西瓜視頻、飛書、瓜瓜龍等,在移動端、Web、Desktop等各終端都有深刻研究。
就是如今!客戶端/前端/服務端/端智能算法/測試開發 面向全球範圍招聘!一塊兒來用技術改變世界,感興趣能夠聯繫郵箱 chenxuwei.cxw@bytedance.com,郵件主題 簡歷-姓名-求職意向-指望城市-電話。