非凡的速度提升
二进制大小很重要

我去年关注的一个问题是,虽然 WebKit 在常用基准测试中越来越快,但由于新特性和优化的副作用,一些操作却变得越来越慢。

WebKit 是一个快速发展的项目,每天都有新特性和优化出现。大多数这些变化的一个结果是二进制文件大小的快速增加。但二进制文件大小真的重要吗?从单个补丁来看,增长是无害的,几乎检测不到。但几个月后,你就会有成千上万个补丁,它们加在一起会使得引擎更重、更慢。

下图显示了 WebCore 去年二进制大小的变化。每增长一个额外的兆字节大约需要 1.5 个月的时间。

WebKit 二进制大小的增长以三种方式影响速度:内存局部性启动时间内存使用

指令的内存局部性影响 CPU 缓存;代码越分散,CPU 核心等待执行任务的时间就越多。通常,我们希望减少缓存未命中(无论是来自指令缓存、TLB、页面错误等)的数量。由于缓存大小和内存速度的增长速度不如我们的二进制文件增长快,我们不能指望新硬件来弥补。我们需要通过软件来解决这个问题。

启动时间受两个因素影响:最重要的是从磁盘加载二进制文件(这与 CPU 速度相比非常慢)。内存局部性也起作用,因为所有缓存都是冷的。

内存使用相当直接,WebKit 需要的代码越多,指令页面加载就越多。二进制文件的内存占用是一个相对次要的问题;并不是所有内容都时刻驻留在实际内存中,而且与现代网页使用的内存量相比,增加几兆字节只是一个很小的量。

我们如何解决这个问题?没有两个补丁是完全相同的,所以我将在下面讨论三个更大的原则。

更好的代码

有趣的是,我们发现在某些情况下编译器做得“不好”,因为 C++ 代码阻止了编译器的优化。这通常发生在开发人员试图节省几行代码,而不是编写更明确的算法时。

一个简单的人工示例将比解释更好地说明这一点。我们来看下面的代码

inline void updateCachedWidth() {
    m_cachedWidth = computeWidth();
    m_cachedWidth *= deviceScaleFactor();
}

由此,编译器将生成如下内容

call computeWidth()
store the result at the address of m_cachedWidth
call deviceScaleFactor()
load m_cachedWidth from its address
multiply the two values
store the result at the address of m_cachedWidth

你可能会想:为什么编译器把值存放在中间而不是保存在寄存器中?这是一个常见问题:编译器不知道函数调用是否会导致 m_cachedWidth 被修改,因此它必须从内存中重新加载值,而不是将其保留在寄存器中。

稍作修改

inline void updateCachedWidth() {
    double newWidth = computeWidth();

    newWidth *= deviceScaleFactor();
    m_cachedWidth = newWidth;
}

我们得到了我们期望的结果

call computeWidth()
keep the result in one available register.
call deviceScaleFactor()
multiply the result with the value in register
store the result at the address of m_cachedWidth

内联函数

内联函数是提高性能的最佳工具之一,但同时也是最危险的工具之一。

一方面,内联消除了寄存器溢出和跳转。这意味着更好的寄存器分配,CPU 需要跟踪的分支更少,更多的优化机会等等。当内联的代码很小时,生成的二进制文件既更小又更高效。

另一方面,如果内联的代码比调用开销更大,我们最终会得到更大的二进制文件,结果可能比非内联代码更快,也可能更慢。

内联更大的函数如履薄冰,你总是冒着撑爆指令缓存并使程序比原来慢得多的风险。

似乎有一种倾向是过度内联代码,以便在分析器上消除函数调用。虽然这在配备巨大 CPU 缓存的强大开发计算机上效果很好,但在笔记本电脑和嵌入式系统上效果并不总是那么好。这也很难调查,因为问题在分析器中表现得不那么明显。

过度的内联通过两种方式解决。首先,对于广泛使用的小型内联函数,我们努力通过使用不同的指令或改变其算法工作方式来使其更小。对于更大的函数,我们要么取消内联,要么将其拆分为热路径(内联)和冷路径(非内联)函数。

静态初始化器

静态变量有时会显著增加二进制文件大小,而对运行时性能没有帮助。

让我们来看一个简单的例子

Object* someObject()
{
    static Object* someObject = new Object();
    return someObject;
}

这会转换为这些 ARM Thumb 指令

__Z10someObjectv:
00000ef8        b5b0    push    {r4, r5, r7, lr}
00000efa    f2401510    movw    r5, 0x110
00000efe        af02    add r7, sp, #8
00000f00    f2c00500    movt    r5, 0x0
00000f04        447d    add r5, pc
00000f06        7828    ldrb    r0, [r5, #0]
00000f08        2801    cmp r0, #1
00000f0a        d106    bne.n   0xf1a
00000f0c    f24000fc    movw    r0, 0xfc
00000f10    f2c00000    movt    r0, 0x0
00000f14        4478    add r0, pc
00000f16        6804    ldr r4, [r0, #0]
00000f18        e00d    b.n 0xf36
00000f1a        2008    movs    r0, #8
00000f1c    f000e834    blx 0xf88
00000f20        4604    mov r4, r0
00000f22    f7ffffb3    bl  __ZN6ObjectC1Ev
00000f26    f24000de    movw    r0, 0xde
00000f2a        2101    movs    r1, #1
00000f2c    f2c00000    movt    r0, 0x0
00000f30        7029    strb    r1, [r5, #0]
00000f32        4478    add r0, pc
00000f34        6004    str r4, [r0, #0]
00000f36        4620    mov r0, r4
00000f38        bdb0    pop {r4, r5, r7, pc}

很疯狂吧?代码首先检查对象是否已初始化,然后根据结果加载地址或初始化内存(以及对象)。

WebKit 中的许多静态变量的存在都有充分的理由——它们是需要在进程生命周期内存在的对象。但有些只是为了方便而添加的,我们努力用更直接的内存使用方式来替换它们。

最终结果

有了这些工具,我们在改进代码方面取得了不错的进展。正如我们之前所见,WebCore 是最糟糕的罪魁祸首,让我们看看它在过去一年中的大小是如何变化的

第一个月是引言中提到的不受控制的增长。之后,随着各种重构在图表中造成凹陷,增长速度放缓。这些重构带来的大部分改进很快就被 WebCore 的自然增长抵消了。

四月中旬,情况开始变得严峻。出现了两个主要的凹陷。第一个是内联几项改进的结果。第二个大下降是移除来自 Chromium 项目的特性和清理代码的结果。

之后,我们看到工程师们更好地平衡代码改进和新特性,出现了下降趋势。

请记住,许多人一直忙于添加新特性。WebCore 的某些部分显著增长,而其他部分则缩小以作补偿。

WebKit 的变化则不那么剧烈

WebKit 库本来就比较小。十月份的大幅下降是默认启用 C++11 的结果。一些重构使得移除了一些 Objective-C 代码,但总体变化很小。

JavaScriptCore 在过去一年里稳步增长

JavaScriptCore 从优化 JIT 层获得了许多新的优化,这导致其二进制文件缓慢增长。

结论

二进制文件大小只是整体性能的间接指标,但有时查看它可以帮助找到隐藏在 C++ 算法到机器码转换中的大问题。

从过去几个月来看,我们至少为未来的代码获得了一些指导方针,以避免二进制文件大小爆炸性增长

  • 尽量在代码中保持明确,以帮助编译器理解你在做什么。
  • 仔细考虑是否应该内联大块代码。
  • 对于不常运行和/或初始化微不足道的代码,不要使用静态初始化器。

有什么好的二进制优化故事吗?请通过 @awfulben 告诉我。