在具有240个或更多元素的数组上循环时,为什么会对性能产生较大影响?


230

当在Rust中的数组上运行求和循环时,当CAPACITY> = 240 时,我注意到性能大幅下降。CAPACITY= 239快80倍。

Rust对“短”数组进行了特殊的编译优化吗?

与编译rustc -C opt-level=3

use std::time::Instant;

const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;

fn main() {
    let mut arr = [0; CAPACITY];
    for i in 0..CAPACITY {
        arr[i] = i;
    }
    let mut sum = 0;
    let now = Instant::now();
    for _ in 0..IN_LOOPS {
        let mut s = 0;
        for i in 0..arr.len() {
            s += arr[i];
        }
        sum += s;
    }
    println!("sum:{} time:{:?}", sum, now.elapsed());
}


4
也许240您溢出了CPU缓存行?如果是这种情况,您的结果将取决于CPU。
rodrigo

11
转载于此。现在,我猜测它与循环展开有关。
rodrigo

Answers:


355

简介:低于240时,LLVM完全展开了内部循环,这使它注意到它可以优化重复循环,从而打破了基准。



您发现了一个不可思议的阈值,超过该阈值LLVM将停止执行某些优化。阈值为8字节* 240 = 1920字节(您的数组是usizes 的数组,因此,假设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
}

这个简单的代码将大致产生人们所期望的程序集:一个添加元素的循环。但是,如果更改240239,则发出的程序集相差很大。在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寄存器(xmm0xmm1)并行使用,因此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_LOOPSCAPACITY + IN_LOOPS。这是造成巨大性能差异的原因。


补充说明:您对此可以采取任何措施吗?并不是的。LLVM必须具有不可思议的阈值,否则,LLVM优化可能要花一些时间才能永远完成某些代码。但是我们也可以同意该代码是高度人为的。实际上,我怀疑会发生如此巨大的差异。在这些情况下,由于全循环展开而引起的差异通常甚至不是2倍。因此,无需担心实际用例。

关于惯用的Rust代码的最后一点说明:arr.iter().sum()是汇总数组所有元素的更好方法。并且在第二个示例中对此进行更改不会导致所发出的装配存在任何显着差异。除非您认为它会影响性能,否则应使用简短的惯用版本。


2
@ lukas-kalbertodt感谢您的出色回答!现在,我也明白了为什么sum在非本地直接更新的原始代码s运行得慢得多。for i in 0..arr.len() { sum += arr[i]; }
Guy Korland

4
@LukasKalbertodt 在LLVM中发生了其他事情,打开AVX2不会造成太大的改变。也生锈了
Mgetz,

4
@Mgetz有趣!但是,让阈值依赖于可用的SIMD指令对我来说听起来并不疯狂,因为这最终决定了完全展开循环中的指令数量。但不幸的是,我不能肯定地说。有一个LLVM开发人员回答这个问题会很甜蜜。
卢卡斯·卡尔伯特

7
为什么编译器或LLVM没有意识到可以在编译时进行整个计算?我本来希望对循环结果进行硬编码。还是使用Instant预防措施?
创意的名称,

4
@JosephGarvin:我认为这是因为完全展开会允许稍后的优化传递看到这一点。请记住,优化的编译器仍然关心快速编译以及提高效率的asm,因此他们必须限制他们所做的任何分析的最坏情况下的复杂性,因此无需花费数小时/天就可以编译出带有复杂循环的讨厌的源代码。但是,是的,这显然是对大小> = 240的优化遗漏了。我想知道是否不是为了避免破坏简单的基准测试而优化循环内部的循环?可能不是,但是也许。
彼得·科德斯

30

除了Lukas的答案,如果您想使用迭代器,请尝试以下操作:

const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;

pub fn bar() -> usize {
    (0..CAPACITY).sum::<usize>() * IN_LOOPS
}

感谢@Chris Morgan提供有关范围模式的建议。

优化的装配是相当不错的:

example::bar:
        movabs  rax, 14340000000
        ret

3
甚至更好的是,(0..CAPACITY).sum::<usize>() * IN_LOOPS它会产生相同的结果。
克里斯·摩根

11
我实际上会解释说,程序集实际上并没有进行计算,但是在这种情况下,LLVM已经预先计算了答案。
Josep

我感到惊讶的rustc是,没有机会进行这种降低强度的运动。但是,在此特定上下文中,这似乎是一个定时循环,您故意不希望对其进行优化。重点是从头开始重复该次数,然后除以重复次数。在C语言中,(非正式)习惯用法是将循环计数器声明为volatile,例如Linux内核中的BogoMIPS计数器。有没有办法在Rust中实现这一目标?可能有,但我不知道。呼叫外部fn可能会有帮助。
戴维斯洛

1
@Davislor:volatile强制内存同步。将其应用于循环计数器只会强制实际重新加载/存储循环计数器值。它不会直接影响循环体。这就是为什么使用它的更好方法通常是将实际的重要结果分配给volatile int sink或在循环后(如果存在循环承载的依赖项)或每次迭代后分配一些东西,以使编译器优化其想要但强制执行的循环计数器兑现你想要的结果在寄存器中,因此它可以存储起来。
彼得·科德斯

1
@Davislor:我认为Rust具有内联的asm语法,类似于GNUC。您可以使用内联的asm来强制编译器在寄存器中具体化一个值而不必强制其存储它。在每个循环迭代的结果上使用该函数可以阻止其优化。(如果您不小心的话,也可以通过自动向量化)。例如MSVC中的“ Escape”和“ Clobber”等价词解释了2个宏(同时询问如何将它们移植到MSVC上,这是不可能的),并链接到Chandler Carruth的演讲,其中他展示了它们的用法。
彼得·科德斯
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.