编译器优化:浮点数为何难以向量化
编译器向量化整数求和循环毫不犹豫,会把一个标量循环变成一次处理八个元素的 AVX2 代码。同样的变换应用到浮点求和上,几乎从不默认启用。原因并不是缺失了某种优化 —— 而是源于”浮点运算是如何工作的”这一事实所要求的正确性约束。
整数求和:向量化成功
给定一个对整数跨度求和的函数:
int sum(std::span<const int> data);
针对支持 AVX2 的处理器,编译器通常会生成使用 vpaddd 的代码,一次并行相加八个 32 位整数。主循环结束后,向量寄存器中存储的八个部分和通过”水平归约”(horizontal reduction)合并为一个标量。
这之所以可行,是因为整数加法满足结合律。数学恒等式 (a + b) + c = a + (b + c) 对任何大小的整数都精确成立,没有例外。编译器可以自由地重排和重组加法,结果不会变。
浮点求和:向量化失败
同样结构换成 float:
float sum(std::span<const float> data);
产生的代码大致是这样:
.L2:
vaddss xmm0, xmm0, DWORD PTR [rdi]
vaddss xmm0, xmm0, DWORD PTR [rdi+4]
vaddss xmm0, xmm0, DWORD PTR [rdi+8]
vaddss xmm0, xmm0, DWORD PTR [rdi+12]
vaddss xmm0, xmm0, DWORD PTR [rdi+16]
vaddss xmm0, xmm0, DWORD PTR [rdi+20]
vaddss xmm0, xmm0, DWORD PTR [rdi+24]
vaddss xmm0, xmm0, DWORD PTR [rdi+28]
add rdi, 32
cmp rax, rdi
jne .L2
vaddss 是标量单精度加法。循环展开了八次迭代,但没有使用 vaddps(打包向量加)。每次加法都依赖于前一次的结果。运算本质上是串行的。
编译器为何无法向量化
浮点数用固定的位数存储。每次运算都会把结果舍入到最近的可表示值。这种舍入意味着浮点加法不满足结合律:
(a + b) + c != a + (b + c)
差异通常很小,但不是零;对某些输入 —— 数量级差异很大的值 —— 差异甚至可能很显著。经典的例子是把一个大数加到许多小数上:如果大数吸收了所有有效位,那么小数一个一个加上去时贡献为零,但如果小数先相互求和,它们是会贡献的。
向量化求和循环会改变加法顺序。它不再是从左到右依次累加元素 0 到 N-1,而是并行地维护若干独立的部分和,最后再把它们合并。对整数而言这是数学上等价的;对浮点数,它会产生不同的结果。
C 和 C++ 标准默认要求浮点运算符合 IEEE 754。除非程序员显式授权,否则编译器不允许重排浮点运算。向量化需要重排,所以向量化被阻止。
启用向量化
fast-math 全局开关
-O3 -ffast-math
这个标志(或等价的 -Ofast)启用了一组放松的浮点假设:运算可以重排、有符号零可以忽略、无穷和 NaN 可以被当作不会出现。开启 -ffast-math 后,编译器会为浮点求和生成 AVX2 向量化代码。
问题在于它的作用范围。-ffast-math 作用于整个翻译单元。同一个文件里任何依赖严格 IEEE 754 行为的代码 —— 比如检查 NaN、正确处理无穷、依赖精确舍入来保证数值算法正确性 —— 都可能悄无声息地产生错误结果。
按函数的属性(GCC)
__attribute__((optimize("fast-math")))
float sum(std::span<const float> data) {
float total = 0;
for (float x : data) total += x;
return total;
}
这种写法只把”放松的浮点语义”应用到这一个函数,其余程序不受影响。它既能产生向量化代码,又能限制精度取舍的爆炸半径。缺点是它是 GCC 特有的扩展,没有标准 C++ 的对应写法。
std::reduce(基于标准,但实际有限)
#include <numeric>
float sum(std::span<const float> data) {
return std::reduce(data.begin(), data.end(), 0.0f);
}
std::reduce 的定义允许非确定的执行顺序 —— 不同于严格顺序的 std::accumulate。标准的本意是让 std::reduce 允许向量化。实际上,当前版本的 GCC 和 Clang 在没有额外标志的情况下并不会为浮点 std::reduce 生成向量化代码。未来的编译器版本可能会有所改进。
选择合适的取舍
是否启用 fast-math,取决于你的代码如何使用结果。
对于渲染、游戏物理、机器学习推理这类”近似结果可接受、性能至关重要”的场景,-ffast-math 或按函数的属性是合理的选择。重排加法带来的数值差异通常可以忽略。
对于数值求解器、金融计算、或任何依赖特定舍入行为以保证收敛或正确性的代码,改变浮点模型可能产生细微甚至灾难性的错误结果。默认的保守行为是有原因的。
最有针对性的做法是:只对那些需要向量化的特定函数应用放松的语义;用基准测试验证性能提升是真实的;用测试用例验证数值差异在可接受的范围内。