Prhub

#55133 [Spec Decode] Fix Qwen3 DSpark d2t requirement for padded-vocab drafts

原始 PR 作者 orestis-z 合并时间 2026-09-14 22:07 文件变更 2 提交数 9 评论 19 代码增减 +38 / -15

执行摘要

DSpark 补齐词表草稿不再误报缺 d2t

PR 正文说明:Qwen3DSparkForCausalLM 用 uses_reduced_vocab = self.config.draft_vocab_size != self.target_vocab_size 判定草稿是否为精简词表,从而要求 lm_head + d2t。但草稿词表可以在「补齐到物理 embedding 尺寸」后大于目标的逻辑 vocab_size:Inkling-Small-NVFP4 的 get_vocab_size() 为 200058,而 embed/unembed 是 [201024, 4096]、draft_vocab_size 为 201024,这类草稿覆盖完整词表、映射是恒等的,却在加载时报出 “Reduced-vocabulary Qwen3 DSpark checkpoints must include a d2t mapping”。该检查由 @Zhou248 在 #52560 中加入(正文中 cc),本 PR 只把 d2t 门控从 != 改为 <,使剪枝草稿行为不变、补齐草稿不再误报。

值得精读,但只需读源码两处条件与新增测试:这是一个「一行判据改对语义」的典型案例,展示了对 != 与 < 在模型加载契约中差异的精确把握,也示范了把「加载期校验」与「构造期参数创建」绑定为同一不变式的做法。阅读时重点看三件事:(1) __init__ 与 load_weights 的判据为何必须同步、不同步会怎样;(2) 恒等映射路径 map_draft_to_target 是否真的等价于过去的全零 d2t;(3) 务必跟进 CodeRabbit 提出的 padded 行 mask 建议——建议维护者为 compute_draft_logits / DSparkSpeculator._sample_logits 另开 issue 或小 PR,补上 [target_vocab_size, draft_vocab_size) 的 mask 与「高 padded logit」回归测试,否则本 PR 只是把失败从加载期推迟到采样期。

讨论亮点

评审总体轻量:维护者 benchislett 只留下两条风格意见——先是一句空的 “suggestion”,随后回复自己的评论 “one comment is enough”,指向 load_weights 中重复的说明注释;作者以提交 a634ab91(Co-authored-by: Benjamin Chislett)应用建议,最终 benchislett 以 “LGTM” APPROVED。真正有技术含量的是 coderabbitai 的 Major 发现(未解决):它挂在新条件行 if self.config.draft_vocab_size < self.target_vocab_size: 上,要求 compute_draft_logits 与 DSparkSpeculator._sample_logits 的采样路径在 dense sampling 和 topk 之前,把 target_vocab_size 到 draft_vocab_size 之间的补齐行 mask 掉,理由是这些 padded 行可能产出非法目标 id,并建议补一条「高 padded 行 logit」的回归测试。该线程在本 PR 中没有得到回复或修复。另外 claude[bot] 因 PR 来自 fork 而跳过了自动评审,所以核心逻辑的第三方复核主要依赖 CodeRabbit 这条意见。

实现拆解

  1. 构造期判据收紧(vllm/model_executor/models/qwen3_dspark.py::Qwen3DSparkForCausalLM.__init__):把 if self.config.draft_vocab_size != self.target_vocab_size: 改为 <,并补充注释「只有 pruned draft 才需要 d2t 把 id 散射上去,draft >= target 的补齐草稿是恒等映射」。结果是 draft_vocab_size >= target_vocab_size 时 self.draft_id_to_target_id = None,不再凭空创建一个长度为 draft_vocab_size 的 long 参数(该参数在补齐检查点里本来就不存在)。

  2. 加载期判据与构造期对齐(同文件 load_weights):uses_reduced_vocab = self.config.draft_vocab_size < self.target_vocab_size。这个布尔量同时驱动三条后续逻辑——缺 lm_head 的报错、缺 d2t 的报错,以及 orig_to_new_substr["draft_id_to_target_id"] = None 的权重名过滤。改完后形成不变式:只有在 __init__ 真的建了 draft_id_to_target_id 时才要求 checkpoint 带 d2t,两处判据不再可能出现「构造期不建参数、加载期却强制要权重」的自相矛盾。相邻的 uses_expanded_input_vocab(config.vocab_size > target_vocab_size)分支保持原样,输入词表扩展仍需自带 embed_tokens。

  3. 运行期映射退化为恒等:map_draft_to_target 在 self.draft_id_to_target_id is None 时直接 return draft_ids,等价于过去要求的「全零 d2t」(draft_ids + 0),因此采样结果语义不变,只是少一次 gather。PR 正文也据此论证 id 仍落在目标物理 embedding 范围内,不会越界。

  4. 测试配套(tests/model_executor/test_qwen3_omni.py,+33/-13):新增 _dspark_vocab_stub(input_vocab_size, draft_vocab_size) 桩函数,集中构造「只带 load_weights 所需字段」的 DSpark 实例(config、target_vocab_size=100、model=Mock(confidence_head=None)),并让原有 test_qwen3_dspark_rejects_incomplete_vocab_weights 复用它;新增参数化用例 test_qwen3_dspark_accepts_draft_vocab_at_least_target[100, 101],直接 model.load_weights([]) 断言不抛异常。其中 draft=101 > target=100 的用例在旧 != 逻辑下必然失败,是本次修复的精确回归护栏;(100, 40, [lm_head], "must include a d2t mapping") 等剪枝场景仍保持报错,确保放宽没有波及真正的 reduced-vocab 路径。

  5. 无配置 / schema / 部署改动:不涉及 CLI、环境变量、KV cache 或权重格式,加载器以外无连带修改;测试通过 @pytest.mark.skip_global_cleanup 与既有用例保持一致。

