1. 大家來找茬:記一起 clang 開啟 -Oz 選項引發(fā)的血案

        共 11559字,需瀏覽 24分鐘

         ·

        2021-08-03 16:59


        前言

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

        問題

        在 Xcode 中我們可以對 clang 編譯器設(shè)置不同的優(yōu)化等級,比如在 Debug 模式下默認會使用 -O0,在 Release 模式默認使用 -Os(兼顧執(zhí)行速度和體積),但是在一些性能要求不大的場景,我們可以使用 -Oz級別,開啟后編譯器會針對代碼體積采取更加激進的優(yōu)化手段。
        公司的一個視頻組件為了減包開啟 clang 的 -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)存。
        預(yù)期中這些 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 被推遲的原因。
        通過閱讀代碼發(fā)現(xiàn):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。
        在 ARC 下對象是通過諸如 objc_autoreleaseReturnValue / objc_autorelease 的 C 函數(shù)來觸發(fā) autorelease 操作,我們無法通過符號斷點到 -[SampleData autorelease] 來確認釋放時機,除非把代碼改回 MRC,所以這里得通過特殊的方式:
        在工程中添加如下一個類,并在 compiler flag 設(shè)置 -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];
        ...
        如果改寫成以下形式,發(fā)現(xiàn)內(nèi)存暴漲現(xiàn)象就會消失:
           
        - (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.
        由于 autorelase 是一個有比較大開銷的操作,所以 ARC 會盡可能將其優(yōu)化掉,但是從這個現(xiàn)象我們可以猜測,開啟 -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ù)
        其中調(diào)用的兩個 _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
        所以這里生成的代碼邏輯是符合預(yù)期的:
        1. 調(diào)用 objc_msgSend(self, @selector(videoReaderOutput), ...) 返回一個 autoreleased 對象
        2. 然后對返回的對象調(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 代碼中編譯器插入的內(nèi)存管理相關(guān)指令非常常見,所這些操作多數(shù)會被 outlined(讀者如果對其實現(xiàn)細節(jié)感興趣可以參考這個演講)。

        ARC 優(yōu)化

        但是為何指令被 outline 后 ARC 的優(yōu)化會失效呢?留意到 mov x29, x29 這條指令,它實際上并沒有做任何有意義的操作(將 x29 寄存器的值又存到 x29),它只是個特殊的標記,是編譯器用于輔助運行時進行優(yōu)化的手段, videoReaderOutput 的實現(xiàn)中返回 autorelease 對象是一個這樣的調(diào)用:

           
        return objc_autoreleaseReturnValue(ret);
        其運行時的實現(xiàn)大致如下:
           
        // 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 的對象即可。
        對應(yīng)地 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 后被暴露。

        同時,這也是一個編譯器的 bug,不應(yīng)該將此處代碼進行 outline 導(dǎo)致 ARC 的優(yōu)化失效,這個 bug 直到最近才在 LLVM 里面被修復(fù)。
        同樣是使用 ARC 的 Swift 也有類似的問題,在某些 ARC 優(yōu)化(比如 -enable-copy-propagation )沒有開啟的情況下一些對象的生命周期可能會被延長,然后這個現(xiàn)象被開發(fā)者利用,在編譯器保證之外的生命周期使用該對象,一開始可能沒有問題,但是一旦這些優(yōu)化由于編譯器的升級或者代碼的改動突然生效了,那么之前使用對象的地方可能就會訪問到一個被釋放的對象,更多具體的例子可以參考 WWDC 21 的 Session 10216。
        相關(guān)鏈接
        • 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ù)團隊

        字節(jié)跳動終端技術(shù)團隊(Client Infrastructure)是大前端基礎(chǔ)技術(shù)的全球化研發(fā)團隊(分別在北京、上海、杭州、深圳、廣州、新加坡和美國山景城設(shè)有研發(fā)團隊),負責整個字節(jié)跳動的大前端基礎(chǔ)設(shè)施建設(shè),提升公司全產(chǎn)品線的性能、穩(wěn)定性和工程效率;支持的產(chǎn)品包括但不限于抖音、今日頭條、西瓜視頻、飛書、瓜瓜龍等,在移動端、Web、Desktop等各終端都有深入研究。
        就是現(xiàn)在!客戶端/前端/服務(wù)端/端智能算法/測試開發(fā) 面向全球范圍招聘!一起來用技術(shù)改變世界,感興趣可以聯(lián)系郵箱 [email protected],郵件主題 簡歷-姓名-求職意向-期望城市-電話

        移動研發(fā)平臺 veMARS 是終端技術(shù)團隊基于字節(jié)跳動過去九年在抖音、今日頭條、西瓜視頻、飛書、瓜瓜龍等 App 研發(fā)中的實踐成果,沉淀并在火山引擎開放。致力于為開發(fā)者提供移動開發(fā)解決方案,幫助企業(yè)降本增效,打造高質(zhì)量、高性能的優(yōu)質(zhì) App 體驗。
        ?? 點擊閱讀原文,免費體驗。

        瀏覽 83
        點贊
        評論
        收藏
        分享

        手機掃一掃分享

        分享
        舉報
        評論
        圖片
        表情
        推薦
        點贊
        評論
        收藏
        分享

        手機掃一掃分享

        分享
        舉報
          
          

            1. 做爱在线观看免费观看高清 | 免费的一级A片 | 插进去在线观看 | 又肥又胖的老奶奶的走红原因 | 少妇口述交换夫全经历 |