Prhub

#52147 Standardise weight tying on `ParallelLMHead.tie_weights`

原始 PR 作者 hmellor 合并时间 2026-08-13 23:10 文件变更 56 提交数 1 评论 2 代码增减 +191 / -214

执行摘要

统一 56 个模型 weight tying 表达,收敛到 ParallelLMHead.tie_weights

PR body 明确指出:vLLM expresses tied word embeddings in three different ways. Only one of them, self.lm_head = self.lm_head.tie_weights(embed_tokens), dispatches through quant_method.tie_weights。其中 self.lm_head.weight = embed_tokens.weight(33 处)完全绕过量化方法,对需要 repack 权重的量化方案是错误的;self.lm_head = embed_tokens(23 处)在 tied 分支根本不构造 ParallelLMHead,导致 lm_head 类型契约不一致。除此之外,本 PR 是作者自己的 #51665 的 tying-standardisation 半部分,特意拆分出来单独评审。

值得精读。这是理解 vLLM 模型构造规范(ParallelLMHead + quant_method.tie_weights 分发)的极佳样本,也展示了如何把跨 56 个文件的机械重构做得干净:单 commit、模式统一、边界明确(五个硬编码模型除外)、明确标注两个行为后果。对量化开发者尤其值得关注 tie_weights 的分发语义;对架构师可以学习作者拆 PR 的策略——把 #51665 中独立性最强的 tying-standardisation 拆出单独评审。建议后续跟进 #51665 合入后的 untie 能力和 skip_prefixes 清理。

讨论亮点

本 PR 实际没有人工 review 评论文档:claude[bot] 自动评论说明 fork 来源禁用自动评审,Isotr0py 直接 APPROVED(无评论)。最有价值的讨论内容来自作者在 PR body 中主动提出的两点 review note:

  • A transient vocab × hidden allocation before the head is tied.
  • The quant config is now consulted for the lm_head prefix where it previously never was.

以及关于 ernie45 的例外说明:

In ernie45_moe.py and ernie45_vl_moe.py the tie runs outside the is_last_rank check, so with PP > 1 self.lm_head is a PPMissingLayer, which has no tie_weights. That path already raised AttributeError on PPMissingLayer.weight before this change, so it is not a regression, but the failure mode changes. Worth fixing separately.

说明作者对改动边界和潜在回退有清醒判断,但这些取舍没有被 reviewer 追问验证。

实现拆解

  1. 变更入口:全部改动集中在 vllm/model_executor/models/ 下约 50 个 CausalLM 模型的 __init__lm_head 构造逻辑,每个文件改动模式相同(约 +7/-8 行),无新增文件。
  2. 第一类转换(33 处):将 self.lm_head.weight = embed_tokens.weight 替换为 self.lm_head = self.lm_head.tie_weights(embed_tokens)。由于此时 ParallelLMHead 已存在,这是一行语义替换,不改变结构;修复的是量化方法(如 GPTQ、MXFP4 等需要 repack)无法介入 tied 权重的问题。
  3. 第二类转换(23 处):将 tied 分支的 self.lm_head = embed_tokens 改为无条件先构造 ParallelLMHead(带 quant_configprefix=maybe_prefix(prefix, "lm_head")),再在 tie_word_embeddings 为真时调用 tie_weights。这带来两个作者明示的后果:a) tied 分支出现一次临时的 vocab × hidden 全量参数分配;b) quant_config 现在会为 lm_head 前缀被查询(此前 tied 路径从不查询)。
  4. 保留不动gemmagemma2commandrcohere2_moempt 五个硬编码 assert config.tie_word_embeddings 且没有真实 lm_head 的模型保持原样;所有 load_weights 现有的 skip_prefixes(如 ["lm_head."])一律不改,保证权重加载行为不变。ernie45_moe.pyernie45_vl_moe.py 中 tie 逻辑位于 is_last_rank 检查之外,PP > 1 时 self.lm_headPPMissingLayer,作者标注为非回归但失败模式变化、建议单独修复。
  5. 测试与验证:本 PR 未新增自动化测试文件。作者手工在 CPU 上构造 Qwen3-0.6B、Qwen2-0.5B、OPT-125m、Mamba-130m、Bloom-560m,断言 lm_headParallelLMHeadweight 与 embedding 是同一 nn.Parameter 对象;另用真实 Qwen3-0.6B checkpoint 走 load_weights 验证 tied 头共享存储且数值匹配;并跑通现有量化套件 tests/model_executor/test_qwen3_5_quantization.py(2 passed)。
