一、引言
Qwen3.8-27B 在昆仑芯 P800(8×96G)上的 Dense 推理基线稳定在单流 52-57 tok/s。团队的目标是在不更换硬件的前提下,通过 MTP 投机解码同时改善单流延迟与并发吞吐,开启方式只有一行参数。服务拉起后,首个 spec decode step 即触发 illegal memory access,8 个 Worker 全部退出,且问题稳定复现。这不是一次普通的算子兼容问题:崩溃链路贯穿 Python 接口层、模型移植层与 C++ 预编译算子层,每一层都在不同阶段以不同方式失效。排查过程中,接口层的 11 个雷、状态回滚语义的冲突、预编译 kernel 的 spec 分支越界依次浮出水面。本文完整记录从崩溃到官方算子栈升级后单流反超 Dense 的全过程,中间还夹着一个临时 workaround 带来的“单车”阶段。国产 XPU 的软件栈迭代节奏与通用 GPU 生态不同,预编译算子与框架版本之间的配对成本往往被低估,这个案例的价值在于完整呈现了一次跨层故障的定位与收敛过程。
二、背景
MTP(Multi-Token Prediction)投机解码的基本结构是双模型协作。drafter 是一个轻量预测头,每轮预测 num_speculative_tokens 个候选 token;target model 在一次 forward 中同时验证这些候选。验证通过的数量称为 acceptance length,它直接决定吞吐增益:接受长度越高,单次 forward 摊销的 token 数越多。验证未通过的 token 需要回滚模型状态,因此投机解码的正确性高度依赖状态推进与回滚语义的一致性,任何一处提前或遗漏回滚都会反映为接受率异常甚至越界。以 num_speculative_tokens=1 为例,drafter 先预测一个候选 token,target model 连同已确认前缀一起做一次双 token 验证;候选被接受则一次 forward 产出两个 token,候选被拒绝则只产出确认 token,并把被拒绝候选引入的状态变更回滚。理解这一机制对后续排查至关重要:崩溃现场不会直接说明是状态推进错误还是回滚错误,必须结合参数组合判断。
Qwen3.8-27B 采用 qwen3_5 结构,使用 GDN 线性注意力,状态型 recurrent 算子密集。MTP 的 spec 分支会改变这些算子的状态推进路径,其中因果卷积更新函数 causal_conv1d_update 尤其关键:它在非 spec 路径处理单个 token,在 spec 路径需要根据 num_accepted_tokens 决定哪些状态保留、哪些状态回滚。启用配置如下:
--speculative-config '{"num_speculative_tokens": 1, "method": "mtp"}'运行环境为 vllm-kunlun + vLLM 0.15.1,算子栈为 kunlun_ops。上游 vLLM 的 spec conv 由 Triton kernel 实现,携带完整的 conv_state_token_offset 状态偏移逻辑;kunlun_ops 的因果卷积 spec 分支由预编译 C++ kernel 承接。同一语义在不同实现之间的偏差,为后续崩溃埋下伏笔。
三、阶段一:接口层 11 个雷
首轮适配中,vllm-kunlun 的 eagle.py(drafter)与 vLLM 0.15.1 gpu_model_runner 存在系统性接口不匹配。vLLM 0.15.1 对 model runner 的参数约定做了一次集中调整,eagle.py 仍沿用旧版本约定。第一轮定位共 11 个问题:
| 位置 | 变化 |
|---|---|
| prepare_next_token_ids_padded | 参数由 6 个变为 5 个 |
| 多模态输入 | mm_embeds 改为 mm_embed_inputs |
| CUDA Graph 配置 | use_cuda_graph / cudagraph_batch_sizes 属性缺失 |
| positions | 访问方式变更 |
| 多模态判断 | is_multimodal_model 属性缺失 |
| 其余 6 处 | batch 扩展、序列长度字段等接口不一致 |
这 11 个雷并非孤立存在,而是版本漂移的集中爆发。prepare_next_token_ids_padded 的参数个数变化会直接让调用栈在入口处抛 TypeError;mm_embeds 改为 mm_embed_inputs 影响多模态分支的输入装配;use_cuda_graph 与 cudagraph_batch_sizes 的缺失会使 drafter 无法正确判断图捕获条件;positions 的访问方式变更会破坏位置编码;is_multimodal_model 的缺失则使分支选择逻辑失效。eagle.py 以 drafter 身份调用 gpu_model_runner,参数签名、属性名与访问方式任一不匹配,都会在运行时暴露。问题相互叠加,drafter 无法稳定调用,第一阶段暂缓,转入模型侧移植。
四、阶段二:PR 384/423 移植
第二阶段基于 PR 384/423 完成 qwen3_5_mtp.py 移植与 registry 注册。qwen3_5_mtp.py 负责把 MTP 头挂接到 qwen3_5 主干,registry 注册使框架能够按模型名正确解析 MTP 结构。改动横跨 9 个文件:
eagle.py 全替换
causal_conv1d.py graphsafe
qwen3_next.py 6 hunk
gdn_attn.py 13 hunk
chunk.py 3 hunk
fused_recurrent.py 1 hunk
mtp_mamba_state_patch.py 等这 9 个文件覆盖 drafter、主干模型与 recurrent 算子三部分,改动粒度各不相同,说明 MTP 移植不是单点接入,而是一次贯穿推理链路的改造。关键修复之一是 block_table_tensor_cpu。vLLM 0.15.1 已移除该属性,drafter 又需要 CPU 侧 block table 完成采样与序列管理。实现上从 GPU block_table 派生一份 CPU 副本,作为 drafter 侧的基础数据结构。该修复打通了 Python 侧数据通路,服务随后进入 READY 状态。READY 只是开始,真正的崩溃发生在第一个投机解码 step。
五、阶段三:精确定位崩溃
服务 READY 后,首个 spec decode step 稳定崩溃,8 个 Worker 全崩。排查采用 enforce_eager + checkpoint 逐层二分。enforce_eager 关闭 CUDA Graph,使故障点不再被整图重放掩盖,报错位置能够指向真实算子;checkpoint 逐层开关用于把崩溃范围收敛到具体层。两个手段组合后,排查顺序如下:
- 先确认 Python 侧数据流:draft 的 1144 已正确 scatter 到 input_ids,采样与输入构造无问题;
- 再确认 target forward 的崩溃范围,收敛到 linear_attn 层 _forward_core;
- 继续下钻到 causal_conv1d_update 的 spec 分支:num_accepted_tokens 非 None,num_accepted=[1],seq_len=2。
该参数组合要求 kernel 只对第一个 token 做正式状态推进,对第二个 draft token 只计算输出、不落 state,随后由上层按接受情况决定是否回滚。预编译 spec kernel 缺少这一偏移逻辑,把两个位置的状态一起写入,越界发生在写回阶段。数据流正确而算子崩溃,说明问题不在调度,而在 kernel 对 spec 分支的处理。根因在 kunlun_ops 0.1.58 的预编译 C++ spec conv kernel 越界。上游 vLLM 的 spec conv 是 Triton kernel,包含完整的 conv_state_token_offset 状态偏移与回滚逻辑;kunlun_ops 的同名实现不共享这些修复。输入一进入 spec 分支,kernel 按错误偏移访问 state 缓冲区,直接触发非法访存。这次定位也说明,国产 XPU 上的 illegal memory access 不能停留在 Python 栈分析,真正触发条件藏在 spec 分支的参数组合里;把数据流与算子行为分开验证,是避免在调度层空转的关键。
六、阶段四:两步 workaround
官方修复未发布前,需要一条能跑的临时路径。方案是用两次非 spec kernel 调用模拟一次 spec 推进:
# 两步 workaround:先推进 base,再推进 draft,不回滚
causal_conv1d_update(base_state, base_token) # base 推进
causal_conv1d_update(draft_state, draft_token) # draft 推进
# 不回滚;回滚语义交给 vllm update_states第一步推进 base token 的 state,第二步推进 draft token 的 state,完成后不回滚,把回滚语义统一交给 vllm 的 update_states 处理。这样 causal_conv1d_update 始终走非 spec 分支,规避了预编译 spec kernel 的越界。
结果 0 崩溃,关键指标如下:
| 指标 | 数值 |
|---|---|
| 单流接受率 | 91.8% |
| 并发接受率 | 65.4% |
| iteration | 37-44ms |
| 单流 overall | 44-47 tok/s |
| Dense 基线 | 57 tok/s |
此前一版 workaround 曾“提前回滚 draft state”,与 update_states 的回滚叠加,导致并发接受率崩塌到 1.2%。改为不回滚后恢复。该差异说明 MTP 的正确性不仅取决于 kernel 能跑,还取决于 state 回滚语义只被一处接管。提前回滚是这一阶段最隐蔽的一处背刺。workaround 阶段验证了数据通路与调度逻辑,但性能低于 Dense,属于能用的“单车”。从 44-47 tok/s 到反超 Dense,中间隔着一条官方 spec kernel 的修复路径。workaround 的接受率数据同时说明,单流与并发接受率存在天然差距,并发下序列状态更复杂,回滚语义被放大的概率更高。
七、阶段五:官方算子栈升级
kunlun_ops 0.1.122 + xmlir 1.0.0.1(20260428 版)发布后,因果卷积 spec 分支改为单次官方 kernel,带 num_accepted_tokens 参数。升级后 0 崩溃,spec kernel 修复得到确认。升级过程并非一步到位,经历两轮 ABI 排查:
- 先缺 xbfloat16 C1 符号,通过 shim 转发 C2 解决;
- 再缺 31 参 xfa::gated_delta_net,0409 xmlir 只有 30 参,参数数量对不上。
符号缺失的定位依赖对算子库导出表的逐项比对,shim 只做转发不改变语义,保证上层调用不受影响;参数数量差异的暴露来自运行时对函数签名的严格检查,两个问题分别卡在加载期与调用期。最终定位到 20260428 xpytorch 包同时包含两者,版本配对完成。版本配对完成后,spec 分支不再需要 workaround 模拟,官方 kernel 一条路径同时覆盖 spec 与非 spec 两种调用形态。官方算子栈的版本配对是落地的关键前提。
八、阶段六:性能对比
官方 spec kernel 稳定后,MTP 单流 overall 55-62 tok/s,已经摆脱 workaround 的性能损失。继续打开 FP16_FAST_OPT=3:
export XPU_SET_RECURRENT_GATED_DELTA_RULE_FWDV2_FP16_FAST_OPT=3该开关启用全 FP16 recurrent 加速,把 GDN 线性注意力的 recurrent 路径整体压到 FP16 执行。生效后数据如下:
| 配置 | 单流 tok/s | 并发 8 | 并发 32 | 并发 256 |
|---|---|---|---|---|
| Dense 基线 | 52-57 | - | - | - |
| MTP workaround | 44-47 | - | - | - |
| MTP 官方 spec kernel | 55-62 | 246 | 709 | 1482 |
| MTP 官方 kernel + FP16_FAST_OPT=3 | 55-59 | - | - | 1609-1622 |
MTP 256 并发从 1482 提升到 1609-1622(+9%),追平 Dense;单流 55-59,反超 Dense 52-57;Mean acceptance length 1.83。接受长度 1.83 意味着每轮 target forward 平均验证 1.83 个 token,在 num_speculative_tokens=1 的配置下已经接近理论上限。并发档位 8/32/256 分别达到 246/709/1609-1622,吞吐随并发线性放大。单流场景下 MTP 的收益来自接受长度带来的 forward 摊销,Dense 的固定开销被摊薄;并发场景下收益来自验证吞吐的放大,256 并发 +9% 表明 recurrent 路径已成为新的热点,FP16 加速正好命中该热点。
九、结论
投机解码在国产 XPU 上从“一跑就崩”到反超 Dense,依赖三个条件:
- 定位到预编译 kernel 的 spec 分支越界,确认问题在 C++ 算子层而非调度层;
- 理解 state 回滚语义只应有一处接管,workaround 的提前回滚与 update_states 的冲突教训直接影响了后续设计;
- 官方算子栈与运行库的精确版本配对,kunlun_ops 0.1.122 与 20260428 xpytorch/xmlir 的组合缺一不可。
对国产 XPU 生态而言,官方算子栈的快速迭代是决定性变量,workaround 的价值在于为升级争取时间,最终收益仍来自官方 kernel 修复与 FP16 加速的组合。两步 workaround 是“单车”,官方 spec kernel 加 FP16 加速是“摩托”。MTP 在 P800 上的单流与并发收益已成立,后续可继续观察更多并发档位与更长序列下的稳定性。对后续工作而言,这套排查流程可以复用:先用 enforce_eager 与 checkpoint 收敛范围,再以参数组合触发分支,最后用官方版本配对验证修复。