参考
不同硬件和推理引擎模型输出的精度差异
在不同硬件和推理引擎上,同一个深度学习模型输出存在精度差异是一个常见且正常的现象。这种差异源于多个层面因素的叠加,从底层的硬件算术逻辑到上层的软件优化策略,每一个环节都可能引入微小的数值变化。
CPU vs. GPU 的不同算术逻辑
例如torch.cuda 和 cpu 相同运算,结果不一样
-
一些CPU(如x86架构)在执行浮点计算时,会使用80位的扩展精度寄存器进行中间计算,最后再将结果截断为32位(FP32)或64位(FP64),而GPU则严格按照32位或16位进行计算,这种不同的中间精度会导致最终结果出现微小偏差。
-
并行计算顺序: GPU是高度并行的处理器。浮点数的加法和乘法不满足严格的结合律(即 \((a+b)+c\) 不完全等于\(a+(b+c)\))。在GPU上,一个大规模的求和运算会被拆分成数千个并行任务,其求和顺序是不确定的,每次运行都可能不同,从而导致最终结果的微小波动
1
2
3
4
5(0.1 + 1e20) - 1e20 >>> 0 0.1 + (1e20 - 1e20) >>> 0.1 -
专用计算单元: 现代GPU(如NVIDIA GPU)包含专门用于AI计算的Tensor Cores,而CPU则使用AVX等指令集。这些专用单元在执行矩阵乘法等操作时,为了效率可能会采用与通用计算单元不同的算法或数值表示,从而引入差异
算子(Kernel)实现的差异
即使是实现同一个数学公式,不同硬件和框架的底层算子(Kernel)实现也大相径庭。
- 硬件层面:
- NVIDIA GPU 依赖于其 cuBLAS 和 cuDNN 库。
- 华为 Ascend NPU 依赖于其 CANN (Compute Architecture for Neural Networks) 库。
- 这些库是厂商针对自家硬件深度优化的“黑盒”,其内部的算法选择、分块策略、内存使用方式和精度处理都有差异,导致计算路径不同。
- 框架层面:
- 像 vLLM 和 MindIE 这样的高性能推理框架,为了极致的优化(例如处理 PagedAttention),会实现高度定制化的算子。这些自定义算子的算法逻辑和数值稳定性可能与 PyTorch 的原生实现不同,从而引入差异。
- “批次不变性”的缺失 (Lack of Batch Invariance):
- 这是一个更深层次、但至关重要的原因。理想情况下,对一个样本进行推理的结果,不应该受到同时处理的其他样本(即批次大小)的影响。
- 然而,许多高性能算子(如矩阵乘法)为了在不同批次大小下都达到最优性能,会选择不同的内部算法或分块策略。例如,处理小批量时可能不使用 Tensor Cores,而处理大批量时则会使用。
- 这导致了 模型(输入[0]) 的结果不等于 模型(输入)[0]。
- 在一个推理服务中,服务器的负载是动态变化的,这意味着你的请求被处理时的批次大小是“随机”的。不确定的批次大小 + 缺乏批次不变性的算子 = 用户层面不确定的输出结果。这完美解释了为什么即使在同一台机器上,连续两次相同的请求也可能得到不同的结果。
解码策略的放大效应(Amplification via Decoding)
计算层面产生的微小数值差异,会在语言模型的生成(解码)过程中被急剧放大。
- 贪心解码 (Greedy Decoding, temperature = 0):
- 即使采用确定性的贪心解码,当两个候选 Token 的 logits 值非常接近时(例如 Token_A: 10.001 vs Token_B: 10.002),硬件或算子引入的微小计算误差足以改变它们的排序,导致 argmax 选择了另一个 Token。
- 采样解码 (Sampling, temperature > 0):
- 在采样解码中,微小的 logits 差异会改变整个词表的概率分布。即使排序不变,概率的微小变化也可能导致在随机采样时选出完全不同的 Token。
- 蝴蝶效应: 一旦在某个生成步骤中选择了不同的 Token,这个 Token 就会成为下一步生成的输入,导致后续的整个文本序列走向完全不同的方向,从而产生巨大的差异。
如何系统性地诊断和定位问题
当遇到不同环境导致模型效果差异巨大时,可以按照以下步骤进行排查。
建立一个可控的比较基准
首先要排除掉所有已知变量,确保是在对等的比较。
- 统一模型和精度: 确保使用完全相同的模型权重,并且使用相同的数值精度(例如,统一使用 BF16,避免 FP16 和 BF16 混用)。不使用或使用相同的量化策略。
- 固定解码策略: 采用贪心解码 (temperature = 0, top_p = 1) 来消除解码过程中的随机性。
- 固定随机种子: 为所有相关库(PyTorch, NumPy, etc.)设置固定的随机种子。
- 关闭动态优化: 禁用 Prefix Cache、NTP 等可能影响精度的动态优化选项。
- (可选,用于调试)强制确定性算法: 在 PyTorch 中,可以设置 torch.use_deterministic_algorithms(True)。这会强制 PyTorch 使用确定性的(但通常更慢的)算法实现,有助于定位问题是否源于 cuDNN 的非确定性选择。
- 验证输入: 确保经过 Chat Template 渲染后的输入 Tokens ID 序列是完全一致的。
定位差异来源
-
检查 Logits 分布: 开启 logprobs 选项,获取每个生成 Token 的 logits 或 log probabilities。逐个 Token 对比两个平台的输出,找到第一个出现差异的 Token,并检查其 logits 值的差异程度。
-
进行算子级差异检查: 这是最深入的调试方法。
-
修改模型的前向传播函数,逐层(或逐个算子)保存其输出的隐藏状态(hidden states)。
- 在两个平台上分别运行并保存这些张量。
-
计算每层输出张量的相对误差 $$( A - B ₂ / A ₂)$$ 或余弦相似度。 - 定位误差突然增大的层。例如,如果前几层的余弦相似度都是 0.99999,但某一层突然降到了 0.99,那么问题很可能出在这一层或其紧邻的上一层。
-
进行统计学意义的评估
在生产环境中,我们更关心模型的宏观表现而非微观的数值一致性。
- 使用评测框架: 利用 OpenCompass、lm-eval-harness 等框架,在标准的学术评测集(如 GSM8k, MMLU, C-Eval)上运行模型。
- 分析结果: 比较两个平台上的评测分数。一般来说,如果核心指标(如准确率)的差异在 3% - 5% 以内,通常可以认为是可接受的范围。如果差异过大,则说明存在显著的性能衰退,需要深入排查。
解决方案与延伸思考
- 调试时: 严格遵循上述诊断步骤,特别是使用贪心解码和确定性算法来定位问题。
- 生产中:
- 接受并评估: 认识到一定程度的差异是正常的性能权衡。重点是通过评测框架来确保模型的宏观能力没有显著下降。
- 保持环境一致: 尽量在开发、测试和生产环境中使用相同的硬件、驱动和软件栈。
- 重新进行提示词工程: 当切换到新的硬件或推理框架后,模型的“偏好”可能发生微小变化。重新进行一轮提示词(Prompt)调优可能是必要的。
延伸问题
- 量化后的模型一定比量化前差吗?
- 不一定。量化引入的噪声有时可以起到类似 Dropout 的正则化作用,可能打破模型对某些特征的“过拟合”,反而提升其泛化能力,在某些场景下效果会更好。
- 大模型天然就是不稳定的吗?
- 是的。从根本上说,大语言模型是概率模型。在实际应用中,为了生成内容的多样性和创造性,几乎都会使用 temperature > 0 的采样解码,这使其输出天然具有随机性。因此,所有的评测结果都只有统计学意义。
- 效果不一致一定是硬件或算子差异吗?
- 不一定。很多时候,这可能是由推理框架本身的 Bug 引起的。在定位问题时,不能排除软件缺陷的可能性。