给 LLM 推理引擎找 bug,难在哪里 [testing-llm-serving-engines]

这个项目结束得不太完美。这篇复盘一下我们前后的尝试、为什么后端的 bug 一直测不出来,以及最后的一点想法。

在碰推理引擎之前,我做的是 PyTorch、TensorFlow 这类 ML 框架的 API 测试(FlashFuzz、Centaur)。虽然能抓到不少 bug,但做久了会觉得它们对 LLM 的实际影响有限——底层算子和 kernel 的正确性是明面上的问题,不仅我们在测,大厂和很多研究者也都在盯着。

但往上一层看,像 vLLM、SGLang 这种现在各家公司都在跑的推理系统,很少有人做端到端的测试。单算子写对了,不代表整个系统拼起来是对的。推理引擎里有复杂的调度、显存管理、kernel 配合和多卡通信,一旦在效率或正确性上出问题,影响面要大得多。

抱着这个想法,过去大半年我一直在给 vLLM 和 SGLang 找 bug。但做下来和最初设想的差得很远:我们最后整理的 40 多个 bug 大多在前端,比如 tool-call parser、API 请求处理、配置校验和权重加载;而真正想测的后端(scheduler、KV cache、kernel、多卡通信),换了好几种办法,测出来的也基本只是奇怪配置下的 crash 和 hang,silent error 和 SDC 很难找到。

1. 只看外部输出测不出 bug(Configuration Testing 与数值比对)

我们最早做的是 configuration testing:用静态分析把引擎合法的启动参数提取出来,组合成不同的配置成对地跑。每对配置只差一个字段,固定 prompt,看会不会 crash、hang,或者算出来的 perplexity 差得太多。

前期在 SGLang 上跑了大约 5,500 个配置,vLLM 也跑了很多轮。跑了一个月我们就放弃了:一是引擎的配置空间太大,手里没有很好的办法给配置排优先级;二是参数 fuzzing 在想法上没什么新意,找出来的 bug 又少又浅;更重要的是我们意识到,在推理系统里,怎么判断输出对不对(oracle problem),比怎么生成输入要难得多。

推理引擎的输出本来就会变,即使温度设为 0,当 batch 大小、attention backend 或硬件不同时,生成的 token 也可能不一样,不能只靠检查输出文本和 perplexity:

  • 输出文本看不出异常:有人统计过五个推理引擎的 929 个 bug,超过 35% 的症状不是崩溃。比如在 sglang#17839 里,KV cache 的地址指针全错了,gsm8k 还能有 75% 的准确率。我试着把 Qwen2.5-7B 的 30% KV cache 换成无关 prompt 的内容,准确率只从 87% 掉到 50%;换成零才掉到 5%,而真实的 bug 很少把数据整块清零。attention 本来就会忽略无关的 context,基础设施造成的一部分错误也被它一起忽略了。
  • Perplexity 没有信号:perplexity 异常这个 oracle 我们用了三个月,一个 bug 也没找到。

