关于字节跳动:一起来找茬记一起-clang-开启-Oz-选项引发的血案

7次阅读

共计 6956 个字符,预计需要花费 18 分钟才能阅读完成。

作者:字节跳动终端技术 —— 刘夏

前言

笔者来自字节跳动终端技术 AppHealth (Client Infrastructure – AppHealth) 团队,在工作中咱们会对开源 LLVM 及 Swift 工具链进行保护和定制,推动各项编译器优化在业务场景中的落地。编译器作为一个简单的软件也会有 bug,也会有各种兼容性和正确性的问题,这里咱们分享一则开启 clang 的 -Oz 优化选项时发现的编译器缺点。

问题

在 Xcode 中咱们能够对 clang 编译器设置不同的优化等级,比方在 Debug 模式下默认会应用 -O0,在 Reelase 模式默认应用 -Os(兼顾执行速度和体积),然而在一些性能要求不大的场景,咱们能够应用 -Oz级别,开启后编译器会针对代码体积采取更加激进的优化伎俩。

公司的一个视频组件为了减包开启 clang 的 -Oz 优化级别进行编译,但在开启后的测试中发现,视频组件在导出视频时呈现内存暴涨而后产生 OOM 闪退,并且能够稳固重现。通过 Instruments 及 Xcode 的 Memory Graph 性能能够看到大量的 GLFramebuffer 被创立,而每个 GLFramebuffer 中会持有一个 2MB 的 CVPixelBuffer,导致占用大量内存。

预期中这些 GLFramebuffer 应该被复用而不是反复创立,但通过日志发现每次获取时都没有可用的 buffer,于是就一直创立新的 buffewr。在代码逻辑中,buffer 是否能重用依赖于 -[GLFramebuffer unlock] 是否被调用,然而通过观察发现:这些 buffer 会沉积到导出工作完结后才被 unlock,所以咱们须要 找到 unlock 被推延的起因

通过浏览代码发现:GLFramebuffer 会被一个 SampleData 对象持有,并在 -[SampleData dealloc] 被调用时对 GLFramebuffer 进行 unlock,当 SampleData 对象被放到 autoreleasepool 中堆积起来就会呈现内存暴涨,合乎后面察看到 buffer 批量 unlock 的景象(在 autoreleasepool 批量开释对象的时候)。

留神到之前不开启 -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 研发中的实际成绩,积淀并凋谢。致力于为开发者提供挪动开发解决方案,帮忙企业降本增效,打造高质量、高性能的优质 App 体验。

正文完
 0