【问题标题】:Why isn't there a branch prediction failure penalty in this Rust code?为什么这个 Rust 代码中没有分支预测失败惩罚?
【发布时间】:2020-04-22 14:39:40
【问题描述】:

我编写了这个非常简单的 Rust 函数:

fn iterate(nums: &Box<[i32]>) -> i32 {
    let mut total = 0;
    let len = nums.len();
    for i in 0..len {
        if nums[i] > 0 {
            total += nums[i];
        } else {
            total -= nums[i];
        }
    }

    total
}

我编写了一个基本的基准测试,它使用有序数组和随机数组调用方法:

fn criterion_benchmark(c: &mut Criterion) {
    const SIZE: i32 = 1024 * 1024;

    let mut group = c.benchmark_group("Branch Prediction");

    // setup benchmarking for an ordered array
    let mut ordered_nums: Vec<i32> = vec![];
    for i in 0..SIZE {
        ordered_nums.push(i - SIZE/2);
    }
    let ordered_nums = ordered_nums.into_boxed_slice();
    group.bench_function("ordered", |b| b.iter(|| iterate(&ordered_nums)));

    // setup benchmarking for a shuffled array
    let mut shuffled_nums: Vec<i32> = vec![];
    for i in 0..SIZE {
        shuffled_nums.push(i - SIZE/2);
    }
    let mut rng = thread_rng();
    let mut shuffled_nums = shuffled_nums.into_boxed_slice();
    shuffled_nums.shuffle(&mut rng);
    group.bench_function("shuffled", |b| b.iter(|| iterate(&shuffled_nums)));

    group.finish();
}

criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

令我惊讶的是,这两个基准测试具有几乎完全相同的运行时间,而 Java 中的类似基准测试显示两者之间存在明显差异,这可能是由于在 shuffled 情况下分支预测失败。

我见过提到条件移动指令,但如果我 otool -tv 可执行文件(我在 Mac 上运行),我在 iterate 方法输出中看不到任何内容。

谁能解释为什么 Rust 中的有序和无序案例之间没有明显的性能差异?

【问题讨论】:

  • 我怀疑这与 Rust/LLVM 如何将此类循环优化为 SIMD 指令有关(我相信 Java 无法做到这一点)。
  • @Frxstrem,是的,在我的计算机上它使用 AVX ISA,即使在 Rust Playground 中,它也会使用“如果小于条件移动”指令 cmovll 来扁平化逻辑。
  • @sshashank124: 是的,启用了完全优化 (-O3) 现代提前编译器后端(如 LLVM 和 GCC)通常会执行“if-conversion”分支到 CMOV 或其他无分支序列。这也是自动矢量化的先决条件。

标签: performance rust compiler-optimization branch-prediction llvm-codegen


【解决方案1】:

总结:LLVM 能够通过使用 cmov 指令或非常巧妙的 SIMD 指令组合来移除/隐藏分支。


我用 Godbolt 来view the full assembly(和-C opt-level=3)。我将在下面解释组件的重要部分。

开头是这样的:

        mov     r9, qword ptr [rdi + 8]         ; r9 = nums.len()
        test    r9, r9                          ; if len == 0
        je      .LBB0_1                         ;     goto LBB0_1
        mov     rdx, qword ptr [rdi]            ; rdx = base pointer (first element)
        cmp     r9, 7                           ; if len > 7
        ja      .LBB0_5                         ;     goto LBB0_5
        xor     eax, eax                        ; eax = 0
        xor     esi, esi                        ; esi = 0
        jmp     .LBB0_4                         ; goto LBB0_4

.LBB0_1:
        xor     eax, eax                        ; return 0
        ret

这里,函数区分了 3 种不同的“状态”:

  • 切片为空 → 立即返回 0
  • 切片长度≤7 → 使用标准顺序算法 (LBB0_4)
  • 切片长度 > 7 → 使用 SIMD 算法 (LBB0_5)

那么让我们来看看这两种不同的算法吧!


标准顺序算法

请记住,rsi (esi) 和 rax (eax) 设置为 0,rdx 是指向数据的基指针。

.LBB0_4:
        mov     ecx, dword ptr [rdx + 4*rsi]    ; ecx = nums[rsi]
        add     rsi, 1                          ; rsi += 1
        mov     edi, ecx                        ; edi = ecx
        neg     edi                             ; edi = -edi
        cmovl   edi, ecx                        ; if ecx >= 0 { edi = ecx }
        add     eax, edi                        ; eax += edi
        cmp     r9, rsi                         ; if rsi != len
        jne     .LBB0_4                         ;     goto LBB0_4
        ret                                     ; return eax

这是一个简单的循环遍历num 的所有元素。不过,在循环体中有一个小技巧:从原始元素ecx 开始,一个否定值存储在edi 中。通过使用cmovl,edi 被原始值覆盖如果原始值为正。这意味着edi 总是会变成正数(即包含原始元素的绝对值)。然后添加到eax(最后返回)。

