信任你的编译器:现代C++
摘要
本文对比了旧的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 管道在
相似文章
当编译器让你惊喜
Matt Godbolt 探讨了编译器优化如何将 O(n) 求和循环转换为 O(1) 的闭式解,突出了 Clang 和 GCC 如何采用循环展开和数学简化等复杂技术来大幅提升代码性能。
面向现代C++ CPU的高效C++编程,第4章/第2部分
本书草稿章节提供了一个信息图以及对现代C++ CPU的CPU时钟周期中操作成本的详细分析,涵盖乘法、除法和RTTI,并附有各种架构的延迟表。
我测试了Claude的“快速C++”,并没有更快
一位开发者对Claude的C++代码进行了基准测试,发现要求达到最高速度往往会引入内存安全违规,而实际性能却没有提升,从而打破了速度与安全之间权衡的假设。
使用SIMD加速std::copy_if
一篇博文,分析和实现了在AMD Zen 4上使用AVX-512指令的SIMD加速版本的std::copy_if,并进行了性能分析和与编译器自动向量化的对比。
C语言中在C++中仍然无法工作的构造——以及一些已发生变化的构造
一篇更新经典调查的博文,关于C语言中在C++中无法工作的构造,涵盖了C++20和C23标准中影响兼容性的变化。