如何防止 Rust 基准测试库优化掉我的代码?

4

我有一个简单的想法,想在Rust中进行基准测试。但是,当我使用test::Bencher来测量时,我要比较的基准情况是:

#![feature(test)]
extern crate test;

#[cfg(test)]
mod tests {

    use test::black_box;
    use test::Bencher;

    const ITERATIONS: usize = 100_000;

    struct CompoundValue {
        pub a: u64,
        pub b: u64,
        pub c: u64,
        pub d: u64,
        pub e: u64,
    }

    #[bench]
    fn bench_in_place(b: &mut Bencher) {
        let mut compound_value = CompoundValue {
            a: 0,
            b: 2,
            c: 0,
            d: 5,
            e: 0,
        };

        let val: &mut CompoundValue = &mut compound_value;

        let result = b.iter(|| {
            let mut f : u64 = black_box(0);
            for _ in 0..ITERATIONS {
                f += val.a + val.b + val.c + val.d + val.e;
            }
            f = black_box(f);
            return f;
        });
        assert_eq!((), result);
    }
}

编译器会进行优化,使得此部分内容被完全省略,结果为:
running 1 test
test tests::bench_in_place ... bench:           0 ns/iter (+/- 1)

如您在代码片段中所见,我已尝试采用文档中提出的建议进行编码, 具体包括:

  • 使用test::black_box方法来隐藏实现细节,以防止编译器优化。
  • 从传递给iter方法的闭包中返回计算的值。

还有其他我可以尝试的技巧吗?

2个回答

4
这里的问题在于编译器能够看到循环的结果每次iter调用闭包时都相同(只需将某个常量加到f中),因为val从未改变。
查看汇编代码(通过向编译器传递--emit asm参数)可以证明这一点:
_ZN5tests14bench_in_place20h6a2d53fa00d7c649yaaE:
    ; ...
    movq    %rdi, %r14
    leaq    40(%rsp), %rdi
    callq   _ZN3sys4time5inner10SteadyTime3now20had09d1fa7ded8f25mjwE@PLT
    movq    (%r14), %rax
    testq   %rax, %rax
    je  .LBB0_3
    leaq    24(%rsp), %rcx
    movl    $700000, %edx
.LBB0_2:
    movq    $0, 24(%rsp)
    #APP
    #NO_APP
    movq    24(%rsp), %rsi
    addq    %rdx, %rsi
    movq    %rsi, 24(%rsp)
    #APP
    #NO_APP
    movq    24(%rsp), %rsi
    movq    %rsi, 24(%rsp)
    #APP
    #NO_APP
    decq    %rax
    jne .LBB0_2
.LBB0_3:
    leaq    24(%rsp), %rbx
    movq    %rbx, %rdi
    callq   _ZN3sys4time5inner10SteadyTime3now20had09d1fa7ded8f25mjwE@PLT
    leaq    8(%rsp), %rdi
    leaq    40(%rsp), %rdx
    movq    %rbx, %rsi
    callq   _ZN3sys4time5inner30_$RF$$u27$a$u20$SteadyTime.Sub3sub20h940fd3596b83a3c25kwE@PLT
    movups  8(%rsp), %xmm0
    movups  %xmm0, 8(%r14)
    addq    $56, %rsp
    popq    %rbx
    popq    %r14
    retq
.LBB0_2:jne .LBB0_2之间的部分是对iter的调用编译成的代码,它会反复运行您传递给它的闭包中的代码。 #APP #NO_APP对是black_box调用。 您可以看到iter循环并没有做太多事情:movq只是在寄存器之间以及堆栈之间移动数据,而addq/decq只是在一些整数上进行加法和减法。

在循环上面看到了movl $700000, %edx:这将常量700_000加载到edx寄存器中... 令人怀疑的是,700000 = ITERATIONS * (0 + 2 + 0 + 5 + 0)。(代码中的其他内容并不那么有趣。)

