LMSYS/SGLang博客详解Batch-1解码优化,小米声称MoE模型推理达1000 tok/s,小米团队分享了Blackwell架构上的推测解码实现细节。
两种空转时间
先修尺子,再修机器
让 host 先行
PDL:堆叠小内核的延迟底
两次融合与一次重调
bf16 路由器门控与 lm_head
KDA 在推测解码下
batch 1 推测的经济性
DSpark:高质量块Drafting
DSpark 的不同之处
47% 空转,及其机制
时间现在花在哪里
Batch-1 decode 越来越重要。例如,小米 MiMo 在 6 月宣布了 MiMo-V2.5-Pro UltraSpeed,号称在万亿参数 MoE 模型上达到 1000 tok/s 的 decode 速度。
Batch 1 不给推理栈任何掩盖开销的余地。没有 batch 分摊启动成本,没有并发填充流水线气泡,足够的算术强度也不足以让巧妙的分块付出代价。关键路径上的每一微秒都是用户等待的每一微秒。
这篇文章是关于在 4 块 NVIDIA Blackwell GPU 上为 Ling-3.0-flash(一个混合线性注意力 MoE 模型)压低这个底线。它涵盖两条推测解码路径。在 NEXTN/MTP 路径上,我们将单请求 decode 从 288 tok/s 提升到 606 tok/s,平均 TPOT 从 3.33 ms 降至 1.53 ms。第二条路径是 DSpark,一个建立在相同栈上的置信度调度推测解码器:1000 请求运行在平均 TPOT 0.78 ms 下达到 1120 tok/s,接受长度 9.95。最后这个比较是受控的:NEXTN 和 DSpark 在同一台机器上用相同命令测量,平均 TPOT 低 1.9 倍(1.53 ms 到 0.78 ms)。文章其余部分讲述时间都花在哪里,以及如何把它们省回来。
最终结果:平均 TPOT 下降 54%(3.33 ms 到 1.53 ms),单请求吞吐提升 2.1 倍(288 到 606 tok/s)。在受控的 1000 请求比较中,DSpark 达到平均 TPOT 0.78 ms 和 1120 tok/s。
优化路线是:host 先行,然后 PDL 链接,然后内核优化,然后 DSpark。移除每步的 host pin 让准备工作藏到 GPU 工作背后;PDL 随后链接了 MoE、路由器、KDA 和 all-reduce 路径;两次融合、一次 KDA 重调,以及 bf16 路由器/lm_head GEMM 缩短了剩余的 GPU 关键路径。
数值作为带宽旋钮:将路由器门控和 lm_head 从 fp32 移到 bf16 是结构化改动后最大的变化,贡献约 +10%。
全程测量纪律:在任何 host 侧结论之前进行有 profile vs 无 profile 的校准、冷启动权重微基准测试,以及基于平均 TPOT 而非单窗口峰值做 A/B 决策。
DSpark 提高了每个验证步骤提交的字元数:在并发度 1 时接受长度 9.95、1120 tok/s,平均 TPOT 0.78 ms。与同一 1000 请求基准上的 NEXTN 相比,平均 TPOT 低 1.9 倍。

图 1. 四个配置的头条结果。
在 GSM8K 上,相同栈的得分为:准确率 0.889,无效 0.000,延迟 341.5 s,输出吞吐 511.1 tok/s。
所有运行均使用 4 块 Blackwell GPU、TP4、bf16、并发度 1、greedy 解码,以及相同的固定 8192 输入 / 1024 输出随机工作负载。从左到右,各列分别是初始 NEXTN 基线、修复 draft-extend 图后的 NEXTN、调优后的最终 NEXTN,以及 DSpark。前两个是较短 campaign 的检查点;后两个是受控比较,每个都在同一台机器上对相同 1000 请求进行测量。峰值吞吐只在最后两次运行之间比较,因为它是在固定一秒窗口上的最大值。
这里有两个重要的定义,因为两者共同解释了为什么即使在并发度 1 时输出吞吐也不简单地是平均 TPOT 的倒数:SGLang 的 TPOT 不包括 TTFT,而输出吞吐用总输出 token 数除以总基准 wall 时间(见 bench_serving 指南)。本文所有头条基准运行都使用合成随机工作负载;接受长度尤其取决于 prompt 和输出分布,所以 9.95 是这个工作负载的接受长度而非模型的。

