源码少写一行就会更快吗?从 as-if 规则到汇编验证编译器优化

时间:2026/04/09

下面的代码看起来做了一亿次计算:

1
2
3
4
5
6
void benchmark() {
double sum = 0.0;
for (int i = 0; i < 100'000'000; ++i) {
sum += i * 0.5;
}
}

在优化构建中,整个循环可能完全消失。原因并不是编译器算得特别快,而是 sum 从未影响程序的可观察行为;执行循环与删除循环,对一个符合标准的程序没有可见区别。

这正是性能分析最容易踩的坑:我们在 C++ 源码中看到的工作,不一定存在于最终机器码;反过来,一段“更底层”的手写代码也不保证生成更好的指令。本文会用一个可运行的数组变换,从语言语义、优化级别、向量化和汇编四个角度建立可靠的验证方法。

1. 编译器为什么有权改写我们的代码?

C++ 的 as-if 规则允许实现以任何方式转换程序,只要最终可观察行为与抽象机要求一致。通俗地说,编译器要保留的是程序语义,不是源码步骤。

因此,它可以进行:

  • 常量折叠,把 3 * 7 + 1 直接变成 22
  • 死代码消除,删除不会影响结果的计算;
  • 内联,消除函数调用边界;
  • 循环不变量外提,避免重复加载不变值;
  • 自动向量化,用一条 SIMD 指令处理多个元素;
  • 指令选择、寄存器分配和调度。

这些不是互相孤立的技巧。内联可能暴露常量,常量传播又可能删除分支,简化后的循环才有机会向量化。只看某个源代码表达式,很难推断完整优化流水线后的结果。

什么属于“可观察行为”?

文件或终端 I/O、对 volatile 对象的规定访问、原子与线程同步产生的行为等都可能构成约束。普通局部变量的中间结果若永远不被使用,通常没有必须保留的理由。

这不代表应该用 volatile 阻止基准代码被优化。volatile 会改变访问语义,可能让测试对象变成另一段程序,而且它不是线程同步机制。更好的做法是让计算结果以低成本方式离开被测区域,例如在计时之后校验或输出一个校验和,并检查生成代码。

2. -O0-O2-O3 到底承诺什么?

优化级别是编译器命令行约定,不是 C++ 标准的一部分。大致可以这样理解:

选项 常见目的 注意事项
-O0 降低编译优化,方便源码级调试 机器码通常不能代表发布性能
-O1 启用一批成本较低的优化 具体集合取决于编译器版本
-O2 常见发布构建的平衡点 仍需结合项目配置验证
-O3 更积极地优化循环、内联等 可能增加代码体积,不保证更快
-Os / -Oz 偏向减小代码体积 可用性与行为依编译器而异

相同的 -O3 在 Clang 与 GCC 中不保证启用完全相同的优化,也可能随版本变化。构建系统还可能叠加目标架构、链接时优化(LTO)、调试信息和浮点选项。

-ffast-math 一类选项会允许编译器放宽部分严格浮点语义,例如重排运算或弱化对特殊值的处理。它不是“免费的更快浮点”,只能在数值要求明确允许时使用,并要针对误差、NaN、无穷和舍入行为测试。

3. 为什么 Release 代码与 Debug 代码差别巨大?

-O0 往往尽量保持便于调试的结构:局部变量可能反复落栈,小函数保留调用,循环也按源码形式执行。开启优化后,许多抽象边界会消失,值可能只存在于寄存器,甚至根本没有独立实体。

因此,在 Debug 构建上比较两种高层写法,常常是在测“谁更适合未优化执行”,不是发布环境中的真实差异。性能实验至少应记录:

1
2
3
4
5
6
编译器及版本
完整编译和链接参数
目标 CPU / 架构
Debug 或 Release 配置
输入规模与数据分布
重复次数与统计方法

没有这些上下文,一张耗时截图很难复现,也无法解释差异来自源码、编译器还是机器。

