当前位置:首页 > 逆向工程 > 正文

改 batch 组成就改输出:逐位一致要付 53% 的延迟

约 4602 字 · 预计阅读 12 分钟
一句话总结:同一个 prompt,只要同批里塞进别的请求,GPU 的累加顺序就变,浮点结果最后几位就不同;BlockLock 用"只锁被保护请求的计算几何"把这件事修掉,代价是相对 vLLM 的批不变方案延迟降 53.5% 到 55%。

先说一个反直觉的事实:大模型推理不是确定性的。

同一句 prompt,同样的参数,跑两次可能给你不同的字。不是因为采样,temperature 设成 0 也一样。

原因藏在 GPU 的 GEMM 里。

同一个 prompt,batch 里换个邻居就变了

矩阵乘法的累加顺序会影响结果。这是浮点数的老问题:加法不满足结合律。

(a + b) + c 和 a + (b + c) 在实数上相等,在 float32 上可能差最后一位。

GPU 做批量矩阵乘的时候,怎么切分、按什么顺序累加,取决于这一批的形状——batch 里有几个请求、每个请求多长、pad 到多少。同一条请求单独跑,和它跟七个别人一起跑,走的累加路径不一样。

结果就是:输出会变。

这件事平时没人管,因为差的那几位通常翻不动 argmax。但在三种场景里是要命的:审计(要复现某次输出)、A/B 测试(要保证两次只差一个变量)、前缀缓存(缓存键算出来不一致就命中不了)。

BlockLock 只锁住被保护的那条请求

现有的两条路都有明显代价。

一条叫批不变 kernel(batch-invariant kernels):把每个算子的累加顺序都固定死。彻底,但要重写大量 kernel,性能也塌。

另一条是把 prefill 隔离出来,decode 用固定形状加 padding。缺点是放弃了共享 prefill 的计算,还要为 padding 白算。

BlockLock 走第三条:保留共享的批量 prefill,只固定被保护请求看到的那部分几何形状。

两种模式:

  • BlockLock-Rows:用固定宽度的行,所有请求的批量 decode 照旧,只保证被保护那条的行形状不变
  • BlockLock-Pack:终端锚定打包,塞一个隔离的 dummy 段,然后把被保护请求的 KV 状态提取出来,做 target-only decode

论文在五个稠密模型(Qwen3-8B/14B、Llama-3.1-8B-Instruct、Gemma-3-4B/12B-IT)和三种单卡(A6000、A100、H100)上测,报告"目标序列和 Top20 logit 逐位一致"。

我用 float32 复现 BlockLock 的误差:2.6e-6

拿不到 GPU,我用 numpy 复现了机制:4096 维的点积,按不同分块大小累加,模拟不同 tiling。

