第3章:IEEE 754标准与舍入模式

好,我们接着聊。GPU和CPU算同一个公式,结果不一样——这个问题我在项目里遇到过太多次了。刚入行那会儿,我还以为是硬件坏了。后来才发现,根子往往就在这个IEEE 754标准和舍入模式上。

说白了,计算机里的浮点数不是我们手写数学时那种连续的数。它能表达的数值是离散的,有精度上限。标准定了怎么存、怎么算、怎么舍入。你如果不管这套规则,一致性检查就是白做。

3.1 浮点数的存储格式

我习惯先看存储。IEEE 754定义了两种常用格式:单精度和双精度。GPU集群里,单精度(FP32)和半精度(FP16)很常见;CPU那边双精度(FP64)用得多。这两种格式的位宽不一样,精度和范围自然也不一样。

格式 总位数 符号位 指数位 尾数位 近似精度(十进制)
FP16(半精度) 16 1 5 10 约3.3位有效数字
FP32(单精度) 32 1 8 23 约7.2位有效数字
FP64(双精度) 64 1 11 52 约15.9位有效数字

数值公式是:(-1)^s × 2^(e - bias) × 1.f。这个偏置值(bias)很重要,我刚开始做并行计算时经常算错,导致写的精度比对脚本全是错的。要特别小心。

3.2 五种舍入模式

你想想看,一个数的小数位超过了尾数位怎么办?必须舍入。IEEE 754定义了五种舍入模式。GPU和CPU如果在某个运算上用了不同的舍入模式,结果不一样是必然的。

  • 舍入到最近,偶数优先:最常见的一种。结果落在两个可表示数之间时,取距离最近的那个;距离相等时,选偶数(最低有效位为0的那个)。这是默认模式,大部分编译器、GPU和CPU的默认行为就是这个。
  • 向+∞舍入:往正无穷方向取,也叫向上取整。
  • 向-∞舍入:往负无穷方向取,也叫向下取整。
  • 向0舍入:直接截断,不往正或负方向扩展。这个最简单,但精度损失在你做大量累加时会显著变大。
  • 舍入到最接近,向0取整:很少用,知道就行。

我在项目中遇到过一个问题:在GPU上用CUDA的`__fadd_rn`做加法,CPU上用默认的round-to-nearest-even,结果每次比都有微小差异。后来查到是GPU驱动里把舍入模式改成了round-to-zero——因为那个内核太长了,编译器做了激进优化。所以,不要假设两边的舍入模式一定一样。

注意:不要假设默认舍入模式就是向偶数舍入。很多GPU的低级API(如CUDA PTX)允许指令级指定舍入模式。如果两边指令用了不同的舍入后缀,就不要再花时间查别的了——先查舍入模式。

3.3 浮点异常与特殊值

除了舍入,还得处理特殊情况。上溢出、下溢出、除零、无效操作(比如0.0/0.0)。IEEE 754规定,这些情况会产生特定的特殊值:

  • NaN(Not a Number):无效操作的结果。
  • Infinity:上溢出或除零。
    - -Infinity:负方向上溢出或负除以零。
  • Denormalized numbers(非规格化数):当下溢出到无法用最小规格化数表示时,表示一类稀疏的、精度逐渐丢失的数。计算很慢,很多GPU支持flush-to-zero,直接把非规格化数变成0,这会带来精度差异。

我曾经看过一个诡异的Bug:在A100上训练模型,反向传播时梯度在某些层突然变成NaN。后来发现,是CPU端预处理时把一个极小量用非规格化数表示了,而GPU启用了flush-to-zero,把那个极小量直接视为0,导致后面的除法变成了除以0。所以一致性检查时,得专门对比那些靠近零边界的数值。

3.4 不同舍入路径与一致性检查策略

知道了这些规范,怎么落实到检查上?我个人的做法是三步:

  1. 确认两边用的浮点格式是否一致——有些CPU库默认用FP64,而GPU内核写的是FP32,不仔细看就会把双精度和单精度结果硬比。
  2. 确认两边用的舍入模式——查看编译器选项、运行时API、库文档。例如GCC的`-frounding-math`、CUDA的`--ftz=true`和`--prec-div=false`等。
  3. 对比时设定合理的误差容限——不要求完全相等,而是用相对误差或ULP(Unit in the Last Place)比较。