4. 怎样写一个不会被轻易删掉的最小示例?

下面的函数对连续数组执行 output[i] = input[i] * scale + biasmain 最后计算并输出校验和,使数组变换影响可观察结果。

程序使用 C++20 的 std::span,不依赖第三方库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
#include <numeric>
#include <span>
#include <stdexcept>
#include <vector>

void scale_and_bias(
std::span<const float> input,
std::span<float> output,
float scale,
float bias) {
if (input.size() != output.size()) {
throw std::invalid_argument("input/output size mismatch");
}

for (std::size_t i = 0; i < input.size(); ++i) {
output[i] = input[i] * scale + bias;
}
}

int main() {
const std::vector<float> input{1, 2, 3, 4, 5};
std::vector<float> output(input.size());

scale_and_bias(input, output, 2.0F, 1.0F);

const float checksum = std::accumulate(
output.begin(), output.end(), 0.0F);
std::cout << "checksum = " << checksum << '\n';
}

在支持 C++20 的 Clang 环境中编译:

1
2
clang++ -std=c++20 -O2 -Wall -Wextra -pedantic optimize.cpp -o optimize
./optimize

预期输出:

1
checksum = 35

校验和避免示例中的主要计算成为完全无用的结果,但这个小输入只适合验证正确性,不适合测量性能。真正的基准需要更大的输入、预热、多次迭代和统计分析,并应确认初始化、日志和分配没有混入计时区间。

5. 如何查看这段代码生成了什么?

使用 Clang 生成当前目标架构的汇编:

1
clang++ -std=c++20 -O3 -S optimize.cpp -o optimize.s

也可以分别生成未优化和优化版本进行比较:

1
2
clang++ -std=c++20 -O0 -S optimize.cpp -o optimize_O0.s
clang++ -std=c++20 -O3 -S optimize.cpp -o optimize_O3.s

在 macOS Apple Silicon 上会看到 AArch64 指令,在 Intel Mac 或多数 x86-64 机器上会看到 x86 指令。不要用 xmmymm 等 x86 寄存器名作为跨平台的唯一向量化证据;AArch64 使用另一套寄存器和指令命名。

汇编文件中标准库初始化和异常路径可能很多。首先定位 scale_and_bias 对应符号,再关注:

  • 循环每轮处理一个还是多个元素;
  • 是否存在标量主循环、向量主循环和尾部处理;
  • 数组值是否反复从内存加载;
  • 是否有循环内函数调用;
  • 分支和边界检查被放在什么位置。

函数名经过 C++ 名字修饰后不一定直观,可以使用 nm、反汇编工具或 Compiler Explorer 帮助定位。工具参数在 macOS、GNU/Linux 及不同二进制格式间有差异,应以当前工具链文档为准。

6. 如何让编译器直接说明向量化结果?

只靠肉眼猜 SIMD 很费时。Clang 可以输出循环优化备注:

1
2
3
4
5
clang++ -std=c++20 -O3 \
-Rpass=loop-vectorize \
-Rpass-missed=loop-vectorize \
-Rpass-analysis=loop-vectorize \
optimize.cpp -o optimize

备注可能指出循环已向量化,也可能说明失败原因。具体文本与是否报告取决于 Clang 版本、目标架构和上下文,不能把某一条固定消息当成跨环境测试断言。

GCC 也有优化报告选项,但参数与输出格式不同。项目使用哪套编译器,就查看对应版本的官方文档,不要把 Clang 参数直接复制到 GCC。

优化备注是“编译器做了什么”的证据,仍不是“程序变快多少”的证据。向量循环可能受内存带宽限制,额外版本检查也有成本;最终还要用真实输入测量。

7. 为什么一个简单循环仍可能无法向量化?

数据依赖让多次迭代不能并行

1
2
3
for (std::size_t i = 1; i < size; ++i) {
values[i] += values[i - 1];
}

