浮点语义与快速数学优化

讲解浮点语义如何约束编译器优化:IEEE 754 的舍入、NaN 与符号零规则,为什么结合律与分配律在浮点上不成立,-ffast-math 的各子开关分别放宽了什么,以及如何在数值稳定性与性能之间做出工程取舍,附可运行的 C 代码与测试策略。

1. IEEE 754:编译器不能随便动的契约

一句话总结: IEEE 754 给浮点运算定义了精确到每一位的语义,编译器必须逐条遵守;-ffast-math 的本质是「允许编译器违约」,用它换性能就要接受结果变化。

整数运算满足结合律、交换律、分配律,编译器可以放心重排。浮点不行。IEEE 754 规定每一次基本运算(加、减、乘、除、开方)都必须正确舍入(correctly rounded):结果等于「无限精度真值」按当前舍入模式取整到最近的可表示数。

0.1 + 0.2 == 0.30000000000000004   // 不是 0.3,因为 0.1/0.2 本身就无法精确表示
(0.1 + 0.2) + 0.3 != 0.1 + (0.2 + 0.3)   // 加法不满足结合律

这套契约还包括几条容易被忽视的规则:

规则含义对优化的影响
结合律不成立(a+b)+c ≠ a+(b+c)不能重排求和顺序
分配律不成立a*(b+c) ≠ a*b+a*c不能展开/合并乘法
符号零+0.0 与 -0.0 不同x == 0.0 为真但符号不同,不能把 x*0 优化成 0
NaN 传播任何含 NaN 的运算得 NaN不能假设 x == x 为真
无异常终止除零得 inf,不抛异常不能把 1/x 优化成断言 x != 0
// 严格遵守 IEEE 754 时,这几条「显然」的优化都是错的
double a = x - x;        // 不能优化成 0(x 为 inf 或 NaN 时结果是 NaN)
double b = x * 0.0;      // 不能优化成 0(x 为 inf/NaN 时结果是 NaN)
double c = x / x;        // 不能优化成 1.0(x 为 0/inf/NaN 时是 NaN)
int    d = (x != x);     // 不能优化成 0(NaN 时 x != x 为真)

2. 严格语义下的优化禁区

在这些约束下,编译器能做的浮点优化相当有限。常见的中端优化在浮点上要么被禁用,要么只在特定条件下启用。

2.1 常量折叠仍可做

只要操作数全是编译期常量,折叠是安全的——因为折叠用的就是目标平台的浮点语义。但要注意跨平台一致性:0.1 + 0.2 在 x86-64 SSE 与某些 32 位 x87 上可能不同(x87 有 80 位扩展精度),所以编译器不能把「按本机算出的常量」当作跨平台真值。

const double k = 1.0 / 3.0;   // 可折叠,但结果依赖目标的舍入行为

2.2 乘法分配与强度削减的边界

x * 2.0 可以被改写成 x + x(对 2.0 成立),但 x * 3.0 不能改写成 x + x + x(舍入误差不同)。同理,x / 2.0 可以改写成 x * 0.5(除以 2 是精确的),但 x / 3.0 不能改写成 x * (1.0/3.0),因为 1.0/3.0 本身有舍入误差。

double f(double x) {
    return x * 2.0;      // 可优化为 x + x
}
double g(double x) {
    return x / 3.0;      // 不能优化为 x * 0.3333333333333333
}

2.3 循环变换的限制

向量化、循环交换、归约重排这些高性能优化,本质都依赖结合律:

// 严格语义下不能向量化(重排求和顺序会改变结果)
double sum(const double *a, int n) {
    double s = 0.0;
    for (int i = 0; i < n; ++i) s += a[i];
    return s;
}

编译器必须证明「重排不改变结果」才敢动手,而这在浮点上几乎不可能证明,于是默认放弃。这就是为什么「同样的循环,开 -ffast-math 后性能翻几倍」——不是编译器变聪明了,而是约束被放开了。

优化依赖的性质严格模式下
向量化归约结合律禁用
循环交换结合律禁用
a*b + c → FMA更少舍入需 -ffp-contract=fast
公共子表达式消除纯函数性可用(浮点运算无副作用)
常量传播纯函数性可用