图 2. Ling-3.0-Flash 架构:42 层交错排列,35 层 KDA 线性注意力层与 7 层 MLA 全注意力层,MoE 拥有 512 个专家。
Ling-3.0-flash 是一个混合注意力 MoE 模型(BailingMoeV3),后面大部分内容都来自"混合"这个词。
每六个注意力层中有五个是 KDA。这就是为什么 MLA 注意力在 8k 上下文下每步只需 244 µs,也是为什么这个模型首先是一个好的 batch-1 目标:注意力便宜、batch 极小,剩余在关键路径上的就是权重带宽和启动延迟,而这正是这篇文章所讨论的机制。
我们用 NEXTN 推测解码进行 decode,steps=5,topk=1,draft_tokens=6。一次 decode step 是三个 CUDA Graph 以接力方式执行。

图 3. 每步三个图。Draft 模型提出一个 6 字元的链,目标模型在一次前向中对全部六个打分,而 extend 图用目标的真实隐藏状态重放已接受的 prefix 以产生下一轮的种子。裁决本身(eagle_sample)发生在 verify 图内部;host 延迟一步才知道有多少字元被接受。
Draft 是一个单层 NEXTN 模型,自回归运行:五步但只有四次前向,因为第一个候选来自上一轮的种子,第五个从第四次前向的 top-k 读出。Verify 是完整 42 层目标在全部六个链位置上的一次前向。Extend 修正 draft 的 KV cache(它只见过 draft 自己的猜测),并将下一轮的种子交回。
在 CPU 上跨三个图传递的什么都没有。固定形状加上填充使得每个依赖接受数的计数都成为 GPU 索引而非 host 值;持久缓冲区让生产者图直接写入消费者缓冲区;那些确实需要在 CPU 上获取值的决策(EOS、stop strings、detokenization)通过一条侧流 D2H 和延迟一步消费的 copy_done 事件传递。下面一切都建立在这个特性之上。
我们开始时,GPU 每步大约忙碌三分之二。Batch 1 下的空转时间有两种,它们需要不同的诊断,因为修复方法毫无共同之处:
Host 模式空转。每步三次图重放,其中执行数百个内核节点,图之间的缝隙里有 Python 粘合剂。(三次重放,不是三次前向:draft 图的捕获体包含全部四次 draft 前向,所以自回归 draft 循环成本一次重放而非四次。)如果 host 的每步循环比 GPU 的 step 更长,GPU 就会饿死。修复方法是把 host 工作藏起来并缩小它。
GPU 模式空转和 GPU 模式成本。一旦 host 被藏起来,剩余的就是权重带宽(每步每个 MoE 层冷读取约 94 MB 被激活的专家权重)加上数百个小内核节点的固有延迟底。Batch 为 1 时两者都无法分摊。修复方法是 dtype 工作、融合和启动依赖调度。

