跳到正文
北京时间
原文
LMSYS:Blog(Chatbot Arena 团队)· RadixArk SGLang Team, Ant Ling Infra Team·· 2026-08-21精选AI 评分63

Ling-3.0-flash 在 4 块 Blackwell GPU 上如何将批处理 1 解码延迟降低 54%

Blog Chasing the Batch-1 Floor: Ling-3.0-flash Speculative Decode on Blackwell Batch-1 decode keeps getting more important. Xiaomi MiMo, for example, announced MiMo-V2.5-Pro UltraSpeed in June, claiming 1,000 tok/s decode on a one-trillion-parameter MoE model. Batch 1 gives an ... RadixArk SGLang Team, Ant Ling Infra Team

AI 导读

蚂蚁 Ling Infra 团队与 RadixArk SGLang 团队将 Ling-3.0-flash 混合线性注意力 MoE 模型的单请求解码速度从 288 tok/s 提升至 606 tok/s,平均 TPOT 从 3.33 ms 降至 1.53 ms。

推荐理由

对做单请求低延迟推理的团队,可迁移的是先把主机从 GPU 进度上解绑再优化 GPU 关键路径,否则主机的同步等待会变成每步的 GPU 气泡,这与具体模型无关。

正文 · AI 翻译

Batch-1 解码正变得越来越重要。例如,小米 MiMo 在 6 月发布了 MiMo-V2.5-Pro UltraSpeed,声称在万亿参数 MoE 模型上实现了 1,000 tok/s 的解码速度。

Batch 1 让推理栈没有任何隐藏开销的空间。没有批次可以分摊启动成本,没有并发可以填补流水线气泡,也没有足够的算术强度让精巧的 tiling 策略产生回报。关键路径上的每一微秒,都是用户等待的一微秒。

这篇文章讲述如何在 4 块 NVIDIA Blackwell GPU 上,将混合线性注意力 MoE 模型 Ling-3.0-flash 的这个下限进一步压低。文章涵盖两条投机解码路径。在 NEXTN/MTP 路径上,我们将单请求解码从 288 tok/s 提升到 606 tok/s,平均 TPOT 从 3.33 ms 降至 1.53 ms。第二条路径是 DSpark,一个基于同一技术栈、采用置信度调度的投机解码器:在 1000 请求的测试中达到 1120 tok/s,平均 TPOT 为 0.78 ms,接受长度为 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 达到了 0.78 ms 的平均 TPOT 和 1120 tok/s 的吞吐量。
  • 优化路线依次是:主机端预跑(host run-ahead)、PDL 链式编排、内核优化,最后是 DSpark。移除每步的主机端固定(pin)操作后,准备工作得以隐藏在 GPU 计算之后;PDL 随后将 MoE、路由器、KDA 和全归约(all-reduce)路径串联起来;两次融合、一次 KDA 重调,以及将路由器与 lm_head 的 GEMM 改为 bf16,进一步缩短了剩余的 GPU 关键路径。
  • 把数值精度当作带宽调节旋钮:将路由器门控(router gate)和 lm_head 从 fp32 改为 bf16,是结构改动之后收益最大的一项,大约带来 +10% 的提升。
  • 全程贯彻测量纪律:在得出任何主机端结论之前先做有剖析与无剖析的校准、冷权重微基准测试,以及基于平均 TPOT 而非单窗口峰值来做 A/B 决策。
  • DSpark 提高了每个验证步骤提交的 token 数量:在并发数为 1 时,接受长度(accept length)为 9.95,吞吐达 1120 tok/s,平均 TPOT 为 0.78 ms。在同样的 1000 请求基准上对比 NEXTN,平均 TPOT 降低了 1.9 倍。

Headline results across the four configurations

图 1. 四种配置下的核心结果。

指标(8192 输入 / 1024 输出,单并发,贪心解码,TP4 bf16)基线草稿扩展图修复之后NEXTN,经调优使用 DSpark
平均输出吞吐量288 tok/s526 tok/s606 tok/s1120 tok/s
平均 TPOT3.33 ms1.76 ms1.53 ms0.78 ms
中位 TPOT——1.56 ms0.51 ms
峰值输出吞吐量——1099 tok/s1945 tok/s
接受长度3.143.133.259.95

在 GSM8K 上,同一技术栈的得分为:准确率 0.889,无效 0.000,延迟 341.5 秒,输出吞吐量 511.1 token/秒。

所有运行均使用 Ling-3.0-flash,在 4 块 Blackwell GPU 上,TP4、bf16、并发数 1、贪心解码,以及相同的固定 8192 输入 / 1024 输出随机工作负载。从左到右,各列依次显示初始 NEXTN 基线、草稿扩展图修复后的 NEXTN、最终调优后的 NEXTN,以及 DSpark。前两项是较短的竞选检查点;后两项是受控对比,各自在同一台机器上对相同的 1000 个请求进行测量。峰值吞吐量仅在最后两次运行之间进行比较,因为它是固定一秒窗口内的最大值。