2.4 为什么浮点运算仍是「纯」的

一个常见的误解是「浮点运算有副作用,所以不能随便动」。恰恰相反:浮点运算没有副作用(不写内存、不设 errno 除非显式启用),因此 CSE、死代码消除、指令调度都可以放心做。受限的只是代数性质——不能假设它满足结合律与分配律。

double h(double x, double y) {
    double t = x * y;      // t 若未被使用可安全删除
    double u = x * y;      // 与 t 相同,可 CSE 复用
    return u;
}

区分「副作用」与「代数性质」是理解浮点优化的关键:前者决定能不能删/能不能复用,后者决定能不能重排/能不能变形。

3. -ffast-math 到底放宽了什么

-ffast-math 不是一个开关,而是一组子选项的集合。在 GCC/Clang 里它等价于:

-ffast-math =
  -fno-math-errno          # 不保证 math 函数设置 errno
  -funsafe-math-optimizations   # 允许不安全的代数变换
  -ffinite-math-only       # 假设没有 inf 和 NaN
  -fno-rounding-math       # 不关心舍入模式
  -fno-signaling-nans      # 不关心信号 NaN
  -fcx-limited-range       # 复数乘除不做范围检查
  -fexcess-precision=fast  # 允许中间结果用更高精度

逐个看它们解锁了什么:

子选项放宽的假设能启用的优化
-ffinite-math-only无 NaN/Infx-x → 0、x*0 → 0、x/x → 1
-funsafe-math-optimizations允许结合/分配律向量化归约、循环重排、a*b+a*c → a*(b+c)
-fno-math-errno不设 errnosqrt 等可内联成指令
-fno-rounding-math舍入模式固定常量折叠更激进
-fno-trapping-math无浮点异常陷阱允许投机执行浮点运算
-fexcess-precision=fast中间精度可浮动x87 上减少精度截断
// 开 -ffast-math 后,下面这段可能被向量化
double dot(const double *a, const double *b, int n) {
    double s = 0.0;
    for (int i = 0; i < n; ++i) s += a[i] * b[i];
    return s;
}

这些子开关可以单独使用,不必整包打开。只想向量化归约、又不想让 isnan 失效时,可以只开 -funsafe-math-optimizations:

# 只放宽代数变换,保留 NaN/Inf 语义
clang -O2 -funsafe-math-optimizations -fno-fast-math a.c -o a
# 只允许 FMA 收缩
clang -O2 -ffp-contract=fast -fno-fast-math a.c -o b

3.1 FMA 与收缩

FMA(Fused Multiply-Add) 把 a*b + c 合成一条指令,中间乘积不截断,只有一次舍入。它提高了精度,但结果与「先乘后加」不同,因此默认行为由 -ffp-contract 控制:

取值语义
off不生成 FMA
on只在同一语句内收缩(C 标准允许)
fast跨语句也可收缩(GCC 默认)
clang -ffp-contract=off -O2 a.c -o a_off
clang -ffp-contract=fast -O2 a.c -o a_fast
# 两条二进制对同一输入可能给出不同结果

因为 FMA 改变了结果,可复现构建与跨平台一致性要求明确固定 -ffp-contract。这也是为什么很多数值库在文档里写明「启用 FMA 后结果可能不同」。

4. 数值稳定性与算法改写

放宽语义不是唯一的提速路径。很多时候,改写算法能在保持严格语义的前提下同时拿到速度与精度。

4.1 Kahan 求和

朴素求和的误差随项数线性增长;Kahan 补偿求和(compensated summation) 用一个补偿项记录被舍入掉的部分,把误差降到与项数无关:

double kahan_sum(const double *a, int n) {
    double s = 0.0, c = 0.0;
    for (int i = 0; i < n; ++i) {
        double y = a[i] - c;
        double t = s + y;
        c = (t - s) - y;    // 精确算出本次加法丢掉了多少
        s = t;
    }
    return s;
}

代价是每项多几次浮点运算,但结果对顺序不敏感——这反而让编译器可以安全地向量化,因为不同顺序的结果在补偿后足够接近。这是「用算法换优化空间」的典型例子。

