第 9 章 MTP1 dual-lane overlap

MTP 和 speculative decoding 本身是公开技术。这一章要写的,是如何把它翻译到 cuda + duck(tp32) 这组非常规异构系统上——以及为什么最终落地的,是“不加第二组鸭”的 dual-lane 方案。


9.1 公开背景

MTP/speculative decoding 不是这里的发明

DeepSeek-V3 的技术报告明确把 MTP 写成模型训练的一项目标,并指出它可用于推理阶段的 speculative decoding 加速:

"We investigate a Multi-Token Prediction (MTP) objective and prove it beneficial to model performance. It can also be used for speculative decoding for inference acceleration."

vLLM 的官方文档也把 speculative decoding 定义为一类降低 inter-token latency 的推理技术,并把 MTP 列为受支持的方法之一,指出“MTP 在目标模型有原生支持时通常收益较高”。

这些都是在 GPU 同构系统里的讨论。这里要做的,是在 cuda + duck(tp32) 的异构分工下,想清楚 MTP 还能不能做、怎么做。

DeepSeek-V3.2 的 layer 61

DeepSeek-V3.2 的 checkpoint 里,除了 61 层主干 decoder,还有一套 model.layers.61.*——这就是 MTP 头,包含完整的 attention、FFN 以及额外的 hnorm / enorm / eh_proj / shared_head

在调查阶段(2026-03-26),把官方 inference/ 代码、本机 transformers 5.3.0Megatron-LM 实现逐一对照之后,确认了几件事:

  1. 官方 inference/convert.py 显式跳过 model.layers.61.*,也就是官方自己的推理代码暂时也没有接 MTP。
  2. 本机 transformers 版本同样没有实现 MTP forward。
  3. 真正可以借鉴的实现,在 Megatron-LM/megatron/core/transformer/multi_token_prediction.py,且注释里明确写了:MTP layers don't use KV cache——在推理时 MTP 头只是一个 speculative “next head”,吃当前 hidden state 和已接受 token 的 embedding,不需要自己维护历史 KV。