这里有两个定义很重要,因为它们共同解释了为什么即使在并发数为 1 时,输出吞吐量也不是平均 TPOT 的简单倒数:SGLang 的 TPOT 不包含 TTFT,而输出吞吐量是用总输出 token 数除以基准测试总墙钟时间(参见 bench_serving 指南)。本篇文章中所有头条基准测试运行均使用合成 random 工作负载;接受长度尤其取决于提示词和输出分布,因此 9.95 是该工作负载的接受长度,而非模型的接受长度。


该模型

Ling-3.0-Flash architecture: 42 layers interleaving 35 KDA linear-attention layers with 7 MLA full-attention layers over a 512-expert MoE

图 2. Ling-3.0-Flash 架构:42 层中交错排列 35 层 KDA 线性注意力层与 7 层 MLA 全注意力层,构建于 512 专家 MoE 之上。

Ling-3.0-flash 是一款混合注意力 MoE 模型(BailingMoeV3),下文的大部分内容都源于“混合”这个词。

层数共 42 层:35 层 KDA 线性注意力 + 7 层 MLA 全注意力
MoE512 个路由专家 + 1 个共享专家,top-8(+1),moe_intermediate_size 768
隐藏层维度2560
词表大小约 157k,通过词表并行的 lm_head 提供服务
权重bf16 下每 rank 约 63 GB
部署4 块 NVIDIA Blackwell GPU,TP4,bf16,NEXTN 投机解码

每六个注意力层中有五个是 KDA。这正是 MLA 注意力在最终性能剖析中、8k 上下文下每步仅需 244 微秒的原因,也是该模型一开始就成为 batch-1 良好测试目标的原因:当注意力成本低廉、批次又极小时,关键路径上剩下的就是权重带宽和启动延迟,而这正是本文要讨论的场景。

batch-1 单步的形态

我们使用 steps=5, topk=1, draft_tokens=6 的 NEXTN 投机解码进行解码。一个解码步骤由三个 CUDA graph 接力完成。

Three graphs per decode step

图 3. 每步三个 graph。草稿模型提出一条 6-token 链,目标模型在一次前向传播中为全部六个 token 打分,extend graph 则用目标的真实隐藏状态重放被接受的 token 前缀,以生成下一轮的种子。判定本身(eagle_sample)发生在 verify graph 内部;主机在延迟一步后才得知有多少 token 被接受。

草稿是一个单层 NEXTN 模型,以自回归方式运行:五个步骤但只有四次前向传播,因为第一个候选 token 来自上一轮的种子,第五个则从第四次前向传播的 top-k 中读出。Verify 是对完整 42 层目标模型在全部六个链位置上的一次前向传播。Extend 修正草稿的 KV cache——它只见过草稿自身的猜测——并将种子交还给下一轮。

CPU 上三张图之间交叉传递的内容为零。固定形状加上填充,让每个依赖接受计数的量都变成 GPU 索引而非主机值;持久化缓冲区让生产者图可以直接写入消费者缓冲区;而真正需要在 CPU 上取值的决策(EOS、停止字符串、去分词)则通过旁路 D2H 和延迟一步消费的 copy_done 事件来完成。下面的一切都建立在这个性质之上。

两种空闲时间

我们刚开始时,GPU 大约有三分之二的步长时间处于忙碌状态。batch 为 1 时的空闲时间有两种,需要分别诊断,因为对应的修复手段毫无共同之处:

  1. 主机侧空闲。每一步要重放三张图,图内执行数百个内核节点,图与图之间的接缝处还有 Python 胶水代码。(是三次重放,而不是三次前向:草稿图捕获的主体包含了全部四次草稿前向,所以自回归草稿循环只消耗一次重放,而非四次。)如果主机的每步循环耗时超过 GPU 的步长,GPU 就会挨饿。修复方法是隐藏并压缩主机侧的工作量。
  2. GPU 侧空闲与 GPU 侧开销。主机侧被隐藏之后,剩下的就是权重带宽(每个 MoE 层每步大约冷读 94 MB 的已激活专家权重)加上数百个小内核节点固有的延迟下限。batch 为 1 时两者都无法摊薄。修复方法是 dtype 处理、算子融合和启动依赖调度。

Two shapes of idle time at batch 1

图 4. 空闲的两种形态。上图:主机循环比 GPU 的工作更长,因此空洞少而宽,落在各计算图之间的接缝处。下图:主机被隐藏后,剩下的是数百个 1.5-6 微秒的内核节点,其启动开销与算术耗时相当,再加上权重读取本身。

