yuqi-zheng

编译器优化:浮点数为何难以向量化


编译器向量化整数求和循环毫不犹豫,会把一个标量循环变成一次处理八个元素的 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 或按函数的属性是合理的选择。重排加法带来的数值差异通常可以忽略。

对于数值求解器、金融计算、或任何依赖特定舍入行为以保证收敛或正确性的代码,改变浮点模型可能产生细微甚至灾难性的错误结果。默认的保守行为是有原因的。

最有针对性的做法是:只对那些需要向量化的特定函数应用放松的语义;用基准测试验证性能提升是真实的;用测试用例验证数值差异在可接受的范围内。