Featured image of post temperature=0 也复现不了的 bug:DeepInfer 把「确定性」拆成三层,多数人只开了第一层

temperature=0 也复现不了的 bug:DeepInfer 把「确定性」拆成三层,多数人只开了第一层

同一 prompt 跑两次不一样,不是模型玄学,是推理栈里有一层没钉死。DeepInfer 给出采样确定性、批不变性两个独立开关,而它自己的示例脚本只满足一半条件——这篇给自建推理服务的后端一份能照着查的清单。

评测集上个月 62.3,这个月 61.8,代码没改,模型没换。你把那条掉分的 case 捞出来,用同样的 prompt 在本地跑五遍,五遍五个结果。

多数人的处理方式是加个 temperature=0,或者干脆认了:“大模型本来就有随机性。“然后重试取多数、放弃分数的可比性、线上 badcase 复现不了就搁着。

DeepSeek 上周把内部推理栈 DeepInfer 开源了。仓库七天涨了 17757 star,但 HN 上 881 条评论大半在争"推理到底赚不赚钱”。夹在中间真正值钱的东西只有一句 README 注释:把 DEEPINFER_BATCH_INVARIANT=1、DEEPINFER_ENABLE_MTP_VARLEN=0 和固定的 seq_seed 三项同时满足,才能拿到逐 token 可复现(并且注明 NVIDIA only)。

因为"确定性"从来不是一个开关,是三层互不包含的性质。 这篇文章把它拆开,给你一张能对着自己启动脚本查的表。

先划清边界:这套栈门槛很高——SM100 或 Ascend 950、GCC≥13、Python 3.12、CUDA 13 或 CANN 9.2、源码编译没有 pip 包,只支持一个默认 checkpoint。绝大多数人的机器跑不了,也别为这个迁移框架。 但"确定性分几层"这件事,跟你用什么框架无关。

temperature=0 只关掉了采样噪声,没关掉算术

大部分人脑子里的随机性来源只有一个:采样。温度不为零,每次都从分布里抽签,所以结果不同。温度归零,永远取 argmax,于是"确定"了。

这个理解只覆盖了第一层。

实际上一路走到 token id 出来,至少经过三个会引入不确定性的地方:

采样层。从 logits 里挑哪个 token。这里的随机性来自随机数生成器的状态。

前向层的浮点归约。GPU 算矩阵乘、算 attention,是把很多数加起来。加法在浮点里不满足结合律,那么一批数据切成几块、哪一块先累加,末几位就不一样。末几位一旦不同,两个非常接近的候选 token 的排序可能翻转——于是 argmax 给出不同的 id。这就是为什么 temperature=0 不等于可复现:你关掉的是抽签,没关掉算术。

硬件与并行度层。换卡、换驱动、换 CUDA 版本、换张量并行度,数值全部重新洗一遍。

三层互不包含。第二层对了第一层不对,结果照样飘;第一层第二层都对了,换台机器还有可能漂。

确定性推理的三层:采样层由 seq_seed 决定;前向层由归约顺序决定,DeepInfer 提供 batch-invariant 开关但仅限 NVIDIA;硬件与并行度层没有框架级开关,只能自建数值基线

图注:每一层的不确定性由不同东西决定,所以开关也必须分开验收。依据 DeepInfer README 与源码整理。

把确定性当成"这一项解决了没有”,你永远查不到是哪儿漏的;把它当成三层,你才知道该在哪一层下断言。

采样能被钉死,因为每个 token 只用自己那把钥匙

DeepInfer 的采样实现里有一句 docstring,写得非常坦白:

1
2
Deterministic in (seq_seeds, sample_positions, is_nextn_predict):
independent of batch size and row order

可复现的输入是三个:seed、位置、是否在投机解码的草稿阶段。输出跟 batch 大小、行顺序无关。

为什么它天生跟 batch 无关?因为实现里每个 token 行开一个独立的 CTA,各自初始化自己的随机数流:

1
2
T.rng_init(seed, seq)
seq = sample_positions[row] * 3 + sample_case

随机数种子不是"一批里第几个",而是"第几个位置"。第 7 个 token 的钥匙,不管这批是 1 条还是 8 条,永远是同一把。N_CHUNKS = ceildiv(vocab_size, BD) 这个切分参数只跟词表大小有关(vocab ≥ 8192 时走 threads=256、bd=2048),跟 batch 无关。所以这里不存在"整行跨 CTA 归约"的操作,也就没有归约顺序可变这回事。

is_nextn_predict 那一位要单独换条流,是为了避免投机解码的草稿 token 跟正式 token 在同一个位置上拿到同一把钥匙——不然草稿和验证会用同一组随机数。

这一层为什么容易做对?因为它没有跨行归约,天然批不变。而前向层不是。

反过来说:这一层如果没配好,你连第一层都不算开了。 sampling_params.py 里 seed 的处理是这样的:seed=None 时用 random.getrandbits(64) 现生成一个。你不传 seed,它不会报错,也不会警告,就是每请求随机一次。

要钉死前向,就得钉死归约顺序——这就是 NVIDIA only 的来由

前向层的不确定性,来源是把归约切成几块。split-k 这类 kernel 会按当前 batch 的形状决定切分方式:同样一个矩阵乘,batch 里塞 1 行和塞 8 行,可能选到不同数量的 split,累加顺序跟着变,浮点末几位跟着变。

要让结果跟 batch 组成无关,只有一个办法:把切分方式固定死,不管这一批多大多小都用同一套归约树——也就是 batch-invariant。这样做等于放弃掉一部分动态调优的空间,有些形状会更慢。

README 明确标注 batch-invariant 只在 NVIDIA 上提供。Ascend 侧能跑这套栈,但这个性质没有承诺。 HN 上有条评论问 Ascend 950 的采购价,没人回答——成本数据目前是空白。

