推理精度对齐

参考

不同硬件和推理引擎模型输出的精度差异

在不同硬件和推理引擎上,同一个深度学习模型输出存在精度差异是一个常见且正常的现象。这种差异源于多个层面因素的叠加,从底层的硬件算术逻辑到上层的软件优化策略,每一个环节都可能引入微小的数值变化。

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) 库。
    • 这些库是厂商针对自家硬件深度优化的“黑盒”,其内部的算法选择、分块策略、内存使用方式和精度处理都有差异,导致计算路径不同。
  • 框架层面:
    • vLLMMindIE 这样的高性能推理框架,为了极致的优化(例如处理 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 就会成为下一步生成的输入,导致后续的整个文本序列走向完全不同的方向,从而产生巨大的差异。

如何系统性地诊断和定位问题

当遇到不同环境导致模型效果差异巨大时,可以按照以下步骤进行排查。

建立一个可控的比较基准

首先要排除掉所有已知变量,确保是在对等的比较。

  1. 统一模型和精度: 确保使用完全相同的模型权重,并且使用相同的数值精度(例如,统一使用 BF16,避免 FP16 和 BF16 混用)。不使用或使用相同的量化策略。
  2. 固定解码策略: 采用贪心解码 (temperature = 0, top_p = 1) 来消除解码过程中的随机性。
  3. 固定随机种子: 为所有相关库(PyTorch, NumPy, etc.)设置固定的随机种子。
  4. 关闭动态优化: 禁用 Prefix Cache、NTP 等可能影响精度的动态优化选项。
  5. (可选,用于调试)强制确定性算法: 在 PyTorch 中,可以设置 torch.use_deterministic_algorithms(True)。这会强制 PyTorch 使用确定性的(但通常更慢的)算法实现,有助于定位问题是否源于 cuDNN 的非确定性选择。
  6. 验证输入: 确保经过 Chat Template 渲染后的输入 Tokens ID 序列是完全一致的。

定位差异来源

  1. 检查 Logits 分布: 开启 logprobs 选项,获取每个生成 Token 的 logits 或 log probabilities。逐个 Token 对比两个平台的输出,找到第一个出现差异的 Token,并检查其 logits 值的差异程度。

  2. 进行算子级差异检查: 这是最深入的调试方法。

    • 修改模型的前向传播函数,逐层(或逐个算子)保存其输出的隐藏状态(hidden states)。

    • 在两个平台上分别运行并保存这些张量。
    • 计算每层输出张量的相对误差 $$(   A - B   ₂ /   A   ₂)$$ 或余弦相似度
    • 定位误差突然增大的层。例如,如果前几层的余弦相似度都是 0.99999,但某一层突然降到了 0.99,那么问题很可能出在这一层或其紧邻的上一层。

进行统计学意义的评估

在生产环境中,我们更关心模型的宏观表现而非微观的数值一致性。

  • 使用评测框架: 利用 OpenCompass、lm-eval-harness 等框架,在标准的学术评测集(如 GSM8k, MMLU, C-Eval)上运行模型。
  • 分析结果: 比较两个平台上的评测分数。一般来说,如果核心指标(如准确率)的差异在 3% - 5% 以内,通常可以认为是可接受的范围。如果差异过大,则说明存在显著的性能衰退,需要深入排查。

解决方案与延伸思考

  1. 调试时: 严格遵循上述诊断步骤,特别是使用贪心解码和确定性算法来定位问题。
  2. 生产中:
    • 接受并评估: 认识到一定程度的差异是正常的性能权衡。重点是通过评测框架来确保模型的宏观能力没有显著下降。
    • 保持环境一致: 尽量在开发、测试和生产环境中使用相同的硬件、驱动和软件栈。
    • 重新进行提示词工程: 当切换到新的硬件或推理框架后,模型的“偏好”可能发生微小变化。重新进行一轮提示词(Prompt)调优可能是必要的。

延伸问题

  • 量化后的模型一定比量化前差吗?
    • 不一定。量化引入的噪声有时可以起到类似 Dropout 的正则化作用,可能打破模型对某些特征的“过拟合”,反而提升其泛化能力,在某些场景下效果会更好。
  • 大模型天然就是不稳定的吗?
    • 是的。从根本上说,大语言模型是概率模型。在实际应用中,为了生成内容的多样性和创造性,几乎都会使用 temperature > 0 的采样解码,这使其输出天然具有随机性。因此,所有的评测结果都只有统计学意义。
  • 效果不一致一定是硬件或算子差异吗?
    • 不一定。很多时候,这可能是由推理框架本身的 Bug 引起的。在定位问题时,不能排除软件缺陷的可能性。