i 次迭代读取上一次刚写出的结果,这是循环携带依赖。编译器不能简单地把多次迭代同时执行,否则会改变语义。

指针可能互相重叠

原始指针接口可能允许调用者传入相同或部分重叠的范围:

1
2
void add(const float* left, const float* right,
float* output, std::size_t size);

若某次写入 output[i] 会影响后续对 leftright 的读取,向量化可能不安全。编译器有时会生成运行时别名检查:确认范围不重叠就走向量版本,否则走保守版本。

C++20 的 std::span 表达连续范围和长度,却不承诺两个范围互不重叠。某些编译器提供 __restrict 等扩展来声明无别名契约,但它不是所有 C++ 工具链中的同一标准语法;承诺错误会导致未定义行为或错误结果。优先从 API 所有权和数据流上消除含糊,再结合目标编译器决定是否使用扩展。

浮点归约受运算顺序约束

1
2
3
4
float sum = 0.0F;
for (float value : values) {
sum += value;
}

浮点加法不满足严格结合律,改变累加顺序可能改变舍入结果。在严格浮点语义下,编译器未必能把归约任意重排。是否允许重排是精度与性能契约的一部分,不能只为看到 SIMD 指令而擅自打开快速数学选项。

控制流或调用无法被分析

复杂分支、不可内联的间接调用、不可见函数体和不规则内存访问都会增加分析难度。解决方法不是盲目手写展开,而是先查看优化报告,确认真正的阻碍因素。

8. inline 为什么不能强制函数内联?

inline 关键字主要拥有语言层面的定义规则,例如允许符合要求的定义出现在多个翻译单元。编译器可以不内联一个 inline 函数,也可以内联没有写 inline 的函数。

1
2
3
inline int add_one(int value) {
return value + 1;
}

是否内联取决于函数大小、调用频率估计、优化级别、异常路径和代码体积等成本模型。链接时优化能让编译器跨翻译单元看到更多函数体,但会改变构建成本,也不保证每个调用都内联。

模板和 lambda 常把具体实现暴露在调用点,因此给内联和常量传播提供机会;这就是“零成本抽象”常见的实现基础之一。它依然是优化机会,不是语言级性能保证。

9. 未定义行为为什么会让汇编看起来反直觉?

编译器只需要保持符合标准程序的语义。一旦某条执行路径发生未定义行为(undefined behavior,UB),标准不再规定那次执行必须得到什么结果。

典型例子包括:

  • 有符号整数溢出;
  • 越界访问;
  • 解引用空指针或悬空指针;
  • 数据竞争;
  • 违反对象生命周期或别名规则。
1
2
3
bool greater_after_increment(int value) {
return value + 1 > value;
}

在标准 C++ 中,若 value + 1 发生有符号溢出就是 UB。编译器可以基于“符合定义的执行不会溢出”进行推理,从而把比较简化。把溢出理解为所有平台都按二进制回绕,会导致对机器码的错误预期。

无符号整数按模运算回绕是有定义的,但 signed/unsigned 混算又可能带来意外转换。索引常用 std::size_t 是为了匹配容器大小语义,不代表所有计数都应该无符号;需要表达负值或差值时,应选择符合领域的类型。

调试优化差异时,先用警告、未定义行为检测器和最小测试排除错误,再讨论编译器是否“优化过头”。检测器自身会改变代码布局和速度,适合找错误,不适合作为发布性能结果。

10. 为什么源代码更“底层”不一定更快?

比较以下写法:

1
sum += left[i] * right[i];
1
2
3
const float x = left[i];
const float y = right[i];
sum += x * y;

在优化构建中,两个临时变量通常只是帮助表达含义,未必对应额外内存存取。优化器可能为两种写法生成相同代码。

反过来,手工展开循环、缓存所有局部值或用晦涩位运算替换清楚表达式,可能增大代码、提高寄存器压力,甚至阻碍编译器识别标准模式。