这两种空闲形态描述了 TPOT 中步耗时(step-time)这一侧。另一个杠杆是每一步承诺多少个 token:平均 TPOT ≈ 步耗时 / 平均接受长度。本文其余部分将沿着这些杠杆展开。主机预跑(host run-ahead)与接缝处理消除了主机模式空闲;PDL、dtype 变更、算子融合与重新调优缩短了 GPU 关键路径;投机采样调优与 DSpark 增加了每个目标步所承诺的 token 数。当阻塞式 D2H 读取重新引入主机瓶颈时,DSpark 稍后会重新审视第一类空闲。

先修尺子,再修机器

测量设置的三项属性塑造了下方每一个数字。

剖析器会放大主机侧事件的开销。CUPTI 会为其记录的每个主机事件增加额外开销。在相同配置下,被剖析的步骤测得耗时 5.2 ms,而真实步骤(根据未剖析运行中 TPOT × 接受长度的反推计算)为 4.9 ms。这 0.3 ms 的差距与我们想要分析的主机侧效应处于同一量级,因此剖析轨迹可能显示出在关闭剖析器时并不存在的跨 rank 等待。GPU 内核耗时来自硬件时间戳,比主机侧计时更可信,但也并非完全免疫:追踪仍会扰动启动时序、并发性、缓存状态以及 CUDA 图执行,而且 Nsight Systems 文档也指出 CUDA 和图节点追踪可能带来显著开销(用户指南)。因此,这里得出的每一个主机侧结论都先经过了剖析与未剖析的校准。

微基准测试对冷权重核函数的运行结果偏乐观。一个循环反复调用同一核函数时,其 2.6 MB 的门控权重会常驻在 L2 缓存中;而真实模型中,连续两次调用同一层之间,约 94 MB 的专家流量会把 L2 缓存冲刷干净。热态 7 微秒,冷态 11 微秒:这一差距足以扭转与库版 GEMV 对比时的排名。

峰值吞吐量是单窗口统计量。该基准的峰值数字是在固定 1 秒网格上取最大值,因此它带有大约 ±5% 的相位带:TTFT/TPOT 的偏移会重新切分网格,而一个使平均吞吐量提升 2.3% 的改动,打印出来却可能显示为从 909 降到 858。在固定种子下,两种读数都能精确复现,因此可复现性并不能区分真实信号与相位噪声。这里的 A/B 决策基于平均 TPOT × 平均接受长度。该乘积是对单步时间的推导估计,而非直接测量值(两个聚合值的乘积并不等于乘积的聚合值),但它在多次运行中保持稳定,且在这些运行中对接受长度的漂移不敏感——这正是 A/B 判据所需要的。我们报告峰值,但从不针对它进行优化。

正确性有自己的把关门槛,应用于每一个被保留的改动:对 256 token 贪心生成进行逐字节精确比对,接受长度变化保持在 0.05 以内,并在交错插入温度采样请求后重新运行贪心生成,以捕获状态污染。那些合理改变舍入方式的改动(bf16 门控、单次舍入合并)会在提交信息中明确说明,并改用接受度和任务指标进行验证,而非逐位一致性。

让主机端先行运行

这是后续整个优化行动所依赖的结构性改动,本质上是一个主机端空闲修复。

From lockstep to deep pipelining

图 5. 从锁步到深度流水线。之前:每一步主机都会在 resolve_seq_lens_cpu 处阻塞,等待前一个验证图在 GPU 上完成,因此超前执行会重置为零,每个主机预分段都会变成 GPU 气泡。之后:队列深度达到一整步,验证 k+1 的启动比其自身执行提前了整整一步,唯一剩下的同步是一个延迟一步才被消费的 copy_done 事件。

cudaGraphLaunch 一直是异步的,GPU 上的草稿 → 验证 → 扩展顺序是免费的:同一流,FIFO。所以问题从来不是验证是否等待草稿,而是主机是否每一步都被 GPU 的进度所牵制。

确实如此。在 spec-v2 下,调度器并不知道接受长度,因此在构建下一批次时,FutureMap.resolve_seq_lens_cpu() 会从 GPU 取回 new_seq_lens:以发布事件为门控,在私有流上复制,然后执行 synchronize()d。主机并非在等待微秒级的复制操作,而是在等待上一个验证图执行完毕。每步的中位开销为 485 微秒,且预跑深度每一步都会重置为零。

原因在于 needs_cpu_seq_lens 标志,它由 spec-v2 涉及的每个后端进行 OR 运算。trtllm_mla 在全部三种角色中都声明了 False;其兄弟线性注意力后端 GDNAttnBackend 和 Mamba2AttnBackend 都显式声明了 False。KDAAttnBackend 从未声明它,因此继承了基类默认值 True,尽管它与其两个兄弟后端运行着相同的基类元数据代码。

声明 needs_cpu_seq_lens = False 折叠了 OR 操作,并移除了逐步同步。正确性论证是逐点进行的:KDA 的元数据从不读取 CPU 镜像,而回放填充来自 forward_batch.num_padding。

