SGLang KL 一致性测试实战:校准 prefill-vs-decode logprob 阈值、定位状态型发散与调试指南
【免费下载链接】sglangSGLang is a high-performance serving framework for large language models and multimodal models.项目地址: https://gitcode.com/GitHub_Trending/sg/sglang
导读
SGLang 的kl_test_utils会把同一段 token 用两种完全不同的方式各打分一次——一次作为 prefill 的输入 logprob,一次作为 decode 的输出 logprob——然后比较二者的 KL 散度。这篇文章完整讲解这套 KL 一致性测试:它究竟在测什么(是状态而非答案质量)、两个独立条件如何叠加成零散度、三个 cache-hit helper 各自覆盖哪条路径、如何像 CI 一样运行、如何选取并论证阈值,以及当 KL 数值异常偏高时,如何借助 forward-hook dumper 把发散定位到单个算子。读完你可以为某个模型新增 KL 测试、为kl_div阈值辩护,或系统排查一个超标的 KL 数字。
KL 一致性测试在测什么:状态,不是答案质量
kl_test_utils的核心思路是对同一个 token 打两次分:一次以 prefill 输入 logprob 的形式(几千 token 一起前向),一次以 decode 输出 logprob 的形式(每次只前向一个 token),然后比较这两份 logprob 的 KL 散度(实现见 python/sglang/test/kl_test_utils.py)。
两条路径跑的是不同的 kernel、不同的 shape,因此二者的一致并不是关于"回答质量"的断言,而是关于状态的断言:它能捕获 radix cache 前缀复用后未能复现全新 prefill 的结果、过期的 conv/mamba checkpoint、滑动窗口池提前驱逐了它仍然需要的内容等状态类缺陷。
这一点与任务准确率类测试(如 gsm8k)形成鲜明对比:gsm8k 通过并不能说明什么——准确率对少量被污染的 token 不敏感,而 KL 检查恰恰对这些 token 极其敏感。换句话说,KL 一致性是比端到端准确率更敏感、更早触发的精度守卫。
两个独立条件叠加才产生零散度
要达到 bit 级一致(avg_kl_div为零或 1e-9 量级),需要同时满足两个相互独立的条件,而它们失败的原因毫无关联。判断一个非零的 KL 属于哪个条件,就完成了大部分调试工作。
条件 1:路径上的每个算子都是 batch-invariant(批不变)的。
一个 token 的结果不能依赖于有多少个 token 与它共享同一次前向。注意这是跨两条路径的性质,而不是各路径内部的:一个 kernel 完全可以在 M=1 时高度可复现、在 M=N 时也高度可复现,却在两者之间不一致——这正是 tile size 切换或消息大小相关的 reduction 所做的事情。
条件 2:两条路径计算的是同一个函数。
decode 在某位置的上下文与状态,必须等于一次全新 prefill 在该位置计算出的结果——相同的 KV 集合、相同的滑动窗口、相同的 conv/mamba 状态、能够复现重算结果的缓存前缀恢复。这是逻辑问题而非算术问题,任何数值精度上的粉饰都救不了它。
这两个条件相互独立,且一次测量就能把二者分开:在条件 1 满足的前提下,match和decode_cache_hit恰好读为 0,而prefill_cache_hit在前缀恢复出错时保持非零。同一台服务器、同一批 prompt——浮点噪声不可能替代码选择路径,所以 helper 特有的发散必然是条件 2。
调试顺序应当与此对应:先解决条件 1。在它成立之前,它的噪声比条件 2 产生的任何信号都高几个数量级,会将其完全掩盖。
三个 helper 的区别在于谁碰了缓存
KLDivergenceMixin(见 python/sglang/test/kits/kl_divergence_kit.py)运行后两个 helper,而底层三个 helper 函数都定义在 python/sglang/test/kl_test_utils.py:
| Helper | 缓存参与方式 |
|---|---|
test_input_output_logprobs_match_helper | 两侧都先 flush 缓存;完全不碰缓存 |
test_input_output_logprobs_match_prefill_cache_hit_helper | 先把 prompt 完整 prefill 一次以预热缓存,随后再次生成时从缓存恢复前缀(cached_tokens > 0才纳入比较) |
test_input_output_logprobs_match_decode_cache_hit_helper | decode 侧在预热过的缓存上运行(两轮对话,第二轮的cached_tokens超过首轮前缀长度才纳入比较) |
三者不可互换,选择时要有意识:只有 cache-hit 那一对(prefill 与 decode 两个变体)才真正练习了前缀复用。
从源码看,prefill_cache_hit_helper的流程是:_flush_cache后用max_new_tokens=0做一次纯 prefill 预热;再以相同input_ids发起带return_logprob=True的生成;对每个cached_tokens == 0的样本打印 "Prefill cache miss ... skipping" 并跳过(除非设置了SGLANG_TEST_SKIP_CACHE_HIT_ASSERT,否则还会断言命中数超过样本数一半)。decode_cache_hit_helper则用第一轮 prefill+decode 的结果加上逗号 token 组成第二轮输入,要求cached_tokens > len(first_turn_input_ids[i]) + 1才算命中。
发散局限在某个 helper 里本身就是诊断信息:match干净而prefill_cache_hit脏,说明是恢复路径错了,而不是算术错了——浮点噪声不会挑选代码路径。
按 CI 的方式运行它
KLDivergenceMixin的默认值是max_samples=32、max_new_tokens=512(prefill 与 decode 各自独立可调:kl_div_prefill_max_new_tokens、kl_div_decode_max_new_tokens)。不要用更少的样本做表征。
avg_kl_div是 k3 估计器:exp(logr) - 1 - logr,其中logr = input_logprob - output_logprob,对采样 token 的 logprob 应用(见 python/sglang/test/kl_test_utils.py 中compare_kl_divergence,L101-L120)。它对尾部呈指数敏感,均值被少数几个 token 拖着走:在 4 个样本时,同一配置测出过 0.049 到 0.158 的波动——3 倍的散布足以让任何基于它的 A/B 比较失效。
当目的是表征而非门禁时,应报告尾部分布统计量——超过某个阈值的 token 占比、以及最大值——而不是均值。
如果模型带滑动窗口,要生成到越过滑窗的长度,让 decode 完整经历从 prompt token 交接给生成 token 的过程——交接处正是窗口驱逐与 checkpoint 轮转真正发生的地方。
另外注意get_input_ids的缓存键:f"{tokenizer_path}_{max_prompt_tokens}_{num_samples}"(python/sglang/test/kl_test_utils.py L40),数据集来自 LongBench V2(THUDM/LongBench-v2),每条 prompt 会被截断到max_prompt_tokens的 0.5x–1.5x 随机长度。不同的num_samples会通过这个缓存键静默选中不同的 prompt 集——这是后文"harness 测错东西"一节中的一个真实踩坑点。
条件 1:确定性与 batch 不变性不是一回事
这个区分决定了阈值是否有意义:
- 确定性(Deterministic):相同输入、相同 shape、每次运行得到相同结果。
- 批不变(Batch-invariant):一个 token 的结果不依赖于批里有多少其他 token 与它共享前向。
KL 检查拿"数千 token 的 prefill"对比"每次一 token 的 decode step",所以它测的是第二种。--enable-deterministic-inference两个都买——它把 aten kernel 换成固定 reduction 的版本,并固定 NCCL 算法与 channel 数——但只对它覆盖到的 kernel 生效。那些从不经过 aten 算子的自定义 kernel 不在batch_invariant_ops集合内(见 python/sglang/srt/batch_invariant_ops/init.py),仍然保持 shape 相关。
后果:在开启 deterministic inference 之后,如果 KL 非零且在每个 helper 里都出现,说明路径上仍有 kernel 是 batch 相关的。此时应该去定位它(见下文),而不是放宽阈值。
一个算子需要多少 batch 不变性
对逐 token 算子——GEMM、norm、激活、router 的线性层——一个 token 的输出只依赖它自己那一行,因此固定 reduction 顺序就是全部要求。一旦其结果与一次 launch 里有多少行无关,它就达标了。
有两类算子需要更多,而残余的非零通常就藏在它们身上:
- 跨 token 做 reduction 的算子——对 KV 范围的 attention、以及任何 collective。只固定算术顺序不够,如果范围仍会变化:all-reduce 的树形随消息大小变化,或 attention 的 split 块边界随 query 数变化,都会让同一个 token 因所在 batch 不同而经历不同的 reduction。要固定的是 shape,而不仅是顺序。
- 跨调用携带状态的算子——conv 窗口、SSM checkpoint。它们单次调用是 batch-invariant 的却仍然发散,因为存下来的状态被后续请求复用了。这属于条件 2,任何 reduction 顺序的工作都够不着。
所以"让一切 batch-invariant"对逐 token 的多数算子关闭了条件 1,残余集中在上述两类。
MoE 会把这点放大到稠密模型没有的程度
Top-k routing 是对近乎平局的分数做离散决策。gate 权重 1e-8 的差异就会翻转 token 被路由到的专家,输出完全分叉,42 层逐层放大。在一个 MoE checkpoint 上实测:一个 gate GEMM 在 M=8 与 M=16 之间切换 tiling,产生 1.6e-5 的 logits 差异,演变为单个高置信 token 上 20–37 nat 的差异,以及 0.177 的 KL。
同规模的稠密模型同样的根因只产生约 1e-4 量级的差异。所以 MoE 上百分之零点几的 KL不是更严重 bug 的证据,而是同一类数值差异被放大了。不要按稠密模型的类比来校准 MoE 阈值。
条件 2:两条路径必须计算同一个函数
一旦条件 1 成立,剩下的就是状态 bug,而它出现在哪个 helper 里就指明了哪条路径:恢复结果无法复现重算前缀,会只出现在prefill_cache_hit里,另外两个保持恰好为 0。
读取这个签名时,看三个维度:
- **哪些序列发散。**发散集中在批里少数几个请求、其余 bit 级一致,说明是这些请求触发的条件,而不是系统性偏移。拿它们的 prompt 长度、
cached_tokens、page 与 checkpoint 间隔的余数,与通过的请求对比。 - 在生成的哪个位置发散。从第一个生成 token 起就连续发散,说明生成开始时状态就已经错了,故障在前缀恢复而不是 decode。从生成中途才开始发散,则指向 decode 期间发生的某件事——窗口交接、checkpoint 轮转。
- **是否是竞态。**在不应有影响的配置下重跑(page size、TP 度、buffer 策略)。跨配置 bit 级一致的数值意味着确定性的逻辑故障,比追竞态便宜得多。
带滑动窗口的模型务必生成越过窗口长度:prompt token 与生成 token 在窗口内的交接处,才是驱逐与 checkpoint 轮转真正执行的地方。
选择阈值:先让条件 1 成立,再断言 stray-ulp 下限
一旦路径上每个 kernel 都 batch-invariant,prefill 与 decode 会逐 bit 一致,诚实的断言是一个 stray-ulp(单个 ulp)下限,而不是容差:
KL_DIV_THRESHOLD = 1e-9 # 实测为 0;任何状态 bug 产生的值都比这高几个数量级宽松的阈值会同时容忍浮点噪声和小的逻辑错误,这正是状态复用 bug 藏身的方式。实践建议:让 KL 用例跑在独立的确定性服务器上并断言接近零,把准确率用例留在生产数值上——一台服务器无法同时服务两者。
阈值是按(model, tp)分别标定的。tp=1 下校准的值不能迁移:tp=1 没有 all-reduce,永远不会练习到 tp>1 时占主导的那类来源。仓库中的实际配置就是按模型分开设置的,例如:
- test/registered/radix_cache/test_swa_radix_cache_kl.py:
gpt-oss-20b、kl_div_thres = 0.02、decode 侧max_new_tokens = 2048(有意生成越过滑动窗口)、tp=1; - test/registered/radix_cache/test_int8_mamba_checkpoint_e2e.py:Qwen3-Next-80B-A3B 上启用
--enable-int8-mamba-checkpoint,int8 是有损编码,cache-hit 发散天然大于 bf16/fp8 复用(实测 prefill ~0.044、decode ~0.024),因此把kl_div_thres_prefill = 0.10、kl_div_thres_decode = 0.06设到约 2 倍以覆盖模型差异与运行噪声,同时仍能捕获真实的 int8 回归; - test/registered/e2e/models/test_qwen3_next_models.py、test/registered/radix_cache/test_mamba2_extra_buffer_kl.py 等同样通过混入
KLDivergenceMixin为各模型挂上 KL 检查。
在KLDivergenceMixin中,通过类属性kl_div_thres(通用)、kl_div_thres_prefill/kl_div_thres_decode(分别覆盖两个 cache-hit 变体)即可为某个模型单独覆盖阈值:
from sglang.test.kits.kl_divergence_kit import KLDivergenceMixin from sglang.test.server_fixtures.default_fixture import DefaultServerBase class TestMyModelKL(KLDivergenceMixin, DefaultServerBase): model = "org/my-model" kl_div_thres = 1e-9 # 条件 1 成立后的 stray-ulp 下限 kl_div_max_samples = 32 # 不低于 CI 默认的 32 kl_div_decode_max_new_tokens = 2048 # 越过滑动窗口 other_args = ["--tp-size", "4", "--enable-deterministic-inference"]定位发散:forward-hook dumper 指向算符
消融实验只能回答"是否改变",永远回答不了"在哪里"。forward-hook dumper 直接指向算子,而且已被证明可靠:跑一次,读出的第一个"输入 bit 级一致但输出不同"的层就是算子。dumper 实现见 python/sglang/srt/debug_utils/dumper.py。
DUMPER_ENABLE=0 DUMPER_SERVER_PORT=reuse DUMPER_NON_INTRUSIVE_MODE=all \ DUMPER_DIR=/path/to/dumps python3 -m sglang.launch_server ... \ --disable-cuda-graph --disable-prefill-cuda-graph curl -X POST localhost:PORT/dumper/configure -d '{"enable": true, "exp_name": "dec"}'有五个设置缺一不可,任何一个错了都会静默失败:
DUMPER_ENABLE=0加上DUMPER_SERVER_PORT=reuse。端口哨兵让may_enable为真从而注册 hooks,而enable=0防止 warmup 阶段 dumping。在启动时启用会在每个 warmup prefill 上都倾倒——一次真实运行曾因此写了 1.8T 填满共享磁盘。应加一个 watchdog,在空闲空间低于下限时杀掉运行。DUMPER_NON_INTRUSIVE_MODE=all。默认的core只写positions、seq_lens、req_pool_indices、input_ids、rids——没有模块张量,也不报任何错。DUMPER_SERVER_PORT=reuse是字面量哨兵而非端口号;/dumper/{method}路由只在恰好等于这个值时注册。- 在
--disable-cuda-graph之上还要--disable-prefill-cuda-graph。有些模型默认把 prefill 放到 CUDA graph 上,而 Python forward hook 在重放中不会执行——prefill pass 于是只倾倒 embedding 就结束了。 - 优先用
dumper.py而不是--debug-tensor-dump-*:后者会断言存在名为model的顶层模块,多模态 wrapper 没有这个模块。
**在读任何 diff 之前先证明对齐。**Decode 的第 k 步与 prefill 的第plen + k行消费同一个 token,所以 embedding 输出必须 bit 级一致;如果不一致,行是错位的,之后每个数字都无意义。这个错误曾导致一次自信而完全错误根因的定位。
结果的读法是:第一个"模块输入 bit 级一致而输出不一致"的层就是算子;它之后的一切都是继承来的。
当发散需要 CUDA graph 才能复现
只在捕获的 graph 下出现的发散会同时击败上述两种探针,且每种失败都是静默的:
- dumper 的 hook 在重放期间不会运行——graph 重放的是 kernel 不是 Python。为了收集 dump 而禁用 graph 同时也会移除发散,所以干净的逐层 diff 毫无意义。确认 bug 在你 dump 所用的完全相同的 flags下仍能复现。
- 任何同步到 host 的操作在捕获期间都会崩溃(
.item()、float()、.tolist())。用torch.cuda.is_current_stream_capturing()守卫探针,否则服务器无法启动。 - 被捕获 kernel 的 Python wrapper 在重放时不会被调用。给它插桩只会记录仍然 eager 的阶段。这应当作为证据而非探针损坏来解读:它说明 kernel 使用的是捕获时绑定的参数,任何每次重放新传入的张量对它都不可见——这本身就是一种 bug 形态。
有效的替代方案是在 graph 之外探测被复用的状态:在某个请求捐出其 checkpoint 的那一刻,记录 slot id、它声称已 checkpoint 的长度、以及存储状态的abs().max()。开/关 graph 各跑一次,按 slot 做 diff。少量内容不同、且声称长度与出错请求前缀吻合的 slot,一轮就能定位写入位置——而十几个消融实验只能框定触发条件。
**让探针证明它真的触发过。**落在未被执行的代码路径上的探针什么也不打印,这与"测了,无差异"无法区分。断言一个最小命中次数,或在入口无条件记录。要插桩每个调用者都会经过的单一咽喉点,而不是某一个调用点。
确认机制,而不是推断机制
两个失败模式最耗时间,都是可以避免的:
- **一个什么都不改变的 flag。**切换前后 bit 级一致的结果意味着 flag 没有生效——某个隐藏条件守卫的 dispatch、某个对该配置永远不会走的路径。在下结论说组件无辜之前,先检查守卫。
- 一个测了别的东西的 harness。要经由 helper 自己的函数去捕获,而不是重建它的输入。重建输入曾导致把一次生成追加了两遍,得出一个看似合理的错误结论;另一次是不同的
num_samples通过get_input_ids的缓存键静默选中了不同的 prompt 集。
logprob 数组按绝对位置索引:logprob_start_len=0时,input_token_logprobs每个输入 token 一个条目,第一个是None,条目k对应input_ids[k]的打分。helper 会切掉尾部,落在生成区间上;做绝对索引的分析必须与之一致。这里的 off-by-one 会读到相邻 token,其 logprob 通常接近到足以伪装成真实信号。
对孤立的论断,收敛成独立的复现脚本。一个十行脚本在 M=1 和 M=288 下调用可疑算子,几秒钟就能判定 batch 不变性,而且应当放在 PR 里、排在任何端到端数字之前。
读代码找嫌疑人是这些方法里最慢的。一次调查曾连续推翻八个各自自洽的代码推导假设,最终靠直接测量被复用的状态一轮就找到了缺陷。优先级应为:单变量 A/B 隔离触发条件 → 追问"这个错误输出是什么问题的正确答案" → 直接探测被复用的状态 → 最后才去读代码寻找解释所测结果的机制。
小结
KL 一致性测试是 SGLang 中对状态正确性最敏感的一类检查:它把 prefill 与 decode 两条路径的 logprob 逐 token 对齐,从而能捕获 radix cache 前缀恢复、conv/mamba checkpoint、滑动窗口驱逐等任务准确率完全免疫的缺陷。掌握它的关键判断是——先分清非零 KL 属于条件 1(某算子 batch 相关)还是条件 2(两条路径函数不同),前者靠--enable-deterministic-inference与算子级定位解决,后者按 helper 签名与状态探测收敛;在二者成立之后再谈阈值,此时诚实的阈值是 1e-9 量级的 stray-ulp 下限,而非一个宽容的容差。
【免费下载链接】sglangSGLang is a high-performance serving framework for large language models and multimodal models.项目地址: https://gitcode.com/GitHub_Trending/sg/sglang
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考