源码层面的优化优先级通常应是:

  1. 改善算法复杂度;
  2. 改善数据布局和访问局部性;
  3. 减少分配、锁竞争和不必要的数据移动;
  4. 根据性能分析定位热循环;
  5. 用优化报告和汇编验证编译器行为;
  6. 最后才做受基准支持的微优化。

汇编是解释证据的一部分,不应取代端到端测量。

11. 怎样设计一个可信的优化实验?

先证明测的是正确程序

两种实现必须产生可接受的相同结果。浮点算法应使用符合业务要求的误差比较,而不是只看一次输出恰好相等。

隔离初始化与被测工作

输入生成、内存分配、文件 I/O 和日志应按研究问题决定是否计入。若只比较循环内核,就把这些工作移出计时区;若研究端到端请求延迟,就不能人为排除真实成本。

防止死代码消除,但别扭曲代码

消费结果、随机化部分输入或使用成熟基准框架提供的优化屏障。不要在内层循环加入输出,也不要默认用 volatile 模拟普通内存访问。

重复测量并控制环境

进行预热,运行多轮,报告中位数或分布而不是最好的一次。CPU 频率、后台负载、热降频和输入分布都会影响结果。需要结合具体平台选择 Instruments、perf、VTune 等工具。

同时保留编译证据

记录编译器版本、命令、向量化备注和关键汇编。这样性能变化才有机会被解释,而不是只剩“换了写法之后快了”。

12. 常见误区如何纠正?

误区一:-O3 一定比 -O2

不对。更激进的展开和内联可能增大代码体积,影响指令缓存;某些程序几乎没有差异。两种配置都要在真实工作负载上测量。

误区二:看到 SIMD 指令就证明优化成功

不充分。还要确认向量路径实际被执行、尾部与别名检查成本、内存带宽以及整体耗时。

误区三:inline 能强制消除调用

不对。它不是强制性能指令。检查优化报告或机器码,而不是检查关键字。

误区四:volatile 可以防止多线程竞态

不对。它不提供原子性、互斥或线程间 happens-before 关系。并发通信应使用 std::atomic、锁或其他同步原语。

误区五:汇编越短就越快

不一定。吞吐受依赖链、延迟、分支、缓存未命中和执行端口等因素影响,静态指令条数只是一个信号。

误区六:编译器不会利用未定义行为

不对。优化建立在标准语义上,UB 会破坏我们对路径和结果的推理。先修正正确性,再评估性能。

13. 什么时候值得查看汇编?

汇编特别适合回答具体问题:

  • 热函数是否成功内联;
  • 循环是否向量化;
  • 边界检查或抽象层是否被消除;
  • 出现了哪些间接调用;
  • 某个常量分支是否仍在运行时执行;
  • 编译器版本升级为什么改变性能。

如果程序主要耗时在磁盘、网络、锁等待或算法复杂度上,先看汇编通常收益有限。性能分析器应先告诉我们时间花在哪里,再对真正热点下钻。

14. 总结

回到开头,那一亿次循环之所以可能消失,是因为编译器优化的是程序可观察语义,而不是忠实复刻每一行源码。

需要记住五点:

  1. as-if 规则允许编译器进行广泛变换,未被使用的工作可能完全删除。
  2. 优化级别和具体优化集合属于编译器版本与构建配置,不是跨平台固定契约。
  3. 连续访问、清晰依赖和可分析调用能提供优化机会,但不保证向量化或内联。
  4. 汇编与优化报告能说明“生成了什么”,基准才能回答“实际快多少”。
  5. 未定义行为会破坏正确性,也会让基于源码直觉的性能分析失去基础。

实践中先用性能分析器定位热点,再保存一条可复现的编译命令,查看优化备注和关键汇编,最后用受控基准验证收益。这个顺序比凭源码外观猜速度可靠得多。