掩盖这一点的方法是black_box输入,例如,我可以像这样编写基准:

#[bench]
fn bench_in_place(b: &mut Bencher) {
    let mut compound_value = CompoundValue {
        a: 0,
        b: 2,
        c: 0,
        d: 5,
        e: 0,
    };

    b.iter(|| {
        let mut f : u64 = 0;
        let val = black_box(&mut compound_value);
        for _ in 0..ITERATIONS {
            f += val.a + val.b + val.c + val.d + val.e;
        }
        f
    });
}

特别的,在闭包内,valblack_box 了,这样编译器就不能为每个调用预先计算加法并重复使用了。

然而,这仍然被优化得非常快:对我来说是1ns/iter。 再次检查汇编代码揭示了问题(我已经将汇编代码缩减到仅包含 APP/NO_APP 对的循环中,即对 iter 的闭包的调用):

.LBB0_2:
    movq    %rcx, 56(%rsp)
    #APP
    #NO_APP
    movq    56(%rsp), %rsi
    movq    8(%rsi), %rdi
    addq    (%rsi), %rdi
    addq    16(%rsi), %rdi
    addq    24(%rsi), %rdi
    addq    32(%rsi), %rdi
    imulq   $100000, %rdi, %rsi
    movq    %rsi, 56(%rsp)
    #APP
    #NO_APP
    decq    %rax
    jne .LBB0_2

现在编译器已经看到val在整个for循环中没有改变,因此它正确地将循环转换为仅对val的所有元素求和(这是4个addq序列),然后将其乘以ITERATIONS(即imulq)。
要解决这个问题,我们可以做同样的事情:将black_box深入移动,使得编译器无法推断出在循环的不同迭代之间的值。
#[bench]
fn bench_in_place(b: &mut Bencher) {
    let mut compound_value = CompoundValue {
        a: 0,
        b: 2,
        c: 0,
        d: 5,
        e: 0,
    };

    b.iter(|| {
        let mut f : u64 = 0;
        for _ in 0..ITERATIONS {
            let val = black_box(&mut compound_value);
            f += val.a + val.b + val.c + val.d + val.e;
        }
        f
    });
}

这个版本现在对我来说需要137,142 ns/iter,尽管对black_box的重复调用可能会造成非常重要的开销(需要重复写入堆栈,然后再读取它)。

我们可以查看asm,以确保:

.LBB0_2:
    movl    $100000, %ebx
    xorl    %edi, %edi
    .align  16, 0x90
.LBB0_3:
    movq    %rdx, 56(%rsp)
    #APP
    #NO_APP
    movq    56(%rsp), %rax
    addq    (%rax), %rdi
    addq    8(%rax), %rdi
    addq    16(%rax), %rdi
    addq    24(%rax), %rdi
    addq    32(%rax), %rdi
    decq    %rbx
    jne .LBB0_3
    incq    %rcx
    movq    %rdi, 56(%rsp)
    #APP
    #NO_APP
    cmpq    %r8, %rcx
    jne .LBB0_2

现在对iter的调用是两个循环:外部循环多次调用闭包(从.LBB0_2到jne .LBB0_2),闭包内部的for循环(从.LBB0_3到jne .LBB0_3)。内部循环确实执行了对black_box的调用(APP/NO_APP),然后进行了5次加法。外部循环将f设置为零(xorl %edi, %edi),运行内部循环,然后对f进行black_box处理(第二个APP/NO_APP)。
(准确基准测试需要技巧!)

非常感谢您提供如此详尽的解释! - zslayton

1
您的基准测试存在问题,优化器知道在基准测试期间您的CompoundValue将是不可变的,因此它可以对循环进行强度降低,并将其编译为常量值。解决方法是在CompoundValue的部分使用test::black_box。或者更好的方法是尝试摆脱循环(除非您想要基准测试循环性能),让Bencher.iter(..)来完成它的工作。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接