一起來找茬:記一起 clang 開啟 -Oz 選項引發的血案


作者:字節跳動終端技術 —— 劉夏

前言

筆者來自字節跳動終端技術 AppHealth (Client Infrastructure - AppHealth) 團隊,在工作中我們會對開源 LLVM 及 Swift 工具鏈進行維護和定制,推動各項編譯器優化在業務場景中的落地。編譯器作為一個復雜的軟件也會有 bug,也會有各種兼容性和正確性的問題,這里我們分享一則開啟 clang 的 -Oz 優化選項時發現的編譯器缺陷。

問題

在 Xcode 中我們可以對 clang 編譯器設置不同的優化等級,比如在 Debug 模式下默認會使用 -O0,在 Reelase 模式默認使用 -Os(兼顧執行速度和體積),但是在一些性能要求不大的場景,我們可以使用 -Oz級別,開啟后編譯器會針對代碼體積采取更加激進的優化手段。

img

公司的一個視頻組件為了減包開啟 clang 的 -Oz 優化級別進行編譯,但在開啟后的測試中發現,視頻組件在導出視頻時出現內存暴漲然后發生 OOM 閃退,並且可以穩定重現。通過 Instruments 及 Xcode 的 Memory Graph 功能可以看到大量的 GLFramebuffer 被創建,而每個 GLFramebuffer 中會持有一個 2MB 的 CVPixelBuffer ,導致占用大量內存。

img

預期中這些 GLFramebuffer 應該被復用而不是重復創建,但通過日志發現每次獲取時都沒有可用的 buffer,於是就不斷創建新的 buffewr。在代碼邏輯中, buffer 是否能重用依賴於 -[GLFramebuffer unlock] 是否被調用,但是通過觀察發現:這些 buffer 會堆積到導出任務結束后才被 unlock,所以我們需要找到 unlock 被推遲的原因

通過閱讀代碼發現:GLFramebuffer 會被一個 SampleData 對象持有,並在 -[SampleData dealloc] 被調用時對 GLFramebuffer 進行 unlock ,當 SampleData 對象被放到 autoreleasepool 中堆積起來就會出現內存暴漲,符合前面觀察到 buffer 批量 unlock 的現象(在 autoreleasepool 批量釋放對象的時候)。

img

注意到之前不開啟 -OzSampleData 對象是不會進入 autorelasepool 的,所以沒有問題,於是接下來我們需要找到為什么開啟 -OzSampleData 對象會被進入 autorelasepool

在 ARC 下對象是通過諸如 objc_autoreleaseReturnValue / objc_autorelease 的 C 函數來觸發 autorelease 操作,我們無法通過符號斷點到 -[SampleData autorelease] 來確認釋放時機,除非把代碼改回 MRC,所以這里得通過特殊的方式:

在工程中添加如下一個類,並在 compiler flag 設置 -fno-objc-arc 關閉 ARC:

// 和 SampleData 一樣都是繼承自 NSObject
@interface BDRetainTracker : NSObject
@end

@implementation BDRetainTracker
- (id)autorelease {
	return [super autorelease]; // 此處設置斷點
}
@end

在重寫的 autorelease 方法設置斷點,然后在 App 啟動后執行:

class_setSuperclass(SampleData.class, (Class)NSClassFromString(@"BDRetainTracker"));

如此一來 SampleDataautorelease 時會在我們設置的斷點停下。通過這種方法結合上下文可以發現 SampleDataautorelease 的時機集中在 -[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

所以這里生成的代碼邏輯是符合預期的:

  1. 調用 objc_msgSend(self, @selector(videoReaderOutput), ...) 返回一個 autoreleased 對象
  2. 然后對返回的對象調用 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 優化級別下 3~4 行和 5~6 行兩段指令因為在多處被使用,於是分別被抽離到獨立的函數進行復用,而原來的地方變成了一條函數調用的指令,數量從 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(讀者如果對其實現細節感興趣可以參考這個演講)。

ARC 優化

但是為何指令被 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,於是兩者配合從而優化掉了一次 autoreleaseretain 操作,但這是編譯器和運行時的優化細節,不應該假設優化一定會被發生。正是由於開啟 -Oz 后,machine outliner 棒打鴛鴦把 objc_msgSendobjc_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等各終端都有深入研究。

火山引擎應用開發套件MARS是字節跳動終端技術團隊過去九年在抖音、今日頭條、西瓜視頻、飛書、懂車帝等 App 的研發實踐成果,面向移動研發、前端開發、QA、 運維、產品經理、項目經理以及運營角色,提供一站式整體研發解決方案,助力企業研發模式升級,降低企業研發綜合成本。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM