Prhub

#48390 [Core] Support fp32 lm_head for generation models via head_dtype (RFC #48305 §3.6)

原始 PR 作者 KKothuri 合并时间 2026-07-13 16:43 文件变更 6 提交数 6 评论 17 代码增减 +249 / -15

执行摘要

支持生成模型的 fp32 lm_head

RL 训练-推理一致性要求 vLLM 的 rollout logits 与在 fp32 中计算 lm_head 的训练器匹配(RFC #48305 §3.6、Issue #19925)。现有 head_dtype 属性(#23810)被限制在池化模型,生成模型静默退化到模型 dtype。此 PR 移除了该限制,提供通过 --hf-overrides '{"head_dtype": "float32"}' 启用 fp32 lm_head 的第一类路径。

该 PR 值得精读,尤其是 _apply_head 中的三路分支设计和平台感知的 fast path 选择。设计决策(CUDA out_dtype vs cast、量化限制、LoRA 拒绝)体现了务实的安全边界。建议关注后续 LoRA 路径实现和更多后端兼容性测试。

讨论亮点

审查中主要讨论了以下几点:

  1. LoRA 路径兼容性:ChatGPT Codex 指出 LogitsProcessorWithLoRA 完全绕过了 _apply_head,导致 --enable-lora 时 fp32 head 设置静默失效。作者在最终版本中在构造函数中添加了显式检查,当 head_dtype != dtype 时抛出 ValueError,而非静默降级。
  2. CUDA out_dtype 路径的适用范围:早期版本中 torch.mm(..., out_dtype=self.head_dtype) 未限制为 fp32,但 PyTorch 仅支持 fp16/bf16 输入时的 fp32 输出,非 fp32 的 head_dtype 会触发运行时错误。最终版本明确仅对 head_dtype == torch.float32is_cuda() 时启用 fast path,其他情况使用通用 cast 路径。
  3. get_top_tokens 路径覆盖:ChatGPT Codex 和 aoshen02 都注意到投机解码的本地 argmax 路径(get_top_tokens)没有应用 head_dtype。最终提交(f2c7804)中作者修复了此问题,使 get_top_tokens 也通过 _apply_head 进行投影。

实现拆解

实现分为五个步骤:

  1. 放宽 ModelConfig.head_dtype 的池化模型限制vllm/config/model.py):移除了 runner_type != 'pooling' 时的警告回退逻辑,使生成模型也能使用 head_dtype 配置值。
  2. LogitsProcessor 中注入 head_dtypevllm/model_executor/layers/logits_processor.py):构造函数中通过 get_current_vllm_config().model_config.head_dtype 获取配置;新增 _apply_head 方法封装 head_dtype 感知的投影逻辑。
  3. 实现 _apply_head 的三路分支:若 head_dtype 为 None 或等于隐藏状态 dtype,走原 quant_method.apply 路径;若为 fp32 且运行在 CUDA,使用 torch.mm(..., out_dtype=float32) 避免额外拷贝;其他情况回退到 F.linear + 显式类型转换。
  4. _get_logitsget_top_tokens 改为使用 _apply_head:替换直接调用 lm_head.quant_method.apply 的代码,确保投机解码的本地 argmax 路径也遵守 head_dtype
  5. 添加限制与错误处理_apply_head 中检查 lm_head.quant_method 是否为 UnquantizedEmbeddingMethod,否则抛出异常;LogitsProcessorWithLoRA 构造函数中检查 head_dtype != dtype 时立即报错,防止静默降级。

测试配套:新增 tests/v1/sample/test_head_dtype.py,包含单元测试覆盖 fp32 投影、非 fp32 head_dtype 的 cast 路径、量化 lm_head 拒绝、get_top_tokens 一致性以及 LoRA 拒绝;在 test_gpt.py 的 PPL 测试中添加了 head_dtype='float32' 变体,确认准确率不变。

文件 模块 状态 重要度
tests/v1/sample/test_head_dtype.py 测试 added 7.93
vllm/model_executor/layers/logits_processor.py 模型层 modified 7.84
vllm/config/model.py 配置 modified 6.1
vllm/lora/layers/logits_processor.py LoRA modified 6.05
tests/models/language/generation_ppl_test/test_gpt.py 测试 modified 4.94
tests/models/language/generation_ppl_test/ppl_utils.py 测试工具 modified 3.32

关键符号

_apply_head _get_logits get_top_tokens head_dtype LogitsProcessorWithLoRA.__init__

关键源码片段

tests/v1/sample/test_head_dtype.py test-coverage

新增完整单元测试,覆盖 fp32 投影、cast 路径、量化拒绝、get_top_tokens 一致性及 LoRA 拒绝,是功能的验证基