文件 模块 状态 重要度
vllm/model_executor/models/glm4.py 模型层 modified 6.25
vllm/model_executor/models/falcon.py 模型层 modified 6.25
vllm/model_executor/models/bailing_moe.py 模型层 modified 6.25
vllm/model_executor/models/qwen3.py 模型层 modified 5.85
vllm/model_executor/models/molmo.py 模型层 modified 5.85

关键符号

ParallelLMHead.tie_weights Glm4ForCausalLM.__init__ FalconForCausalLM.__init__ BailingMoeForCausalLM.__init__ Qwen3ForCausalLM.__init__ MolmoForCausalLM.__init__

关键源码片段

vllm/model_executor/models/glm4.py refactor

第二类转换(self.lm_head = embed_tokens 形式)的代表:PP 分支 + LoRA/PP 模型,展示无条件构造 ParallelLMHead 后按需 tie_weights 的标准写法。

class Glm4ForCausalLM(nn.Module, SupportsLoRA, SupportsPP):
    packed_modules_mapping = {
        "qkv_proj": ["q_proj", "k_proj", "v_proj"],
        "gate_up_proj": ["gate_proj", "up_proj"],
    }
​
    def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
        super().__init__()
        config = vllm_config.model_config.hf_config
        self.model = Glm4Model(
            vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
        )
​
        # 改造前:tied 分支直接 `self.lm_head = self.model.embed_tokens`,
        # 完全不经过 quant_method,量化模型在 tied 场景下会漏掉 lm_head 的量化处理。
        # 改造后:无论是否 tying 都先构造 ParallelLMHead(携带 quant_config),
        # 再统一通过 tie_weights 走 quant_method.tie_weights 分发。
        if get_pp_group().is_last_rank:
            self.lm_head = ParallelLMHead(
                config.vocab_size,
                config.hidden_size,
                quant_config=vllm_config.quant_config,
                prefix=maybe_prefix(prefix, "lm_head"),
            )
            if config.tie_word_embeddings:
                # 唯一合法的 tied 表达:共享 weight 且让量化方法参与处理
                self.lm_head = self.lm_head.tie_weights(self.model.embed_tokens)
        else:
            # PP > 1 时非最后 rank 不持有真实 lm_head
            self.lm_head = PPMissingLayer()
​
        self.logits_processor = LogitsProcessor(config.vocab_size)
vllm/model_executor/models/falcon.py refactor

含默认 tie 语义的特殊模型:Falcon-11B 之外默认 tie_word_embeddings=True,改动同时保留默认值逻辑与 skip_prefixes,是验证 load_weights 行为不变的样本。

class FalconForCausalLM(nn.Module, SupportsPP):
    def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
        super().__init__()
        config = vllm_config.model_config.hf_config
        quant_config = vllm_config.quant_config
        self.transformer = FalconModel(
            vllm_config=vllm_config, prefix=maybe_prefix(prefix, "transformer")
        )
​
        # Falcon-11B 不共享 lm_head 与 word embeddings;
        # 更早的 Falcon 版本没有 tie_word_embeddings 配置项,因此默认视为 True。
        self.tie_word_embeddings = (
            config.tie_word_embeddings
            if config.tie_word_embeddings is not None
            else True
        )
​
        # 与 glm4 相同的收敛模式:先无条件构造 ParallelLMHead,
        # 再按需 tie。此前 `self.lm_head = self.transformer.word_embeddings`
        # 会让 tied 分支连 ParallelLMHead 都不存在,量化与 LogitsProcessor
        # 的输入类型假设都会失效。
        self.lm_head = ParallelLMHead(
            config.vocab_size,
            config.hidden_size,
            quant_config=quant_config,
            prefix=maybe_prefix(prefix, "lm_head"),
        )
        if self.tie_word_embeddings:
            self.lm_head = self.lm_head.tie_weights(self.transformer.word_embeddings)
​
        self.logits_processor = LogitsProcessor(config.vocab_size)
​
    def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
        # 注意:load_weights 的 skip_prefixes 刻意保持不变,
        # 保证 tied 模型的 lm_head 权重依旧跳过加载、由 embedding 共享,
        # 因此权重加载行为与合并前完全一致。
        loader = AutoWeightsLoader(
            self,
            skip_prefixes=(["lm_head."] if self.config.tie_word_embeddings else None),
        )
        return loader.load_weights(weights)
vllm/model_executor/models/bailing_moe.py refactor

代表使用 getattr(config, "tie_word_embeddings", False) 兜底写法的 MoE 模型,且带 SupportsLoRA,体现该模式在更多变体中的一致性。