主机如何敢在不知道第 k 步接受了什么的情况下启动第 k+1 步?因为这些值从不触及 CPU。FutureMap 是一个驻留在 GPU 上的中继:第 k 步的计算图将输出 token、new_seq_lens、top-k 概率和隐藏状态写入由 req_pool_idx 索引的设备缓冲区,而第 k+1 步的计算图则通过相同的索引读取它们。主机只处理索引,而这些它早已知道。

Where the run-ahead slack lives

图 6. 松弛时间的分布位置。面板 A:主机循环(约 4.3 毫秒)适配在 GPU 步骤(约 4.9 毫秒)之下,因此它被完全隐藏。面板 B:当抖动(一次 gloo 广播或一次 GC 暂停)超过松弛时间时,主机完成得较晚,GPU 会在下一个验证边界处等待,此时计算图中的第一个集合通信操作吸收了跨秩偏差。

超前运行也改变了主机成本的形态。不再是每个秩每一步都直接支付其主机时间,而是只有耗尽队列松弛时间的那个秩才需要支付。在一次四秩追踪中,恰好有一个秩处于这种状态:其调度器段的运行时间比同级秩长 5-10 倍,其草稿图启动延迟了 40-80 微秒,其草稿到验证的接缝比其它秩的中位数高出 165 微秒以上,并且它表现出周期性的 400-750 微秒尖峰,带有 GC 特征。其它三个秩在每次会合点都为其自旋等待。可推广的诊断结论是:内核的持续时间并不等于其工作量。一个 20 KB 的嵌入向量全归约操作显示出 150-480 微秒的耗时,这并非全归约本身慢;它是在吸收偏差,只有跨秩的时间对齐才能告诉你哪个秩迟到了。

弥合接缝

随着 lockstep pin 的移除,图与图之间的接缝变得值得压缩。在 CUDA 图重放之前,需要根据实时的 req_to_token 和 seq_lens 将特定步骤的注意力元数据(kv 索引、块表、mamba 状态槽位)重建到图所捕获的静态缓冲区中。这种回填操作每一步都会急切地执行,而它正是接缝的主要组成部分。在 batch 为 1 时,这纯粹是主机端受限的:每个操作需要 5-15 微秒进行调度,1-4 微秒执行。

我们从两个层面着手解决这个问题。首先,融合索引链:assign_extend_cache_locs_uniform 在内核内部计算结束偏移量(统一的 draft_token_num 扩展使得跨行前缀和不再必要),而 _fused_state_indices_kernel 将一次 gather、一次 translate、一次填充哨兵写入以及一次 copy_ 合并为一次启动,同时仔细保留了两者的副作用,包括在填充行上将 req_pool_indices 清零——虽然该函数内部并不需要这一操作,但图中其他被捕获的内核在进行越界安全的 gather 时依赖于此。

其次,将 refill 本身捕获到一个以 (bs, forward_mode) 为键的小型 CUDA graph 中。这之所以可行,是因为 replay 契约已经保证了一个指针稳定性属性:replay ForwardBatch 视图只向后端提供 runner 静态缓冲区和池驻留张量,因此整个 prep 序列具有固定地址。四个安全机制环绕其周围:两次 eager 预热,使 Triton JIT 和 autotune 在捕获之外发生;每个后端的 forward_metadata 对象的快照在每次 replay 前恢复(graph 重放设备操作,快照恢复 Python 指针);如果捕获失败,则永久回退到 eager 模式并发出警告;以及针对 padding、TBO、pdmux 和 LoRA 的防护。它作为 SGLANG_ENABLE_METADATA_GLUE_GRAPH 之后的可选功能提供,并且对于 DFLASH 系列投机解码被强制禁用,因为该路径每一步都在主机端重建其注意力计划,而捕获 refill 会在捕获时冻结该计划。

对于可捕获的内容存在一个硬性边界。标准是:由纯设备内核写入持久缓冲区的 refill 是可捕获的;任何通过 FlashInfer 风格 plan() 的路径则不可捕获。draft 侧无法满足此要求:多步 draft 后端会重新 plan() 主 EAGLE graph 已捕获的包装器,而将该重新规划记录到次级 graph 中会在 replay 时破坏包装器的内部状态。一个相关的要求是捕获必须是幂等的。trtllm_mla 的 _init_cuda_graph_metadata 过去每次调用都会分配新的张量并替换其 decode_cuda_graph_metadata[bs] 条目,这导致在第二次捕获后,较早的 graph 会读取已释放的内存。

PDL:堆叠小型内核的延迟下限

一个 batch-1 步骤会在很短的时间窗口内执行数百个内核节点。在这种规模下,启动和前导开销与数学计算本身的成本几乎相当。程序化依赖启动(PDL)允许消费内核在其生产者仍在运行时就被调度到 SM 上:消费内核会执行所有不依赖生产者输出的部分,并且仅在依赖读取之前于 gdc_wait() 处设置栅栏。

PDL on the router path