文件 模块 状态 重要度
vllm/model_executor/models/qwen3_dspark.py 草稿模型 modified 6.44
tests/model_executor/test_qwen3_omni.py 模型测试 modified 6.01

关键符号

Qwen3DSparkForCausalLM.__init__ Qwen3DSparkForCausalLM.load_weights Qwen3DSparkForCausalLM.map_draft_to_target Qwen3DSparkForCausalLM.compute_draft_logits _dspark_vocab_stub test_qwen3_dspark_accepts_draft_vocab_at_least_target test_qwen3_dspark_rejects_incomplete_vocab_weights

关键源码片段

vllm/model_executor/models/qwen3_dspark.py data-contract

本 PR 的唯一源码改动点:`Qwen3DSparkForCausalLM.__init__` 决定是否创建 `draft_id_to_target_id` 参数,`load_weights` 决定是否强制 checkpoint 带 `lm_head` + `d2t`,两处判据同时从 `!=` 收紧为 `<`,直接决定补齐词表草稿能否加载。

class Qwen3DSparkForCausalLM(DFlashQwen3ForCausalLM):
    def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
        nn.Module.__init__(self)
        self.draft_model_config = vllm_config.speculative_config.draft_model_config
        self.config = self.draft_model_config.hf_config
        # config 未声明 draft_vocab_size 时回退到 vocab_size,保证下面的判据恒有值
        if getattr(self.config, "draft_vocab_size", None) is None:
            self.config.draft_vocab_size = getattr(self.config, "vocab_size", None)
        # ... 这里构造 Qwen3DSparkModel / ParallelLMHead / LogitsProcessor ...
​
        self.target_vocab_size = vllm_config.model_config.get_vocab_size()
        # 【本 PR 核心 1】判据由 `!=` 收紧为 `<`:只有真正「剪枝过」的草稿
        # (draft_vocab_size 是目标词表的子集)才需要 d2t 偏移表把草稿 id 散射到
        # 目标空间;draft_vocab_size >= target_vocab_size(典型是补齐到目标物理
        # embedding 尺寸,如 Inkling-Small-NVFP4:逻辑 200058 / 物理 201024)
        # 覆盖完整词表,二者是恒等映射,因此既不建参数也不该要求 d2t 权重。
        if self.config.draft_vocab_size < self.target_vocab_size:
            self.draft_id_to_target_id = nn.Parameter(
                torch.zeros(self.config.draft_vocab_size, dtype=torch.long),
                requires_grad=False,
            )
        else:
            self.draft_id_to_target_id = None # 恒等映射路径,不再占用参数
​
    def map_draft_to_target(self, draft_ids: torch.Tensor) -> torch.Tensor:
        # 恒等分支等价于过去「全零 d2t」的加法(draft_ids + 0),但省掉一次 gather,
        # 也让 padded 草稿在采样后不需要任何 id 变换。
        if self.draft_id_to_target_id is None:
            return draft_ids
        return draft_ids + self.draft_id_to_target_id[draft_ids]
    def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]):
        model_weights = {}
        includes_embed_tokens = False
        includes_lm_head = False
        includes_draft_id_mapping = False
        includes_confidence_head = False
        for name, loaded_weight in weights:
            # t2d 只在训练期使用;推理期由 d2t 在采样后把草稿 id 映射回目标 id
            if "t2d" in name:
                continue
            if "d2t" in name:
                name = name.replace("d2t", "draft_id_to_target_id")
                includes_draft_id_mapping = True
            elif "lm_head" not in name:
                name = "model." + name
            if "embed_tokens" in name:
                includes_embed_tokens = True
            if "lm_head" in name:
                includes_lm_head = True
            if "confidence_head" in name:
                includes_confidence_head = True
            model_weights[name] = loaded_weight
            # 设置 has_own_embed_tokens / has_own_lm_head,供 load_dspark_model
            # 决定是保留草稿自身权重,还是别名到目标模型的同名权重
            process_eagle_weight(self, name)
