如何防止 Rust 基准库优化我的代码?
How can I prevent the Rust benchmark library from optimizing away my code?
我有一个简单的想法,我正在尝试在 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)
正如您在要点中看到的,我已尝试采用建议 set forth in the documentation,即:
- 使用
test::black_box
方法向编译器隐藏实现细节。
- 从传递给
iter
方法的闭包返回计算值。
我可以尝试其他技巧吗?
您的基准测试的问题是优化器知道您的 CompoundValue 在基准测试期间将是不可变的,因此它可以加强-减少循环,从而将其编译为常量值。
解决方案是对 CompoundValue 的部分使用 test::black_box。或者甚至更好,尝试摆脱循环(除非你想对循环性能进行基准测试),让 Bencher.iter(..) 完成它的工作。
这里的问题是编译器可以看到每次 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 0000, %edx
.LBB0_2:
movq [=10=], 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 , %rsp
popq %rbx
popq %r14
retq
.LBB0_2:
和 jne .LBB0_2
之间的部分是对 iter
的调用编译的结果,它重复 运行 您传递给的闭包中的代码它。 #APP
#NO_APP
对是 black_box
调用。您可以看到 iter
循环并没有做太多: movq
只是从寄存器 to/from 其他寄存器和堆栈移动数据,并且 addq
/decq
只是增加和减少一些整数。
在那个循环上方看,有 movl 0000, %edx
:这是将常量 700_000
加载到 edx 寄存器中……而且,可疑的是 700000 = ITEARATIONS * (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
});
}
特别是,val
在闭包中 black_box
,因此编译器无法预先计算加法并在每次调用时重新使用它。
但是,这仍然优化得非常快:1 ns/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 0000, %rdi, %rsi
movq %rsi, 56(%rsp)
#APP
#NO_APP
decq %rax
jne .LBB0_2
现在编译器发现 val
在 for
循环的过程中没有改变,因此它正确地将循环转换为仅对 [=17= 的所有元素求和](这是 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 0000, %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
),运行 内循环,然后 black_box
ing f
(第二个 APP
/NO_APP
).
(准确地对您想要的基准进行基准测试可能很棘手!)
我有一个简单的想法,我正在尝试在 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)
正如您在要点中看到的,我已尝试采用建议 set forth in the documentation,即:
- 使用
test::black_box
方法向编译器隐藏实现细节。 - 从传递给
iter
方法的闭包返回计算值。
我可以尝试其他技巧吗?
您的基准测试的问题是优化器知道您的 CompoundValue 在基准测试期间将是不可变的,因此它可以加强-减少循环,从而将其编译为常量值。
解决方案是对 CompoundValue 的部分使用 test::black_box。或者甚至更好,尝试摆脱循环(除非你想对循环性能进行基准测试),让 Bencher.iter(..) 完成它的工作。
这里的问题是编译器可以看到每次 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 0000, %edx
.LBB0_2:
movq [=10=], 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 , %rsp
popq %rbx
popq %r14
retq
.LBB0_2:
和 jne .LBB0_2
之间的部分是对 iter
的调用编译的结果,它重复 运行 您传递给的闭包中的代码它。 #APP
#NO_APP
对是 black_box
调用。您可以看到 iter
循环并没有做太多: movq
只是从寄存器 to/from 其他寄存器和堆栈移动数据,并且 addq
/decq
只是增加和减少一些整数。
在那个循环上方看,有 movl 0000, %edx
:这是将常量 700_000
加载到 edx 寄存器中……而且,可疑的是 700000 = ITEARATIONS * (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
});
}
特别是,val
在闭包中 black_box
,因此编译器无法预先计算加法并在每次调用时重新使用它。
但是,这仍然优化得非常快:1 ns/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 0000, %rdi, %rsi
movq %rsi, 56(%rsp)
#APP
#NO_APP
decq %rax
jne .LBB0_2
现在编译器发现 val
在 for
循环的过程中没有改变,因此它正确地将循环转换为仅对 [=17= 的所有元素求和](这是 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 0000, %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
),运行 内循环,然后 black_box
ing f
(第二个 APP
/NO_APP
).
(准确地对您想要的基准进行基准测试可能很棘手!)