图 7. 路由路径上的 PDL。没有 PDL 时,每个内核只能在前一个内核完全退出后才启动,门控矩阵向量乘的冷 HBM 权重加载处于关键路径上。使用 PDL 后,权重分块的加载与生产者无关,因此会在 gdc_wait() 之前发出,2.6 MB 的冷读取便在生产者的尾部之下飞过;路由 top-k 也以同样的方式预取其偏置。

我们串联了三条链:MoE 主链(moe_align → up-GEMM → 激活 → down-GEMM → combine → all-reduce)、路由链(norm → gate matvec → top-k)以及 KDA 链(conv1d_update → 循环 delta 规则 → 门控 norm)。有两个设计要点值得关注。

与生产者无关的加载要放在等待之前。这就是图中全部技巧所在,也正是它让 PDL 对于延迟受限内核而言,不仅仅是消除启动开销那么简单。

Inductor 内核无法携带 PDL 属性。小 M 的 MoE combine 是一个 torch.compile 生成的内核;要加入这条链,就需要将其替换为代码库中的 Triton reduction 加上 GDC。这带来了一个数值上的副作用:fp32 sum × scale 只做一次最终类型转换,而旧路径会进行两次舍入。结果精度略有提升,但并非逐位相等,提交信息中对此已有说明。

后来,我们在 PTX griddepcontrol 中发现一个现象后升级了语义:launch_dependents 只会释放依赖项的启动,而消费者的 wait 总是会对生产者网格的完全退出(complete retirement)做栅栏同步。把触发点从生产者的末尾移到生产者自身等待之后紧接的位置,可以让消费者的序言(prologue)与生产者主体有更多重叠,而不仅仅是尾部,但有一个前提条件:消费者仍必须在其早期启动与每次读取生产者输出之间保留自己的 gdc_wait()。这是每个消费者自身的属性,并非普遍保证,因此我们逐个内核检查,并转换了六个。早期触发带来的收益也不是确定性的:驱动程序可能会提前启动依赖网格,而实际能实现多少重叠取决于当时的调度和资源压力(CUDA 编程指南)。fused_moe 将此置于 M ≤ 512 检查之后:在预填充(prefill)形状下,提前释放大型消费者网格会从生产者那里抢占 SM,而在解码(decode)形状下则是纯收益。

PDL 是纯调度语义。累积顺序不变的改动在比特级别上保持一致;门控矩阵向量乘(gate matvec)通过了 4/4 的 GDC 开/关比特比较。

两次融合与一次重新调优

moe_align,在配对轴(pair axis)上。Triton 融合 MoE GEMM 以 block_size 的 tile 形式消费 token,其中每一行共享同一个专家,而 moe_align_block_size 负责构建该置换。通用路径需要两次内核启动:在每位专家的偏移量最终确定之前,任何 token 都无法就位;这些偏移量来自一次跨整个网格的扫描,而设备级屏障只存在于内核边界处。也存在单次启动的变体,但它将每线程的专家计数器暂存在共享内存中,因此仅限于 64 个或更少的专家;513 专家的解码阶段总是需要付出两次启动的代价。

替代方案在配对轴上运作:一次 [NP, NP] 的成对比较即可让每个 (token, slot) 对在其桶内获得稳定排名,并同时得到该桶的容量;随后,每个桶中排名第 0 的代表即可推导出填充后的计数、按桶排序的独占偏移量、发布的总数,以及每个块的专家 ID。整个过程与专家数量无关,因此专家数量上限随之消失。显而易见的替代做法——在填充后的专家轴(最多 1024 个桶)上做直方图和前缀和——虽然正确,但会在关键路径上给单个 SM 带来约为其所替代的两个内核 3 倍的工作量。这正是配对轴在此处成为关键承载的原因。

与参考实现相比,有两处刻意的偏离,均以消费者不变量为依据:桶内顺序在配对索引上是稳定的,而非原子调度顺序(每个配对都写入自己的输出行,因此消费者与顺序无关);超出已发布总数的缓冲区尾部保持未写入状态(消费者 CTA 在读取该区域之前会提前退出)。一个隐患:配对张量的规模为 O(NP²)。在 NP=64 时,它们完全驻留在寄存器中(约 4 微秒,与 CUDA 双内核路径相当);在 NP=256 时,则会溢出到本地内存,每次启动约耗费 230 微秒。调度闸门是一个硬性的 numel ≤ 64;更大的批次则回退到 CUDA 路径。

up-GEMM 尾声中的 SwiGLU。将 silu(gate) * up 折叠进 MoE up-GEMM 的尾声,可省去每个 MoE 层一个独立的激活内核,以及中间缓冲区的整个“先写后读”过程。其布局技巧是在权重加载时对 w13 按专家进行行交错排列,使 gate 与 up 落在同一输出 tile 的相邻偶/奇数列上。由于每个 GEMM 输出列都是独立的点积,交错在比特层面是中性的。