图 4. 两种空转形态。上:host 循环比 GPU 的工作更长,所以空洞少而宽,落在图之间的缝隙里。下:一旦 host 被藏起来,剩余的是数百个 1.5–6 µs 的内核节点,其启动底与算术成本相当,加上权重读取本身。
这两种空转描述了 step 时间这边的 TPOT。另一个杠杆是每步提交多少字元:平均 TPOT ≈ step 时间 / 平均接受长度。文章其余部分追踪这两个杠杆。Host 先行和缝隙工作移除 host 模式空转;PDL、dtype 改动、融合和重调缩短 GPU 关键路径;推测调优和 DSpark 增加每个目标 step 提交的字元数。DSpark 后面会在一个阻塞 D2H 读取重新引入 host pin 时重新审视第一类。
测量设置的三个属性塑造了下面每一个数字。
Profiler 会放大 host 侧事件。CUPTI 每记录一条 host 事件都会产生额外开销。在相同配置下,一次 profile 得到的 step 测量值为 5.2 ms,而从 TPOT × accept length 反推的真实 step 为 4.9 ms(无 profile 的运行)。0.3 ms 的差距与我们想要分析的 host 侧效应处于同一量级,因此 profile trace 可能显示出在真实运行中根本不存在的跨 rank 等待。GPU kernel 时长来自硬件时间戳,比 host 侧计时更可信,但并非完全免疫:tracing 仍然会干扰 launch 时机、并发状态、缓存状态和 CUDA graph 执行,Nsight Systems 文档也指出了 CUDA 和 graph-node tracing 可能带来的显著开销(user guide)。因此,这里每一个 host 侧结论都经过了 profile 与非 profile 状态的校准。
微基准对冷 weight kernel 过于乐观。循环反复调用同一 kernel 会使 2.6 MB 的 gate weight 保留在 L2 中,而真实模型在连续调用同一层时会产生约 94 MB 的 expert 流量来刷新 L2。热 7 µs,冷 11 µs:足以推翻针对库 GEMV 的排名结论。
峰值吞吐是一个单窗口统计量。基准测试的峰值数字是在固定的 1 秒网格上取最大值,因此大致带有 ±5% 的相位带:移位的 TTFT/TPOT 会重新切分网格,而一个使平均吞吐提升 2.3% 的变化可能表现为从 909 降至 858。两种读数在固定 seed 下都能精确复现,因此可复现性无法将信号与相位分离。此处的 A/B 决策基于 mean TPOT × mean accept length。这个乘积是一个导出的 step 时间估计值而非实测值(两个聚合量的乘积不是乘积的聚合量),但它在多次运行中稳定,且对这些运行中 accept-length 的漂移不敏感——这正是一个 A/B 判断标准所需的。我们报告峰值但从不以它为目标进行优化。
正确性有自己的关卡,应用于每一项变更,且在变更保留之前:256-token 贪婪生成的字节级比较,accept length 在 0.05 以内变化,以及在插入 temperature 采样的请求后重新运行贪婪生成以捕获状态污染。合法改变舍入方式的变更(bf16 关卡、单次舍入 combine)在 commit message 中说明了这一点,并使用 accept 和任务指标而非比特奇偶性来验证。
这是整个 campaign 所依赖的结构性变更,而它本质上是一个 host 模式空闲修复。

图 5. 从锁步到深度流水线。之前:每一步 host 都会阻塞在 resolve_seq_lens_cpu 中等待前一个 verify graph 在 GPU 上完成,因此 run-ahead 深度每次都重置为零,每个 host prep 段都成为一个 GPU 气泡。之后:队列深度为完整的一步,verify k+1 的 launch 领先其自身执行整整一步,唯一的同步需求是一个延迟一步消费的 copy_done 事件。
cudaGraphLaunch 始终是异步的,GPU 上的 draft → verify → extend 顺序是自由的:同一 stream,FIFO。因此问题从来不是 verify 是否等待 draft。而是 host 是否每一步都被绑定到 GPU 进度上。
确实是被绑定的。在 spec-v2 下,scheduler 不知道 accept length,因此 FutureMap.resolve_seq_lens_cpu() 在构建下一个 batch 时将 new_seq_lens 从 GPU 拉回:受限于一个 publish 事件,在私有 stream 上复制,然后 synchronize()。host 等待的不是微秒级的拷贝。而是等待前一个 verify graph 执行完成。每步中位数成本:485 µs,run-ahead 深度每一步都重置为零。
原因是 needs_cpu_seq_lens 标志,它是所有参与 spec-v2 的 backend 的 OR 结果。trtllm_mla 在全部三种角色中都声明为 False;兄弟线性注意力 backend GDNAttnBackend 和 Mamba2AttnBackend 都明确声明了 False。KDAAttnBackend 从未声明它,继承了基类默认值 True,尽管它与两个兄弟 backend 运行相同的基类元数据代码。
声明 needs_cpu_seq_lens = False 后,OR 结果坍缩,per-step synchronize 被移除。正确性论证是逐点的:KDA 的元数据从不读取 CPU 镜像,而 replay padding 来自 forward_batch.num_padding。
host 怎么敢在不知道 step k 接受了什么的情况下 launch step k+1?因为这些值从不触碰 CPU。FutureMap 是一个 GPU 驻留的中继:step k 的 graph 将 output tokens、new_seq_lens、top-k 概率和 hidden states 写入由 req_pool_idx 索引的设备缓冲区,step k+1 的 graph 通过相同索引读取它们。host 只处理索引,而索引是它已经知道的。