class BailingMoeForCausalLM(nn.Module, SupportsPP, SupportsLoRA):
    def __init__(
        self,
        *,
        vllm_config: VllmConfig,
        prefix: str = "",
    ) -> None:
        super().__init__()
        config = vllm_config.model_config.hf_config.get_text_config()
        quant_config = vllm_config.quant_config
        self.model = BailingMoeModel(
            vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
        )
        # 部分配置没有 tie_word_embeddings 字段,用 getattr 兜底为 False
        self.tie_word_embeddings = getattr(config, "tie_word_embeddings", False)
​
        if get_pp_group().is_last_rank:
            # 无论是否 tied 都先构建 ParallelLMHead,避免此前 tied 分支
            # 直接复用 word_embeddings 而绕过量化方法的问题。
            self.lm_head = ParallelLMHead(
                config.vocab_size,
                config.hidden_size,
                quant_config=quant_config,
                prefix=maybe_prefix(prefix, "lm_head"),
            )
            if self.tie_word_embeddings:
                self.lm_head = self.lm_head.tie_weights(self.model.word_embeddings)
            self.logits_processor = LogitsProcessor(config.vocab_size)
        else:
            self.lm_head = PPMissingLayer()

评论区精华

ernie45_moe 在 PP > 1 下 tie_weights 的失败模式变化 设计

作者在 PR body 的 Note for reviewers 中主动指出:ernie45_moe.py 和 ernie45_vl_moe.py 的 tie 逻辑位于 is_last_rank 检查之外,PP > 1 时 self.lm_head 是 PPMissingLayer,没有 tie_weights 方法。此前该路径已在 PPMissingLayer.weight 上抛 AttributeError,所以不是回归,但失败模式发生变化。

结论:作者认定非回归,建议单独修复(Worth fixing separately),本次不做处理。 · 未解决,作者标注另开修复

tied 分支新增临时分配与 quant_config 咨询 设计

作者在 PR body 列出第二类转换的两个值得 review 的后果:1) tied 分支在 tie 之前有一次临时 vocab × hidden 分配;2) quant config 现在会在 lm_head 前缀上被咨询,而此前 tied 路径从不咨询。

结论:作者将其作为有意取舍提出,认为这是统一表达的必要代价;未引发 reviewer 追问。 · 已由作者说明,reviewer 无异议(Isotr0py APPROVED)

风险与影响

  1. 初始化内存与耗时:第二类转换的 23 个模型在 tied 分支现在会先完整分配一个 ParallelLMHeadtie_weights 共享权重,产生一次临时的大块分配。以 Qwen3-0.6B 规模估算(vocab 约 152K、hidden 1024、BF16),单次临时分配约 300 MB 级,大词表模型(如 Qwen3 多语言系列)初始化峰值内存和耗时会有可见上升;tie 之后多余参数被 GC 回收,但峰值仍在。
  2. 量化路径首次覆盖 tied lm_head:改动后 quant_config 会在 tied 场景下被咨询,quant_method.tie_weights 的分发逻辑(可能涉及 repack 或参数替换)对许多模型组合是首次执行。作者只验证了 test_qwen3_5_quantization.py 一个量化套件,其余量化方法与 tied 模型的组合缺少显式覆盖,若某个量化实现与 tied 组合存在隐藏 bug,会在模型加载期暴露。
  3. 测试覆盖缺口:56 个文件改动无新增自动化测试,CI 依赖既有套件。像 moss_audiohrm_textsarvamseed_oss 这类较新或小众模型未必在测试矩阵中,回归风险未完全消除。
  4. ernie45 的 PP 路径:失败模式从 PPMissingLayer.weight 抛错变为 PPMissingLayer.tie_weights 抛错,虽非回归但报错信息变化,可能影响排障。
  5. 外部兼容性:依赖 lm_head 直接等于 embedding 模块的插件或自定义模型代码会观察到类型变化(从 Embedding 变为 ParallelLMHead),但共享 weight 参数保证算子行为一致。

影响面覆盖 vLLM 所有使用权重绑定的模型族(Qwen 系列、Mistral、Falcon、GLM、Bloom、OPT、Mamba、Molmo 等数十个),涉及模型构造契约的收敛:lm_head 在 tied 场景下从此统一为 ParallelLMHead(共享 weight),并统一经过 quant_method.tie_weights 分发。对用户的推理结果和权重加载行为理论上无变化(所有现有 skip_prefixes 未动),但对量化 tied 模型是潜在的正确性修复。对工程团队而言,这消除了三种写法并存的维护成本,为 #51665 的 maybe_untie_word_embeddingsAutoWeightsLoader 别名参数跳过和约 50 处 skip_prefixes=["lm_head."] 清理奠定基础,属于模型层持续演进的承上启下改动。

跨 56 文件统一重构 tied 分支新增临时大分配 量化路径首次覆盖 tied lm_head 缺少新增自动化测试 ernie45 PP 路径失败模式变化

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论