推理引擎的“蝴蝶效应” -- 从V32 模型的输出不一致说起

2026-01-21

摘要

本文确认了 DeepSeek-V32 模型在输入长度为 4K的场景中 KV缓存 不一致并非输入或写入错误,而是稀疏化注意力(Sparse Atttenion)的输入的索引顺序不一致引发的输出差异,且该差异可在最小复现脚本中稳定复现。

背景

起因是排查推理引擎的一个异常现象:同一个长请求,在不同 DP(Data Parallel) 节点上得到的部分KV 缓存 ( KV Cache ) 不一致,在我们刻意关闭 batch 干扰、仅保留单请求和并行度 1 的情况下仍然复现,这个现象很不自然,于是我们决定把这条链路拆开,一段一段去验证精度问题。

实验方法和结果

仅 dump 结果,不对 topk 做矫正

针对 DeepSeek 模型,当前的计算逻辑可以简单拆分为 attn0q_a/b_proj, bmm_wkc, kv_a_proj, rotary_emb计算)、attn1mqa, bmm_wkv, o_proj 计算) 和混合专家 (MoE) 三大部分,因此实验的第一步是对每一层的 attn0attn1 输出进行导出并做严格对比。 attn0 的导出代码

attn1的导出代码

结果显示第 0 层的 attn0 可以做到 每个比特级别 (bitwise) 一致,而 attn1 不能做到完全一致,这一步直接把我们的归因的注意力集中到 attn1 内部的稀疏化注意力输出。为了让方法可复现,我们使用 diff_safetensors.py 对每一层的 attn0attn1 输出做逐项对比,必要时输出差异窗口与直方图,脚本细节见附录。 layer0的 attn0 的结果对比

attn1 的 layer0 的输出结果对比

可以看到,对比结果显示attn1 的计算部分中,主要的不一样有三点:attn_bmm_outputattn_output, 以及最后的 output 不一致。而我们已知:attn1 计算部分中,最开始的计算就是flashMLASparse Attntion,它的输出不一致,显然会造成后续所有算子输出的不一致。

因此接着我们继续追溯 Sparse Attention 的输入,输入路径包含 QKV、kvcache 以及 index,其中 QKV 经过逐项对比保持一致,因此问题不在 QKVkvcache 需要通过 pagetable_1 索引取得,我们将 pagetable_1 排序后再按排序后的索引去取 kvcache,结果可以完全对齐,这说明 kvcache 内容一致但顺序不同,问题进一步收敛到 index 的差异。为了排除索引顺序差异只是表象这一可能性,我们对 pagetable_1 的索引集合进行严格比较,并验证排序后 kv_cache_by_pagetable 一致,从而确定差异来自 index 顺序而非索引集合,这一步把问题从写入路径排除后稳定落在索引顺序上。 kvcache 排序后严格相等

最后一步是最小复现,我们使用 test_attn_mqa_determinism.py脚本 (具体代码见附录),保持 topk 集合不变,仅交换 index 顺序并在多个 GPU 上重复运行,结果稳定出现 BF16 量级差异,这说明 index 顺序变化足以引发 Sparse_Attention 输出差异,进而导致 attn1 输出不一致,以下为一次执行的 shell 输出。

run=0 device=cuda:0 same=False max_diff=0.00390625 same_bmm=False max_diff_bmm=0.0625
run=1 device=cuda:0 same=False max_diff=0.00390625 same_bmm=False max_diff_bmm=0.09375
run=2 device=cuda:0 same=False max_diff=0.00390625 same_bmm=False max_diff_bmm=0.125
run=3 device=cuda:0 same=False max_diff=0.00390625 same_bmm=False max_diff_bmm=0.0625
run=4 device=cuda:0 same=False max_diff=0.00390625 same_bmm=False max_diff_bmm=0.0625
run=5 device=cuda:0 same=False max_diff=0.00390625 same_bmm=False max_diff_bmm=0.09375
run=6 device=cuda:0 same=False max_diff=0.00390625 same_bmm=False max_diff_bmm=0.0625
run=7 device=cuda:0 same=False max_diff=0.00390625 same_bmm=False max_diff_bmm=0.0625

修改topk结果,输出后排序

很自然地,我们已知 topk 输出的时候,index 其实是无序的;那么我们会做这么一个实验,若将 topk 的输出做一次排序,岂不是可以做到两次同样的请求下,输出能一致?也可以作证前文的初步结论。于是我们可以在送入 attn 前,对 topk_indices 使用 torch.sort(stable=True)进行排序。然而,实验结果显示,在 layer7 的时候,topk 的结果发生了变化,两次 req 产生的 topk 结果并不一致!

