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/Inf | x-x → 0、x*0 → 0、x/x → 1 |
-funsafe-math-optimizations | 允许结合/分配律 | 向量化归约、循环重排、a*b+a*c → a*(b+c) |
-fno-math-errno | 不设 errno | sqrt 等可内联成指令 |
-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) - 1 | x(直接避免灾难性抵消) |
| 向量归一化 | 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 是把这份合同换成一张免责声明——在热点数值内核上用可以,在通用代码里全局开则是自找麻烦。理解每一项放宽的代价,才能既拿到向量化的性能,又保住结果的可信度。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。