【译文】性能轮盘赌:代码对齐的运气
导言
好天气是特定的天气。结论:没有所谓的好天气。
飞行员谚语
提要:同样的机器代码,放在不同的地址,性能可能大相径庭。
作为软件开发人员,我们常常认为特定代码的性能完全由代码本身及其运行的硬件决定。这种假设让我们在优化代码以提高性能时有一种可控感。虽然这种假设在大多数情况下是正确的,但本文旨在探讨挑战这种控制概念的现象。此外,我还将提供一个沙盒,使用 Rust 编程语言来演示这一现象。
为什么要关注?
对齐代码会带来显著的性能差异,差异范围从 5% 到 20%(极端情况下甚至高达 30%)不等。令人惊讶的是,这些差异可能并不总是直接归因于代码中的更改。关键是要谨慎行事并验证结果,以避免陷入错误优化和所谓 “性能波动 “的陷阱。我将演示如何轻松确定是否存在这种情况。
我遇到过一些奇特的情况,只需改变源代码中函数定义的顺序,性能就会提高 10%。然而,在对源代码进行其他看似无关的更改后,这种性能提升往往会消失。
实验设置
让硬件开始工作吧,好吗?我们将从简单的迭代阶乘计算开始:
fn factorial(mut n: u64) -> u64 {
let mut m = 1u64;
while n > 1 {
m = m.saturating_mul(n);
n -= 1;
}
m
}
那么我们需要一个宏,在编译时产生一定数量的 nop
指令。
#[proc_macro]
pub fn make_asm_nops(_item: TokenStream) -> TokenStream {
let nop_count: usize = read_nop_count();
let nops = std::iter::repeat(r#""nop""#)
.take(nop_count)
.collect::<Vec<_>>()
.join(", ");
let code = format!(
"#[inline(always)] unsafe fn __asm_nops() {{ std::arch::asm! {{ {} }} }}",
nops
);
code.parse().unwrap()
}
fn read_nop_count() -> usize {
option_env!("NOP_COUNT").unwrap_or("1").parse().unwrap()
}
如果编译时使用 NOP_COUNT=3
,将产生
std::arch::asm! { "nop", "nop", "nop" }
我们将在factorial()
函数的循环中使用这个宏。这样我们就可以
- 精确控制循环长度
- 将后续函数对齐到不同的 16 字节边界(x86 上函数的默认对齐方式)。
fn factorial(mut n: u64) -> u64 {
let mut m = 1u64;
while n > 1 {
m = m.saturating_mul(n);
n -= 1;
unsafe {
__asm_nops();
}
}
m
}
现在,我们需要一种方法来生成 10 个相同的阶乘函数。为了完成这项工作,我们需要采取一些技巧,但最终,我们得到了一个包含 10 个相同函数副本的可执行文件:
$ nm target/release/same-code-different-performance | grep factorial | rustfilt
0000000000009b00 t same_code_different_performance::factorial_1
0000000000009b50 t same_code_different_performance::factorial_2
0000000000009ba0 t same_code_different_performance::factorial_3
0000000000009bf0 t same_code_different_performance::factorial_4
0000000000009c40 t same_code_different_performance::factorial_5
0000000000009c90 t same_code_different_performance::factorial_6
0000000000009ce0 t same_code_different_performance::factorial_7
0000000000009d30 t same_code_different_performance::factorial_8
0000000000009d80 t same_code_different_performance::factorial_9
0000000000009dd0 t same_code_different_performance::factorial_10
请注意,所有奇数函数的地址都是偶数。这一细节稍后会变得很重要。
然后,我编写了一个简单函数,用于测量这 10 个重复函数的性能,并输出最慢函数和最快函数之间的差值。
理论上,这些函数的性能应该没有差别。毕竟,它们是完全相同的,只有函数地址不同。
实验数据
为了衡量性能,我测量了 10 个副本中最慢和最快函数之间的性能差距(max-min)。以下结果来自配备至强 E5 2651
处理器(Ivy Bridge,基本上是 Sandy Bridge 的缩减版)的 AWS c1.medium
实例。
$ ./run
NOP_COUNT=1 max-min difference = 4
NOP_COUNT=2 max-min difference = 3
NOP_COUNT=3 max-min difference = 2
NOP_COUNT=4 max-min difference = 3
NOP_COUNT=5 max-min difference = 4
NOP_COUNT=6 max-min difference = 2
NOP_COUNT=7 max-min difference = 2
NOP_COUNT=8 max-min difference = 3
NOP_COUNT=9 max-min difference = 2
NOP_COUNT=10 max-min difference = 6
NOP_COUNT=11 max-min difference = 3
NOP_COUNT=12 max-min difference = 4
NOP_COUNT=13 max-min difference = 3
NOP_COUNT=14 max-min difference = 59
NOP_COUNT=15 max-min difference = 59
NOP_COUNT=16 max-min difference = 39
NOP_COUNT=17 max-min difference = 31
NOP_COUNT=18 max-min difference = 57
NOP_COUNT=19 max-min difference = 57
NOP_COUNT=20 max-min difference = 39
NOP_COUNT=21 max-min difference = 30
NOP_COUNT=22 max-min difference = 56
NOP_COUNT=23 max-min difference = 57
NOP_COUNT=24 max-min difference = 45
NOP_COUNT=25 max-min difference = 45
NOP_COUNT=26 max-min difference = 46
NOP_COUNT=27 max-min difference = 46
NOP_COUNT=28 max-min difference = 52
NOP_COUNT=29 max-min difference = 1
NOP_COUNT=30 max-min difference = 2
NOP_COUNT=31 max-min difference = 2
NOP_COUNT=32 max-min difference = 3
NOP_COUNT=33 max-min difference = 2
NOP_COUNT=34 max-min difference = 3
NOP_COUNT=35 max-min difference = 3
NOP_COUNT=36 max-min difference = 3
NOP_COUNT=37 max-min difference = 3
NOP_COUNT=38 max-min difference = 3
NOP_COUNT=39 max-min difference = 3
NOP_COUNT=40 max-min difference = 3
NOP_COUNT=41 max-min difference = 3
NOP_COUNT=42 max-min difference = 3
NOP_COUNT=43 max-min difference = 3
NOP_COUNT=44 max-min difference = 43
NOP_COUNT=45 max-min difference = 47
NOP_COUNT=46 max-min difference = 46
NOP_COUNT=47 max-min difference = 46
NOP_COUNT=48 max-min difference = 46
NOP_COUNT=49 max-min difference = 46
NOP_COUNT=50 max-min difference = 46
这些结果表明,差异通常在 5ns 以下。不过,在 NOP 计数为 14-28 和 44-50 时,每次调用最慢函数和最快函数之间存在 30-50ns 的差异。
现在,让我们深入研究 NOP_COUNT=14
的特定值下各个函数之间的差异。
$ NOP_COUNT=14 cargo run --release 2>&1 | sort
factorial_1 = 356
factorial_2 = 302
factorial_3 = 354
factorial_4 = 297
factorial_5 = 356
factorial_6 = 302
factorial_7 = 354
factorial_8 = 297
factorial_9 = 356
factorial_10 = 302
从根本上说,所有偶函数都很快,而所有奇函数都很慢。这些结果具有显著的稳定性和可重复性,即使使用准则也是如此:
$ NOP_COUNT=14 cargo run --release --features=criterion -- --bench
factorials/1 time: [351.05 ns 351.14 ns 351.23 ns]
factorials/2 time: [295.58 ns 295.69 ns 295.92 ns]
factorials/3 time: [348.73 ns 348.80 ns 348.88 ns]
factorials/4 time: [295.61 ns 295.66 ns 295.71 ns]
factorials/5 time: [350.39 ns 350.44 ns 350.51 ns]
factorials/6 time: [295.16 ns 295.25 ns 295.38 ns]
factorials/7 time: [348.79 ns 348.85 ns 348.91 ns]
factorials/8 time: [295.70 ns 295.79 ns 295.92 ns]
factorials/9 time: [351.01 ns 351.07 ns 351.14 ns]
factorials/10 time: [295.17 ns 295.22 ns 295.26 ns]
为什么会出现这种情况?
简而言之,编译器的机器代码往往缺乏微操作缓存的最佳对齐方式,从而导致一种被称为 DSB thrashing 的现象。
在此,我不会深入探讨英特尔微体系结构。相反,我会尽可能简单地解释这个问题。如果您有兴趣进一步了解英特尔 CPU 解码流水线,我建议您进一步阅读。
CPU 可分为两部分:前端和后端。后端是真正工作的地方。前端的主要职责是对指令流进行解码,并保持稳定的微操作流(μops),以确保后端得到充分利用。如果没有功能强大的前端,即使是最强大的无序执行 CPU 也会产生不理想的性能。为此,英特尔主流 CPU 依赖于三个组件:MITE、LSD 和 DSB。
MITE 充当指令解码器,而 LSD 和 DSB 则共同充当 μop 缓存。它们将单条指令的解码结果存储为微操作。当 LSD/DSB 传递一个 μop 时,解码器引擎 (MITE) 无需再次获取和解码指令。这种机制对于频繁使用的代码段(如热循环和频繁调用的函数)特别有效。
LSD 和 DSB 在可存储的 μops 数量和类型以及指令对齐方面都有限制。不过,对于 Sandy Bridge CPU 来说,有一个关键的限制:每 32 字节对齐的指令窗口只能缓存 18 个 μop 。让我们检查一下factorial_1
和factorial_2
函数的机器代码,以了解它是如何影响代码的。
图中,绿色指令代表热循环(hot loop),红线表示 32 字节边界。很明显,在factorial_1
的情况下,整个热循环都在一个 32 字节的窗口内,因此在 Sandy Bridge 上只能使用 18 个缓存 μOP。相反,factorial_2
的热循环跨越了 32 字节窗口边界,避免了这种限制。默认情况下,编译器会尝试将每个函数对齐到 16 字节,结果两个函数都跨越了 5 个 32 字节窗口。因此,factorial_3
和factorial_4
的情况会重复出现。
如何确定对齐是否是问题所在?
要确定对齐是否导致性能差异,可以指示 Rust 编译器将函数和代码块对齐到特定地址边界。
使用 LLVM 标志 -align-all-functions=6
和 -align-all-nofallthru-blocks=6
编译基准。这些标志可确保所有函数和代码块都对齐到 64 字节边界 (26)11。如果对齐确实是原因,那么任何性能差异都会消失。
让我们看看是否有帮助:
$ RUSTFLAGS="-C llvm-args=-align-all-functions=6 -C llvm-args=-align-all-nofallthru-blocks=6" NOP_COUNT=14 cargo run --release
factorial_1 = 356
factorial_2 = 356
factorial_3 = 356
factorial_4 = 356
factorial_5 = 356
factorial_6 = 356
factorial_7 = 356
factorial_8 = 355
factorial_9 = 356
factorial_10 = 356
是的,差异是解决了,但现在所有函数的速度都变慢了,因为所有热循环都以 32 字节边界对齐。
另外,如果您可以访问平台上的硬件性能计数器,则可以分析两个变体3 的 DSB_MISS_PS 和 DSB2MITE_SWITCHES.PENALTY_CYCLES 计数器的值。
是否应始终对齐以获得更好的性能?
一般来说,不应该。不能保证对齐代码会带来更好的性能。事实上,在许多情况下,在更大的边界上对齐代码会使代码变得更大更慢,如本文所示。
英特尔优化手册中有一些建议:
在执行解码 ICache 中的代码时,大部分直接分支的所有指令字节都应位于 64B 缓存行中,并靠近缓存行的末端。其目标应位于或靠近 64B 缓存行的开头。
在执行传统解码流水线的代码时,大部分被执行的直接分支的所有指令字节应位于 16B 对齐的内存块中,并靠近该 16B 对齐内存块的末端。其目标应位于或接近 16B 对齐内存块的开头。
不过,这些建议的指导意义有限。
在 CI 中,我是否应该始终对齐以获得一致的结果?
我没有在 CI 中使用这些标志的经验,所以请慎重对待我的话。
严格来说,对齐应能提供更一致的性能,但前提是底层代码相同。如果两个基准函数不同,编译器可能会根据所涉及的单个指令的数量和长度为每个函数生成不同的布局。我不清楚改变对齐要求是否会带来更公平的比较。
因此,我不会在默认情况下使用对齐标志,只是为了仔细检查看似相同的函数是否因为对齐问题而在性能上存在差异。
如何解决这类问题?
遗憾的是,我没有直接的答案。即使是被视为系统级的语言,也只是提供了控制这种行为的基本手段。即使有,也改变不了什么。在这种情况下,优化目标不是平台(如 x86),而是微体系结构(如 Alder Lake),我相信这对大多数软件来说都太麻烦了。
问题仅限于 x86 吗?
我在 M3 Max 芯片上遇到过一些情况,有可能是微操作缓存效率低下造成的。不过,我不能断言这是根本原因。我不知道在 macOS/aarch64 平台上是否有任何替代 μops 性能计数器的方法来证实这一假设。
还有证据表明,ARM Cortex A53/A57 微体系结构很容易因代码放置14 而出现此类性能不稳定问题。
结论
代码对齐会对软件性能产生重大影响,在英特尔芯片上影响可能高达 20%。但是,作为软件开发人员,我们无法完全控制它。即使我们能够控制,微体系结构的广泛多样性也使得这类优化变得不切实际。
不过,意识到这一因素至少能让我们避免不必要的过度优化和追逐性能鬼魂。基于 LLVM 的 Rust 编译器有一些重要选项,允许我们检查性能波动是否由对齐问题引起。
感谢 Jon Gjengset 和 Xuanwo 对本文的反馈。
- you can easily verify this using
objdump --disassemble-symbols=[FN] [BIN]
↩︎ - although results are reproducible with criterion, it might require different value of
NOP_COUNT
. This is because changing dependencies as well as code will change binary layout hence changing the number of nops required to reproduce the issue. ↩︎ - Code alignment issues ↩︎ ↩︎
- Causes of Performance Instability due to Code Placement in x86 ↩︎ ↩︎
- The microarchitecture of Intel, AMD, and VIA CPUs. Sections 11.2-11.4 ↩︎
- https://en.wikichip.org/wiki/intel/microarchitectures/sandy_bridge_(client)#Decoding ↩︎
- Macro Instruction Translation Engine ↩︎
- Loop Stream Detector or Loopback Buffer ↩︎
- Decoded ICache or Decoded Stream Buffer ↩︎
- There are materials describing quite precise rules on how DSB caching works in the Sandy Bridge–Sky Lake microarchitecture span. DSB is structured into 32 sets of 8 ways, with each way capable of storing up to 6 μops, totaling 1536 μops in all. But I’m unaware of any information regarding Ice Lake and later microarchitectures. ↩︎
- https://easyperf.net/blog/2018/01/25/Code_alignment_options_in_llvm ↩︎
- in this case aligned code is slower because I’m intentionally using single byte nops to generate a lot of μops in a loop. If you use multibyte nops the effect goes away. ↩︎
- see. Section 3.4.2.5: Optimization for Decoded ICache ↩︎
- Towards ameliorating measurement bias in evaluating performance of generated code ↩︎
本文文字及图片出自 Performance Roulette: The Luck of Code Alignment
你也许感兴趣的:
- 【译文】C 和 C++ 优先考虑性能而非正确性
- 时隔半年,Linux性能重新超越Windows 11
- 各大主流编程语言性能PK,结果出乎意料
- 过度使用懒加载对 Web 性能的影响
- 2017年的golang、python、php、c++、c、java、Nodejs性能对比
- 十条命令在一分钟内检查 Linux 服务器性能
- 关于系统性能优化的十个建议
- 陈皓:性能测试应该怎么做?
- 大话程序猿眼里的高并发
- 将 Web 应用性能提高十倍的10条建议
你对本文的反应是: