简介:低于240时,LLVM完全展开了内部循环,这使它注意到它可以优化重复循环,从而打破了基准。
您发现了一个不可思议的阈值,超过该阈值LLVM将停止执行某些优化。阈值为8字节* 240 = 1920字节(您的数组是usize
s 的数组,因此,假设x86-64 CPU,则长度乘以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编译器浏览器上查看。这是程序集的一小部分:
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/也是这个Q&A有关多个蓄电池隐藏延迟(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编译器资源管理器上)
的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()
是汇总数组所有元素的更好方法。并且在第二个示例中对此进行更改不会导致所发出的装配存在任何显着差异。除非您认为它会影响性能,否则应使用简短的惯用版本。