def logits(x, chunk):
    prod = (x[:, None] * W).astype(np.float32)
    parts = prod.reshape(K_DIM // chunk, chunk, N_CLASS).sum(axis=1, dtype=np.float32)
    acc = np.zeros(N_CLASS, dtype=np.float32)
    for row in parts:                      # 按分块顺序串行累加
        acc = (acc + row).astype(np.float32)
    return acc

chunk 就是"切多大一块",换成 GPU 上的说法就是 tile 形状。跑 3000 组输入,比较 chunk=4096 和 chunk=32 的结果:

指标 数值
logit 绝对误差中位2.62e-6
误差 p997.39e-6
误差最大1.14e-5
argmax 翻转率0.00%

误差是真实存在的,量级 10⁻⁶。但 600 组随机输入里,argmax 一次都没翻。

第一反应是"这个问题没那么严重"。往下算才发现不是。

要多小的间距才会翻转

翻转的条件很简单:两个候选的分数差距小于误差。

人工构造一对"近似并列"的候选,让它们的权重只差一个极小量,测翻转率:

两个候选的相对差距 argmax 翻转率
1e-20.0%
1e-40.2%
1e-624.6%
1e-845.0%

差距到 1e-6 就开始翻,到 1e-8 已经是掷硬币。

改 batch 组成就改输出:逐位一致要付 53% 的延迟

图 1:横轴为两个候选的相对差距(对数),纵轴为 argmax 翻转率。数据来源:本文 numpy 实验,每个间距 500 次

那么真实词表里,前两名差距小到什么程度的样本有多少?

按 15 万词表、分数近似独立正态采样:

词表规模 前两名间距中位 间距 < 1e-4 的占比
80.430.0000%
1,0000.210.2000%
150,0000.150.1000%

1e-4 以下的占 0.1%。乘上该量级的翻转率 0.2%,单步翻转概率约 2×10⁻⁶。

逐位一致的代价:53.5% 的延迟

单步 2×10⁻⁶ 听着可以忽略。但一层里有多个 GEMM,一个 token 过 60 层,一条回答 200 个 token。

按 60 层算,每个 token 至少 60 次翻转机会:60 × 2e-6 = 1.2e-4。200 个 token 累积:1 − (1 − 1.2e-4)^200 ≈ 2.4%。

也就是说,每 40 条长回答里,大概有一条会出现至少一位不同。

而一位不同就够了——生成的 token 一变,后面所有的 KV 都跟着变,整条序列分叉。所以"差一位"和"完全不同"是一回事。

这就是 BlockLock 值得做的原因,也是它那个代价的由来:

对比对象 延迟 吞吐
BlockLock-Rows vs vLLM Batch-Invariant−53.5% ~ −55.0%+115.2% ~ +122.2%

注意这个对比的方向。 是"比 vLLM 的批不变方案快 53%",不是"比普通 vLLM 快 53%"。普通 vLLM 本来就不保证逐位一致,不在同一个比较维度里。

这个区别很关键,我看到不少转述把它写成"延迟降低 53%",读者会以为是白捡的。

坑清单

1. 把对比基线读错。

"相对 vLLM Batch-Invariant 延迟降 53.5%"和"延迟降 53.5%"是两回事。前者说明 BlockLock 比已有方案好,后者暗示比不开保护快——后者不可能,任何一致性保护都要付代价。

2. 以为 temperature=0 就是确定性的。

贪心解码只保证"给定同一组 logits 选最大的那个",不保证 logits 一样。logits 变了,argmax 就可能变。

3. 用 CPU 的 float32 结果推 GPU 的误差。

我在 numpy 上测出的 2.6e-6 是模拟值。真实 GPU 用 TF32、FP16、甚至 FP8 累加,误差量级完全不同,通常更大。这条数据只能说明机制,不能说明量级。

4. 忽略 padding 的影响。

batch 里最长的请求决定 pad 长度。同一个请求,跟一个 100 token 的邻居同批,和跟一个 8000 token 的邻居同批,pad 后的形状完全不同。这是最容易复现的一种不一致。

5. 只比最终文本,不比 logits。

两段文本一样不代表 logits 一样。论文特意报了"Top20 logit 逐位一致",比只看文本严得多。要做缓存键,必须比 logits。

结语

这件事的价值在于它划清了一条线:推理的可复现性不是免费的,它是一项要单独买的功能。

日常对话用不上,差一位没人发现。但审计、回归测试、缓存一致性这三类场景,必须有。BlockLock 的意义是把这个功能的价格从"重写 kernel"降到"多付一半延迟"。

上次拆 safetensors 头部是从文件里读答案,这次是从浮点数的行为里读答案。两者都不是黑魔法,是有人愿意去算。

我这次测出来的翻转率是 0,看起来像"问题不存在"。但把 margin 扫一遍就知道,问题在等一个足够小的间距。没测出来不等于没有,可能只是样本不够刁钻。

参考资料

目录导航
  • 同一个 prompt,batch 里换个邻居就变了
  • BlockLock 只锁住被保护的那条请求
  • 我用 float32 复现 BlockLock 的误差:2.6e-6
  • 要多小的间距才会翻转
  • 逐位一致的代价:53.5% 的延迟
  • 坑清单
  • 结语
    • 参考资料
  • 相关文章:

    发表评论

    ◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。