# SPDX-License-Identifier: Apache-2.0
"""Tests for running the generation lm_head in fp32 via ``head_dtype``."""import torch
from vllm.model_executor.layers.logits_processor import LogitsProcessor
from vllm.model_executor.layers.vocab_parallel_embedding import (
    UnquantizedEmbeddingMethod,
)class _FakeLmHead:
    """模拟 lm_head,支持量化和 shard 信息"""
    def __init__(
        self,
        weight: torch.Tensor,
        quantized: bool = False,
        shard_indices: object | None = None,
    ):
        self.weight = weight
        # 用 object() 表示量化版本,UnquantizedEmbeddingMethod() 表示未量化
        self.quant_method = object() if quantized else UnquantizedEmbeddingMethod()
        self.shard_indices = shard_indicesdef _build_processor(vocab_size: int) -> LogitsProcessor:
    """构造 LogitsProcessor,屏蔽 TP gather 对测试的影响"""
    lp = LogitsProcessor(vocab_size)
    lp._gather_logits = lambda logits: logits # 禁止 TP gather
    return lpdef test_fp32_head_runs_projection_in_fp32(default_vllm_config):
    # 设置 head_dtype = float32
    lp = _build_processor(64)
    lp.head_dtype = torch.float32
​
    hidden_states = torch.randn(4, 16, dtype=torch.bfloat16)
    weight = torch.randn(64, 16, dtype=torch.bfloat16)
​
    logits = lp._get_logits(hidden_states, _FakeLmHead(weight), None)
​
    # 验证输出是 fp32 且有限
    assert logits.dtype == torch.float32
    assert torch.isfinite(logits).all()
​
    expected = torch.nn.functional.linear(hidden_states.float(), weight.float())
    torch.testing.assert_close(logits, expected)def test_get_top_tokens_honors_head_dtype(default_vllm_config):
    # get_top_tokens 也必须遵循 head_dtype
    from unittest import mock
    lp = _build_processor(64)
    lp.head_dtype = torch.float32
​
    # 设置 shard_indices 以通过内部检查
    import types
    hidden_states = torch.randn(4, 16, dtype=torch.bfloat16)
    weight = torch.randn(64, 16, dtype=torch.bfloat16)
    lm_head = _FakeLmHead(
        weight,
        shard_indices=types.SimpleNamespace(
            num_org_vocab_padding=0, org_vocab_start_index=0
        ),
    )
    # 模拟 TP 大小为 1
    with mock.patch(
        "vllm.model_executor.layers.logits_processor.get_tensor_model_parallel_world_size",
        return_value=1,
    ):
        top = lp.get_top_tokens(lm_head, hidden_states, None)
​
    expected = torch.nn.functional.linear(
        hidden_states.float(), weight.float()
    ).argmax(dim=-1)
    assert torch.equal(top, expected)
vllm/model_executor/layers/logits_processor.py core-logic

核心变更:新增 `_apply_head` 方法,修改 `_get_logits` 和 `get_top_tokens` 以尊重 head_dtype,实现 fp32 投影的 fast/slow 路径

# vllm/model_executor/layers/logits_processor.py
# (节选:__init__ 头部注入 head_dtype,_apply_head 三路分支)class LogitsProcessor(PluggableLayer):
    def __init__(self, vocab_size: int, ...) -> None:
        super().__init__()
        # ... 既有初始化 ...
        # 从全局配置读取 head_dtype,默认为 None(使用模型 dtype)
        model_config = get_current_vllm_config().model_config
        self.head_dtype = (
            model_config.head_dtype if model_config is not None else None
        )
​
    def _apply_head(
        self,
        lm_head: VocabParallelEmbedding,
        hidden_states: torch.Tensor,
        embedding_bias: torch.Tensor | None,
    ) -> torch.Tensor:
        """Project hidden states through the lm_head, honoring head_dtype."""
        # 情况 1: head_dtype 未设置或与模型 dtype 一致 → 走原量化路径
        if self.head_dtype is None or self.head_dtype == hidden_states.dtype:
            return lm_head.quant_method.apply(
                lm_head, hidden_states, bias=embedding_bias
            )
​
        # head_dtype 与模型 dtype 不一致时,要求 lm_head 未量化
        if not isinstance(lm_head.quant_method, UnquantizedEmbeddingMethod):
            raise ValueError(
                "A head_dtype different from the model dtype is only "
                "supported for an unquantized lm_head."
            )
​
        # 情况 2: CUDA + fp32 → 使用 torch.mm(out_dtype=float32) 避免拷贝权重
        if (
            self.head_dtype == torch.float32
            and current_platform.is_cuda()
            and hidden_states.is_cuda
        ):
            flat = hidden_states.reshape(-1, hidden_states.shape[-1])
            logits = torch.mm(
                flat, lm_head.weight.t(), out_dtype=self.head_dtype
            )
            if embedding_bias is not None:
                logits = logits + embedding_bias.to(self.head_dtype)
            return logits.reshape(*hidden_states.shape[:-1], -1)
​
        # 情况 3(回退): 非 CUDA 或非 fp32 → 显式转换二者后做线性投影
        return F.linear(
            hidden_states.to(self.head_dtype),
            lm_head.weight.to(self.head_dtype),
            embedding_bias.to(self.head_dtype) if embedding_bias is not None else None,
        )
