Prhub

#50940 [R3] Unify routed expert shape configuration

原始 PR 作者 aoshen02 合并时间 2026-08-05 11:20 文件变更 7 提交数 6 评论 2 代码增减 +146 / -46

执行摘要

统一 routed experts 拓扑配置解析并修复 Kimi K3/Gemma 4

R3 维护了不完整的 model-config 解析,仅识别部分 experts-per-token 字段并单独重实现 expert-count 解析,导致 worker 与 scheduler 初始化偏离 vLLM 规范元数据。Kimi K3 使用 num_experts_per_token 因此初始化失败;Gemma 4 的配置没有 num_experts_per_tok 属性导致日志读取时抛 AttributeError。PR body 明确说明:"This change removes the R3-specific expert-count parser and gives all R3 allocation and logging paths one validated (layers, experts, experts_per_token) source, with top-k normalized once by the model-architecture convertor."(#50460 为 Gemma 4 修复的原始来源,本 PR 将其作为首个 commit 保留原作者署名。)

值得精读。该 PR 是一个教科书式的数据契约统一案例:将散落的别名解析收敛到单一 convertor,通过 ModelConfig 透传,删除重复实现。关注两点:一是 ModelArchConfigConvertorBase.get_num_experts_per_token() 的别名列表与 None 归一化策略,这是后续扩展新模型时需要维护的清单;二是 ModelArchitectureConfig 必填字段新增对构造方的影响模式,可作为未来新增规范字段的参考模板。

讨论亮点

唯一的技术讨论发生在 review 阶段:Isotr0pyvllm/config/model.py 第 1441 行(get_num_experts_per_tok 方法)评论 "Should we move this to model_arch_config?",作者 aoshen02 回复 "Nice catch!"。该评论对应的 diff hunk 显示早期版本是在 ModelConfig 内直接遍历 hf_text_config 的多个别名,而非从 model_arch_config 读取;后续提交将解析下沉到 ModelArchConfigConvertorBase.get_num_experts_per_token()ModelConfig 仅做透传。这条讨论促成了数据契约的最终形态:解析逻辑收敛到 convertor,单一事实来源。

实现拆解

  1. 数据契约扩展:在 vllm/config/model_arch.pyModelArchitectureConfig 中新增 num_experts_per_token: int 字段;在 vllm/transformers_utils/model_arch_config_convertor.py 新增 get_num_experts_per_token() 方法,依次尝试 num_experts_per_toknum_experts_per_tokentop_k_expertsmoe_topkmoe_top_k 五种别名,并对 None 归一化为 0;convert() 中将其写入 ModelArchitectureConfig
  2. 配置访问器统一vllm/config/model.pyModelConfig 新增 get_num_experts_per_tok() 方法,直接返回 model_arch_config.num_experts_per_token,使所有调用方通过同一个规范字段读取。
  3. R3 路径重构vllm/model_executor/layers/fused_moe/routed_experts_capturer.py 删除自定义的 _get_num_experts_per_tok(hf_config)get_num_experts(hf_config),新增 _get_routed_experts_shape(vllm_config) 统一从 ModelConfig 获取 (num_layers, num_experts, num_experts_per_tok) 并校验为正数;RoutedExpertsCapturerRoutedExpertsManager 初始化都改用该函数,同时修复了 manager 日志中直接访问 hf_config.num_experts_per_tok 的问题(即 Gemma 4 修复)。
  4. FFN 性能配置复用vllm/v1/metrics/perf.py 中的 parse 将原来对 ["num_experts_per_tok", "moe_topk"]getattr_from_list 替换为 model_config.get_num_experts_per_tok(),复用同一 accessor。
  5. 测试配套tests/config/test_model_arch_config.py 新增参数化测试 test_num_experts_per_tok_aliases 覆盖五种别名,test_num_experts_per_tok_none_is_normalized 验证 None 归一化为 0;tests/model_executor/test_routed_experts_capture.py 新增 _make_model_config helper 与 test_routed_experts_manager_uses_gemma4_top_k_expertstest_routed_experts_manager_uses_kimi_k3_experts_per_token,分别用 top_k_expertsnum_experts_per_token 驱动 RoutedExpertsManager 并断言 buffer shape 为 (max_num_slots, num_layers, top_k)
  6. 提交历史:首个 commit 是 #50460 的 Gemma 4 修复(作者 yihengz),后续 5 个 commit 逐步完成统一解析、别名支持与 None 归一化,演进方向清晰。
文件 模块 状态 重要度
vllm/model_executor/layers/fused_moe/routed_experts_capturer.py 路由捕获 modified 8.14
vllm/transformers_utils/model_arch_config_convertor.py 配置转换 modified 6.67
tests/model_executor/test_routed_experts_capture.py 路由捕获 modified 6.82

关键符号

get_num_experts_per_token get_num_experts_per_tok _get_routed_experts_shape

关键源码片段

vllm/model_executor/layers/fused_moe/routed_experts_capturer.py data-contract

R3 捕获链路的两个核心类(RoutedExpertsCapturer 与 RoutedExpertsManager)都改用统一的 _get_routed_experts_shape,删除自定义 parser 并修复 Gemma 4 日志缺陷。

# vllm/model_executor/layers/fused_moe/routed_experts_capturer.py
# 统一从 ModelConfig 读取 R3 需要的三个拓扑参数,替代原先各自从 hf_config 找别名的
# 两套 parser(_get_num_experts_per_tok 和 get_num_experts)。
# 这样 worker 端与 scheduler 端共享同一个规范数据源,避免命名差异导致的分歧。
def _get_routed_experts_shape(vllm_config: VllmConfig) -> tuple[int, int, int]:
    model_config = vllm_config.model_config
    num_layers = model_config.get_total_num_hidden_layers()
    num_experts = model_config.get_num_experts()
    # 该 accessor 直接返回 model_arch_config.num_experts_per_token,
    # 由 convertor 在模型配置加载时统一解析所有别名(如 top_k_experts)。
    num_experts_per_tok = model_config.get_num_experts_per_tok()
    if num_layers <= 0 or num_experts <= 0 or num_experts_per_tok <= 0:
        raise ValueError(
            "Routed-experts capture requires positive layer, expert, and "
            "experts-per-token counts, got "
            f"{num_layers=}, {num_experts=}, {num_experts_per_tok=}."
        )
    return num_layers, num_experts, num_experts_per_tok
​
​
class RoutedExpertsCapturer:
    def __init__(
        self,
        max_num_batched_tokens: int,
        vllm_config: VllmConfig,
        kv_cache_config: KVCacheConfig,
    ) -> None:
        # 只取 layers 与 per-token top-k;expert 总数由 manager 单独用于 dtype 选择。
        num_layers, _, num_experts_per_tok = _get_routed_experts_shape(vllm_config)
        logger.info(
            "RoutedExpertsCapturer: allocating buffer with "
            "max_tokens=%d, num_layers=%d, num_experts_per_tok=%d "
            "(hf_config.model_type=%s)",
            max_num_batched_tokens,
            num_layers,
            num_experts_per_tok,
            vllm_config.model_config.hf_text_config.model_type,
        )
        # 设备端 transit buffer 使用 int32,向上对齐 router 原生 topk_ids dtype,
        # 且 NCCL 对 uint8/uint16 支持不一,int32 通用性更好。
        self.device_buffer = torch.zeros(
            (max_num_batched_tokens, num_layers, num_experts_per_tok),
            dtype=torch.int32,
            device=current_platform.device_type,
        )
        self.dp_rank = vllm_config.parallel_config.data_parallel_rank
        self.tp_size = vllm_config.parallel_config.tensor_parallel_size
        self.attn_gid = get_routed_experts_attn_gid(kv_cache_config)
​
​
class RoutedExpertsManager:
    def __init__(
        self,
        vllm_config: VllmConfig,
        kv_cache_config: KVCacheConfig,
    ) -> None:
        self.attn_gid = get_routed_experts_attn_gid(kv_cache_config)
        attn_group = kv_cache_config.kv_cache_groups[self.attn_gid]
        self.block_size = attn_group.kv_cache_spec.block_size
​
        # 同样走统一的 shape 入口;manager 还需要 num_experts 来决定 slot buffer
        # 的窄 dtype(专家数 <= 256 用 uint8,否则 uint16),以控制多 GB 缓冲占用。
        num_layers, num_experts, num_experts_per_tok = _get_routed_experts_shape(
            vllm_config
        )
        max_num_slots = kv_cache_config.num_blocks * self.block_size
        expert_id_dtype = np.uint8 if num_experts <= 256 else np.uint16
        self.routed_experts_by_slot = np.zeros(
            (max_num_slots, num_layers, num_experts_per_tok),
            dtype=expert_id_dtype,
        )
        logger.info(
            "RoutedExpertsManager CPU buffer: %.2f GB "
            "(slots=%d, layers=%d, top_k=%d, dtype=%s)",
            self.routed_experts_by_slot.nbytes / 1e9,
            max_num_slots,
            num_layers,
            num_experts_per_tok,
            self.routed_experts_by_slot.dtype.name,
        )
vllm/transformers_utils/model_arch_config_convertor.py data-contract

新增 get_num_experts_per_token() 并把结果写入 ModelArchitectureConfig,是本次统一的单一事实来源。

# vllm/transformers_utils/model_arch_config_convertor.py
# 在模型架构配置加载阶段统一解析“每 token 路由专家数”的命名差异。
# 不同模型家族用不同字段:
# - DeepSeek 系列:num_experts_per_tok
# - Kimi K3:num_experts_per_token
# - Gemma 4:top_k_experts
# - DBRX 风格:moe_topk / moe_top_k
class ModelArchConfigConvertorBase:
    def get_num_experts_per_token(self) -> int:
        names = [
            "num_experts_per_tok",
            "num_experts_per_token",
            "top_k_experts",
            "moe_topk",
            "moe_top_k",
        ]
        # getattr_iter 依次尝试;最后 or 0 把 None 归一化为 0,
        # 保证 ModelArchitectureConfig 拿到确定的 int 值,
        # 后续 R3 校验(<= 0 时报错)能显式暴露配置缺失。
        return getattr_iter(self.hf_text_config, names, 0) or 0
​
    def convert(self, supports_multimodal: bool = True) -> ModelArchitectureConfig:
        model_arch_config = ModelArchitectureConfig(
            architectures=self.get_architectures(),
            model_type=self.hf_config.model_type,
            text_model_type=getattr(self.hf_text_config, "model_type", None),
            hidden_size=self.get_hidden_size(),
            total_num_hidden_layers=self.get_num_hidden_layers(),
            total_num_attention_heads=self.get_total_num_attention_heads(),
            head_size=self.get_head_size(),
            vocab_size=self.get_vocab_size(),
            total_num_kv_heads=self.get_total_num_kv_heads(),
            num_experts=self.get_num_experts(),
            # 新增规范字段,与 num_experts 并列成为模型拓扑元数据的一部分。
            num_experts_per_token=self.get_num_experts_per_token(),
            quantization_config=self.get_quantization_config(),
            is_deepseek_mla=self.is_deepseek_mla(),
            is_mm_prefix_lm=self.is_mm_prefix_lm(supports_multimodal),
            rswa_window=self.rswa_window(),
            derived_max_model_len_and_key=self.derive_max_model_len_and_key(),
        )
        return model_arch_config
tests/model_executor/test_routed_experts_capture.py test-coverage

新增 Gemma 4(top_k_experts)与 Kimi K3(num_experts_per_token)两个回归测试,直接验证 manager 初始化与 buffer shape。

# tests/model_executor/test_routed_experts_capture.py
# 构造一个瘦身的 ModelConfig 替身,复用真实的 convertor 解析逻辑,
# 让测试覆盖到“别名归一化 -> manager buffer shape”的完整链路。
def _make_model_config(hf_config):
    num_experts_per_token = ModelArchConfigConvertorBase(
        hf_config, hf_config
    ).get_num_experts_per_token()
    model_config = SimpleNamespace(
        hf_text_config=hf_config,
        model_arch_config=SimpleNamespace(
            num_experts_per_token=num_experts_per_token,
        ),
    )
    # 用真实 ModelConfig 的类方法绑定到替身上,保证行为一致。
    model_config.get_num_experts = lambda: hf_config.num_experts
    model_config.get_num_experts_per_tok = lambda: (
        ModelConfig.get_num_experts_per_tok(model_config)
    )
    model_config.get_total_num_hidden_layers = lambda: hf_config.num_hidden_layers
    return model_config
​
​
# Gemma 4 的配置没有 num_experts_per_tok,只有 top_k_experts;
# 回归用例确保 manager 初始化不再抛 AttributeError。
def test_routed_experts_manager_uses_gemma4_top_k_experts():
    hf_config = SimpleNamespace(
        num_experts=8,
        top_k_experts=2,
        num_hidden_layers=3,
    )
    vllm_config = SimpleNamespace(model_config=_make_model_config(hf_config))
    manager = RoutedExpertsManager(vllm_config, _make_kv_cache_config())
    # 期望 shape = (num_blocks * block_size, num_layers, top_k) = (8, 3, 2)
    assert manager.routed_experts_by_slot.shape == (8, 3, 2)
​
​
# Kimi K3 使用 num_experts_per_token 字段,覆盖该别名。
def test_routed_experts_manager_uses_kimi_k3_experts_per_token():
    hf_config = SimpleNamespace(
        num_experts=8,
        num_experts_per_token=2,
        num_hidden_layers=3,
    )
    vllm_config = SimpleNamespace(model_config=_make_model_config(hf_config))
    manager = RoutedExpertsManager(vllm_config, _make_kv_cache_config())
    assert manager.routed_experts_by_slot.shape == (8, 3, 2)

评论区精华

get_num_experts_per_tok 是否应下沉到 model_arch_config 设计

Isotr0py 在 `vllm/config/model.py` 的 `get_num_experts_per_tok` 实现上评论 "Should we move this to `model_arch_config`?",指出遍历 hf_config 别名的逻辑应放到 model arch 配置层,而不是 ModelConfig 方法内。

结论:作者接受建议("Nice catch!"),并将别名解析逻辑移到 `ModelArchConfigConvertorBase.get_num_experts_per_token()`,`ModelConfig.get_num_experts_per_tok()` 最终只读取 `model_arch_config.num_experts_per_token`。 · 已解决

风险与影响

  1. 行为变更风险get_num_experts_per_token() 对缺失属性返回 0 而非抛异常,R3 capturer 会因 num_experts_per_tok <= 0 触发 _get_routed_experts_shapeValueError,错误信息更明确但启动失败路径从 AttributeError 变为 ValueError;依赖旧行为的调用方(如测试中的 SimpleNamespace 构造)需同步适配。
  2. 兼容性风险ModelArchitectureConfig 新增必填字段 num_experts_per_token,任何直接构造该 dataclass 的代码(如测试 _make_model_config 需要通过 convertor 填充)若未提供该字段会构造失败;好在新增字段无默认值,编译器/类型检查可提前暴露。
  3. 别名覆盖不全风险:五种别名覆盖了 DeepSeek(num_experts_per_tok)、Kimi K3(num_experts_per_token)、Gemma 4(top_k_experts)、DBRX 系列(moe_topk/moe_top_k),但若未来模型使用其他命名(如 num_experts_per_head 之类)仍会漏掉;不过归一化为 0 后 R3 的校验会显式失败,不至于静默错误。
  4. 验证局限:PR body 中的运行时验证是在容器内手工拷贝文件完成的,且只覆盖了 Gemma 4 冒烟与 Qwen3.6-35B GSM8K,未覆盖 Kimi K3 真实模型启动;Kimi K3 的验证仅靠单元测试模拟配置。

影响范围集中在 R3 routed experts 捕获链路(worker 端 RoutedExpertsCapturer 与 scheduler 端 RoutedExpertsManager)以及 model arch 数据契约。用户侧:修复了 Gemma 4 与 Kimi K3 在 --enable-return-routed-experts 下的启动失败,MoE 模型路由追溯功能可覆盖更多模型家族。系统侧:ModelArchitectureConfig 新增字段影响所有模型配置路径,但 convertor 是统一入口,风险可控。团队侧:消除 R3 自定义 parser 是长期维护性改进,未来新 MoE 模型只需在 convertor 的别名列表扩展即可;perf.py 与 R3 共用同一 accessor,避免两处解析漂移。

字段契约必填新增 别名覆盖有限 启动失败路径改变 真实 Kimi K3 未验证

关联 Issue

#50460 [Bugfix][Model] Fix Gemma4 routed-expert manager initialization
#50874 [Bugfix][R3] Size monolithic routing replay buffer for DP

完整报告

参与讨论