TP Rank 1: debug_indexer_topk_tensor_file_7_tp1.safetensors
─────────────────────────────────────────────────────────────────────────
[Basic Checks]
logits_valid allclose      : True
ref_sorted equal(sorted)   : True
row_lens allclose          : True
row_starts allclose        : True
topk_result equal(sorted)  : False
>> topk_result mismatch at 654597 (window 654596:654599)
┏━━━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━━━━━┓
┃    idx ┃    a ┃    b ┃ abs_diff ┃
┡━━━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━━━━━┩
654596166516650654597166616671654598166716681└────────┴──────┴──────┴──────────┘
topk_sorted equal(sorted)  : False
>> topk_sorted mismatch at 654597 (window 654596:654599)
┏━━━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━━━━━┓
┃    idx ┃    a ┃    b ┃ abs_diff ┃
┡━━━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━━━━━┩
654596166516650654597166616671654598166716681└────────┴──────┴──────┴──────────┘
[Shape Info]
TP1 shapes:
  A: topk=(323, 2048) int32 | logits=(323, 2590) float32
  B: topk=(323, 2048) int32 | logits=(323, 2590) float32
[Comparison Logic]
TP1: A vs B topk_sorted equal           = False
TP1: A topk_sorted vs ref_sorted equal  = True
TP1: B topk_sorted vs ref_sorted equal  = False
TP1: A vs B topk_indices equal(sorted)  = False
>> TP1_A_vs_B_topk_sorted mismatch at 654597 (window 654596:654599)
┏━━━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━━━━━┓
┃    idx ┃    a ┃    b ┃ abs_diff ┃
┡━━━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━━━━━┩
654596166516650654597166616671654598166716681└────────┴──────┴──────┴──────────┘
>> TP1_A_vs_B_topk_indices mismatch at 654597 (window 654596:654599)
┏━━━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━━━━━┓
┃    idx ┃    a ┃    b ┃ abs_diff ┃
┡━━━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━━━━━┩
654596166516650654597166616671654598166716681└────────┴──────┴──────┴──────────┘

可以看出,确实是存在 A 请求的 topk 输出能和 ref 的结果匹配,但是 B 请求的 topk 的输出不能和 ref 的结果匹配,且 A 和 B 请求的 topk 的输出相差一。这就很微妙,为何这里能不一样?难道是 topk 算子计算有误吗?我们要知道,由于 topk 本身的不稳定性,如果有分数相同的两个 index,此时哪个先哪个后是完全未知的,如果这俩分数正好在 topk 输出范围的边缘,就会造成输出偏差。为此,我们需要进一步证实,是否是这个原因?

用 torch.sort 替换 topk,输出对比

承接上文,为了验证是否是 topk 的问题,我们可以直接将 topk 换掉,用 torch.sort(stable=True)的方式来选取 topk 个输出。按照这个思路,我们得到了意料之内结论,一直到第 61 层的输出时,结果仍然能保持一致。 第 61 层的输出对比 此时我们回到 layer7 去查看修改后的 topk 的输出,以及是否存在同分数的情况: 第 8 层的 topk 对比 从图中可以看到,确实是存在了同分数的情况,且也正好落在 topk 的范围边缘(V32 模型选前 2048 个数),导致了如果是非稳定排序,就会导致输出的 index 相差一位。

结论

问题的根因是稀疏化注意力输入的索引顺序不一致,虽然索引集合一致但顺序不同,导致注意力模块计算放大成输出差异,随着每经过一层的 topk 和注意力计算,误差逐步累加,最后输出的差异就如下图对比的结果一样差之千里。因此我们得出结论:对于 DeepSeek-V32 这类使用稀疏化注意力的模型,其面对相同的输入,输出本身就会存在差异,且随着层数的增多,逐步形成肉眼可观测到的差异。不仅如此,topk 的不稳定排序同样会导致其自身输出在面对同分数,且索引在输出范围边缘时,输出就会存在波动:表现为输出的 index 相差一位,且结果会进一步“污染” 后续的所有计算,从而导致哪怕是相同的请求,中途的 kvcache 也会逐渐不一致。 两个一样的 req 的逐层输出结果误差范围对比

进一步探索

这一步可以把原因拆成两层,第一层是 topk 的稳定性问题,topk 在 GPU 上为了吞吐通常使用分块筛选与并行归约,尤其在 score 相等或近似相等时无法保证稳定排序,索引集合是一致的但顺序可能改变,这是性能优先带来的可接受不确定性。 第二层是 attn 的数值确定性问题,softmax 的稳定形式是先取最大值再归一化,但是在 GPU 上进行加速计算时,会选取online-softmax , 在合并两个块的结果时,会用到如下的合并公式 : $$ \text{m}=\max(m_A, m_B)) \tag{1} $$ $$ \text{s}= (exp(m_A-m) \cdot s_A+ \exp(m_B-m) \cdot s_B) \tag{2} $$

有限精度下加法非结合导致不同块顺序出现不同舍入轨迹,所以顺序变化会改变最终输出,这属于数学算法在有限精度下的数值特性而非功能缺陷。具体到这次的 index 顺序变化,会导致每次计算 online-softmax 时,每次合并的这一步算法,更新的 s 的值不一致,导致了在 BF16 上的精度差异。

附录

下面给出复现与对比用到的脚本,便于完整复现和验证。

diff_safetensors.py

diff_safetensors

test_attn_mqa_determinism.py

test_attn_mqa_determinism