摘要: 当长度小于等于240时,LLVM完全展开内部循环,并且这使其注意到它可以优化掉重复循环,从而打破了您的基准测试。
你找到了一个神奇的阈值,超过该阈值LLVM会停止执行某些优化。该阈值为8个字节* 240 = 1920个字节(假设x86-64 CPU,您的数组是一个usize
数组,因此长度乘以8个字节)。在这个基准测试中,仅针对长度为239的特定优化负责巨大的速度差异。但让我们从慢开始:
(所有代码都使用-C opt-level=3
编译)
pub fn foo() -> usize {
let arr = [0; 240];
let mut s = 0;
for i in 0..arr.len() {
s += arr[i];
}
s
}
这段简单的代码将产生大致预期的汇编代码:一个循环将元素加起来。但是,如果您将240
更改为239
,则生成的汇编代码会有很大区别。在Godbolt Compiler Explorer上查看。这是汇编代码的一小部分:
movdqa xmm1, xmmword ptr [rsp + 32]
movdqa xmm0, xmmword ptr [rsp + 48]
paddq xmm1, xmmword ptr [rsp]
paddq xmm0, xmmword ptr [rsp + 16]
paddq xmm1, xmmword ptr [rsp + 64]
; more stuff omitted here ...
paddq xmm0, xmmword ptr [rsp + 1840]
paddq xmm1, xmmword ptr [rsp + 1856]
paddq xmm0, xmmword ptr [rsp + 1872]
paddq xmm0, xmm1
pshufd xmm1, xmm0, 78
paddq xmm1, xmm0
这就是所谓的“循环展开”:LLVM将循环体复制了多次,以避免执行所有那些“循环管理指令”,即增加循环变量、检查循环是否已结束并跳转到循环开始。
如果你想知道的话:`paddq`和类似的指令是SIMD指令,允许并行地对多个值求和。此外,同时使用两个16字节的SIMD寄存器(`xmm0`和`xmm1`)使得CPU的指令级并行性基本上可以同时执行两个这样的指令。毕竟,它们彼此独立。最后,两个寄存器被相加,然后水平累加到标量结果。
现代主流x86 CPU(不包括低功耗Atom)在L1d缓存中命中时确实可以每个时钟周期加载2个向量,并且`paddq`吞吐量也至少为每个时钟周期的2个,在大多数CPU上具有1个周期的延迟。请参见https://agner.org/optimize/,以及关于多个累加器如何隐藏延迟(FP FMA的点积),并在吞吐量上形成瓶颈的此问答。
LLVM在未完全展开时会对小型循环进行一定程度的展开,并仍然使用多个累加器。因此,即使没有完全展开,对于由LLVM生成的循环来说,前端带宽和后端延迟瓶颈通常也不是一个很大的问题。
但是仅靠循环展开就不能解释80倍性能差异!至少不是仅靠循环展开。让我们看一下实际的基准测试代码,它将一个循环放在另一个循环中:
const CAPACITY: usize = 239;
const IN_LOOPS: usize = 500000;
pub fn foo() -> usize {
let mut arr = [0; CAPACITY];
for i in 0..CAPACITY {
arr[i] = i;
}
let mut sum = 0;
for _ in 0..IN_LOOPS {
let mut s = 0;
for i in 0..arr.len() {
s += arr[i];
}
sum += s;
}
sum
}
(在 Godbolt Compiler Explorer 上查看)
对于 CAPACITY = 240
的汇编看起来很正常:有两个嵌套的循环。(在函数开头,有相当多的初始化代码,我们将忽略它。)但是对于 239
,它看起来非常不同!我们看到初始化循环和内部循环被展开了:到目前为止都是预期的。
重要的区别在于,对于 239
,LLVM 能够发现内部循环的结果不依赖于外部循环! 因此,LLVM 会发出基本上仅执行内部循环(计算总和),然后通过多次添加 sum
来模拟外部循环的代码!
首先,我们看到几乎与上面相同的汇编(表示内部循环的汇编)。之后,我们看到这个(我进行了注释以说明汇编代码;带有 *
的注释尤其重要):
; at the start of the function, `rbx` was set to 0
movq rax, xmm1 ; result of SIMD summing up stored in `rax`
add rax, 711 ; add up missing terms from loop unrolling
mov ecx, 500000 ; * init loop variable outer loop
.LBB0_1:
add rbx, rax ; * rbx += rax
add rcx, -1 ; * decrement loop variable
jne .LBB0_1 ; * if loop variable != 0 jump to LBB0_1
mov rax, rbx ; move rbx (the sum) back to rax
; two unimportant instructions omitted
ret ; the return value is stored in `rax`
如您所见,内部循环的结果被获取并且重复加上外部循环运行了多少次,然后返回。LLVM仅能执行此优化是因为它认识到内部循环与外部循环无关。
这意味着运行时从CAPACITY * IN_LOOPS
变为CAPACITY + IN_LOOPS
。这就是巨大性能差异产生的原因。
另外需要注意的是:有什么办法可以解决这个问题吗?实际上不行。LLVM必须设置这样的魔术阈值,否则在某些代码上进行LLVM优化可能需要永远的时间。但我们也可以认为这段代码高度人工制造。实际上,我怀疑不会发生如此巨大的差异。在这些情况下,由于完全展开循环而引起的差异通常甚至不到2倍。因此,不必担心真实用例。
最后提一下Rust惯用代码风格:arr.iter().sum()
是求和数组所有元素的更好方式。在第二个示例中更改这一点不会导致汇编输出方面的显著差异。除非你已测量证明它会影响性能,否则应使用简短和惯用的版本。