位级一致性正是这里的关键所在。被替换的内核使用 -use_fast_math 编译,因此收尾阶段逐指令复现它:mul + ex2.approx.ftz 用于 __expf、div.approx.ftz,并在乘积上做一次最终舍入。微妙之处在于:FlashInfer 在 float 中实例化激活函数子,因此 silu 在乘法之前绝不会落入 bf16。若在此处舍入,结果会经历双重舍入,并在很大一部分输入上产生偏差。这在文档中不可见,在容差检查中也不可见;它需要对整个输入范围做逐元素的位级比较才能发现。

KDA 链式验证的 tile 经济性。融合的 conv1d + gating delta-rule 验证内核此前已存在;这些提交对其进行了重新调优。在采用图内计时的 rotating-cold 测试中,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 维度进行 tile 切分从不影响 K 维度的归约顺序,因此在 num_warps=4 处,每个 BV 与基线在比特级别完全一致,重新调优不带来任何数值风险。

bf16 路由器门控与 lm_head

结构变更之后最大的一项改动是 dtype 变更。在 batch 1 中,router gate 和 lm_head 是纯带宽瓶颈:每一步解码都会冷读每个 MoE 层的 gate 权重(bf16 下为 2.6 MB)以及 vocab 并行的 lm_head 投影,而这两者都没有足够的运算量来掩盖读取延迟。将两者从 fp32 改为 bf16 可将这些字节减半;端到端来看,这大约带来了 +10% 的提升,是主机 run-ahead 修复之后所有单项改动中收益最大的一项。与上述其他舍入改动一样,这一项也在其提交信息中做了声明,并通过接受长度和任务指标而非位级一致性来验证。

投机解码下的 KDA

被拒绝的投机 token 会让 KV 缓存条目无害地过期,但它已经就地破坏了循环状态。线性注意力与投机解码并不能免费共存。

使其可行的方案是:在验证阶段,循环以禁用状态更新的方式运行,并将每个链位置的后续状态写入中间缓冲区;在判定之后,commit_mamba_states_after_verify 将最后一个被接受位置对应的状态复制到持久槽位中。先暂存,再提交。这也是紧凑投机缓存被限制为 topk=1 的原因:对于链而言,被接受的前缀是唯一的,状态可以按位置索引;而对于树而言,被接受的路径只是众多路径之一,状态就必须按树路径来索引了。

性能分析显示,KDA 解码受带宽限制,主要是 K×V 状态上的 HBM 流量,因此在融合和 tile 重新调优之外,这里已经没有太多可挖掘的空间。我们没有将实际达到的带宽与 Blackwell 峰值进行对比测量,所以请将此视为一种形态观察,而非 roofline 分析结果。

batch 1 下投机解码的经济学

权重带宽主导地位有一个反直觉的推论:验证更多 token 几乎不花成本。验证 4 个 token 和验证 6 个 token 读取的权重完全相同。在 batch 1 下加深投机深度,每增加一步只需多一次廉价的草稿前向传播(草稿只有单层),再加上 KDA 链式验证递归中增加的串行成本,换来的是更长的接受长度。

我们通过扫描实验来确定最优值,而不是凭空假设(这次扫描发生在融合方案之前;之后最优值发生了移动,详见下文):

步数 / 草稿 token接受长度平均 TPOT单步耗时(推算)稳态 tok/s
3 / 43.111.51 ms4.70 ms662
4 / 53.371.45 ms4.89 ms690
5 / 63.451.55 ms5.35 ms645

步长时间列是 TPOT × 接受长度,属于推导估计值而非直接测量结果。每增加一个步骤,耗时约占步长时间的 4-9%,而边际接受增益则呈几何级数衰减(d5 → d6 仅增加 0.08)。盈亏平衡条件大致为 Δaccept > 0.05 × accept。最优配置也在移动:在融合包落地、步长时间下降之后,(5, 6) 成为更优配置;一旦 fp8 权重进一步压缩固定基础开销,就需要再做一次扫描。

DSpark:高质量分块草稿生成

调节 NEXTN 的深度只是在固定形状算法上拨动一个一维旋钮。更大的杠杆在于改变算法本身,而本次行动的后半段正是致力于让 DSpark 对齐同一目标,并给予它同样的 batch-1 处理。

DSpark 的不同之处

DSpark 算法本身是公开的。我们的工作在于将这一公开方案适配到 Ling-3.0-flash、长上下文在线蒸馏以及 batch-1 Blackwell 推理服务栈上。我们的适配在四个方面有所不同。

分布对齐的数据。我们主要在 Ling-3.0-flash 的后训练数据上进行蒸馏,因此草稿模型训练时所面对的数据分布与推理服务时一致。我们还在蒸馏过程中使用多种采样设置,以提高轨迹多样性,并增强在投机解码下的鲁棒性。

由消融实验驱动的草稿模型设计。我们没有直接继承 Ling-3.0-flash 的架构,而是针对草稿模型的关键设计选择进行了系统性消融实验,包括是否复用 Ling-3 的注意力结构,以及使用哪种 RoPE 变体(部分交错还是完全交错)。我们保留了在接受长度与延迟之间取得最佳权衡的设计。

