信任你的编译器:现代C++

Hacker News Top 工具

摘要

本文对比了旧的C++性能技巧与现代编译器的能力,表明编译器现在能够将朴素代码优化得比手工调整的技巧更好。包含在AMD Zen 5上使用Clang 21的基准测试。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/07/05 13:31

# Categorica 源: https://categorica.io/blog/2026.06.29_trust_your_compiler/ ## 相信你的编译器 C++ 程序员通过耳濡目染吸收了大量性能智慧: 快速逆平方根 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#fn-1)、XOR 交换 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#fn-2)、手工展开循环 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#fn-3)、Duff's device (https://categorica.io/blog/2026.06.29_trust_your_compiler/#fn-4)、异常很慢、虚调用很慢、除法很慢、查表通常比计算快。即便这些曾经为真,但硬件、软件和编译器的进步已经极大地改变了局面,以至于许多规则如今已不再适用。John Carmack 的 DEC Alpha 和 Pentium Pro 与现代 Zen 5 x86_64 内核几乎毫无相似之处——这块芯片的分支预测器本身可能就拥有比当时 iD 办公室所有工作站加起来更多的晶体管。此外,LLVM 的 Clang 和 GNU 的 GCC 已经足够先进,能从原始输入生成最优代码。例如,两大编译器都具备优化通,基本上会检查某段代码是否实际上是伪装起来的 popcount (https://categorica.io/blog/2026.06.29_trust_your_compiler/#fn-5)。事实上,编写“巧妙”的代码可能会更“糟糕”;它可能掩盖意图,让优化器难以理解,从而可能损失矢量化、内联和特定目标下的降级处理。本文中我们将回顾几个老技巧,将它们与直观版本进行比较,并观察它们实际的表现。 以下数据产生于: - Ubuntu 24.04 LTS (使用更新后的 Linux 内核 6.18.1) - AMD Ryzen 9 9950X,16 核 / 32 线程 - 128 GB DDR5/3600 内存 - Clang 21.1.1,`-O3 -ffast-math -mtune=native` 本文附带了基准测试代码。所有基准测试均使用 Google Benchmark (https://github.com/google/benchmark)。 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#fn-6) ## 目录 1. 第一部分:新狗,老把戏 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#part1) - 快速逆平方根 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#fast-isqrt) - Popcount 和位操作 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#popcount) - 数值食谱与行指针 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#matrix) - 遍地 `const&` 与转发意图 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#const) 1. 第二部分:标准库 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#part2) - Ranges 和算法 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#ranges) - 异常 vs `std::expected` vs 错误码 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#exceptions) - 虚函数 vs 静态多态 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#virtual) 1. 如何使用基准测试 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#benchmarks) 2. 结束语 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#remarks) ## 第一部分:新狗,老把戏 ### 快速逆平方根 来自 Quake III (https://categorica.io/blog/2026.06.29_trust_your_compiler/#fn-7) 的 `Q_rsqrt`,在此重现: ``` float Q_rsqrt( float number ) { long i; float x2, y; const float threehalfs = 1.5F; x2 = number * 0.5F; y = number; i = * ( long * ) &y; i = 0x5f3759df - ( i >> 1 ); y = * ( float * ) &i; y = y * ( threehalfs - ( x2 * y * y ) ); return y; } ``` 关于其原理已有大量文章解释 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#fn-1),我们在此跳过。简而言之,90 年代末 CPU 的浮点单元与今日天差地别。在开发 Quake 3 Arena 期间,iD 的开发人员发现大量时间花在了计算新光照模型所需的顶点法线上。其中大部分时间耗费在一个操作上:逆平方根。他们的方法是放弃精度,对这个操作采用“估算加改进”的方法。它当时可以大幅击败朴素的 `1.0f / sqrtf(x)`:在 8087 FPU 上,`FSQRT` 和 `FDIV` 可能需要超过 500 个周期,而 iD 的方法使用了廉价的整数操作加上一次牛顿迭代 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#fn-8)。 #### 现代 CPU Intel 通过 SSE (https://categorica.io/blog/2026.06.29_trust_your_compiler/#fn-9) 将 `rsqrtss` 和 `rsqrtps` 引入 x86 架构;AVX 后来增加了 `vrsqrtss` 和 `vrsqrtps` 变体以支持更宽的 SIMD 宽度。这些指令直接计算近似倒数平方根,具有文档化的误差界限,且延迟远低于 x87 `fsqrt` (https://categorica.io/blog/2026.06.29_trust_your_compiler/#fn-10)。ARMv8/AArch64 拥有类似的倒数平方根估算指令 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#fn-11): | ISA | 标量 | 打包 | 宽度 | |-----------|--------------------|-----------------------|-----------| | x86-64 SSE | `rsqrtss` | `rsqrtps` | 4 x f32 | | x86-64 AVX | `vrsqrtss` | `vrsqrtps` | 8 x f32 | | ARMv8 NEON | `frsqrte` (标量) | `frsqrte` | 4 x f32 | #### 这为什么重要? 在现代 C++ 中编写这段代码可能类似于: ``` constexpr float Q_rsqrt(float number) noexcept { static_assert(sizeof(float) == sizeof(std::uint32_t)); auto i = std::bit_cast(number); auto magic = 0x5f3759dfu - (i >> 1); auto y = std::bit_cast(magic); return y * (1.5f - (number * 0.5f * y * y)); } ``` 与朴素版本对比: ``` constexpr float naive_rsqrt(float x) noexcept { return 1.0f / std::sqrt(x); } ``` 现在比较在 Compiler Explorer 上使用 `-std=c++23 -O3 -ffast-math -march=znver4` 生成的汇编代码 (https://godbolt.org/z/Yb39q5j8K) (使用了 Clang 21.1.0)。基准测试运行使用了 `-march=native`;汇编摘录使用了 `znver4` 以使目标明确。`-ffast-math` 标志很重要:它让编译器可以做出激进的、可能有损精度的浮点假设 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#fn-12)[13](https://categorica.io/blog/2026.06.29_trust_your_compiler/#fn-13),因此本示例假设输入为正数且有限,并且不保留所有严格的 IEEE 浮点边界情况。 ``` Q_rsqrt(float): movd eax, xmm0 sar eax mov ecx, 1597463007 sub ecx, eax mulss xmm0, dword ptr [rip + .LCPI0_0] movd xmm1, ecx movdqa xmm2, xmm1 mulss xmm2, xmm1 mulss xmm0, xmm2 addss xmm0, dword ptr [rip + .LCPI0_1] mulss xmm0, xmm1 ret naive_rsqrt(float): vrsqrtss xmm1, xmm0, xmm0 vmulss xmm0, xmm0, xmm1 vfmadd213ss xmm0, xmm1, dword ptr [rip + .LCPI1_0] vmulss xmm1, xmm1, dword ptr [rip + .LCPI1_1] vmulss xmm0, xmm1, xmm0 ret ``` #### 基准测试 理论上说得通,但实际是否如此? | Benchmark | 时间 (标量) | 时间 (n=1024) | 时间 (n=65536) | |-----------|-------------|----------------|-----------------| | `Q_sqrt` | 380 ns | 24.5 ns | 1865 ns | | `naive_rsqrt` | 373 ns | 25.0 ns | 2161 ns | 标量情况基本持平,直观版本甚至略微领先。在数组核中,Quake 版本在本次特定运行中稍快,但这并不足以证明该技巧应作为默认选择:其源码可读性差,有效域更窄,并且仍然只在编译器与 CPU 已尽力工作的情况下才具竞争力。此外,朴素方式提供了明确的误差界限。 #### 结论 让编译器去工作。你能获得相近的性能、更清晰的代码,以及一个表意明确的实现。 ### Popcount 和位操作 C++20 带来了 `<bit>`:`std::popcount`、`std::countl_zero`、`std::countr_zero`、`std::bit_width`、`std::has_single_bit` 等 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#fn-14)。在 x86 上,当启用了相关的目标特性时,这些函数中的许多会映射为单条指令: | 函数 | x86-64 (BMI/POPCNT) | ARMv8 | |------------------------|---------------------|---------------------------| | `std::popcount` | `popcnt` | `cnt` + `addv` | | `std::countl_zero` | `lzcnt` | `clz` | | `std::countr_zero` | `tzcnt` | `rbit` + `clz` | 对比: ``` constexpr int modern(std::uint64_t x) noexcept { return std::popcount(x); } constexpr int kernighan(std::uint64_t x) noexcept { int c = 0; while (x != 0U) { x &= x - 1U; ++c; } return c; } constexpr int swar(std::uint64_t x) noexcept { x = x - ((x >> 1) & 0x5555'5555'5555'5555ULL); x = (x & 0x3333'3333'3333'3333ULL) + ((x >> 2) & 0x3333'3333'3333'3333ULL); x = (x + (x >> 4)) & 0x0f0f'0f0f'0f0f'0f0fULL; return static_cast((x * 0x0101'0101'0101'0101ULL) >> 56); } ``` 在带有 `-march=native` 且 `popcnt` 可用的情况下,`pop_modern` 可变成一条指令。更有趣的是,现代编译器可能也能识别出老技巧为 popcount:在此基准配置下,Kernighan 循环和 SWAR 版本也折叠为 `popcnt`。若没有目标指令,区别就会重现:Kernighan 是一个数据依赖循环,而 SWAR 是一段简短的移位和掩码序列。标准写法无论何种情况都能明确表达意图。在 C++20 之前,你可以对 GCC/Clang 使用 `__builtin_popcountll` 或对 MSVC 使用 `__popcnt64`。`std::popcount` 是可移植的写法 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#fn-15),并且在没有硬件 `popcnt` 的目标上,实现仍然可以提供高效的软件回退。 #### 基准测试 | Benchmark | 时间 | |-----------------------------|-----------| | `BM_popcount_modern` | 94.5 ns | | `BM_popcount_kernighan` | 94.5 ns | | `BM_popcount_swar` | 94.5 ns | #### 结论 使用 `<bit>`,除非你面向的是一个不提供该头文件的独立嵌入式环境。 ### 数值食谱与行指针 传承下来的智慧之一是:使用行指针访问矩阵比索引计算更快。在乘法更昂贵、地址生成硬件能力较弱、编译器简化索引的空间更小的时代,这更容易令人信服。《C语言数值食谱》——Press 等人著名的红皮书——也让这种风格得以普及。它的 `dmatrix`/`nrutil` 辅助函数使用行间接寻址,使得算法可以用 `[i][j]` 方式编写,保持数学符号,并使从 Fortran 的转换更直接。这可以是一个合理的接口选择;但它本身并非性能上的胜利。 感兴趣的读者可在基准代码中找到矩阵类的三个实现: - `flat_matrix` (访问通过乘法完成,数据连续) - `nr_matrix` (访问通过解引用行指针完成,数据连续) - `scattered_matrix` (与 `nr_matrix` 相同,但数据非连续存储:行之间分配了垃圾数据) `nr_matrix` 和 `scattered_matrix` 定义 `operator[]` 返回 `float*`。它们都存储一个指针数组——指向矩阵的行,然后 `operator[]` 返回索引行的指针。 #### 基准测试 我们在这些矩阵上运行两个内核:行主序求和和列主序求和。主要结论并非“乘法是免费的”或“行指针总是糟糕的”;而是布局和遍历顺序占主导地位。连续平坦布局和 Numerical Recipes 风格的布局彼此接近。故意分散的布局则完全崩溃,尤其是当工作集变大时。 | Benchmark | 时间 (400 x 400) | 时间 (4000 x 4000) | |-----------------------------------|------------------|---------------------| | `flat_matrix` 行主序内核 | 6062 ns | 987614 ns | | `nr_matrix` 行主序内核 | 6065 ns | 987632 ns | | `scattered_matrix` 行主序内核 | 6171 ns | 2323394 ns | | `flat_matrix` 列主序内核 | 33148 ns | 11258036 ns | | `nr_matrix` 列主序内核 | 36418 ns | 11450827 ns | | `scattered_matrix` 列主序内核 | 39054 ns | 14800609 ns | #### 结论 避免仅因看起来巧妙或历史习惯而使用间接寻址。优先使用连续存储,并采用与之匹配的遍历模式。如果可能,避免自行编写线性代数内核:Eigen、BLAS/LAPACK、GLM 或供应商调优的库通常是更好的起点。 ### 遍地 `const&` 与转发意图 我们中的许多人继承的另一个习惯是:“始终使用 `const&` 传递;拷贝很昂贵”。当函数只是观察其参数时,这仍然是一个好规则: ``` double norm(std::vector const& xs); ``` 但对于包装代码来说,它是错误的抽象。如果函数的任务是将参数传递给其他东西,`const&` 破坏了信息。右值变成了常量左值,移动构造被禁用。重载解析会改变,调用者的意图丢失了。 考虑一个旧式版本的 `emplace` 风格包装器: ``` template class bag { public: template T& emplace(Args const&... args) { return xs_.emplace_back(args...); } private: std::vector xs_; }; ``` 这看起来无害,但它会在不经意间造成性能损失。即使调用者写的是: ``` b.emplace(std::string(1024, 'x')); ``` 在 `emplace_bad` 内部,参数现在变成了 `std::string const&`。vector 不能从中移动,只能拷贝。 现代版本保留了调用者的值类别: ``` template class bag { public: template T& emplace(Args&&... args) { return xs_.emplace_back(std::forward(args)...); } private: std::vector xs_; }; ``` 现在左值保持为左值,右值保持为右值,重载解析看到的是调用者实际写的内容。同样的区别贯穿于现代 C++ API: ``` // 观察者:const& 没问题 void draw(widget const& w); // 消费/存储:按值传递可能非常好 void set_name(std::string name) { name_ = std::move(name); } // 转发构造:转发引用是正确的工具 template auto make_widget(Args&&... args) { return widget(std::forward(args)...); } ``` #### 基准测试 | Benchmark | 时间 (n=64) | 时间 (n=1024) | 时间 (n=4096) | |-------------------------------|-------------|----------------|----------------| | 使用 `const &` | 12.2 ns | 18.6 ns | 72.1 ns | | 使用完美转发 | 6.55 ns | 8.74 ns | 31.0 ns | 可以看到,在此示例中,完美转发击败了 const 引用参数。这是因为在将字符串 emplacing 到内部 vector 时,我们可以省略一次拷贝。 #### 结论 这并非说“永远不要使用 `const&`”。更精确地说: - 当观察一个现有对象时,使用 `T const&`; - 当你需要自己的拷贝并在内部移动时,使用 `T` 按值传递; - 对于显式的接收器,使用 `T&&`; - 对于通用的转发包装器,使用转发引用。 旧 C++ 表达意图的方式较少,因此 `const&` 成了普遍反射。现代 C++ 给了我们更丰富的词汇。利用类型系统来保留意图。 ## 第二部分:标准库 现代 C++ 标准库已有显著改进。我们大多数人都遇到过某些工程师,他们在工作中不鼓励、包装或完全禁止 STL。或许现在是重新考虑的时候了:标准算法和容器更可读、经过更好测试,并且日益得到更好的优化。 ### Ranges 和算法 考虑一个简单的任务: > 给定原始电压采样值,校准为毫伏,计算与目标值的残差,平方,应用权重,并求和成本。 ``` inline double raw_loop(std::span xs) noexcept { double sum = 0.0; for (double volts : xs) { auto mv = calibrated_mv(volts); auto err = residual(mv); sum += weighted_square(err); } return sum; } inline double algorithm_call(std::span xs) noexcept { return std::accumulate( xs.begin(), xs.end(), 0.0, [](double acc, double volts) { auto mv = calibrated_mv(volts); auto err = residual(mv); return weighted_square(err) + acc; }); } inline double ranges_pipeline(std::span xs) noexcept { auto costs = xs | std::views::transform(calibrated_mv) | std::views::transform(residual) | std::views::transform(weighted_square); return std::ranges::fold_left(costs, 0.0, std::plus{}); } ``` 这里故意写得简短,而且这对优化器来说也是一个有利情况:转换简单、可见且可内联;`std::ranges::fold_left` 是对视图管道的一个左折叠 (https://categorica.io/blog/2026.06.29_trust_your_compiler/#fn-16)——相当于 `f(f(f(f(init, x1), x2), ...), xn)`。在此条件下,抽象基本消失。 #### 基准测试 | Benchmark | 时间 (n=1024) | 时间 (n=65536) | |-------------------|----------------|-----------------| | 原始循环 | 36.0 ns | 2400 ns | | 使用 `<numeric>` | 36.0 ns | 2395 ns | | 使用 `<ranges>` | 37.9 ns | 2417 ns | 基准测试结果一致:三个版本基本等价。Ranges 管道在

相似文章

当编译器让你惊喜

Lobsters Hottest

Matt Godbolt 探讨了编译器优化如何将 O(n) 求和循环转换为 O(1) 的闭式解,突出了 Clang 和 GCC 如何采用循环展开和数学简化等复杂技术来大幅提升代码性能。

我测试了Claude的“快速C++”,并没有更快

Reddit r/LocalLLaMA

一位开发者对Claude的C++代码进行了基准测试,发现要求达到最高速度往往会引入内存安全违规,而实际性能却没有提升,从而打破了速度与安全之间权衡的假设。

使用SIMD加速std::copy_if

Lobsters Hottest

一篇博文,分析和实现了在AMD Zen 4上使用AVX-512指令的SIMD加速版本的std::copy_if,并进行了性能分析和与编译器自动向量化的对比。