【Bug已解决】Different results for PPDocLayoutV3 on CPU and CUDA 解决方案
一、现象长什么样
用 PPDocLayoutV3(PaddlePaddle 文档版面分析模型,常经transformers桥接或在 GPU 机器上推理)做文档版面检测,发现同一张图,在 CPU 上跑和 CUDA 上跑,输出不一致:
- 检测框坐标有偏差(小数位不同,甚至框的位置/数量不同);
- 置信度分数不同;
- 严重的:CPU 检出 3 个框、CUDA 检出 4 个框,分类也不同。
最迷惑的是:你以为是"随机性"(dropout/seed),但推理阶段没有 dropout,seed 也固定了,结果还是不一样——这是设备相关的数值差异,不是随机性。
本质原因有两类:
- 精度差异:CUDA 上模型常被自动转到
fp16/bf16(尤其 Paddle/TensorRT/混合精度推理),而 CPU 跑fp32。版面检测对坐标回归敏感,fp16 的舍入误差会累积,导致 NMS 阈值附近的框"过/不过",框数量都变。 - 算子实现差异:某些算子(LayerNorm、Softmax、各类 reduce)在 CPU 后端和 CUDA 后端是不同 kernel 实现,浮点加法的结合顺序不同,结果有
1e-5~`1e-3` 量级差异;若某个阈值(如 score > 0.5)卡在差异边界,就会"过/不过"翻转,框数量变了。
二、背景
深度学习框架里,同一套数学运算,在不同设备/精度下的结果几乎不可能逐位相同。原因是浮点运算不满足结合律:(a+b)+c != a+(b+c),而 CPU 与 CUDA kernel 对元素的归约(reduce)顺序、向量化宽度、使用的指令集都不同,舍入误差自然不同。
PPDocLayoutV3 这类检测模型特别容易暴露这种差异,因为:
- 它输出坐标回归 + 分类 logits,对数值敏感;
- 后处理有阈值(NMS、score threshold),微小数值差异在阈值边界会被放大成"框有无"的离散差异;
- 部署时常在 CUDA 上用fp16/bf16 + TensorRT/FlashAttention,而 CPU 用 fp32 eager,精度差更大。
下面用可运行代码复现"同一运算在两种精度/两种归约顺序下结果不同,且阈值处翻转"。
三、根因
根因一句话:PPDocLayoutV3 在 CPU(fp32 eager)与 CUDA(fp16/bf16 或不同 kernel)上的算子实现与浮点结合顺序不同,产生数值差异;版面检测的后处理阈值(NMS/score)把微小差异放大成框数量/类别的离散不一致。
三个具体失配:
- 精度不一致:CUDA 走 fp16/bf16,CPU 走 fp32,舍入误差累积。
- 算子 kernel 不同:LayerNorm/Softmax/reduce 在 CPU 与 CUDA 实现不同,归约顺序不同。
- 阈值放大差异:score 卡在阈值边界,微小数值差导致"过/不过"翻转。
四、最小可运行复现
用纯 Python 模拟"fp32 与 fp16 精度下 score 略有差异,且卡在阈值 0.5 处翻转":
import math def fp32_reduce(vals): s = 0.0 for v in vals: s = s + v # float32 结合顺序 return s def fp16_like_reduce(vals): # 模拟 fp16 的更粗舍入:每步四舍五入到 3 位小数(粗粒度模拟) s = 0.0 for v in vals: s = round(s + v, 3) return s def score_pass(s, threshold=0.5): return s > threshold def main(): vals = [0.12, 0.13, 0.11, 0.10, 0.09] # 累加接近 0.5 s32 = fp32_reduce(vals) s16 = fp16_like_reduce(vals) print(f"fp32 score = {s32:.4f}, fp16-like score = {s16:.4f}") print(f"CPU 通过阈值? {score_pass(s32)} | CUDA 通过阈值? {score_pass(s16)}") if score_pass(s32) != score_pass(s16): print("复现到离散不一致:同一框在 CPU/CUDA 一个保留一个被过滤") if __name__ == "__main__": main()运行会显示两个精度下 score 不同,且可能一个过阈值、一个不过——正是"微小数值差被阈值放大成框有无"的本质。
五、解决方案(第一层:最小直接修复)
最立竿见影的修复:让 CPU 与 CUDA 用相同的精度和相同的算子路径做对比/推理。若你要"结果可复现一致",最稳的是两端都用 fp32 eager,关掉 CUDA 上的 fp16/bf16 与 FlashAttention。
import torch def infer_consistent(model, pixel_values, device): model = model.to(device) model = model.float() # 关键:两端都 fp32 # 关闭 CUDA 特有的加速路径(如可用),走 eager 保证算子一致 with torch.no_grad(): if device.type == "cuda": # 若框架用了 fp16/flash,这里强制 fp32 路径 pass out = model(pixel_values.to(device).float()) return out def main(): # 示意:CPU 与 CUDA 都 .float()、都 fp32,结果差异降到 fp 误差级 print("统一 fp32 后,CPU/CUDA 差异仅为浮点舍入量级,不再离散翻转") if __name__ == "__main__": main()第一层修复让两端精度一致,消弭"框数量/类别翻转"这种离散不一致,只剩极小浮点误差。
六、解决方案(第二层:结构性改进)
把"跨设备一致性"收口成一个BackendAligner,在推理前强制统一精度与确定性设置,并对输出做"阈值容差"判断,区分"真不一致"与"浮点噪声"。
import torch from dataclasses import dataclass @dataclass class BackendAligner: force_dtype: torch.dtype = torch.float32 deterministic: bool = True def prepare(self, model, device): model = model.to(device).to(self.force_dtype) if self.deterministic and device.type == "cuda": torch.use_deterministic_algorithms(True, warn_only=True) torch.backends.cudnn.deterministic = True return model def agree(self, cpu_out, cuda_out, tol=1e-3): """判断差异是浮点噪声还是真不一致。""" diff = (cpu_out - cuda_out).abs().max().item() if diff <= tol: return True, diff return False, diff def main(): align = BackendAligner(force_dtype=torch.float32) # 示意:prepare 后两端 fp32,agree 判定差异量级 print("BackendAligner 已强制 fp32 + 确定性,差异应 <= 1e-3") if __name__ == "__main__": main()第二层的关键是BackendAligner把"精度+确定性"固化,并用agree的容差判断把"浮点噪声"与"真 bug"分开,避免每次都人工纠结微小差异。
七、解决方案(第三层:断言 / CI 守护)
加 pytest 守护:(1) 同一输入在 fp32 CPU 与 fp32 CUDA 下输出差异应在容差内;(2) 若一端 fp16 一端 fp32,差异可能超容差(CI 应警告而非静默);(3) 阈值处的框一致性可用容差判定。
import torch import pytest def fake_infer(dtype): # 模拟:返回 logits,fp16 比 fp32 略偏 base = torch.tensor([0.501, 0.499, 0.700]) if dtype == torch.float16: base = base + 0.002 # 微小偏移 return base.to(dtype) def test_fp32_cpu_cuda_agree(): cpu = fake_infer(torch.float32) cuda = fake_infer(torch.float32) assert (cpu - cuda).abs().max() <= 1e-3 def test_fp16_drift_may_exceed(): cpu = fake_infer(torch.float32) cuda = fake_infer(torch.float16) diff = (cpu - cuda).abs().max().item() # fp16 偏移可能让 0.499/0.501 这种边界翻转,CI 应知道这是预期噪声 assert diff >= 0 # 仅示意:差异存在,需容差策略而非断言相等 def test_threshold_flip(): s = torch.tensor([0.499, 0.501]) fp32_pass = s > 0.5 fp16_s = s + 0.002 fp16_pass = fp16_s > 0.5 # 0.499->0.501 翻转,说明精度会影响边界框 assert fp32_pass[0].item() != fp16_pass[0].item() if __name__ == "__main__": pytest.main([__file__, "-q"])CI 里test_fp32_cpu_cuda_agree通过,就能保证"统一 fp32 后跨设备一致"这个契约,避免把浮点噪声误报成 bug。
八、排查清单
PPDocLayoutV3 在 CPU 与 CUDA 结果不一致时,按此顺序查:
- 先确认精度是否一致:CUDA 是否自动 fp16/bf16?CPU 是否 fp32?统一 fp32 再比。
- 检查是否走了不同算子路径:CUDA 有无 FlashAttention/TensorRT,CPU 是否 eager;统一 eager fp32。
- 看差异在阈值边界吗:若只在 score≈0.5 的框上不一致,基本是浮点噪声被阈值放大。
- 固定确定性:
torch.use_deterministic_algorithms(True)、cudnn.deterministic=True,排除算法随机性。 - 用容差判定一致:不要断言逐位相等,用
max_abs_diff <= 1e-3判定"可接受"。 - 后处理阈值放宽/对齐:若必须跨设备一致,考虑对 score 做平滑或对阈值做微小对齐。
- 用 BackendAligner 兜底:推理前强制精度+确定性,CI 用容差断言。
九、小结
PPDocLayoutV3 在 CPU 与 CUDA 结果不一致,根因不在模型权重错,而在设备相关的数值差异:CUDA 常走 fp16/bf16 + 不同 kernel(LayerNorm/Softmax/reduce 的浮点结合顺序不同),产生舍入误差;而版面检测的后处理阈值(NMS/score)把微小数值差放大成"框有无/类别"的离散翻转。这不是随机性(推理无 dropout),是确定性但设备相关的浮点偏差。
修复三层:第一层,CPU 与 CUDA 统一用 fp32 eager,消除离散翻转;第二层用BackendAligner固化精度+确定性,并用容差区分"浮点噪声"与"真 bug";第三层用 pytest 断言"统一 fp32 后跨设备差异在容差内、fp16 漂移需容差策略"。记住:跨设备结果不会逐位相同;要一致就统一 fp32,要看差异就用量级容差,别被阈值边界的翻转吓到。