NVIDIA 也有个例外:官方 Q&A 文档自己承认 decoder 侧的 SWA replay 用的是 approximate SWA KV cache,只重算最后 128 个 token(精确重建要 2560 token × 20 层)。这是近似值,跟 batch 无关——意味着即使你把两个开关都开了,换机器、换上下文长度的边界,分数仍可能动。

顺带说一个容易被忽略的读法:这套栈开源了三块关键组件没给——KV cache 系统、E/P/D 分离、DP 负载均衡,官方原话是这三块"tightly coupled to our internal infrastructure"。所以它是能跑通的推理引擎,不是能直接替掉你线上 vLLM 的完整服务。

官方把「数值变了」做成了要走流程的变更,而它的示例脚本还没走这条路

这套栈里我最认同一件事,是它怎么对待"数值变了"。

仓库里有一份 PPL 基线文件,注释写着逐样本精确比较(compared exactly sample by sample)。要改基线不能手改 JSON,得跑 refresh_offline_decode_ppl.py 重新生成;那个脚本的 --output 参数只支持单节点,多节点会直接报错让你走默认路径。

翻译成团队语言就是:数值变化是要签字的。 你在用自己的推理栈,也可以抄这个制度——把"某次升级后评测分数变了 0.5 分"从"大概是随机波动"变成"谁的 PR 动了它,给个理由"。

但有意思的地方在下一层。

README 给的确定性条件是三项同时成立:BATCH_INVARIANT=1、MTP_VARLEN=0、固定 seq_seed。

我去翻了它的示例脚本:

  • offline_inference.py 里确实设了 BATCH_INVARIANT,但写法是 setdefault,而且加了 if accelerator_kind() == "nvidia" 的判断——非 NVIDIA 静默跳过,不报错。
  • _prompts.py 里确实给了 seq_seed=i。
  • 但整个 examples 目录 grep 不到 MTP_VARLEN。_config.py 里只 setdefault 了 USE_NEXTN_DECODE=5。

按 README 的字面条件,这条示例路径只满足了"两条半"。这不构成框架的 bug——示例脚本不是评测脚本,严格度本来就不一样。但足够说明一件事:“仓库提供了这个能力"和"你的运行路径享受到了这个能力"之间,隔着一段没人替你填的距离。

顺带提一句它的测试:tests/models/test_sampler.py 是验采样分布正确性的,它自己也设置 BATCH_INVARIANT=1,但它验的是"从分布里抽出来的东西分布对不对”,不是"两次运行逐 token 一样"。别把测试通过当成确定性成立——这两件事在同一份代码里,但不是同一件事。

你能抄走的不是那两行变量

先给那张表。四类不确定性,逐个说清谁管、怎么验:

  • 采样噪声:由随机数流决定。验法:固定 seq_seed 跑两遍,比 token_id。拿不到固定 seed 的 API,这一层就无解——很多托管服务根本不让你传 seed。
  • 前向浮点:由 kernel 归约顺序决定。验法:固定 seed 和 prompt token ids,跑两遍不同 max_num_seqs(1 和 8),比 token_logprobs。
  • 并行度变化:由张量并行/数据并行配置决定。验法:改一次并行度,跟基线比 logprob。这一层没有框架级开关,只能靠自建基线。
  • 硬件驱动变化:换卡、升 CUDA、升驱动。验法同上。这一层连 batch-invariant 都保不了。

这张表最有用的一条,是那个二分法:

两次运行序列不一致 → 采样层出了问题。序列一致但 logprob 不一致 → 前向层在漂,采样层是干净的。

这比"跑一遍看着差不多"有用得多,因为它直接告诉你去翻哪个开关。前者查 seed 有没有传、MTP_VARLEN 有没有关;后者查 batch 是否变了形状、并行度有没有动。

第二个能带走的是启动脚本体检。四条 grep 就够:

  • BATCH_INVARIANT——有没有设?设了是不是 setdefault,被别处覆盖了?
  • MTP_VARLEN 和 USE_NEXTN_DECODE——投机解码是开着还是关了?
  • seq_seed——是每请求固定传的,还是落到默认路径随机生成?
  • seed 从哪来——如果代码里有 seed=None,它实际是 getrandbits(64)。

最后这条最阴。它不报错,不警告,跑了也不异常,只是你所有关于可复现的假设都失效了。

至于性能代价,诚实讲:MTP_VARLEN=0 等于放弃变长验证的收益,官方 benchmark 表里 conc=16 时是 −1.5%、conc=60 时 +5.4%、conc=96 时 +11.0%(EP64/DP64 配置)。低并发几乎不亏,高并发才疼——所以小集群做评测和回归时开着确定性,生产高并发关掉,是完全说得通的用法,不必一刀切。

那谁该认真对待这件事?

8 卡自建的团队:你现在拿不到 batch-invariant(那要 SM100,且这套栈门槛不止于此),但你今天就能做的是两件便宜事——写一个"同 prompt 跑两遍比 token id"的脚本进 CI,把数值变化当变更管理。这两件不需要换框架。

几十卡以上的推理平台:batch-invariant 才有讨论价值,因为你们的 batch 形状在动态变化,split-k 切分跟着变是真实存在的分数漂移源。但也别急着迁——它只支持一个模型,缺 KV cache 与 PD 分离、缺 DP 负载均衡,这三块恰恰是生产里最难自己补的。

对绝大多数人,正确的动作是去 grep 自己现在的启动脚本。你可能发现你以为开着的确定性,压根没开;也可能发现你从来没传过 seed,一直在拿随机的钥匙,跟"模型本来就有随机性"这个说法和平共处了半年。