大家來找茬:記一起 clang 開啟 -Oz 選項引發(fā)的血案
前言
-Oz 優(yōu)化選項時發(fā)現(xiàn)的編譯器缺陷。
問題
-O0,在 Release 模式默認使用 -Os(兼顧執(zhí)行速度和體積),但是在一些性能要求不大的場景,我們可以使用 -Oz級別,開啟后編譯器會針對代碼體積采取更加激進的優(yōu)化手段。
-Oz 優(yōu)化級別進行編譯,但在開啟后的測試中發(fā)現(xiàn),視頻組件在導(dǎo)出視頻時出現(xiàn)內(nèi)存暴漲然后發(fā)生 OOM 閃退,并且可以穩(wěn)定重現(xiàn)。通過 Instruments 及 Xcode 的 Memory Graph 功能可以看到大量的 GLFramebuffer 被創(chuàng)建,而每個 GLFramebuffer 中會持有一個 2MB 的 CVPixelBuffer ,導(dǎo)致占用大量內(nèi)存。
GLFramebuffer 應(yīng)該被復(fù)用而不是重復(fù)創(chuàng)建,但通過日志發(fā)現(xiàn)每次獲取時都沒有可用的 buffer,于是就不斷創(chuàng)建新的 buffewr。在代碼邏輯中, buffer 是否能重用依賴于 -[GLFramebuffer unlock] 是否被調(diào)用,但是通過觀察發(fā)現(xiàn):這些 buffer 會堆積到導(dǎo)出任務(wù)結(jié)束后才被 unlock,所以我們需要找到 unlock 被推遲的原因。
GLFramebuffer 會被一個 SampleData 對象持有,并在 -[SampleData dealloc] 被調(diào)用時對 GLFramebuffer 進行 unlock ,當 SampleData 對象被放到 autoreleasepool 中堆積起來就會出現(xiàn)內(nèi)存暴漲,符合前面觀察到 buffer 批量 unlock 的現(xiàn)象(在 autoreleasepool 批量釋放對象的時候)。
-Oz 時 SampleData 對象是不會進入 autorelasepool 的,所以沒有問題,于是接下來我們需要找到為什么開啟 -Oz 后 SampleData 對象會被進入 autorelasepool。
objc_autoreleaseReturnValue / objc_autorelease 的 C 函數(shù)來觸發(fā) autorelease 操作,我們無法通過符號斷點到 -[SampleData autorelease] 來確認釋放時機,除非把代碼改回 MRC,所以這里得通過特殊的方式:
-fno-objc-arc 關(guān)閉 ARC:
// 和 SampleData 一樣都是繼承自 NSObject
@interface BDRetainTracker : NSObject
@end
@implementation BDRetainTracker
- (id)autorelease {
return [super autorelease]; // 此處設(shè)置斷點
}
@end
autorelease 方法設(shè)置斷點,然后在 App 啟動后執(zhí)行:
class_setSuperclass(SampleData.class, (Class)NSClassFromString(@"BDRetainTracker"));
SampleData 被 autorelease 時會在我們設(shè)置的斷點停下。通過這種方法結(jié)合上下文可以發(fā)現(xiàn) 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 時編譯器進行了優(yōu)化,對象并不會進入 autoreleasepool,方法返回后就馬上被釋放了,查看 LLVM 的相關(guān)文檔:
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.
-Oz 后此處的編譯器對應(yīng)的優(yōu)化失效了,讓我們查看 SampleData *videoData = [self videoReaderOutput] 處的匯編:
adrp x8, #0x1018b5000
ldr x1, [x8, #0x1c0] ; 加載 @selector(videoReaderOutput)
bl _OUTLINED_FUNCTION_40_100333828 ; 調(diào)用外聯(lián)函數(shù)
bl _OUTLINED_FUNCTION_0_1003336bc ; 調(diào)用外聯(lián)函數(shù)
_OUTLINED_FUNCTION_ 函數(shù)的內(nèi)容如下:
_OUTLINED_FUNCTION_40_100333828:
mov x0, x20
b imp_stubsobjc_msgSend
_OUTLINED_FUNCTION_0_1003336bc:
mov x29, x29
b imp_stubsobjc_retainAutoreleasedReturnValue
-
調(diào)用 objc_msgSend(self, @selector(videoReaderOutput), ...)返回一個 autoreleased 對象 -
然后對返回的對象調(diào)用 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
Machine Outliner
編譯器在 -Oz 優(yōu)化級別下 3~4 行和 5~6 行兩段指令因為在多處被使用,于是分別被抽離到獨立的函數(shù)進行復(fù)用,而原來的地方變成了一條函數(shù)調(diào)用的指令,數(shù)量從 4 條變成 2 條,從而達到減包的目的,這便是 LLVM 的 Machine Outliner 所做的事情,在 -Oz 下它會被默認開啟來達到更極致的代碼體積縮減(在其它優(yōu)化級別下需要通過 -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 都調(diào)用了 do_something,盡管參數(shù)都不一樣,但是我們能從匯編看到一些重復(fù)出現(xiàn)的指令序列(這里用 ARMv7 架構(gòu)的匯編方便演示)
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,編譯器通過構(gòu)造一個后綴樹可以找到它們的最長公共子串是 ABC,那么 ABC 這一段就可以被剝離成一個獨立的函數(shù):
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 優(yōu)化
但是為何指令被 outline 后 ARC 的優(yōu)化會失效呢?留意到 mov x29, x29 這條指令,它實際上并沒有做任何有意義的操作(將 x29 寄存器的值又存到 x29),它只是個特殊的標記,是編譯器用于輔助運行時進行優(yōu)化的手段, videoReaderOutput 的實現(xiàn)中返回 autorelease 對象是一個這樣的調(diào)用:
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 中記錄此處做了優(yōu)化,然后回計數(shù) +1 的對象即可。
videoReaderOutput 的調(diào)用方會使用 objc_retainAutoreleasedReturnValue 引用住對象,實現(xiàn)如下:
// 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,于是兩者配合從而優(yōu)化掉了一次 autorelease 和 retain 操作,但這是編譯器和運行時的優(yōu)化細節(jié),不應(yīng)該假設(shè)優(yōu)化一定會被發(fā)生。正是由于開啟 -Oz 后,machine outliner 棒打鴛鴦把 objc_msgSend 和 objc_retainAutoreleasedReturnValue 的調(diào)用指令及標記 outline 了,導(dǎo)致這個優(yōu)化沒有觸發(fā),對象進入 autoreleasepool。
總結(jié)
所以本質(zhì)上這既是一個開發(fā)者的疏忽:使用占用大內(nèi)存的臨時對象后沒有及時增加 autoreleasepool 將其釋放,只是 ARC 的優(yōu)化將這個問題隱藏,最終在開啟 -Oz 后被暴露。
-enable-copy-propagation )沒有開啟的情況下一些對象的生命周期可能會被延長,然后這個現(xiàn)象被開發(fā)者利用,在編譯器保證之外的生命周期使用該對象,一開始可能沒有問題,但是一旦這些優(yōu)化由于編譯器的升級或者代碼的改動突然生效了,那么之前使用對象的地方可能就會訪問到一個被釋放的對象,更多具體的例子可以參考 WWDC 21 的 Session 10216。
LLVM 的相關(guān)文檔
https://clang.llvm.org/docs/AutomaticReferenceCounting.html#unretained-return-values
Machine Outliner
https://llvm.org/doxygen/MachineOutliner_8cpp_source.html
Outlined 相關(guān)演講
http://www.llvm.org/devmtg/2016-11/Slides/Paquette-Outliner.pdf
指令被 outline 后 ARC 的優(yōu)化
https://github.com/opensource-apple/objc4/blob/cd5e62a5597ea7a31dccef089317abb3a661c154/runtime/objc-object.h#L929-L984
bug 修復(fù)
https://github.com/llvm/llvm-project/commit/ed4718eccb12bd42214ca4fb17d196d49561c0c7
WWDC 21 的 Session 10216
https://developer.apple.com/videos/play/wwdc2021/10216
關(guān)于字節(jié)終端技術(shù)團隊
