☰
vllm-ascend 投机解码 KV 滑窗(Draft KV Sliding Window):原理、配置与源码实现解析
2026/10/14 14:46:40 网站建设 项目流程
  • 人工智能
  • 大模型
  • 模型推理服务
  • Ascend
  • CANN

【免费下载链接】vllm-ascend

Community maintained hardware plugin for vLLM on Huawei Ascend

项目地址:https://gitcode.com/gh_mirrors/vl/vllm-ascend
点击查看免费下载

投机解码(Speculative Decoding)的草稿模型通常只在 4k–8k 的短上下文上训练,当运行时的上下文长度远超训练长度时,草稿模型对完整 KV cache 的注意力会偏离其训练分布(out-of-distribution),导致接受率崩溃、投机反而成为净损失。vllm-ascend 通过Draft KV Sliding Window(草稿 KV 滑窗)机制,把草稿模型的注意力限制在最近draft_window_size个 token 之内,同时保持目标模型完整注意力不受影响,从而在长上下文中恢复接受率。本文基于 speculative_kv_sliding_window.md 设计文档,结合仓库源码与测试用例,完整讲解该特性的背景、窗口数学、配置方法、MRV1/MRV2 实现路径与调参建议,让读者可以直接落地使用并深入理解其底层原理。

背景:短上下文训练的草稿为何在长上下文"崩溃"

投机解码依赖草稿模型预测多个候选 token,再由目标模型一次验证。草稿模型的经济性建立在"预测准确"之上——如果草稿大部分被拒绝,验证开销就白白浪费。而草稿模型(如 EAGLE3 / DFlash / DSpark 的各类 drafter)为了控制训练成本,通常只在数千 token 的短上下文中训练:

  • 推理时,运行上下文逐渐增长并远超训练长度(例如达到 32k token);
  • 草稿模型对完整 KV cache的注意力随之进入训练时从未见过的分布区间;
  • 结果表现为接受长度(acceptance length)崩溃:以 GLM-5.2 搭配早期 DSpark 草稿为例,在 32k 上下文中平均接受长度从约 5 掉到约 1(见设计文档原文),投机从"加速"变成"净损失"。

核心思路:只给草稿开一个窗口,目标模型保持全量

KV sliding window 的解决思路非常克制,只有一句话:把草稿模型的注意力限制在最近draft_window_size个 token 内,目标模型完全不动。

  • 目标模型始终对完整上下文做注意力,因此生成质量不受影响;
  • 草稿模型只读取更短的、落在其训练分布内的窗口,恢复预测能力;
  • 对已发生接受率崩溃的草稿,该机制可显著恢复接受率:同一 GLM-5.2 DSpark 草稿在 32k 上下文时接受长度从约 1.0 恢复到约 4.7–5.5,原评估中端到端吞吐提升最高达 +381%(数值引自设计文档,具体效果因模型与场景而异)。

一句话总结:窗口只裁剪"草稿读什么",从不裁剪"目标读什么"和"草稿写哪里"。

配置方法:通过 additional_config 启用

该特性通过 vllm-ascend 的additional_config开启(对应--additional-config '<json>'命令行参数),默认关闭(键缺失即不启用):

{"draft_window_size": 2048}

draft_window_size是AscendConfig中一个显式声明的 pydantic 字段,默认值为null(即不启用),定义见 ascend_config.py:

draft_window_size: int | None = None

注意AscendConfig对未知键采用extra="forbid"严格校验,拼写错误会直接快速失败;字段支持宽松的 int 强转("4096"字符串会被解析为整数 4096,这一点有 test_ascend_config.py 单测覆盖)。

适用与不适用场景

草稿方法是否支持说明
EAGLE3✅ 支持在默认(MRV1)模型运行器上生效
DFlash✅ 支持在默认(MRV1)模型运行器上生效
DSpark✅ 支持在默认(MRV1)模型运行器上生效
MTP❌ 强制禁用MTP 复用目标模型自身的层,给草稿开窗口等于给目标开窗口,配置了也会被忽略并打印警告

相关校验逻辑在 llm_base_proposer.py:当method == "mtp"时记录警告"draft method is MTP, which is not compatible with draft_window_size; the window is ignored";另外,当DSpark 草稿是随 DeepSeek-V4 目标原生一起训练时,会额外打印一条警告——因为这类草稿本身已经具备长上下文稳定性,强行开窗反而会降低接受率。

窗口数学:全在设备端完成的一次性裁剪