​
        uses_expanded_input_vocab = self.config.vocab_size > self.target_vocab_size
        # 【本 PR 核心 2】与 __init__ 使用同一判据:只有 draft < target 才算
        # reduced vocab。改前用 `!=`,导致补齐词表的草稿被误判为精简词表,
        # 加载时抛出 "must include a d2t mapping",尽管它根本不需要 d2t。
        uses_reduced_vocab = self.config.draft_vocab_size < self.target_vocab_size
​
        if uses_expanded_input_vocab and not includes_embed_tokens:
            raise ValueError(
                "Qwen3 DSpark checkpoints whose input vocab_size is larger than "
                "the target vocabulary must include embed_tokens weights."
            )
        if uses_reduced_vocab and not includes_lm_head:
            # 目标 lm_head 覆盖不了被剪掉的草稿词表,无法共享,必须自带
            raise ValueError(
                "Reduced-vocabulary Qwen3 DSpark checkpoints must include "
                "lm_head weights; the full target lm_head cannot be shared."
            )
        if uses_reduced_vocab and not includes_draft_id_mapping:
            # 补齐词表的草稿现在不再走到这里(draft >= target 直接跳过),
            # 因此 Inkling-Small-NVFP4 这类检查点可以正常加载。
            raise ValueError(
                "Reduced-vocabulary Qwen3 DSpark checkpoints must include a "
                "d2t mapping so sampled draft ids can be converted to target ids."
            )
​
        # 权重名过滤:缺失的模块统一置 None,交给 AutoWeightsLoader 跳过;
        # padded 草稿没有 d2t 权重,这里同时剔除 draft_id_to_target_id,
        # 与 __init__ 中「没有创建该参数」保持一致,避免加载器报缺参数。
        orig_to_new_substr = {"mask_embedding": None}
        if not includes_embed_tokens:
            orig_to_new_substr["embed_tokens"] = None
        if not includes_lm_head:
            orig_to_new_substr["lm_head"] = None
        if not includes_draft_id_mapping:
            orig_to_new_substr["draft_id_to_target_id"] = None
        if self.model.confidence_head is None or not includes_confidence_head:
            self.model.confidence_head = None
            orig_to_new_substr["confidence_head"] = None
        mapper = WeightsMapper(orig_to_new_substr=orig_to_new_substr)
        loader = AutoWeightsLoader(self)
        loader.load_weights(model_weights.items(), mapper=mapper)
        self.model._build_fused_kv_buffers()
tests/model_executor/test_qwen3_omni.py test-coverage

测试配套:抽出可复用的 DSpark 加载桩 `_dspark_vocab_stub`,并新增参数化用例覆盖 `draft_vocab_size == 100` 与 `> 100`(补齐场景)时必须加载成功,同时保留剪枝场景仍报错的既有用例。

def _dspark_vocab_stub(input_vocab_size: int, draft_vocab_size: int):
    """A Qwen3 DSpark model with only what `load_weights` reads."""
    from vllm.model_executor.models.qwen3_dspark import Qwen3DSparkForCausalLM
​
    # 绕过 __init__ 直接造实例,只注入 load_weights 会读取的字段,
    # 因此测试完全聚焦在加载期判据上,不需要真实权重与 GPU。
    model = Qwen3DSparkForCausalLM.__new__(Qwen3DSparkForCausalLM)
    nn.Module.__init__(model)
    object.__setattr__(
        model,
        "config",
        SimpleNamespace(
            vocab_size=input_vocab_size,
            draft_vocab_size=draft_vocab_size,
        ),
    )
    object.__setattr__(model, "target_vocab_size", 100) # 目标词表固定为 100
    # confidence_head=None 让 load_weights 走「丢弃 confidence_head」的既有分支
    object.__setattr__(model, "model", Mock(confidence_head=None))
    return model
​
​
@pytest.mark.skip_global_cleanup
@pytest.mark.parametrize("draft_vocab_size", [100, 101])
def test_qwen3_dspark_accepts_draft_vocab_at_least_target(draft_vocab_size):
    """A draft vocab >= the target's maps identically, so needs no lm_head/d2t.
​
    Guards the padded case (draft > target): the draft's unembedding is padded
    to its physical size, which is not a reduced vocabulary.
    """
    model = _dspark_vocab_stub(100, draft_vocab_size)
​
    # 空权重集合 + 不抛异常:draft_vocab_size=101 这一档在旧的 `!=` 判据下
    # 会抛 "must include a d2t mapping",是本 PR 修复的精确回归护栏。
    model.load_weights([])

评论区精华

reduced-vocab 判据用 `<` 还是 `!=`:补齐词表草稿的加载契约 正确性