图 6. Slack 所在之处。面板 A:host 循环(约 4.3 ms) fits under GPU step(约 4.9 ms),因此完全被隐藏。面板 B:当抖动(gloo broadcast 或 GC pause)超过 slack 时,host 完成较晚,GPU 在下一个 verify 边界处等待,graph 中的第一个 collective 在那里吸收跨 rank 偏斜。
Run-ahead 也改变了 host 成本的形态。不是每个 rank 每步直接支付其 host 时间,而是只有耗尽队列 slack 的 rank 才需要支付。在一个四 rank trace 中,恰好有一个 rank 处于该状态:其 scheduler 段运行时间比其兄弟多 5-10 倍,其 draft graph launch 延迟了 40-80 µs,其 draft→verify 接缝比其他人中位数高出 +165 µs,且呈现出具有 GC 特征的周期性 400-750 µs 尖峰。其他三个 rank 在每个 rendezvous 处自旋等待它。通用的诊断:一个 kernel 的时长不是它的工作量。一个显示 150-480 µs 的 20 KB embedding all-reduce 不是慢的 all-reduce;它是在吸收偏斜,而且只有跨 rank 时间对齐才能告诉你哪个 rank 落后了。
锁步针定移除后,graph 之间的接缝值得收缩。在 CUDA graph 重放之前,step 特定的注意力元数据(kv indices、block tables、mamba state slots)必须从 live req_to_token 和 seq_lens 重建到 graph 捕获的静态缓冲区中。那个 refill 每步急切运行,是一个接缝的大部分内容。在 batch 1 下它完全是 host 绑定的:每个 op 分派成本 5-15 µs,执行成本 1-4 µs。
我们在两个层面攻击它。首先,融合索引链:assign_extend_cache_locs_uniform 在 kernel 内部计算结束偏移(uniform draft_token_num 扩展使跨行前缀和变得不必要),fused_state_indices_kernel 将 gather、translate、padding-sentinel write 和 copy 折叠为一次 launch,仔细保留两个副作用,包括在填充行上清零 req_pool_indices,该函数本身不需要这个但 graph 中其他捕获的 kernel 依赖它来进行 in-bounds gathers。
其次,将 refill 本身捕获为一个由 (bs, forward_mode) 键控的小 CUDA graph。这之所以可行,是因为 replay 契约已经保证的指针稳定性属性:replay ForwardBatch 视图只向 backend 提供 runner 静态缓冲区和池驻留张量,因此整个 prep 序列具有固定地址。四种安全机制围绕它:两个 eager warmup,使 Triton JIT 和 autotune 发生在捕获之外;每个 backend forward_metadata 对象的快照,在每次重放前恢复(graph 重放设备 ops,快照恢复 Python 指针);如果捕获失败则永久 eager fallback 并发出警告;以及针对 padding、TBO、pdmux 和 LoRA 的 guard。它在 SGLANG_ENABLE_METADATA_GLUE_GRAPH 后面以可选方式发布,对于 DFLASH 系列 speculation 被强制禁用,因为那条路径每步在 host 上重建其注意力计划,捕获 refill 会在捕获时冻结该计划。
可捕获内容有硬边界。标准:由纯设备 kernel 写入持久缓冲区的 refill 是可捕获的;任何经过 FlashInfer 风格 plan() 的都不是。draft 侧未能通过:多步 draft backend 重新 plan() main EAGLE graph 已经捕获的包装器,将该 re-plan 记录到辅助 graph 中会在重放时破坏包装器的内部状态。一个相关要求是捕获是幂等的。trtllm_mla 的 _init_cuda_graph_metadata 过去在每次调用时分配新张量并替换其 decode_cuda_graph_metadata[bs] 条目,这会导致在第二次捕获后更早的 graph 读取已释放的内存。
PDL:堆叠小 kernel 的延迟层
一个 batch-1 step 在短时间窗口内执行数百个 kernel 节点。在这个规模下,launch 和 prologue 开销与数学运算开销大致相当。Programmatic Dependent Launch(PDL)允许 consumer kernel 在其 producer 仍在运行时被调度到 SM 上:consumer 执行所有不依赖 producer 输出的部分,只在依赖读取之前在 gdc_wait() 处设置栅栏。