所以你的if 分支隐藏在cmov 指令中。正如您在this benchmark 中看到的,执行cmov 指令所需的时间与条件的概率无关。这是一个非常了不起的指令!


SIMD 算法

SIMD 版本包含很多指令,我不会在这里完全粘贴。主循环一次处理 16 个整数!

        movdqu  xmm5, xmmword ptr [rdx + 4*rdi]
        movdqu  xmm3, xmmword ptr [rdx + 4*rdi + 16]
        movdqu  xmm0, xmmword ptr [rdx + 4*rdi + 32]
        movdqu  xmm1, xmmword ptr [rdx + 4*rdi + 48]

它们从内存加载到寄存器xmm0、xmm1、xmm3 和xmm5。这些寄存器中的每一个都包含四个 32 位值,但为了更容易理解,想象一下每个寄存器只包含一个值。以下所有指令分别对这些 SIMD 寄存器的每个值进行操作,因此心智模型很好!我在下面的解释听起来好像xmm 寄存器只包含一个值。

主要技巧现在在以下说明中(处理xmm5):

        movdqa  xmm6, xmm5      ; xmm6 = xmm5 (make a copy)
        psrad   xmm6, 31        ; logical right shift 31 bits (see below)
        paddd   xmm5, xmm6      ; xmm5 += xmm6
        pxor    xmm5, xmm6      ; xmm5 ^= xmm6

逻辑右移用符号位的值填充“空的高位”(左侧“移入”的那些)。通过移位 31,我们最终得到每个位置只有符号位!所以任何正数都会变成 32 个零,任何负数都会变成 32 个一。所以xmm6 现在要么是000...000(如果xmm5 是正数)要么是111...111(如果xmm5 是负数)。

接下来这个人工xmm6 被添加到xmm5。如果xmm5 为正,则xmm6 为0,因此添加它不会改变xmm5。然而,如果xmm5 是负数,我们添加111...111,这相当于减1。最后,我们将xmm5 与xmm6 异或。同样,如果 xmm5 一开始是正数,我们将与 000...000 异或,但没有效果。如果xmm5 一开始是负数,我们将与111...111 异或,这意味着我们翻转所有位。所以对于这两种情况:

  • 如果元素是正数,我们什么都不做(add 和 xor 没有任何效果)
  • 如果元素为负数,我们减 1 并翻转所有位。 这是一个二进制补码否定!

所以通过这4条指令我们计算出xmm5的绝对值!再次,由于这种摆弄技巧,没有分支。请记住,xmm5 实际上包含 4 个整数,所以速度非常快!

这个绝对值现在被添加到一个累加器中,同样的还有其他三个包含切片值的xmm 寄存器。 (剩下的代码我们就不详细讨论了。)


带 AVX2 的 SIMD

如果我们允许 LLVM 发出 AVX2 指令(通过 -C target-feature=+avx2),它甚至可以使用 pabsd 指令而不是四个“hacky”指令:

vpabsd  ymm2, ymmword ptr [rdx + 4*rdi]

它直接从内存中加载值,计算绝对值并将其存储在一条指令中的ymm2 中!请记住,ymm 寄存器是xmm 寄存器的两倍(适合 8 个 32 位值)!

【讨论】:

  • 你可能想告诉 LLVM 不要展开循环,这样你就可以看到它在做什么,而不会陷入展开的困境。对于 clang,选项是 -fno-unroll-loops,但该选项名称可能仅适用于 GCC 兼容,而不是 LLVM 自己的内部名称。此外,如果您让它使用 SSSE3 或 AVX2,它有望使用 pabsd 在一条指令中执行 SIMD 绝对值,而不是需要 2 的补码标识 -x = ~(x - 1) bithack。
  • @PeterCordes 谢谢!我添加了一些关于pabsd 的信息。使用 AVX2,组装确实要好得多。
  • 太糟糕了 LLVM 即使在展开时仍然使用索引寻址模式,因此该指令在 Intel CPU 上需要 2 个融合域 uop。 :/Micro fusion and addressing modes。不过,它可能不会在前端成为瓶颈,即使 L1d 缓存中的数据很热,vpabsd [mem] + vpaddd 在 Haswell/Skylake 上总共只有 3 个融合域微指令。 (并且管道是 4 宽,因此有空间用于循环开销。)
  • 感谢您的精彩回答!我从中学到了很多东西-感谢您提供与 Godbolt 的链接!使用它作为 repl,我可以通过使 if 主体更复杂一些来摆脱条件移动(并展示我正在寻找的分支预测失败惩罚)。例如,将total += nums[i] 转换为更大的东西,例如total += nums[i]*(nums[i]-1)。我想这在技术上仍然可以通过条件移动来完成,但是优化器的启发式只是决定分支在这里是更好的方法?
猜你喜欢
  • 2019-10-18
  • 1970-01-01
  • 1970-01-01
  • 2021-02-27
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-10-11
  • 2019-12-28
相关资源
最近更新 更多