4.2 更好的求和顺序

对大动态范围的数据,两两配对求和(pairwise summation) 把误差从 O(n) 降到 O(log n):

double pairwise(const double *a, int lo, int hi) {
    int n = hi - lo;
    if (n <= 8) {
        double s = 0.0;
        for (int i = lo; i < hi; ++i) s += a[i];
        return s;
    }
    int mid = lo + n / 2;
    return pairwise(a, lo, mid) + pairwise(a, mid, hi);
}

它显式地做了「结合律重排」,但重排方式是确定且已知误差有界的,因此可以放心启用。

4.3 精度与表达式的选择

场景差写法好写法
二次方程(-b + sqrt(b*b-4*a*c))/(2*a)用 q = -0.5*(b + sign(b)*sqrt(disc)) 避免相减抵消
小量相加(1+x) - 1x(直接避免灾难性抵消)
向量归一化v / sqrt(dot(v,v))先算 rsqrt 再乘,或用 hypot 避免上溢
指数和逐个 exp 相加减去最大值再算,防上溢

5. 工程实践:如何安全使用 fast-math

5.1 作用域要收窄

不要全局开 -ffast-math。用属性或 pragma 把放宽限制在确实需要的热点函数上:

__attribute__((optimize("fast-math")))
void hot_kernel(const float *a, float *out, int n) {
    for (int i = 0; i < n; ++i) out[i] = __builtin_sqrtf(a[i]);
}
#pragma clang fp reassociate(on) contract(fast) reciprocal(on)
float dot3(const float *a, const float *b) {
    return a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
}
#pragma clang fp reassociate(off) contract(off)

Clang 的 #pragma clang fp 支持细粒度控制 reassociate、contract、reciprocal、exceptions,比整包 -ffast-math 可控得多。

5.2 显式内建函数

想要 FMA 或快速倒数,直接用内建而不是靠编译器猜:

double r = __builtin_fma(a, b, c);       // 显式 FMA,语义明确
float  q = __builtin_sqrtf(x);
// x86 的近似指令(精度约 12 位,需自己牛顿迭代补足)
float  e = _mm_cvtss_f32(_mm_rsqrt_ss(_mm_set_ss(x)));

5.3 测试策略

放宽浮点语义后,逐位相等的单元测试会失败。正确的做法是改断言为容差比较,并同时记录「哪种语义下的期望值」:

// 不推荐:逐位比较
assert(dot(a, b, n) == expected);

// 推荐:相对误差容差
static int approx(double got, double want, double rtol) {
    double diff = fabs(got - want);
    return diff <= rtol * fmax(fabs(want), 1.0);
}
assert(approx(dot(a, b, n), expected, 1e-9));

在 CI 里可以跑两套构建:一套严格(-ffp-contract=off -fno-fast-math)验证数值正确性,一套 -O3 -march=native 验证性能与不崩溃。

5.4 陷阱清单

  • -Ofast 等价于 -O3 -ffast-math,名字看不出来,别顺手用;
  • -march=native 会启用 FMA,与 -ffp-contract=fast 叠加会进一步改变结果;
  • 不同编译器/版本结果不同:GCC 与 Clang 对 -ffast-math 的具体解释有差异,跨编译器复现必须显式列全子选项;
  • x87 的扩展精度:32 位 x86 上中间结果可能用 80 位,导致「看似相等的表达式」结果不同,用 -mfpmath=sse 统一到 SSE;
  • -ffinite-math-only 最危险:它让 isnan/isinf 检查被优化掉,代码里所有 NaN 处理逻辑都会失效。

一句话:浮点语义是编译器与数值正确性之间的合同。默认的严格语义保护你不被「聪明」的优化坑到;-ffast-math 是把这份合同换成一张免责声明——在热点数值内核上用可以,在通用代码里全局开则是自找麻烦。理解每一项放宽的代价,才能既拿到向量化的性能,又保住结果的可信度。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. 查询式编译器与增量类型检查:Salsa 架构
  2. Sanitizer 与编译期安全加固:ASan、TSan 与 CFI
  3. 目标文件格式:ELF、Mach-O 与 COFF