设W为窗口大小(draft_window_size),b为 kernel block size(块大小),K = future_offset为草稿查询位置中超出 adapter 所见seq_lens的个数。adapter 在设备端(NPU)用纯算术一次性算出窗口位置(公式见设计文档,实现在 utils.py 的compute_sliding_window_block_table):

start = floor(clamp(seq_lens + K - W, min=0) / b) * b # 块对齐的窗口起点 windowed_len = seq_lens - start # 窗口内长度,<= W + b - 1 start_block = start // b needed = ceil(windowed_len / b) (上限 max_window_blocks = W//b + 1)

其中start向下对齐到块边界(// b * b),因此实际窗口长度可能比W多出最多b - 1个 token(即windowed_len <= W + b - 1);needed是被裁剪后的 KV 长度输入需要覆盖的块数,上限为max_window_blocks = W // b + 1。

future_offset 为什么因草稿家族而异

future_offset(K)是窗口末端在seq_lens基础上需要额外覆盖的 token 数,因不同草稿家族的seq_lens语义不同而不同:

方法future_offset原因
EAGLE3num_speculative_tokensseq_lens只含上下文长度,K 个草稿查询位置位于其之后,窗口末端必须覆盖seq_lens + K
DFlash / DSpark0输入预处理核已经把查询延伸(bonus + mask tokens)烘焙进seq_lens,因此final = seq_lens

对应源码见 llm_base_proposer.py:future_offset = 0 if self.method in ("dflash", "dspark") else self.num_speculative_tokens。EAGLE3 的seq_lens是纯上下文长度、K 个草稿位在其后,所以窗口末端要覆盖seq_lens + K;DFlash/DSpark 则因为set_inputs_first_pass已把查询延伸计入seq_lens(DSpark 分支还会在cad.seq_lens上累加每请求查询数),不能再加一次 K。

block table 的裁剪方式:gather + 零填充克隆

SlidingWindowAdapter内部维护一个持久的[max_num_reqs, max_window_blocks]的 int32 零张量_block_table_clone(见 utils.py)。每次应用时:

  1. 从完整 block table 按行 gather 列start_block[i] + [0, max_window_blocks)到克隆张量(对越界列 clamp 到full_cols - 1,避免 gather 越界);
  2. 超过needed的列、以及超过完整表宽度的列被清零;
  3. 这些清零列永远不会被读取——因为所有 FIA KV 长度输入都被统一截断到同一个windowed_len。

单测 test_eagle_proposer.py 验证了这一点:apply之后cad.block_table_tensor.data_ptr()等于预分配克隆的指针(block table 重绑定到克隆),而adapter.full_block_table仍指向原始完整表;seq_lens按公式被 clamp/窗口化(如[10, 60, 200, 64]在W=64, K=3, B=16下变为[10, 60, 72, 64])。

两条贯穿始终的不变量

设计文档强调两条不变量,它们是正确性的根基,源码实现也严格遵循:

  1. slot_mapping 永不被窗口化。草稿 KV 写入仍然通过完整 block table、按绝对位置进行,因此 cache 对目标模型、以及对 step 之间变化的窗口大小都保持一致。这一点在 llm_base_proposer.py 中有明确体现:计算 slot_mapping 时,若self.sliding_window is not None,则使用self.sliding_window.full_block_table(未裁剪的完整表)来寻址绝对 KV 位置。
  2. 只有 FIA 读取的内容被限制。FIA 核的 KV 长度输入包括 NPU 上的seq_lens张量以及 CPU 镜像(seq_lens_cpu、_seq_lens_cpu、seq_lens_cpu_upper_bound),它们全部用同一套算术窗口化,保证任何路径都不会越过窗口读取。apply的实现(utils.py)对这四个张量逐一施加相同的src - clamp((src + K - W), 0)//b*b变换;单测 test_utils.py(test_sliding_window_updates_host_length_mirrors)专门断言四个张量窗口化后的值完全一致。

MRV1 实现:SlidingWindowAdapter 与两个应用点

适配器生命周期

SlidingWindowAdapter(utils.py)负责上述窗口数学和持久克隆缓冲。它在AscendSpecDecodeBaseProposer.__init__中构造(llm_base_proposer.py):从已验证的AscendConfig单例读取draft_window_size,然后以窗口大小、kernel block size、max_num_reqs、future_offset和设备为参数创建 adapter。构造条件为draft_window_size is not None and method != "mtp"。

两个应用点:为什么 DSpark 特殊

adapter 的应用发生在草稿的common_attn_metadata上,先于per-layer metadata 的构建。但不同草稿构造 metadata 的方式不同,因此有两个应用点:

  • EAGLE3 / DFlash:在_propose中应用一次于共享的common_attn_metadata(llm_base_proposer.py),随后build_draft_attn_metadata基于窗口化后的公共 metadata 构建各层 metadata。
  • DSpark:_propose路径被绕过——build_draft_attn_metadata的 dspark 分支会用每个 kv-cache group 的完整 block table覆盖common_attn_metadata.block_table_tensor(写入_per_group_block_table_buffers,见 dspark_proposer.py)再构建。因此对 DSpark 而言,adapter 是在 per-group 覆盖之后、builder.build_for_drafting之前应用的(llm_base_proposer.py),这样 FIA 读到的是 per-group 表的窗口化克隆,而不是完整表。

如果对 DSpark 在_propose里提前开窗,会出现"窗口化一个将被丢弃的表 + 用未窗口化的表去对应已窗口化的 seq_lens"的错位,FIA 会读到最旧的块,所以 DSpark 必须在 per-group 覆盖后重新apply(代码注释中明确说明了这一原因)。

MRV2 现状与移植方向

Model Runner V2尚未覆盖该特性。原因是 MRV2 的 speculator 体系在build_attn_metadata内部就把seq_lens/ block table 烘焙进了最终的 per-layer metadata——构建完成之后没有共享的common_attn_metadata可变对象,无法在构建后做变更。

设计文档给出的移植方向是:把窗口应用到builder 的输入上,即围绕 dspark/speculator.py 中_build_draft_attn_metadata(上游主线已更名为_build_uniform_attn_metadata,见 speculator.py 的注释)的调用:

  • 窗口化input_buffers.seq_lens;
  • 准备 per-group 的 block table 克隆;
  • 截断seq_lens_cpu_upper_bound。

窗口数学和上文两条不变量可以直接原样迁移,无需改变。

预期行为与调参建议

场景预期效果
短上下文训练的草稿在长输入上崩溃(接受率随上下文长度下降)窗口把接受率恢复到草稿短上下文水平,收益随输入长度增长而增大
已经长上下文稳定的草稿(近期训练的草稿,或自带 SWA 结构)窗口没有收益;若窗口设得低于草稿训练窗口还会有害——草稿期望看到比窗口允许的更多上下文
端到端效果等于"接受率变化 × 草稿 KV 读取成本下降"的叠加;没有接受率增益时两者大致抵消

经验法则(rule of thumb):draft_window_size设置不低于草稿的训练/原生窗口大小;当接受率在各类上下文长度下已经平稳时,直接关掉(不配置该键)即可。

测试验证:从单测到 e2e

该特性有完整的单测与端到端测试覆盖:

  • 单元测试:test_utils.py 验证四个 KV 长度张量(NPUseq_lens与三个 CPU 镜像)被同一算术窗口化;test_eagle_proposer.py 验证 block table 重绑定到预分配克隆、full_block_table保留、seq_lens正确 clamp;test_llm_base_proposer.py 验证load_model从已验证的AscendConfig读取draft_window_size并构造 adapter。
  • 端到端测试:test_dspark.py 的test_dspark_kv_sliding_window使用draft_window_size=512和超过窗口的 prompt,断言各位置接受率与无窗口基线(dspark_sliding_windowgolden 序列,见 utils.py)匹配;test_spec_decode_heavy.py 的test_eagle3_sliding_window同样以 512 窗口验证 EAGLE3 接受率 golden([0.7, 0.4, 0.3])且保证输出非空、不崩溃。

相关文件速查

  • 共享窗口适配器(窗口数学、克隆缓冲、apply):vllm_ascend/spec_decode/utils.py
  • MRV1 proposer(应用点、配置校验、future_offset、MTP/DeepSeek-V4 警告):vllm_ascend/spec_decode/llm_base_proposer.py
  • DSpark per-group block table 组装:vllm_ascend/spec_decode/dspark_proposer.py
  • 配置字段定义与示例 JSON:vllm_ascend/ascend_config.py
  • MRV2 移植目标(builder 输入窗口化位置):vllm_ascend/worker/v2/spec_decode/dspark/speculator.py
  • 测试:单元测试见 tests/ut/spec_decode/ 下的test_utils.py、test_eagle_proposer.py、test_llm_base_proposer.py;端到端测试见 test_dspark.py 与 test_spec_decode_heavy.py
  • 人工智能
  • 大模型
  • 模型推理服务
  • Ascend
  • CANN

【免费下载链接】vllm-ascend

Community maintained hardware plugin for vLLM on Huawei Ascend

项目地址:https://gitcode.com/gh_mirrors/vl/vllm-ascend
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询