因为文本输出没信号,我们往下找过更细的数值信号:

  • 比对 logprob 分布:拿 HF Transformers 的 fp32 实现当参考(思路和 KL divergence 一样)。结果 vLLM 的 bf16 路径比 HF 自己的 bf16 还接近 fp32,噪声下面什么也看不出来。唯一抓到的 bug 反而在参考实现里:transformers 加载 FP8 compressed-tensors checkpoint 时漏乘了 weight_scale,报上去后修了(transformers#47145)。
  • 逐层比对 hidden state:故意注入的扰动能定位到具体哪一层,但小于该层输出 10% 的扰动会被 bf16 的舍入噪声淹没。

2. 检查内部状态,但 Python 层看不到 GPU(PBT 与 Runtime Verification)

那段时间我读了 Designing Data-Intensive Applications,觉得对于这种复杂状态系统,检查内部不变量(Property-Based Testing / Runtime Verification)更合适:不管模型生成了什么,有些性质必须成立。

我先从 bug 修复历史里挖掘 property。结果发现历史上的 KV cache bug 只占很小一部分,反而是前端的各种 parser 在新旧版本、不同模型下出错的概率更高。顺着这条路,我们用 PBT 找到了不少前端问题。但前端状态小、行为确定、CPU 上就能复现,什么算错也说得清;这些 bug 都不深,找得越多,我越不喜欢它们,因为它们和调度、显存管理、kernel、多卡通信关系不大。

为了测后端,我们用导师之前做的 PyMOP 在引擎内部的方法上挂 monitor,给 vLLM 的 scheduler、KV cache manager 和 block pool 写了 33 条 spec。KV cache 这部分主要查 block 的生命周期和 liveness:refcount 不能小于实际引用它的请求数、不能 double free、还在用的 block 不能出现在 free pool 里、refcount 不为零时不能被 evict。

重放已知 bug 时,RV 很好用。拿 8 个已知 bug 试,跑完的 6 个里抓到了 5 个。最好的例子是 SGLang 的 PR #25015,它修的是 EAGLE draft 的 position 差了 1。当时最终生成的文本是对的,只看输出发现不了,但 monitor 标出了全部 78 次出错的 draft forward。

然而用它去找新 bug,通宵跑了几百个配置、大约 5 亿个事件,一次 violation 都没有。KV cache 这些检查跑了几个月,除了已知的 regression,没有任何新收获。

做下来发现,KV cache 在 Python 这一层的代码本身就很薄,而且在 Python 层做 RV 有几个绕不开的死结:

  1. 进程隔离与并发时序:vLLM V1 把 scheduler 放在单独的 EngineCore 进程里,monitor 只装在父进程上。不设 VLLM_ENABLE_V1_MULTIPROCESSING=0 的话,测试照样跑完,只是一个事件也收不到,也不报错。SGLang 的 scheduler 也是 spawn 出来的,要在同一个进程里观察它就得改启动方式,这又改变了我们要测的并发。开了 overlap scheduler 后,GPU 上的 tensor 可能正被异步改写,从 Python 去读既要强制同步,读到的还可能是改了一半的值,报出假的 violation。
  2. 状态不在 Python 里:我们拿 55 个历史上的 scheduler 和 KV cache bug 估过一遍,这组 spec 能确定抓到的只有 10 个。漏掉的 31 个里,11 个发生在 hook 生效之前(配置校验、初始化、进程启动),8 个在 worker、Triton kernel 或 CUDA event 这一层,Python 的 hook 根本够不到。
  3. CUDA graph 是最大的坎:生产环境都开着 CUDA graph,graph replay 时一行 Python 都不执行,Python 层的 monitor 什么也看不到。收集到的最严重的几个 silent bug 恰好都出在 replay 路径上(比如 SGLang 的 #26880 和 #27338)。前面 PR #25015 之所以能抓到,是因为我们加了 --disable-cuda-graph,让 draft 循环在 eager 模式下一步步执行。而在 capture 阶段插检查也不行,哪怕只是一个 torch.equal 这样的同步,capture 都会直接失败(cudaErrorStreamCaptureInvalidated)。我们第一次跑 baseline 就这样把 server 弄崩了,最后有 22 处 capture 阶段的调用只能跳过不查。

要在 CUDA graph 下做检查,检查本身就得是 GPU 上的 kernel,和模型一起被 capture 进 graph。

后来和 SGLang 的作者聊起,发现他们已经写了一个叫 KV canary 的东西:把检查做成 GPU kernel,patch 到 model.forward 里,跟着 decode 和 EAGLE draft 的 CUDA graph 一起 capture 和 replay。要查哪些 slot 由 host 在 graph 外面算好,写进固定的 device buffer;发现问题就写进 device 上的 ring buffer,再异步送回 host(PyTorch 自带的 async assert 不用同步,可它一触发,整个 CUDA context 就坏了)。

我们拿它试了试,往 req_to_token 里注入一个错位,canary 能抓到;但拿 4 个配置正式跑,最长的一轮覆盖了 128 万个 token,一次也没报。反倒是发现 topk > 1 时,canary 自己的 buffer 大小没算上 EAGLE 的 topk,导致 graph capture 直接崩掉,和 #27338 犯的是同一类错误。

虽然最后也不是一无所获。我由于手上有台 Framework Desktop (AMD GPU) 和导师的 Mac Studio, 在 MLX 和 Rocm-attention 上抓到了几个 bug. 说明这套方法其实是可行的。

3. 为什么决定不做了:触发成本与硬件门槛

除了观测难,和 SGLang 作者聊下来,大家对往下做的难处看法差不多:

  1. 取数据会改变时序:Python 这层的逻辑很薄,真正的读写都在 kernel 里。GPU 上的数据很难拿出来,拿的动作本身就会改变时序(SGLang 内存池的 debug 检查每次分配都要同步一次 GPU,vLLM 统计 NaN 每一步都要把数据拷回 CPU,这些检查在上游 CI 里默认都是关的;如果关掉 CUDA graph 跑 eager 模式,decode 大约要慢 10% 到 20%)。我朋友倒是做出来了如果从 CUDA graph 上实时零额外开销的取数据,这里提前宣传一下他们的工作。
  2. bug 状态极难触发:很多后端 bug 要特定的模型架构,加上推测解码和 CUDA graph,再碰上请求抢占或者某种调度时序才会出现。相比 verification,testing 需要不断跑才能 reach 到有 bug 的代码,而 vLLM 和 SGLang 光 reach 的开销就很大。
  3. 硬件门槛:有些 race 只在特定硬件拓扑和多卡互联下出现。比如我们报的 vllm#55208 是直接读代码看出来的,要复现得同时跑两个引擎实例,再加上 NIXL,我们本地根本跑不了。看 RedHat 在 vLLM summit 的分享,工业界平时的测法也就是堆昂贵的多卡机器,把不同模型和 benchmark 挂着一直跑,看指标掉没掉。按照前面换掉 30% KV cache 准确率还有 50% 的实验来看,这种测法能改进的地方还有很多。

聊完之后,我觉得在现有的硬件条件和纯黑盒/灰盒测试框架下,继续硬往下做的性价比已经很低了,于是决定收手,把精力转去写论文和下一个项目。

几点启示

复盘这大半年的折腾,最后留下这么几点想法:

  1. 后端的复杂状态和并发,可能更适合做 Verification 而不是 Testing。在运行环境复杂、race condition 也复杂的系统上,靠跑测试不一定好使。把 RV 搬到 GPU 上是以后可以做的一件事(UMD 已经有人在做了);但如果让我重新选工具接着做后端,我会换成 verification。比如做 model checking:直接给 scheduler、KV cache manager 或者跨机器的传输协议建模,让 checker 去穷举执行顺序。这样不需要 GPU 和特定的多卡硬件拓扑,也不用指望模型输出里有信号。我们之前挖出来的 1,032 条 property 每条都带一个 TLA+ 风格的草稿,正好可以当建模的起点。实际上已经有人在这条路上走得更远了:Vosti 给确定性推理写了形式化的规约,用 Verus 证明了调度和 prefix-sharing 的 paged KV cache 不会改变输出的 logits;他们的测试还顺便发现,vLLM 的 batch-invariant 模式和 SGLang 的 deterministic 模式在一些执行变化下,logits 其实并不一致。
  2. 如果还要做 Testing,应该去构造能触碰后端调度边界的输入。回头看,testing 也不是一无是处,只是我们之前生成的输入(不管是随机搞启动配置,还是喂常规的 prompt)很难真正打到后端状态机的痛点上。我最近冒出来的一个新想法是:很多深层的后端调度逻辑,其实是由特定的模型输出或 token 状态驱动的。比如 EOS token,如果它在合法的时机(比如多请求并发、prefix caching 命中边界、或者投机采样 draft 的特定位置)出现,就会立刻触发一连串的请求完成结算、KV cache block 回收、batch 重组甚至抢占。与其在外面盲目地 fuzzing 参数,不如专门设计能合法触发这类控制流转换的输入序列,去集中压测后端的调度器。这或许是推理引擎 testing 接下来真正值得试的一个方向。