Figure 7. 路由路径上的 PDL。没有 PDL,每个 kernel 必须在前一个完全退场后才能启动,而 gate matvec 的冷 HBM 权重加载正落在关键路径上。有了 PDL,权重分块的加载是独立于生产者的,因此在 gdc_wait() 之前就发出,一条 2.6 MB 的冷读操作得以在生产者尾部"溜过";路由 top-k 以同样方式预取其 bias。
我们接入了三条链:MoE 主链(moe_align → up-GEMM → activation → down-GEMM → combine → all-reduce)、路由链(norm → gate matvec → top-k)和 KDA 链(conv1d_update → recurrent delta-rule → gated norm)。有两个设计要点值得关注。
与生产者无关的加载要放在 wait 之前。 这就是图中所示的全部技巧,也是 PDL 不仅仅是为延迟受限 kernel 移除启动开销的原因。
Inductor kernel 无法携带 PDL 属性。 small-M MoE combine 是一个 torch.compile 生成的 kernel;接入这条链意味着用仓库里的 Triton reduction 加上 GDC 来替换它。这有一个数值方面的副作用:fp32 sum × scale 最后只做一次 cast,而旧路径会 round 两次。结果是精度略高但不是位相等(bit-equal),这一事实在 commit message 中已声明。
后来我们在 PTX griddepcontrol 中发现后升级了语义:launch_dependents 只释放依赖者的启动,而消费者的 wait 始终会栅栏在生产者 grid 完全退场之上。把触发时机从生产者末尾移到生产者自身 wait 之后立即执行,可以让消费者的 prologue 与生产者的 body 产生更多重叠,而不仅仅是尾部重叠,但有一个前提:消费者仍须在其提前启动和每次读取生产者输出之间保持自己的 gdc_wait()。这是每个消费者的属性,不是全局保证,所以我们逐 kernel 检查并转换了六个。提前触发带来的收益也不是确定性的:驱动可能会提前启动一个依赖的 grid,实际产生多少重叠取决于当时的调度和资源压力(CUDA programming guide)。fused_moe 在 M ≤ 512 检查背后做了此门控:在 prefill 形状下,提前释放大型消费者 grid 会从生产者抢 SM,而在 decode 形状下则是纯收益。
PDL 是纯调度语义。累积顺序不变的变化保持位级相同;gate matvec 通过了 4-of-4 GDC 开/关位比较。
两次融合和一次重调
moe_align,作用于 pair 轴。Triton fused-MoE GEMM 以 block_size 分块消费 token,每行共享一个 expert,moe_align_block_size 构建该置换。通用路径需要两次 kernel 启动:在每个 expert 的偏移量最终确定之前无法放置任何 token,这些偏移量来自网格级 scan,而设备级 barrier 仅存在于 kernel 边界。单次启动变体存在,但它在共享内存中暂存每个线程的 expert 计数器,因此限制在 64 个 expert 以内;513-expert 的 decode 始终要付两次启动的代价。
新方案作用于 pair 轴:[NP, NP] 成对比较为每个 (token, slot) 对在其桶内赋予一个稳定的 rank 及其桶的人口数,一举完成;每个桶的 rank-0 代表随后导出填充计数、桶序独占偏移量、已发布的总数以及每块 expert id。没有任何量随 expert 数量伸缩,因此 expert 数量限制消失。显而易见的替代方案——在填充的 expert 轴上做 histogram 和 cumsum(最多 1024 个桶)——是正确的,但比起它替换的两个 kernel,在关键路径上多了约 3 倍的单 SM 工作量。这正是 pair 轴成为关键路径的原因。
两处对参考实现的刻意偏离,均从消费者不变式出发:桶内顺序按 pair index 而非原子调度顺序保持稳定(每个 pair 写入自己的输出行,因此消费者是顺序不变的),以及缓冲区尾部超出已发布总数的部分保持不写入(消费者 CTA 在读取前就 early-exit)。一个悬崖:成对张量是 O(NP²)。在 NP=64 时完全驻留在寄存器中(约 4 µs,与 CUDA 两 kernel 路径持平),在 NP=256 时溢出到 local memory,每次启动额外花费约 230 µs。分发门控是硬性的 numel ≤ 64;更大的 batch 回退到 CUDA 路径。
SwiGLU 置于 up-GEMM 尾声中。将 silu(gate) * up 融合到 MoE up-GEMM 尾声,移除一个独立的每 MoE 层激活 kernel 以及中间缓冲区的先写后读。布局技巧是在权重加载时按 per-expert 行交错 w13,使 gate 和 up 落在同一输出分块的相邻偶/奇数列。由于每个 GEMM 输出列是独立的点积,交错在位级是中性的。
位奇偶性是这里需要仔细处理的地方。被替换的 kernel 使用 -use_fast_math 编译,因此尾声逐指令复现:mul + ex2.approx.ftz 对应 __expf,div.approx.ftz,以及产品上单次最终舍入。微妙之处:FlashInfer 在 float 中实例化激活 functor,因此 silu 在乘法之前从不会落在 bf16。在那里 round 结果会双重舍入并在大量输入上发散。这一点在文档中看不到,容差检查也看不到;需要对整个输入范围做逐元素位比较。
KDA 链验证分块经济性。融合的 conv1d + 门控 delta-rule 验证 kernel 已经存在;这些 commit 对其进行了重调。在旋转冷测试且带图内计时的条件下,Blackwell 曲线在 T=6 时是单调的:BV=4 为 11.56 µs,8 为 12.53,16 为 12.83,32 为 14.26,64 为 20.7,128 为 38。BV=4 每次调用最高领先 19%,因为 256 CTA 在 148 SM 上是 1.7 个 wave,复制 q/k 卷积 32 次仍比缩短串行链便宜。对 V 维度分块从不改变 K 维度归约顺序,因此在 num_warps=4 时每个 BV 与基线位级完全相同,重调没有数值风险。
bf16 路由门和 lm_head
结构改动之后最大的单一改动是 dtype 变更。在 batch 1 下,路由门和 lm_head 是纯带宽:每个 decode 步骤冷读每个 MoE 层的 gate 权重(bf16 下 2.6 MB)和词汇并行的 lm_head 投影,两者都没有算术可以隐藏读取开销。将两者都跑在 bf16 而非 fp32 会使这些字节数减半;端到端约带来 +10% 的收益,这是主机先行之后任何单一变化中最大的收益。