这个注释(“MTP layers don't use KV cache”)说明,不给 layer 61 维护 KV cache 也是一种合法的实现路线——让 MTP 头每步都从零做一次无状态 attention,只吃“当前 hidden + 当前 token embedding”。第一版实现正是按这个思路来的:DeepseekV32OfficialFp8MtpAttention 初始化时走无状态单 token 路径,没有单独的 cache。

但第一版跑起来之后,接受率极低——greedy 下 top-1 只有 36/208(约 17%)。经分析,根本原因是这个 stateless single-token 路径完全没有建立 MTP 自己的 prefix causal chain:attention 只看当前这一个位置,等于把“一层带 prefix 的 TRM1”近似成“每步独立的单点预测”,MTP draft 分布和主干 verifier 分布大幅错位。正确做法是:在 prompt prefill 阶段先跑一次 MTP1 侧的 causal prefill,decode 时对每个已确认 token 做增量 append。补上 MTP1 prompt prefill、独立 fp8 KV cache(DeepseekV32ContiguousFp8KVCache)和 confirmed-token-only append 之后,greedy 接受率从约 17% 大幅抬升到约 72%。期间还做了 bf16 KV cache 的 ablation,验证它对接受率没有明显提升,最终保留 fp8 cache。


9.2 两条路线的判断

在系统跑通 duck tp32 之后,仔细思考了一下,发现 MTP 有两条可执行的路线。

路线一:cuda bsz=2 + duck (tp32)×2

CUDA 侧很容易支持 bsz=2(一次 forward 两个 token)。在 duck 侧,MoE 的专家路由近似随机——每次 forward 8 个 token(top_k=8),两个 token 命中的专家集合大概率几乎不重叠,因此两个 token 并行跑 duck 相当于“读参数量乘以 2”。

最自然的部署方式是直接上两组鸭,也就是 (tp32)×2 = 64 台鸭子。流程变成:

cuda (bsz=2) + duck (tp32)×2

预期效果:每个 step 的端到端时间基本不变,吞吐量乘以 (1 + accept_rate)

这条路线的吸引力在于实现直观:cuda 侧不需要改什么,duck 侧只需要把 tp 翻倍。但代价是需要再上架 32 台鸭子,以及对应的交换机端口、布线、安装和配置。加上 64 台鸭子的上联带宽需求(64 × 2.5 GbE = 160 Gbps)会让 host uplink 和 PCIe 压力重新进入高风险区间。

路线二:不加机器,做 dual-lane overlap

这里有一个关键观察:现在的系统里,每层 decode 的流程是:

cuda(attention + norm + ...)→ duck(FFN/MoE)

这两段用的是完全不同的资源——CUDA 用的是 GPU 算力和 GPU GDDR7,duck 用的是 CPU + DDR4 + 网络带宽。它们在时间上是串行的,但逻辑上没有理由必须串行。

对两个 token 来说,如果把这条“资源交替使用”的特性利用起来,就可以像流水线一样把两种资源都打满:

token0: [cuda0] [duck0] [cuda1] [duck1] ... [cuda60] [duck60]
token1:         [cuda0] [duck0] [cuda1] [duck1] ...  [cuda60] [duck60]

          token1 的 cuda 与 token0 的 duck 交错

理想情况下,两个 token 的 pair forward 时间的下界是:

pair_time ≈ 2 × max(cuda_per_layer, duck_per_layer)

而单 token 的时间约为:

single_time ≈ cuda_per_layer + duck_per_layer

因此相对 baseline 的理想加速比为:

speedup = (1 + accept_rate) × (cuda + duck) / (2 × max(cuda, duck))

这条推导和 GPU 系统里 “two-batch overlap”(如 SGLang 文档里的 TBO)在抽象层是相通的:都是把两类原本串行的资源尽量重叠。但具体技术上是不同的实现——那里重叠的是通信和计算,这里重叠的是 GPU 和网络/CPU。

为什么选路线二

路线二不需要额外采购设备,也没有新的 uplink/PCIe 风险。

实测下,真实系统里 duck 时间约占 cuda + duck70%,接受率约 80%~90%,代入上面的公式:

speedup_ideal ≈ (1 + 0.85) × 1 / (2 × 0.7) = 1.32x

这是理想上界,真实系统还会有额外开销(layer 61 的 MTP forward、lm_head、proposal 构建与决策、runtime 调度开销等)。端到端加速有多少,并没有一个干净的单点对照——最终 15.3 tok/s 是 MTP1 dual-lane、fp9 final kernels、lm_head 优化、predictive build 等多项改动共同累积的结果。其中 MTP1 dual-lane 的直接贡献可以从 549 条 overlap 样本估算(pair 112ms vs serial 199ms,节省了约 43%),其余改动在这个基础上继续压缩了 per-step 开销。

路线一方案则是在 2025 年想 duck tensor parallel 的时候就同步想到的,但当时意识到 MoE 两组不重叠,没有继续往实现层推。这次路线二跑到 15 tok/s 后,加机器的动力也没有那么强了。


9.3 MTP1 runner:confirmed-token-only 语义

离线验证阶段

在做 online 版本之前,先跑了一段离线验证——让 MTP1 runner 跟着主干解码过程陪跑,统计“如果当时接受 MTP draft,接受率会是多少”。

离线统计口径:每次主干生成 token 之后,计算:

  • 主干 verifier 分布 p(用和真实 decode 完全相同的 warper 语义)
  • MTP draft 分布 q
  • 单步接受概率 alpha(y) = min(1, p(y) / q(y))
  • 期望接受率 sum_y min(p(y), q(y))

这组统计覆盖了 greedy 和 sampling 两种场景,也是后来接 online accept/reject 时决策逻辑的基础。

关于 topic 差异:离线统计期间发现接受率波动相当大:"你好" 这类开放域起手约 70%~80%"104857601是质数吗" 这类强约束推理链在前 1024 tokens 上能稳定到约 90%。这是模型特性决定的,不是实现 bug。

runner 的核心设计

MTP1 runner 的职责定位是:只在已确认的 token 上追加,不跟着 speculative draft 走。

这个选择看起来保守,但实际上是让整个系统设计简单得多:

  • runner 自己不需要 speculative rollback
  • reject 时不需要把 MTP 的状态也回滚一遍
  • 只有主干 flattened runtime 需要处理 speculative state

每次主干确认 token x_{n+1} 之后,runner 的逻辑就是:

  1. 用当前 confirmed hidden state h_nx_{n+1} 跑 layer 61 forward
  2. 得到下一拍 proposal 分布 q 和 draft token d_{n+2}
  3. 把本步的 KV 结果 append 到 MTP1 runner 的 fp8 KV cache

这个“confirmed-only append”的语义,让后续 reject 路径的成本被限制在“不提交 speculative slot”,而不需要真的回拷 KV 内容。


9.4 online 路径:在线 accept/reject

主干 state contract 的改造

接 online 之前,flattened runtime 的 decode state 是隐式自增的:每次 replay 后自动把 token_position_gputoken_cache_seqlens_gpu 等 device scalar 加一,KV cache 的 append_decode() 也隐式按 cached_len 决定写入位置。

这套语义对单 token decode 很顺,但对 speculative 不友好——speculative token 一旦“写进去了”,reject 时必须真的回拷状态。

改造思路是把“物理 slot 写入”和“逻辑长度提交”解耦:

  • KV buffer 物理上允许多写一格 speculative slot
  • 逻辑上只维护 confirmed_len
  • reject 时不擦除 speculative slot,只是不提交;下轮直接覆盖同一个 speculative slot

于是 reject 的代价只剩“被浪费掉的 speculative trunk 计算”,不再有任何 buffer 回拷。

online generate() 的主循环

接入之后的在线生成循环:

  1. prompt prefill 后取得第一个真实 token x_1
  2. 用 MTP1 runner 生成 draft d_2
  3. 主干 forward x_1(得到 verifier 分布 p_2),同时 speculative forward d_2
  4. 根据 p_2q_2 做 accept/reject:
    • greedy:accept iff d_2 == argmax(p_2)
    • sampling:按 u * q(d_2) <= p(d_2) 判断(避免除法,以消除精度顾虑)
  5. accept 时:本轮退出两颗 token,speculative slot 提升为 confirmed;下轮复用 speculative 的 hidden/probs
  6. reject 时:本轮只退出一颗 token;d_2 的 speculative slot 逻辑上不提交

整个在线路径只在 v32_mtp1_online=True + flattened_decode=True 时启用,eager 主干继续走原有 GenerationMixin.generate()


9.5 pair runtime:stage0/stage1 调度

两条 lane 的资源切分

dual-lane 的核心想法是把每层 decode 拆成两个 stage:

  • stage0:GPU 侧——attention、layernorm、residual add、rotary embedding 等;由 CUDA Graph replay 执行
  • stage1:远端 duck 侧——FFN/MoE forward;通过 DPDK 网络发给 duck,等待结果返回

两个 token 对应两条 lane(lane0 = confirmed token,lane1 = speculative draft token)。关键约束只有两条:

  1. 同一 lane:stage0(layer i+1) 依赖 stage1(layer i) 完成
  2. 跨 lane:stage0(lane1, layer i) 依赖 stage0(lane0, layer i) 完成(保证 lane0 优先推进)

在这两个约束下,理想的交错形态就是:

lane0: [s0.0] [s1.0] [s0.1] [s1.1] ... [s0.60] [s1.60]
lane1:        [s0.0] [s1.0] [s0.1] [s1.1] ...  [s0.60] [s1.60]

duck/CPU dispatch(stage1)和下一层的 CUDA Graph(stage0)尽量交错,而不是串行等待。

每层双 lane dispatch buffer

要让两条 lane 真正独立,每层的 staging buffer 必须 lane-aware:

  • stage_graph_inputs(lane_idx=0)lane_idx=1 写入不同 host buffer
  • run_cpu_dispatch(lane_idx)host_output(lane_idx) 也各自独立

否则 lane0 的 stage_inputs 会覆盖 lane1 还没消费完的内容。

对应改动在 qwen3_flattened_runtime.pydeepseek_v32_runtime_common.py 里的 dispatch spec:均从单份 buffer 扩成双份。

显式 stage scheduler

pair runtime 实现为一个显式的 2-stage pipeline scheduler,内部维护 _DualLanePipelineLaneState:每条 lane 的 next_stage0_segment_idxstage0_done_untilnext_stage1_layer_idxstage1_done_until 等状态。

调度主循环:

python
while not all_done:
    # 1. 如果没有 in-flight stage1,优先 launch 一个 ready 的 stage1
    if not stage1_in_flight and has_ready_stage1():
        launch_stage1(pick_ready_stage1_lane())
    # 2. 如果有 ready 的 stage0,主线程立刻跑它
    if has_ready_stage0():
        run_stage0(pick_ready_stage0_lane())
    # 3. 否则等待 in-flight stage1 完成
    else:
        wait_current_stage1()

stage1 的 dispatch 跑在后台线程:等到 CUDA event(stage0 replay 完成后 record)之后,才正式发 duck 请求,从而避免主线程 blocking 等待。

去掉 per-layer 全局同步

旧的 flattened runtime 在每个 graph replay 之后都会 torch.cuda.synchronize(),这会把整个 CUDA stream 推到完成才继续,彻底打掉了 overlap 的可能性。

pair runtime 把这个改成:

  • graph replay 后只 record 一个 CUDA event(torch.cuda.current_stream().record_event()
  • stage1 的 dispatch 线程在进入 duck forward 之前,只等这个特定 event
  • 不再做全设备同步

对应 stderr 输出增加了 overlap 摘要,每个 token 行会带:

olap pair=112ms serial=199ms gain=87ms ratio=0.43 s0=...ms s1=...ms wait=.../...ms

9.6 Rust overlap 接口

为了支持 pair overlap,Rust 侧也新增了专门的接口:

  • issue_mtp_overlap_send_fetch_judge():把两条 lane 的 send/fetch/judge 操作显式拆成 issue 和 finish 两阶段,允许 lane0 的 fetch 和 lane1 的 send 真正交叠
  • begin_mtp_overlap_judge():开始某条 lane 的 duck judge,立即返回
  • finish_mtp_overlap_judge():等待该 lane 的 judge 完成并取回结果

在 Rust FFI 层,mlp/moe forward 的 Python 入口现在会先释放 GIL 再进入全局串行锁(保证 duck 本身仍严格串行),这样主线程和 dispatch 线程不再因为 GIL 而人工串行。

这里有一个关键的运行时约束需要说清:当前 overlap 不是“两条 lane 的 judge 真正并发打到 C++ worker”。dpdk_workers / dpdk_ducks 的 host 协议没有 request-id,也没有 response demux——每个 worker 只有一条响应队列,两个线程同时消费就会串线。因此 Rust 侧新增了一把跨整个 begin+finish 生命周期的 MTP_OVERLAP_OP_GATEMutex<bool> + Condvar):同一时刻只允许一个 send_fetch_judge op 真正在和 DPDK worker 交互,后来的 lane 在 gate 上等待。

换句话说,overlap 实际重叠的是:lane0 的 duck forward(stage1)lane1 的 CUDA graph(stage0) 在时间上交错——duck 侧仍然严格串行,GPU 和 duck 之间的资源才是真正同时在跑的两样东西。


9.7 最终结果

549 条 overlap 样本

最终证据包来自 2026-03-29 的一次 1024 tokens 长跑(prompt:104857601是质数吗)。

从整份 token 日志里提取的 olap pair / serial / gain / ratio 样本共 549 条,统计均值:

指标均值
pair112.481 ms
serial198.892 ms
gain86.410 ms
ratio0.434

ratio ≈ 0.43 意味着 dual-lane overlap 稳定地把双 token 的总时间压到了串行时间的约 57%,每 step 节约了约 86 ms。这不是偶发现象,而是 549 个连续 step 的统计均值。

最终指标

Generation stats: time 67.714s, input_len 11, output_len 1024,
ttft 0.857s, tpot 0.065s, prefill 12.830 tok/s, decode 15.301 tok/s
  • accept=1 891/1023(接受率约 87%
  • decode = 15.301 tok/s
  • tpot = 0.065s(约 65 ms/token

最终 15.3 tok/s 是 persistent judge(第 8 章)、MTP1 dual-lane overlap、lm_head 优化、fp9 final kernels 等多项改动的累积结果。persistent judge 落地后的基准约 9 tok/s,MTP1 online 早期版(不含所有后续优化)约 14 tok/s,最终随所有改动落定到 15.3 tok/s

当前认知的准确性边界

这里有两件事需要区分清楚:

已经达到的:dual-lane runtime 已经接进 active 代码路径,549 条长跑样本证明 overlap 持续有效,accept=1 891/1023 说明在强约束推理类 prompt 上接受率接近 87%

已知不足与后续优化

  • 在低接受率 prompt(如 "你好"70%)上,端到端提升有限,pair step 经常退回单 token 路径
  • lm_head 原本是 final graph 里最大的 GPU 算子(约 2.2ms),每个 step 会被算两遍。在 2026-03-28(commit 309345a)中把存储从 fp32 改为 bf16(bf16 @ bf16 → fp32),降到约 1.2ms
  • build_next_proposal 原本在 steady state 约 5.5~6.0 ms,通过 predictive decide+build 和 device-owned gate(2026-03-29 落地)压到约 3.7~4.8ms 量级
  • pair prefix graph 在 2026-03-29 完成了真正的 batched 化:除 kv_cache_append + decode_context 以外,attention proj / output proj / post-attention layernorm / dense 等均收成 bsz=2,不再是两个单 token 子图简单串接
  • official_fp8_mm 的 small-M heuristic 按 DeepSeek-V3.2 真实 decode shape(M=1/2 的各类 proj)做了一轮实测调整:多数 shape 走 (BLOCK_M=16, warps=4, stages=4)q_b family(K 偏小、N 较大)走 (4, 2, 2)

这些改动都在 final 15.3 tok/s 之前完成,是最终结果的组成部分。

本章所引用的 1024 tokens / 549 overlap samples / 15.301 tok/s2026-03-29 那轮长跑的 overlap 证据包。后续 (2026-04-08) 又补跑了多组 testcase(含 02-prime-2k05-long-input-1 等),这组数字不再作为全文唯一 headline 口径,但仍是 dual-lane overlap 真实有效性的核心历史证据链。


9.8 为什么这一章值得在技术报告里单独写

MTP/speculative decoding 是公开技术,它之所以在这里值得写,是因为把它翻译到这套系统里需要做几个非显然的判断:

  1. 两条路线的取舍不是显然的——(tp32)×2 是完全可行的方案,选 dual-lane 是在权衡了工程代价(不加机器)和理论收益(接近相同)之后做出的。

  2. runtime 结构的改造量不小——把“隐式自增 KV state”改成“显式 slot 写入 + 显式 commit”,把单份 dispatch buffer 扩成 lane-aware,把 per-layer 全局同步改成 per-lane event,这些不是只改几行 Python 的调参,而是需要理解整条 flattened runtime 才能下刀的结构性改造。

  3. Rust 侧的 overlap 接口是新增的——issue_mtp_overlap_send_fetch_judge() 等专门为 pair overlap 设计的接口,以及 GIL-release + 全局串行锁的分层设计,都是这个系统里没有现成对应物的新工作。

  4. 549 个连续 step 的统计均值证明它不是偶发现象——这不是“某次特别幸运地跑出好数字”,而是在一次 1024 tokens 的长跑里,持续稳定地把双 token 的时间压缩了约 43%


项目声明 / Project Disclaimer

本项目为作者以个人身份、利用业余时间推进的个人娱乐项目;除非另有明确说明,它与作者的任何雇主、客户、学校、单位或其他组织均无合作、雇佣、委托、赞助或背书关系,也不代表任何该等主体的立场。除普通个人捐赠外,本项目未获得任何资金支持。

This project is a personal hobby project developed by the author in a personal capacity and in personal time. Unless explicitly stated otherwise, it has no collaboration, employment, commission, sponsorship, endorsement, or institutional affiliation with any employer, client, school, partner organization, or other entity, and it does not represent any such party's views. No funding was received for this project except ordinary personal donations.

许可 / License

除非另有说明,本页原创文字、本站原创图片与本站原创图表采用 CC BY-NC-ND 4.0 发布。
转载时请保留原文标题、署名“JudgeDuck AI”、发布日期与原始链接;禁止商业转载、改写、摘编、翻译或基于原文创作演绎作品。

第三方商标、外部链接内容,以及文中另有标注的材料,不在上述许可范围内。

Unless otherwise noted, the original text, original images, and original figures on this page are licensed under the Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.