When running a sum loop over an array in Rust, I noticed a huge performance drop when CAPACITY >= 240. CAPACITY = 239 is about 80 times faster.

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());
}

推荐答案

Summary:低于240,LLVM完全展开内部循环,这让它注意到它可以优化重复循环,打破你的基准.



You found a magic threshold above which LLVM stops performing certain optimizations.阈值是8字节*240=1920字节(您的数组是一个usize字节的数组,因此长度乘以8字节,假设x86-64 CPU).在这个基准测试中,一个特定的优化——仅针对长度239执行——导致了巨大的速度差.但让我们慢慢开始:

(All code in this answer is compiled with 100)

pub fn foo() -> usize {
    let arr = [0; 240];
    let mut s = 0;
    for i in 0..arr.len() {
        s += arr[i];
    }
    s
}

这段简单的代码将大致生成人们所期望的程序集:一个添加元素的循环.但是,如果将240更改为239,则emits 的部件会有很大的不同.See it on 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

这就是所谓的loop unrolling:LLVM将循环体粘贴一段时间,以避免执行所有那些"循环管理指令",即递增循环变量,判断循环是否已结束以及跳转到循环的开始.

如果你想知道:paddq和类似的指令是SIMD指令,允许并行求和多个值.此外,两个16字节SIMD寄存器(xmm0xmm1)并行使用,因此CPU的指令级并行性基本上可以同时执行其中两条指令.毕竟,它们是相互独立的.最后,将两个寄存器相加,然后水平相加得到标量结果.

现代主流x86 CPU(不是低功耗的Atom)在L1d缓存中运行时,每个时钟可以执行2个向量加载,paddq吞吐量也至少是每个时钟2个,在大多数CPU上有1个周期的延迟.参见https://agner.org/optimize/this Q&A关于多个累加器来隐藏延迟(对于点积,FP FMA的延迟)和吞吐量瓶颈.

LLVM在不是fully展开时,会展开some个小循环,并且仍然使用多个累加器.因此,通常情况下,前端带宽和后端延迟瓶颈对于LLVM生成的循环来说并不是什么大问题,即使没有完全展开.


至少不需要单独展开循环.让我们来看看实际的基准测试代码,它将一个循环放在另一个代码中:

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
}

(On Godbolt Compiler Explorer)

CAPACITY = 240的装配看起来很正常:两个嵌套循环.(在函数的开头,有相当多的代码只是用于初始化,我们将忽略这些代码.)然而,对于239来说,它看起来非常不同!我们看到初始化循环和内部循环被展开:到目前为止,这是意料之中的.

The important difference is that for 239, LLVM was able to figure out that the result of the inner loop does not depend on the outer loop!因此,LLVM发出的代码基本上首先只执行内部循环(计算总和),然后通过加上sum次模拟外部循环!

首先,我们看到与上面几乎相同的程序集(表示内部循环的程序集).后来我们看到了这一点(我对大会进行了解释;*的 comments 尤其重要):

        ; 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只能执行这种优化,因为它知道内部循环独立于外部循环.

This means the runtime changes from 100 to 101.这是造成巨大性能差异的原因.


另外一点:你能做些什么吗?不是真的.LLVM必须有这样神奇的阈值,如果没有它们,LLVM优化可能永远无法在某些代码上完成.但我们也可以同意,这个代码是高度人工的.在实践中,我怀疑会出现如此巨大的差异.在这些情况下,由于全循环展开而产生的差异通常不是系数2.所以不需要担心真实的用例.

注意:一个数组加上arr.iter().sum()个元素就更好了.在第二个例子中改变这一点并不会导致组件之间的任何显著差异.你应该使用简短且惯用的版本,除非你认为这会影响性能.

Rust相关问答推荐

使用nom将任何空白、制表符、白线等序列替换为单个空白

如果成员都实现特征,是否在多态集合上实现部分重叠的特征?

异步FN中的 rust 递归

为什么';t std::cell::ref使用引用而不是非空?

如何实现Serde::Ser::Error的调试

通过RabbitMQ取消铁 rust 中长时间运行的人造丝任务的策略

为什么rustc会自动降级其版本?

了解Rust';s特征对象和不同函数签名中的生存期注释

Rust 中什么时候可以返回函数生成的字符串切片&str?

通过异常从同步代码中产生yield 是如何工作的?

如何以与平台无关的方式将OsString转换为utf-8编码的字符串?

根据掩码将 simd 通道设置为 0 的惯用方法?

方法可以被误认为是标准特性方法

通过mem::transmute将数组展平安全吗?

特征中定义的类型与一般定义的类型之间的区别

匹配结果时的简洁日志(log)记录

如何为枚举中的单个或多个值返回迭代器

如何重写这个通用参数?

在 Rust 中组合特征的不同方法是否等效?

为什么这里需要类型注解?