PR 正文把草稿分为两类:真正剪枝(draft < target)需要 `lm_head` + `d2t` 把 id 散射到目标空间;补齐到物理 embedding 尺寸(draft >= target,如 Inkling-Small-NVFP4 的 201024 vs 200058)覆盖完整词表、映射恒等,却被 `!=` 判据误判为 reduced-vocab 并报 “must include a d2t mapping”。维护者 benchislett 未对语义提出异议。

结论:接受改为 `<`,且 `__init__` 与 `load_weights` 两处同步修改,使「构造期建参数」与「加载期要求权重」保持同一不变式;剪枝路径行为不变。 · 已解决

重复注释清理 style

benchislett 先在 `load_weights` 的新增注释处留下空的 “```suggestion ```”,随后回复自己 “one comment is enough”,指出与 `__init__` 中说明重复。作者以提交 a634ab91(Co-authored-by: Benjamin Chislett)应用建议,删掉 `load_weights` 里的那段说明。

结论:采纳建议,注释只保留在 `__init__` 一处;benchislett 随后 APPROVED(“LGTM”)。 · 已解决

补齐词表行的 logit 未 mask,采样可能产出非法目标 id 正确性

coderabbitai 以 Major(Data Integrity)等级提出:应在 `compute_draft_logits` 与 `DSparkSpeculator._sample_logits` 的 dense sampling / topk 之前,把 `[target_vocab_size, draft_vocab_size)` 的补齐行 mask 掉,并补一条「高 padded 行 logit」的回归测试,确保采样出的 id 始终小于 target_vocab_size。评论挂在新的 `<` 判据行上,本 PR 未回应也未修复;作者端到端验证(解码连贯)与 PR 正文的「不越界」论证只覆盖物理 embedding,未覆盖逻辑词表的合法性。

结论:未解决,遗留为后续工作项;需要单独 PR 在采样前 mask padded 行并补回归测试。 · unresolved

风险与影响

  1. 采样越界(未解决,最高优先级):判据放宽后,补齐草稿的 compute_draft_logits 仍对整个 draft_vocab_size 做 dense softmax / topk,[target_vocab_size, draft_vocab_size) 这些 padded 行没有任何 mask(logit 通常为 0 或未定义),一旦它们排进 topk,采样出的 draft id 会大于等于目标逻辑词表;而 map_draft_to_target 此时是恒等映射,draft_ids + None 的旧 d2t 约束消失后就没有任何环节拦截这些 id,理论上会在 [200058, 201024) 这段「物理存在但逻辑非法」的区间产生 token。PR 正文只论证了不越出物理 embedding、不触发 OOB,未论证不会产出非法 token id。CodeRabbit 明确要求补 mask 与回归测试,本 PR 未处理。
  2. 双处判据必须同步:__init__ 与 load_weights 各写一遍 draft_vocab_size < target_vocab_size,二者一旦分叉就会出现「无参数却强制加载权重」或反之的错配;当前靠注释和测试对齐,缺少一个共享属性(如 uses_reduced_vocab property)来固化。
  3. 测试覆盖不完整:新增用例只覆盖 load_weights 的放行分支,__init__ 中 draft_id_to_target_id is None 的构造路径、map_draft_to_target 的恒等返回都没有单测;PR 正文提到的端到端验证(Inkling DSpark drafter 解码连贯)是人工且不可复现的。
  4. 兼容性方向是放宽而非收紧:draft == target 行为不变,draft > target 从「无法加载」变为「可加载」,不会破坏已有可加载的 checkpoint,回归风险集中在上面第 1 点的运行期语义。

对用户:使用补齐词表 DSpark 草稿(如 Inkling-Small-NVFP4)的投机解码部署从「启动即报错」变为「可正常加载并解码」,属于直接解锁能力;使用真正剪枝草稿(draft < target)的用户完全无感,报错与 d2t 要求保持不变。对系统:影响面局限在 Qwen3DSparkForCausalLM 的权重加载与 id 映射契约,不触及调度、KV cache、注意力后端或前端;load_dspark_model 侧的 has_own_embed_tokens / has_own_lm_head 共享逻辑不受影响,仅是少了一个永远不存在的 draft_id_to_target_id 参数。对团队:改动只有 2 个文件、43 行,评审成本低,但它把「逻辑词表 vs 物理补齐词表」这一 DSpark / Eagle 系草稿模型的通用概念显性化,后续接入其他厂商补齐词表的草稿模型(含 MoE / Mtp 系)时会反复用到同一判据。剩余风险是采样阶段未做 padded 行 mask,如果真实模型的 padded 行 logit 不可忽略,用户体验会从「加载失败」变为「偶发非法 token」,定位成本更高。

采样可能产出非法 token id 缺少 padded 行 mask 回归测试 构造期与加载期双处判据需同步 仅人工端到端验证

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论