与推理服务耦合的在线训练系统。为了支持长上下文和大规模的在线训练,我们构建了 SplitServe Trainer——一个单节点 8-GPU 框架,将资源在训练与 SGLang 推理之间平均分配。在训练过程中,推理侧运行目标模型的前向传播,以产生监督信号,例如供草稿模型使用的目标隐藏状态。这使得生成-训练循环保持在本地,降低了 IO 开销,并提高了长上下文工作负载的训练效率。

SplitServe Trainer layout

图 8. SplitServe Trainer 布局。

接受感知优化。在公开的 DSpark 损失设置基础上,我们额外加入了一个与接受长度相关的损失项,因此草稿模型不仅针对 token 级对齐和中间目标对齐进行训练,还针对在目标验证下更长的被接受前缀进行训练。

Acceptance-aware optimization

图 9. 接受感知优化。

47% 的空闲率,及其背后的机制

首个 DSpark 轨迹运行在 Blackwell TP4 上、batch 为 1,历经 239 次稳态解码迭代,其状态远未达到 NEXTN 路径所达到的水平。中位步进耗时为 10.62 毫秒,其中 GPU 空闲 4.99 毫秒(占 47%)。

空闲并非发生在 CUDA 图内部:图内微间隙在 3 秒内总计约 80 毫秒。空闲全部集中在各图之间的 eager 段中,表现为每步四到五个 100 微秒至 2 毫秒的中等间隙。

FlashInfer 的两种plan()实现,即 fa2BatchPrefillWithPagedKVCacheWrapper和 MLA 封装器,都在接收设备张量,而它们在内部会对每个.to("cpu")执行阻塞式qo_indptr, kv_indptr,以及kv_len_arr。阻塞式 D2H 拷贝会等待流上所有在途操作完成,包括仍在执行的草稿图。因此每一步,CPU 在草稿启动后就被 GPU 进度所牵制;大约 1 毫秒的cudaGraphLaunchCPU 开销没有可隐藏的 GPU 繁忙窗口;而调度器尾部则在这两者之后串行化。

从结构上看,这又是同一个 resolve_seq_lens_cpu 瓶颈:主机端阻塞读取一个设备端驻留的值,导致每一步都把 run-ahead 重置为零,而且发生在一个无关的子系统中。它给出的规律是:在 batch 为 1 时,优先排查主机路径上对设备值的阻塞读取,因为每一次这样的读取都会把整个主机循环从隐藏工作变成 GPU 空泡。

主机端供给的计划

那三个数组根本不需要来自设备端。DFLASH 系列保证 verify 和 draft 的 ForwardBatch 携带 seq_lens_cpu = prefix + draft_token_num,并在三个独立的调用点做了断言,而这恰好等于设备端路径计算出的 kv 长度。主机端其实早就知道它阻塞等待读回的那个答案了。

  • fa2 侧。在捕获时,于每个批次大小的目标验证包装器上安装 fast_prefill_plan,并以 DFLASH 验证输入类型为门控条件,从而不影响 EAGLE 的目标验证;同时加入断言,确保不存在自定义掩码(快速方案不支持自定义掩码;DFLASH 也从不使用掩码)。
  • 在 MLA 一侧,构建 plan kwargs 时seq_lens_cpu完全在主机端算术中完成,零 D2H 拷贝,直接写入kv_indices通过一个新的kv_indices_buf参数,将数据写入包装器的 CUDA 图缓冲区,并调用fast_mla_decode_plan(causal=True),从而跳过了三次阻塞式 D2H 拷贝和四次设备缓冲区刷新。捕获过程仍然会执行真实的plan(),正是这一步填充了缓存模块和包装器的缓冲区。

另外两项修复无需额外工作。graph.replay() 始终是纯入队操作;除了横亘在中间的 D2H 之外,没有任何因素阻止 CPU 将草稿图 → 校验准备 → 校验图连续入队。移除 D2H 后,校验元数据准备和两次图启动都在草稿图仍在执行时即已入队,两张图在 GPU 上背靠背连续运行。

结果

在组合任何方案之前,每个环境标志都单独进行了 A/B 测试:

标志(单独测量)接受长度平均 TPOT结论
无(重叠开启,radix 缓存关闭)4.491.48 ms干净的基线
SGLANG_OPT_FUSED_KDA_VERIFY=14.681.34 ms安全,保留

在固定的投机配置下,标志级时间线全程采用 8192 输入 / 1024 输出 token、并发数为 1:同步调度 1.67 ms → 关闭基数缓存的重叠调度 1.48 ms(调度器尾部的空闲窗口从 1118 µs 缩小到 85 µs)→ 融合 KDA 验证 1.34 ms。

