一、GPU 与 CPU 计算差异概述
咱们先聊聊一个基础问题:同样的算法,在 CPU 上跑和 GPU 上跑,结果为什么不一样?
我刚入行那会儿,遇到过一个挺头疼的事。一个分子动力学模拟程序,在 CPU 集群上跑了三天,结果迁移到 GPU 上才半天就跑完了,但输出的能量数值在小数点后第六位对不上。一开始我以为是自己代码写错了,折腾了一周才发现——这不是 bug,这是计算硬件本身的差异造成的。
这章我们就来拆解一下,GPU 和 CPU 到底在哪些地方“不一样”,以及这些差异会如何影响计算结果的一致性。
1.1 最核心的矛盾:数值精度
其实说白了,计算机处理实数时根本做不到绝对精确。它只能把实数映射到有限的二进制位上。这个映射方式不一样,结果自然就不同。
我们通常用 IEEE 754 浮点标准 来统一这件事。但 CPU 和 GPU 对这个标准的“理解”和执行,存在微妙的偏差。
浮点数的基本构成
你可以把浮点数想象成科学记数法的二进制版本:
Value = (-1)^sign × mantissa × 2^(exponent - bias)
单精度(32-bit)和双精度(64-bit)是最常用的两种格式:
| 类型 | 总位数 | 符号位 | 指数位 | 尾数位 | 最大精度(十进制) |
|---|---|---|---|---|---|
| 单精度 (float32) | 32 | 1 | 8 | 23 | 约 7 位 |
| 双精度 (float64) | 64 | 1 | 11 | 52 | 约 16 位 |
我个人习惯把这件事讲成一个“第一性原理”问题:只要位数有限,舍入误差就不可避免。CPU 和 GPU 在舍入策略、中间精度上做了不同的选择,差异就从这里开始。
1.2 浮点运算的“陷阱”
你想想看,同一个数学公式,用不同的顺序去算,结果能不能完全一致?
我告诉你,不一定。
比如一个最简单的表达式:a + b + c。CPU 可能会先算 a + b,再把结果跟 c 相加。GPU 可能会合并成 a + (b + c),或者干脆用别的组合。在实数域里,加法满足交换律和结合律,但在浮点数世界里,它们不成立。
为什么会这样?因为每一步加法都可能产生舍入。如果两个数数量级差太多,小数的尾数就被“吃掉”了。
经典例子:
设 a = 1.0 × 10^8,b = -1.0 × 10^8,c = 1.0
顺序一:(a + b) + c = 0.0 + 1.0 = 1.0 ✓
顺序二:(a + c) + b = 100000001.0 - 100000000.0 = 0.0 ✗(精度丢失)
类似的问题,当你把代码从 CPU 移植到 GPU 上时,每天都在发生。
1.3 计算模式的内生差异
CPU 走的是 MIMD(多指令多数据) 路子。每个核心可以跑不同的指令,处理不同的数据。寄存器多,缓存大,流水线深。
GPU 走的是 SIMT(单指令多线程) 路子。一批线程(32 个一组叫 warp)执行同一条指令,只是处理的数据不同。
这种架构差异,直接影响结果一致性。
- CPU: 串行执行一条线程,误差累积路径唯一且可重复。
- GPU: 大量并发线程共享算术单元,线程间可能用不同的中间寄存器宽度,甚至因为 warp 调度顺序不同导致全局归约的顺序不一样。
我记得有一次对账,CPU 版本输出的是单精度 float32 累加,GPU 版本默认使用了 部分双精度中间寄存器,最后导致累计和在小数点后第 4 位就出现偏差。这种非对称的中间精度,是 GPU 厂商为性能做出的权衡。
1.4 硬件实现的“黑盒子”
同一套 ISA(指令集架构),不同厂商、甚至同一厂商不同代的产品,内部实现都可能不同。
| 差异维度 | CPU 倾向 | GPU 倾向 |
|---|---|---|
| 指令流水线 | 深度乱序执行,千级指令缓冲 | 轻量按序执行,大宽度并行 |
| 融合乘加(FMA) | 可配置开启或关闭 | 默认开启,且中间精度更高 |
| 分支预测 | 复杂预测器,减少惩罚 | 预测简单,分支发散导致串行 |
| 特殊函数 | 高精度查表+多项式 | 快速近似实现(如 __sinf) |
说白了,GPU 为了吞吐率,在特殊函数(sin、cos、exp、rsqrt 等)上做了精度和速度的交换。CPU 的 sin 可能精确到 1 ULP(最后一位单位),GPU 的快版 __sin 可能只到 3~5 ULP。对于图形渲染,这没问题;对于科学计算,这就是灾难的源头。
我曾经踩过的坑:
在 CUDA 里直接用了 sinf()(而非 __sinf()),结果发现不同架构的 GPU 输出有微小偏差。后来统一改用 CPU 侧的头文件实现来重载,才解决了跨架构复现的问题。
1.5 中间结果精度与 FMA
FMA(Fused Multiply-Add)是 GPU 上非常重要的特性。
普通运算:d = (a * b) + c,先把 a * b 舍入到目标精度,再与 c 相加并再次舍入。
FMA 运算:d = a * b + c,整个计算只在最后舍入一次。
这个差异会导致结果在最低有效位上不同。而且 CPU 的 fma() 和 GPU 的 fma() 实现细节并不完全一致,比如对 NaN 的处理、符号位的规则等。
// 同样的输入,不同的输出
float a = 1.0000001f;
float b = 2.0000001f;
float c = -2.0000002f;
// 标准乘加:两步舍入
float r1 = a * b + c;
// FMA:单步舍入
float r2 = fmaf(a, b, c);
printf("r1 = %.10f\n", r1);
printf("r2 = %.10f\n", r2);
// 通常会在小数点后第7位左右出现差异
一点经验: 如果你在做矩阵乘法或者点积,GPU 的 cuBLAS 默认会大量使用 FMA,并且可能在不同版本中微调顺序。与其纠结于“哪个更对”,不如建立统一的浮点契约。
1.6 总结:差异框架图
我把上面这些差异归纳成一张图,方便你建立整体认知。
我的习惯:
开始一个新项目前,我会先跑一个“精度基线测试”。用一组固定输入分别在 CPU 和 GPU 上运行核心计算函数,记录每条输出结果的 ULP 偏差分布。这样在后续遇到不一致时,能快速定位是算法问题还是硬件差异。
以上这些差异,不是你写代码可以完全避免的。它们是硬件层面的固有属性。但是,理解它们,是设计一致性检查方法的第一步。