连续批处理为何拖慢了LLM推理引擎?一次Metal内核排查实录

连续批处理触发慢速GEMM内核,还通过KV缓存将浮点差异转化为不同的文本生成结果。
作者在为macOS LLM推理引擎添加连续批处理优化时,发现两个并发请求的吞吐不升反降(从16.8降至8.7 tokens/s)。根因在于Candle后端会在输入行数超过1时从GEMV切换到GEMM内核,而小批量下GEMM的调度开销远大于其理论收益。通过将小批量拆分为逐行GEMV并以4行为切换阈值,吞吐得以恢复。更深层的发现是:GEMM与GEMV之间微小的浮点差异足以翻转贪婪解码的token选择,并通过KV缓存产生长程影响——即便触发误差的另一请求早已结束,其写入缓存的数值偏差仍会持续影响后续生成,导致相同prompt产生完全不同的续写内容。
在为自己的Rust版macOS LLM推理引擎 mini-vllm-rs 添加优化时,作者原以为连续批处理(continuous batching)会是下一个唾手可得的性能提升。把多个请求打包处理、在更多token间复用模型权重、看着吞吐量一路攀升——听起来毫无悬念。结果却事与愿违。
在一台配备16GB统一内存的M2 Mac mini上,作者用4-bit量化的Qwen2.5 7B模型测试了两个并发请求,每个请求生成512个token,并关闭了推测解码。预期的吞吐提升没有出现,反而出现了近乎腰斩的下跌:从未批处理的16.8 tokens/s跌到批处理后的8.7 tokens/s。
这篇排查记录揭示了一个容易被忽视的事实:调度器层面的"正确"决策,可能被后端执行层以一种更慢的方式实现——而更隐蔽的是,这种差异还会悄悄改变生成的文本内容。
两行输入,触发了不同的内核
线索藏在作者所用的后端框架 Candle 里。它的量化Metal执行路径中有一个看似微不足道的判断:当输入矩阵的行数等于1时,走GEMV(矩阵乘向量)路径;一旦超过1行,就进入GEMM(矩阵乘矩阵)路径,把多个输入向量一起处理。
// Candle 0.11.0: candle-core/src/quantized/metal.rs, line 372
if src_shape.dim(D::Minus2)? == 1 {
return self.fwd_mv(self_shape, storage, layout);
}
// Line 414 calls into call_quantized_matmul_mm_t
问题就出在这里:把两个请求打包,意味着输入从一行变成了两行,这个微小的变化足以切换到完全不同的计算内核。GEMM理论上能跨行复用权重,但在如此小的批量下,这个实现的额外开销远大于复用带来的收益。
换句话说,作者的调度器做的完全正确——它确实把请求合并了,但后端却选择了一条更慢的执行路径。

GEMV(General Matrix-Vector multiplication)和GEMM(General Matrix-Matrix multiplication)是线性代数库中两类基础运算,在深度学习推理中对应着截然不同的计算模式。GEMV处理的是矩阵与单列向量的乘法,适合批量大小为1的场景:每次只需把权重矩阵的每一行与输入向量做点积,内存访问模式线性且可预测。GEMM则将多个输入向量排列成矩阵,一次性完成批量计算,理论上能通过矩阵分块和缓存重用大幅提升硬件利用率。
然而这种收益依赖于足够大的批量。当矩阵行数极少时,GEMM的分块调度、内核启动和中间缓冲区的开销反而会超过复用带来的好处。在Apple Metal等GPU后端上,量化权重的反量化步骤还会进一步放大这种差距——GEMV可以在读取权重的同时完成反量化,而GEMM的分块逻辑让这一过程变得更加复杂。因此,"批量越大越快"的直觉在极小批量(通常小于4-8行)时往往失效,需要用实测数据而非理论来确定内核切换阈值。
用行级GEMV把速度抢回来
找到病根后,作者加入了一个回退机制:把小批量输入拆分成单行,对每一行分别运行GEMV,再把结果拼接起来。
效果立竿见影。在批量为2时,这个回退方案把吞吐拉回到16.25 tokens/s,与未批处理基线的16.15 tokens/s基本持平。但真正的问题是:随着批量变大,逐行GEMV还能继续领先吗?
基准测试给出了答案:
| 并发请求数 | 未批处理 | 批处理GEMM | 批处理逐行GEMV | GEMV/GEMM吞吐比 |
|---|---|---|---|---|
| 3 | 17.41 | 13.05 | 17.52 | 134.3% |
| 4 | 17.22 | 16.55 | 18.07 | 109.2% |
| 5 | 17.44 | 19.92 | 18.06 | 90.7% |
数据为聚合输出吞吐(tokens/s)
到了5个并发请求时,比值跌破100%,GEMM重新占优——此时分散派发和结果拼接的成本已经超过了一次GEMM调用。基于这个工作负载,作者把4行定为默认的内核切换阈值。
看起来问题解决了?远没有。
同样的prompt,却讲出不同的故事
真正的意外在后面。作者随后测试推测解码,用Qwen2.5 0.5B作为草稿模型每次提议4个token。结果发现:串行处理时整体草稿接受率为32.9%,而并发处理时却是37.1%。相同的请求、相同的模型、相同的提议策略,两次都用贪婪解码——为什么结果会不一样?
为了让数值问题更直观,作者让Qwen写一段科幻场景:一名机械师和一名档案员在月球废弃温室里调查求救信号。两次运行都使用贪婪解码,唯一的区别是:一次让prefill走GEMM,另一次走逐行GEMV。
前597个字符完全一致,之后内容开始分叉:
| Prefill内核 | 分叉后的续写 |
|---|---|
| GEMM | "Mara移向第二扇门,那里立着一台应急终端……" |
| GEMV | "Mara的手电光束划破昏暗,照亮一排排干枯的水培苗床……" |
两段续写都连贯通顺,角色与设定也一致,没有任何"模型崩坏"的迹象。这恰恰说明罪魁祸首是极细微的数值差异。