下面这个例子是Python里查看默认舍入模式,以及在CPU侧改变舍入模式做测试的方法:

import sys
import math

# 查看当前CPU默认舍入模式(CPython默认是round-half-even)
print("默认舍入模式:", sys.float_info.round)

# 模拟两种舍入模式的结果差异
a = 1.25
b = 1.75
print("round-half-even 1.25:", round(a, 1))
print("round-half-even 1.75:", round(b, 1))

# 要模拟round-up,只能用math.ceil或自己写辅助函数
def round_up(v):
    return math.ceil(v * 10) / 10

print("round-up 1.25:", round_up(1.25))
print("round-up 1.75:", round_up(1.75))

在GPU侧,你可以用CUDA的数学函数后缀显式控制舍入模式。比如`__fadd_rn`就是带round-to-nearest-even的加法。

// CUDA 代码示例:显式指定舍入模式
float a = 1.25f;
float b = 2.75f;

// 使用圆整到最近偶数
float c_rn = __fadd_rn(a, b);

// 使用圆整到零(截断)
float c_rz = __fadd_rz(a, b);

// 打印做对比
printf("add rn: %.10f\n", c_rn);
printf("add rz: %.10f\n", c_rz);
经验:当你发现两边结果在低位上差1个ULP时,99%是舍入顺序或舍入模式不同。不要花太多时间怀疑编译器Bug。先确认舍入路径,再做别的排查。

3.5 知识体系:舍入与一致性的关系图

为了把这套逻辑串起来,我画了个流程图。它展示了一个数从现实值到最终输出结果的过程中,哪些环节可能引入差异。

浮点数结果一致性检查中的舍入影响路径 真实数学值 转换为FP16/32/64 GPU 舍入模式 CPU 舍入模式 特殊值处理 经过多次运算(累加、乘法、除法) 一致性比较:绝对相等 / 相对容差 / ULP

核心思路:一致性检查不能只看最后那个数字。你得回溯整个链条:输入格式转换→舍入模式→特殊值处理→运算顺序→累积误差。每一步都可能因为标准细节不一致而产生差异。

3.6 实用兼容做法

既然知道差异根源,那在实际项目中怎么减少头疼?我分享三个习惯:

  • 统一浮点运算库——CPU端用Intel MKL或OpenBLAS时,去掉多线程的乱序累加;GPU端用CUBLAS时,设置CUBLAS_PEDANTIC_MATH,强制使用IEEE兼容路径。这能消除不少莫名其妙的差异。
  • 在GPU端显式关闭flush-to-zero——对于训练任务尤其重要。在CUDA里可以设置__fma_rn和cudaSetDeviceFlags(cudaDeviceScheduleBlockingSync | cudaDeviceMapHost)等方式,迫使GPU按照严格的IEEE标准处理非规格化数。
  • 建立一个参考实现——用一个高精度库(比如Python的decimal或MPFR)计算准确值,然后和GPU、CPU的结果对比,而不是让GPU和CPU互相对比。这样能更快定位是哪一方偏离了正确结果。
# 使用MPFR库做高精度参考(Python接口gmpy2)
import gmpy2
from gmpy2 import mpfr

# 设置高精度
gmpy2.get_context().precision = 128  

# 计算一个可能产生差异的表达式
a = mpfr(1.0) / mpfr(3.0)
b = mpfr(a) * mpfr(1000000.0)
print("高精度参考值:", b)

# 对比单精度FP32的近似结果
import struct
import math
fp32_val = 1.0 / 3.0
res_fp32 = fp32_val * 1000000.0
print("FP32结果:", res_fp32)
# 差异大概率来自舍入

嗯,这个IEEE 754标准与浮点舍入的规矩,我不指望你听完就能把所有问题都搞定。但你至少得知道:两边的结果不一样时,翻代码前先翻标准。理清存储格式、舍入模式、特殊值处理这三块,你的排查效率至少翻倍。