​
    def _get_logits(
        self,
        hidden_states: torch.Tensor,
        lm_head: VocabParallelEmbedding,
        embedding_bias: torch.Tensor | None,
    ) -> torch.Tensor | None:
        logits = self._apply_head(lm_head, hidden_states, embedding_bias) # 改用 _apply_head
        logits = self._gather_logits(logits)
        if logits is not None:
            logits = logits[..., : self.org_vocab_size]
        return logits
​
    def get_top_tokens(
        self,
        lm_head: VocabParallelEmbedding,
        hidden_states: torch.Tensor,
        embedding_bias: torch.Tensor | None = None,
    ) -> torch.Tensor:
        # ... 前置检查 ...
        logits = self._apply_head(lm_head, hidden_states, embedding_bias) # 改用 _apply_head
        # ... 后续软上限、缩放、本地 argmax 等逻辑 ...
vllm/lora/layers/logits_processor.py core-logic

添加了 head_dtype 与 LoRA 不兼容的显式检查,避免静默降级

# vllm/lora/layers/logits_processor.pyclass LogitsProcessorWithLoRA(BaseLayerWithLoRA):
    def __init__(
        self,
        base_layer: LogitsProcessor,
        hidden_size: int,
        dtype: torch.dtype,
        device: torch.device,
        sharded_to_full_mapping: list[int] | None,
    ) -> None:
        super().__init__()
        self.base_layer = base_layer
        self.dtype = dtype
        # The fp32 lm_head path lives in the base LogitsProcessor._get_logits,
        # which this wrapper bypasses. Reject the combination until LoRA supports it.
        head_dtype = getattr(base_layer, "head_dtype", None)
        if head_dtype is not None and head_dtype != dtype:
            raise ValueError(
                "A head_dtype different from the model dtype (e.g. an fp32 "
                "lm_head) is not yet supported with LoRA."
            )
        # 原有初始化逻辑 ...

评论区精华

LoRA 路径兼容性 设计

ChatGPT Codex 指出 `LogitsProcessorWithLoRA` 完全绕过了 `_apply_head`,导致 `--enable-lora` 时 fp32 head 设置静默失效(comment 002)。作者最终在 LoRA 构造函数中添加了显式检查,当 `head_dtype != dtype` 时抛出 `ValueError`。

结论:已通过显式拒绝解决,未来需实现 LoRA 路径的 head_dtype 支持。 · 已解决

CUDA out_dtype 路径限制为 fp32 正确性

ChatGPT Codex 指出早期版本对所有 head_dtype 使用 `torch.mm(out_dtype=self.head_dtype)`,但 PyTorch 仅支持 fp16/bf16 输入时的 fp32 输出,非 fp32 配置会运行时错误(comment 003)。作者在最终版本中明确限制 `torch.mm` 路径仅对 `head_dtype == torch.float32` 且 `is_cuda()` 启用。

结论:已修复:fast path 仅用于 fp32 + CUDA;其他情况使用 cast 回退。 · 已解决

get_top_tokens 路径覆盖 head_dtype 设计

ChatGPT Codex 和 aoshen02 都注意到投机解码的本地 argmax 路径(`get_top_tokens`)没有应用 head_dtype(comment 004 和 aoshen02 评论)。最终提交 `f2c7804` 中作者将 `get_top_tokens` 也改为调用 `_apply_head`。

结论:已通过共享 `_apply_head` 方法修复。 · 已解决

风险与影响

  1. 兼容性:对未设置 head_dtype 的用户无影响;默认行为不变。
  2. 量化模型限制head_dtype != dtype 时仅支持未量化 lm_head,量化模型用户若意外设置会收到错误。
  3. LoRA 不兼容:明确拒绝,但未来扩展需注意。
  4. CUDA 非 fp32 fast path 回退:非 CUDA 或非 fp32 的 head_dtype 使用 cast 路径,会在每步生成中将权重和隐藏状态转换为 head_dtype,增加显存和计算开销,但这是用户主动选择。
  5. get_top_tokens 路径:已在最终版本修复,但若未来有其他调用点遗漏则可能有问题。
  6. 测试覆盖:单元测试和 PPL 测试覆盖了主要路径,但缺少完整的端到端 RL 训练验证。

用户影响:需要 RL 训练-推理一致性的用户现在可以通过 --hf-overrides '{"head_dtype": "float32"}' 在不损失准确率的前提下获得 fp32 logits;现有工作流(如 prime-rl 的 monkeypatch)可以迁移到配置驱动。系统影响:新增配置项,无 Breaking Changes。团队影响:完成了 roadmap 上的长期目标(#23810, #24567),社区碎片化解决方案可收敛。影响范围中等,主要面向 RL 训练用户群体。

仅支持非量化 lm_head LoRA 不兼容(明确拒绝) CUDA fast path 条件严格(fp32 + CUDA 才启用) 非 CUDA 路径有额外显存开销

关联 Issue

#19925 [Feature]: Support casting lm_head to FP32 to get old logprobs in RLHF
#42739 [Bugfix] Fix native Triton top-k/top-p kernel assumes contiguous logi…

完整报告

参与讨论