推测解码的追踪提供了更有力的证据。在第一个产生分歧的token处,草稿模型在两次运行中提议了相同的token ID,但目标模型给出的两个领先得分发生了顺序互换:
| 执行方式 | Token 476 | Token 323 | 最终选中 |
|---|---|---|---|
| 串行准入 | 25.571232 | 25.565775 | 476 |
| 连续批处理 | 25.549618 | 25.562220 | 323 |
这个微乎其微的差别,足以翻转贪婪解码的选择。此后每一个token都沿着不同的前缀展开,草稿接受率自然也随之改变。
贪婪解码(greedy decoding)是最简单的文本生成策略:每一步都选择模型输出概率分布中得分最高的token。由于没有随机采样,通常被认为是确定性的——相同输入理应产生相同输出。但"确定性"的前提是数值结果完全一致,而浮点运算本身并不满足这一点。
浮点数的加法和乘法不具备结合律:(a + b) + c 与 a + (b + c) 在IEEE 754标准下可能得到不同的结果。矩阵乘法涉及大量浮点累加,GEMM和GEMV的内部求和顺序不同,量化权重的反量化时机也不同,最终导致同一权重矩阵对同一输入向量给出相差极微小但符号可能翻转的激活值。当两个候选token的logit得分非常接近时,这种微小差异就足以改变贪婪选择的结果,进而通过自回归生成的"蝴蝶效应"让后续所有token走上不同的分支。
另一个请求早已结束,为何还在影响结果?
最让作者意外的地方在于:当token选择被翻转时,另一个请求其实早就结束了。那它为什么还能产生影响?
关键在于——这里的"行"计的是token位置,而非请求数量。某一个早期批次把新请求的120个prefill token,和已有请求的4个验证token合并在了一起。这124行越过了阈值,于是选择了GEMM而非GEMV,向缓存状态中引入了微小的数值差异。
等到输出真正分叉时,另一个请求已经完成,剩下请求的四token验证又回到了GEMV路径。但切回GEMV并不能撤销早已存入KV缓存的那些差异。
换句话说,如果只盯着文本分叉的那一次迭代去排查,根本找不到真正的原因。为了验证这个解释,作者强制让所有相关输入在两种执行模式下都走GEMV,缩短后的对比中,输出哈希与接受总数都对上了。这并不能证明哪个内核更精确,但支持了上述因果链条。
KV缓存(Key-Value Cache)是Transformer推理中的核心优化手段。在自注意力机制中,每个token生成时需要与序列中所有历史token计算注意力,而这些历史token对应的Key和Value向量在后续步骤中保持不变。KV缓存将这些中间结果存储起来,避免重复计算,使得生成阶段的计算复杂度从O(n²)降低到O(n)。
然而,KV缓存也使得数值误差具有持久性。在连续批处理场景中,多个请求共享同一次前向传播,某个批次写入缓存的Key/Value张量携带了该批次特有的计算路径(例如GEMM而非GEMV)所引入的浮点误差。这些值一旦写入缓存便不会被修改,后续步骤在读取时会将误差纳入注意力计算,从而影响所有依赖这段历史的token生成。即使引入误差的那个批次已经结束、那个请求已经完成,"污染"依然通过缓存状态延续下去,这也是为什么排查此类问题必须从缓存写入时刻而非输出分叉时刻向前追溯。
这次排查留下的启示
这次经历浓缩成一句话:一个批处理决策改变了内核选择,而它的影响在缓存状态中存活了很久,直到批次早已再次变化。
对于正在构建推理引擎的开发者,有几点值得记住:
- 调度层的"合并请求"与执行层的"内核选择"是两件事,前者正确不代表后者高效,必须结合实际工作负载实测阈值。
- 小批量下GEMM未必胜过GEMV,内核切换的开销需要用基准数据而非直觉来决定。
- 贪婪解码并不意味着结果确定可复现,不同执行路径带来的浮点数值差异足以翻转token选择,并通过KV缓存产生长程的"记忆效应"。
- 排查数值问题时,不能只看结果分叉的那一刻,要回溯缓存状态是如何被污染的。
作者在更长的文章中还记录了一个生成循环如何逐步成长为具备清晰调度、模型执行与请求状态边界的服务引擎,以及AI在架构和优化中帮上了什么忙、又在哪些地方需要被质疑。对于想深入推理引擎工程细节的读者,这篇排查实录提供了难得的一手视角。
相关推荐

大厂AI工程化面试:前端监控到Agent全链路可观测性
从PerformanceObserver采集性能指标、onerror与unhandledrejection捕获异常,到RAG链路重排优化、上下文压缩控制token成本,再到对标LangSmith与Sentry的全站AI全链路可观测性体系设计,拆解大厂AI工程化面试的三层递进考点。

WorkBuddy保姆级教程:手把手搭建专属AI Agent工作助理
WorkBuddy保姆级教程:从豆包等聊天模型的局限讲起,手把手教你搭建专属AI Agent工作助理,实现本地文件批量处理与自动化操作,非程序员也能轻松上手。

Windows下用LangChain-Chatchat+DeepSeek搭建本地知识库
本文整理B站教程,详解Windows下用LangChain-Chatchat与DeepSeek搭建本地知识库的全流程,包括Anaconda环境配置、Ollama模型管理、镜像源加速及常见报错解决,助你快速实现RAG问答。