部署配置在并发数为 1 的情况下对 1000 个请求进行了测量:接受长度 9.95,平均 TPOT 0.78 ms,中位数 TPOT 0.51 ms,输出吞吐量 1120 tok/s,峰值 1945 tok/s。9.95 的接受长度是在 block_size 为 16 的 DSpark 草稿模型下测得的;发布的草稿检查点使用 block_size 8。DSpark 不接受 speculative-num-steps / draft-tokens 标志;这些仅适用于 NEXTN。

将中位数 TPOT 乘以平均接受长度得到 0.51 × 9.95 ≈ 5.1 ms,与之前追踪测得的步进时间(KDA 融合前总计约 5.3 ms)接近。这只能作为粗略的一致性检查来理解:它混合了中位数和平均数,而且面对如此宽的分布,它并不是中位数步进时间。直接从追踪中测量步进时间才是收尾的正确方式,而我们尚未对 DSpark 配置这样做。追踪确实支持的是定性结论:主机再次退出了关键路径,剩下的都在 GPU 上。

现在时间花在了哪里

One decode step after the campaign

图 10. 优化活动后的一个 NEXTN 解码步进。

MoE 分组 GEMM 1215 µs,路由器 / 激活 / 胶水小内核 1127 µs,全归约 951 µs,稠密 GEMM 918 µs,KDA 488 µs,8k 上下文下的 MLA 注意力 244 µs,外加 400 µs 的残余空闲。主机已完全隐藏;剩下的都是 GPU 工作,而权重带宽在其中占主导地位。

本次普查采用的是 NEXTN 配置;DSpark 重新分配了步骤(更宽的验证窗口、第二份草稿模型图),但结论不变。

8k 上下文下的 MLA 注意力耗时 244 微秒。长上下文在这里并不是问题,这是混合架构带来的结果。MoE 加稠密 GEMM 大约耗时 2.1 毫秒,其中几乎全部是权重带宽开销。

因此路线图很短:

  1. fp8 权重是剩下的最大杠杆。在 2.1 毫秒的带宽受限工作上把字节数减半,能带来结构性的 15-20% 提升,这远远超出该指标的噪声区间。接受长度(accept-length)的补救方案已经存在(bf16 草稿模型),而块量化(block-quantization)的 TP 约束也是已知的:使用 moe_intermediate_size = 768 且块大小为 128 时,TP4 不可行;它需要 --ep-size 4。
  2. 路由器融合已经达到了合理的终点。把门控矩阵向量乘(gate matvec)折叠进 top-k 核函数会把并行度从 129×M 个 CTA 压缩到 M/BLOCK_M。PDL 链式调用是该路径的正确停止点。
  3. 主机环境工程。对调度进程进行核心绑定和 GC 调优,这实际上是在守护预跑余量(run-ahead slack),而非内核优化。

复现

SGLANG_ENABLE_METADATA_GLUE_GRAPH=1 \
SGLANG_OPT_FUSED_KDA_VERIFY=1 \
SGLANG_ENABLE_FUSED_VERIFY_EXTEND_GRAPH=1 \
python3 -m sglang.launch_server \
  --model-path inclusionAI/Ling-3.0-flash \
  --tp-size 4 --trust-remote-code \
  --speculative-algorithm NEXTN \
  --speculative-num-steps 5 \
  --speculative-eagle-topk 1 \
  --speculative-num-draft-tokens 6 \
  --attention-backend trtllm_mla \
  --flashinfer-allreduce-fusion-backend auto \
  --mem-fraction-static 0.85

对于 DSpark 配置,把投机(speculation)标志替换为 DSpark 草稿检查点:

SGLANG_OPT_FUSED_KDA_VERIFY=1 \
python3 -m sglang.launch_server \
  --model-path inclusionAI/Ling-3.0-flash \
  --tp-size 4 --trust-remote-code \
  --speculative-algorithm DSPARK \
  --speculative-draft-model-path inclusionAI/Ling-3.0-flash-dspark \
  --attention-backend trtllm_mla \
  --flashinfer-allreduce-fusion-backend auto \
  --disable-radix-cache

两种配置都采用相同的基准测试方式:

python3 -m sglang.bench_serving --backend sglang \
  --dataset-name random --num-prompts 1000 \
  --random-input-len 8192 --random-output-len 1024 \
  --random-range-ratio 1.0 --max-concurrency 1

致谢

本工作是 RadixArk SGLang 团队与蚂蚁 Ling 基础设施团队的合作成果。感谢 DeepInfra 和 Novita 在 SGLang 上为 Ling-3.0-flash 提供推理服务。

蚂蚁集团 Ling 基础设施团队(按姓氏字母顺序排列):别体伟、罗远、邱大宇、谭建峰、王同利、余悦、张凯宏。蚂蚁集团 inclusionAI(按姓氏字母顺序排列):曹翔、卢国山、赵俊博。

来源:LMSYS:Blog(Chatbot Arena 团队) · lmsys.org