Rust译文:02-代码内联运行变慢iCache原因
1. 什么是iCache
CPU和主存之间也存在多级高速缓存,一般分为3级,分别是L1, L2和L3. 另外,我们的代码都是由2部分组成:指令和数据.L1 Cache比较特殊,每个CPU会有2个L1 Cache.分别为指令高速缓存(Instruction Cache,简称iCache)和数据高速缓存(Data Cache,简称dCache). L2和L3一般不区分指令和数据,可以同时缓存指令和数据.下图举例一个只有L1 Cache的系统.我们可以看到每个CPU都有自己私有的L1 iCache和L1 dCache.
2. 前言
这是上一篇关于 Rust 中 #inline 的文章的后续文章. 这篇文章比较笼统,也比较啰嗦. 读者,当心! 在讨论内联编译优化时,几乎总是提到以下内容:
内联也会使代码变慢,因为内联会增加代码的大小,使指令缓存变大并导致缓存未命中
我自己已经多次看到以上情形以各种形式重复出现.我还看到了很多基准测试,其中明智地删除内联注释确实提高了性能. 然而,我从来没有看到性能改进能够追溯到 iCache.至少对我来说,这个解释似乎没有根据. 人们认为 iCache 是慢benchmark罪魁祸首,因为其他人这么说,而不是因为这里有一个benchmark大家都认可以证明iCache是罪魁祸首.
这并不意味着 iCache这个答案就是错误的, 只是我个人没有证据证明它比任何其他解释更好. 无论如何,我决定看一个我知道 inline
会导致可观察到的运行慢的特定案例,并并高清楚到底发生了什么. 请注意,这里的目标不是解释 inline
对现实世界的影响,benchmark是人为的.
- 首先,我们的目标是更多地了解用于解释结果的工具.
- 第二个目标是在实践中观察 iCache 的效果,或者为为什么删除内联可以加快编译速度提供替代假设.
3. Benchmark
Benchmark测试基于我的 once_cell Rust 库. 该库提供了一种原语,类似于双重检查锁定.
有一个看起来像这样的函数:
fn get_or_try_init<F, E>(&self, f: F) -> Result<&T, E>
where
F: FnOnce() -> Result<T, E>,
{
if let Some(value) = self.get() {
// Fast path.
return Ok(value);
}
// Slow path.
self.0.initialize(f)?;
Ok(unsafe { self.get_unchecked() })
}
我知道当 initialize 函数没有内联时,性能会显着提高. 事实确实如此(这就是为什么benchmark是综合的,现实世界的例子是关于我们不知道是否需要内联的情况).
但目前还不清楚为什么内联初始化会导致代码变慢.
对于实验,我编写了一个简单的高级benchmark测试,在循环中调用 get_or_try_init
:
const N_LOOPS: usize = 8;
static CELL: OnceCell<usize> = OnceCell::new();
fn main() {
for i in 0..N_LOOPS {
go(i)
}
}
fn go(i: usize) {
for _ in 0..100_000_000 {
let &value = CELL.get_or_init(|| i);
assert!(value < N_LOOPS);
}
}
我还添加了编译时切换来强制或禁止内联:
#[cfg_attr(feature = "inline_always", inline(always))]
#[cfg_attr(feature = "inline_never", inline(never))]
fn initialize() { ... }
您可以在此提交中看到完整的基准测试
运行这两个版本表明 inline(never)
确实要快得多:
$ cargo run -q --example bench --release --features inline_always
330ms
$ cargo run -q --example bench --release --features inline_never
259ms
请注意,我们在这里不使用花哨的统计数据. /usr/bin/time 足以用肉眼看到差异,尽管我们正在寻找的效果非常低级. 因此,一般提示:如果您正在对相对差异(而不是绝对性能)进行基准测试,请不要费心测量纳秒级精度的时间. 取而代之的是,循环基准足以使人类可察觉的变化.
我们如何解释这种差异? 第一步是从等式中删除货物并制作两个二进制文件进行比较:
$ cargo build --example bench --release --features inline_never
$ cp ./target/release/examples/bench never
$ cargo build --example bench --release --features inline_always
$ cp ./target/release/examples/bench always
在 Linux 上,快速访问任何程序性能的最佳工具是 perf stat. 它运行程序并显示一堆 CPU 级别的性能计数器,这可能会解释发生了什么. 由于我们怀疑 iCache 可能是罪魁祸首,让我们包括缓存的计数器:
$ perf stat -e instructions,cycles,\
L1-dcache-loads,L1-dcache-load-misses,L1-dcache-prefetches,\
L1-icache-loads,L1-icache-load-misses,cache-misses \
./always
348ms
6,396,754,995 instructions:u
1,601,314,994 cycles:u
1,600,621,170 L1-dcache-loads:u
4,806 L1-dcache-load-misses:u
4,402 L1-dcache-prefetches:u
69,594 L1-icache-loads:u
461 L1-icache-load-misses:u
1,928 cache-misses:u
$ perf stat -e instructions,cycles,\
L1-dcache-loads,L1-dcache-load-misses,L1-dcache-prefetches,\
L1-icache-loads,L1-icache-load-misses,cache-misses \
./never
261ms
Performance counter stats for './never':
5,597,215,493 instructions:u
1,199,960,402 cycles:u
1,599,404,303 L1-dcache-loads:u
4,612 L1-dcache-load-misses:u
4,290 L1-dcache-prefetches:u
62,268 L1-icache-loads:u
603 L1-icache-load-misses:u
1,675 cache-misses:u
4. 细节
L1-icache-load-misses 有一些差异,但指令也有惊人的差异. 更重要的是,L1-icache-load-misses 的差异很难估计,因为不清楚 L1-icache-loads 是什么. 作为健全性检查,dcache 的统计数据与我们预期的一样. 虽然 perf 从 CPU 获取真实数据,但另一种方法是在模拟环境中运行程序.
这就是 cachegrind 工具所做的. 有趣的事实:cachegrind 的主要作者是@nnethercote,我们在上一篇文章中看到了他的 Rust Performance Book. 让我们看看 cachegrind 对基准测试的看法.
$ valgrind --tool=cachegrind ./always
10s
I refs: 6,400,577,147
I1 misses: 1,560
LLi misses: 1,524
I1 miss rate: 0.00%
LLi miss rate: 0.00%
D refs: 1,600,196,336
D1 misses: 5,549
LLd misses: 4,024
D1 miss rate: 0.0%
LLd miss rate: 0.0%
LL refs: 7,109
LL misses: 5,548
LL miss rate: 0.0%
$ valgrind --tool=cachegrind ./never
9s
I refs: 5,600,577,226
I1 misses: 1,572
LLi misses: 1,529
I1 miss rate: 0.00%
LLi miss rate: 0.00%
D refs: 1,600,196,330
D1 misses: 5,553
LLd misses: 4,024
D1 miss rate: 0.0%
LLd miss rate: 0.0%
LL refs: 7,125
LL misses: 5,553
LL miss rate: 0.0%
请注意,由于 cachegrind 模拟程序,因此运行速度要慢得多. 在这里,我们没有看到 iCache 未命中(I1 — 一级指令缓存,LLi — 末级指令缓存)的大差异. 我们确实看到了 iCache 引用的不同. 请注意,CPU 引用 iCache 的次数应与其执行的指令数相对应. 用 perf 交叉检查数量,我们看到 perf 和 cachegrind 都同意执行的指令数量. 他们也同意 inline_always 版本执行更少的指令.
仍然很难说 perf 的 sL1-icache-loads 是什么意思. 从名字上看,它应该与 cachegrind 的 I refs 相对应,但事实并非如此. 不管怎样,似乎有一件事需要进一步调查 —— 为什么内联会改变执行的指令数量?内联实际上并没有改变 CPU 运行的代码,所以指令的数量应该保持不变. 那我们来看看asm吧!这里正确的工具是cargo-asm. 同样,这是我们将锁定的函数:
fn go(tid: usize) {
for _ in 0..100_000_000 {
let &value = CELL.get_or_init(|| tid);
assert!(value < N_THREADS);
}
}
对 get_or_init 的调用将被内联,对 initialize 的嵌套调用将根据标志被内联. 我们先来看看 inline_never 版本:
push r14 ;
push rbx ; prologue
push rax ;
mov qword, ptr, [rsp], rdi
mov ebx, 100000001 ; loop counter
mov r14, rsp
jmp .LBB14_1
.loop:
cmp qword, ptr, [rip, +, CELL+16], 8
jae .assert_failure
.LBB14_1:
add rbx, -1
je .normal_exit
mov rax, qword, ptr, [rip, +, CELL]
cmp rax, 2
je .loop
mov rdi, r14
call once_cell::imp::OnceCell<T>::initialize
jmp .loop
.normal_exit:
add rsp, 8 ;
pop rbx ; epilogue
pop r14a ;
ret ;
.assert_failure:
lea rdi, [rip, +, .L__unnamed_12]
lea rdx, [rip, +, .L__unnamed_13]
mov esi, 35
call qword, ptr, [rip, +, core::panicking::panic@GOTPCREL]
ud2
然后在 inline_always 版本:
push rbp ;
push r15 ;
push r14 ;
push r13 ; prologue
push r12 ;
push rbx ;
sub rsp, 24
mov r12, rdi
xor ebx, ebx
mov r13d, 1
lea r14, [rip, +, CELL]
mov rbp, qword, ptr, [rip, +, WaiterQueue::drop@GOTPCREL]
mov r15, qword, ptr, [rip, +, once_cell::imp::wait@GOTPCREL]
jmp .LBB10_1
.LBB10_10:
mov qword, ptr, [rsp, +, 8], r14
mov qword, ptr, [rip, +, CELL+8], 1
mov qword, ptr, [rip, +, CELL+16], r12
mov qword, ptr, [rsp, +, 16], 2
lea rdi, [rsp, +, 8]
call rbp
.loop:
add rbx, 1
cmp qword, ptr, [rip, +, CELL+16], 8
jae .assert_failure
.LBB10_1:
cmp rbx, 100000000
je .normal_exit
mov rax, qword, ptr, [rip, +, CELL]
cmp rax, 2
je .loop
.LBB10_3:
mov rax, qword, ptr, [rip, +, CELL]
.LBB10_4:
test rax, rax
jne .LBB10_5
xor eax, eax
lock cmpxchg, qword, ptr, [rip, +, CELL], r13
jne .LBB10_4
jmp .LBB10_10
.LBB10_5:
cmp rax, 2
je .loop
mov ecx, eax
and ecx, 3
cmp ecx, 1
jne .LBB10_8
mov rdi, r14
mov rsi, rax
call r15
jmp .LBB10_3
.normal_exit:
add rsp, 24 ;
pop rbx ;
pop r12 ;
pop r13 ; epilogue
pop r14 ;
pop r15 ;
pop rbp ;
ret
.assert_failure:
lea rdi, [rip, +, .L__unnamed_9]
lea rdx, [rip, +, .L__unnamed_10]
mov esi, 35
call qword, ptr, [rip, +, core::panicking::panic@GOTPCREL]
ud2
.LBB10_8:
lea rdi, [rip, +, .L__unnamed_11]
lea rdx, [rip, +, .L__unnamed_12]
mov esi, 57
call qword, ptr, [rip, +, core::panicking::panic@GOTPCREL]
ud2
我稍微编辑了代码,并突出显示了构成基准测试的大部分的热循环. 查看程序集,我们可以看到以下内容:代码要大得多 — 发生了内联! 函数序言更大,编译器将更多被调用者保存的寄存器压入堆栈 函数尾声更大,编译器需要恢复更多寄存器堆栈帧更大 编译器将一些初始化代码提升到循环之前 两种情况下的核心循环都非常紧凑, 只有少数指令核心循环向上计数而不是向下计数,添加额外的 cmp 指令请注意,iCache 不太可能影响正在运行的代码,因为它是内存中彼此相邻的一小部分指令.
另一方面,具有大立即数的额外 cmp 正好说明了我们观察到的额外指令数量(循环运行了 800_000_000 次).
5. 结论
想出一个基准来证明两种替代方案之间的差异已经够难的了. 更难解释差异 — 可能有很多现成的解释,但它们不一定是真的. 尽管如此,今天我们拥有大量有用的工具. 两个值得注意的例子是 perf 和 valgrind. 工具并不总是正确的 — 根据对问题的常识性理解,对不同的工具进行理智的检查是一个好主意.
试考虑内联技术将函数S内联展开于函数C中:
- 内联使得C占用了更多的寄存器.由于函数S的代码直接在函数C的函数体中展开,造成函数C在程序上下文切换过程中加入了更多的push/pop指令,并且函数C的运行时栈的空间进一步膨胀.与内联版本中每次调用函数C都意味着这些新增的push/pop指令都会运行不同,未内联版本的push/pop指令只存在于函数S的上下文中,并且只有当函数C确实调用函数S时,这些指令才会被运行;
- 基于第一点的基本认识,现在设想函数S在流程控制语句中被调用(循环或条件分支等),编译器可能会提升函数S中的某些指令到条件分支之外,造成这些指令从冷路径变为热路径(冷热路径:因为条件分支可能不会执行,但是位于条件分支之外的代码总会执行,是为热路径);
- 在上述场景中,随着外层函数C的栈中局部变量和流程控制语句增多,编译器的优化反而使得热路径执行效率降低.