Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions tests/model_executor/test_deep_gemm_warmup.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project

import sys
from types import SimpleNamespace

import pytest
Expand Down Expand Up @@ -88,3 +89,53 @@ def test_kernel_registers_itself_as_warmup_provider(is_bmm) -> None:

provider = getattr(layer, "deep_gemm_warmup_provider", None)
assert provider is (None if is_bmm else kernel)


def test_mega_moe_predicate_matches_only_native_mega_moe_layers(monkeypatch) -> None:
"""Without the MegaMoE model modules loaded, nothing matches and nothing is
imported; with them loaded, only DeepSeek-V4 and Kimi K3 MegaMoE layers match."""
name = "vllm.models.deepseek_v4.nvidia.model"
kimi_name = "vllm.models.kimi_k3.nvidia.model"
monkeypatch.delitem(sys.modules, name, raising=False)
monkeypatch.delitem(sys.modules, kimi_name, raising=False)
other = SimpleNamespace(use_native_mega_moe=True, use_mega_moe=True)
assert not deep_gemm_warmup._mega_moe_may_use_deep_gemm(other)
assert name not in sys.modules and kimi_name not in sys.modules

moe_cls = type("DeepseekV4MoE", (), {})
monkeypatch.setitem(sys.modules, name, SimpleNamespace(DeepseekV4MoE=moe_cls))
native, fused = moe_cls(), moe_cls()
native.use_native_mega_moe, fused.use_native_mega_moe = True, False
assert deep_gemm_warmup._mega_moe_may_use_deep_gemm(native)
assert not deep_gemm_warmup._mega_moe_may_use_deep_gemm(fused)
assert not deep_gemm_warmup._mega_moe_may_use_deep_gemm(other)

kimi_cls = type("KimiMoE", (), {})
monkeypatch.setitem(sys.modules, kimi_name, SimpleNamespace(KimiMoE=kimi_cls))
kimi_mega, kimi_fused = kimi_cls(), kimi_cls()
kimi_mega.use_mega_moe, kimi_fused.use_mega_moe = True, False
assert deep_gemm_warmup._mega_moe_may_use_deep_gemm(kimi_mega)
assert not deep_gemm_warmup._mega_moe_may_use_deep_gemm(kimi_fused)


def test_mega_moe_m_values_reach_mega_gate(monkeypatch) -> None:
"""Each bf16_mega_gate config is warmed with an M above the GateLinear
threshold (1 for 128 experts), so the forward actually reaches the kernel."""
dg = SimpleNamespace(
get_bf16_mega_gate_config=lambda m, *_: {"num_sms": -(-m // 16)},
get_block_m_for_mega_moe=lambda *_: 16,
)
monkeypatch.setattr(deep_gemm_warmup, "_import_deep_gemm", lambda: dg)
monkeypatch.setattr(deep_gemm_warmup, "_mega_moe_uses_mega_gate", lambda _: True)
monkeypatch.setattr(
deep_gemm_warmup, "get_ep_group", lambda: SimpleNamespace(world_size=8)
)
buffer = SimpleNamespace(num_max_tokens_per_rank=64)
module = SimpleNamespace(
gate=SimpleNamespace(input_size=4096, output_size=128),
experts=SimpleNamespace(
top_k=6, num_experts=128, get_symm_buffer=lambda: buffer
),
use_sequence_parallel=False,
)
assert deep_gemm_warmup._get_fp8_fp4_mega_moe_m_values(module, 40) == [16, 32, 40]
152 changes: 151 additions & 1 deletion vllm/model_executor/warmup/deep_gemm_warmup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,19 @@
be used during model execution beforehand.
"""

import sys
from typing import TYPE_CHECKING, TypeGuard

import torch
from tqdm import tqdm

import vllm.envs as envs
from vllm.distributed.parallel_state import get_dp_group, is_global_first_rank
from vllm.distributed.parallel_state import (
get_dp_group,
get_ep_group,
is_global_first_rank,
)
from vllm.forward_context import ForwardContext, override_forward_context
from vllm.model_executor.kernels.linear.scaled_mm.deep_gemm import (
DeepGemmFp8BlockScaledMMKernel,
)
Expand All @@ -23,6 +31,7 @@
)
from vllm.tracing import instrument
from vllm.utils.deep_gemm import (
_import_deep_gemm,
fp8_gemm_nt,
get_mk_alignment_for_contiguous_layout,
m_grouped_fp8_gemm_nt_contiguous,
Expand All @@ -32,6 +41,10 @@
from vllm.utils.platform_utils import num_compute_units
from vllm.v1.worker.workspace import current_workspace_manager

if TYPE_CHECKING:
from vllm.models.deepseek_v4.nvidia.model import DeepseekV4MoE
from vllm.models.kimi_k3.nvidia.model import KimiMoE


def _generate_optimal_warmup_m_values(
max_tokens: int, n: int, device: torch.device
Expand Down Expand Up @@ -144,6 +157,22 @@ def _fused_moe_grouped_gemm_may_use_deep_gemm(module: torch.nn.Module) -> bool:
return isinstance(fused_experts, (DeepGemmExperts, TritonOrDeepGemmExperts))


# Model construction already imports the model modules. Avoid importing them here.
def _mega_moe_uses_mega_gate(module: torch.nn.Module) -> "TypeGuard[DeepseekV4MoE]":
# DeepSeek-V4 routes with bf16_mega_gate, Kimi K3 with its GateLinear.
dsv4 = sys.modules.get("vllm.models.deepseek_v4.nvidia.model")
return dsv4 is not None and isinstance(module, dsv4.DeepseekV4MoE)


def _mega_moe_may_use_deep_gemm(
module: torch.nn.Module,
) -> "TypeGuard[DeepseekV4MoE | KimiMoE]":
if _mega_moe_uses_mega_gate(module):
return module.use_native_mega_moe
kimi = sys.modules.get("vllm.models.kimi_k3.nvidia.model")
return kimi is not None and isinstance(module, kimi.KimiMoE) and module.use_mega_moe


FP8_GEMM_NT_WARMUP_CACHE: set[torch.Size] = set()


Expand Down Expand Up @@ -305,6 +334,110 @@ def _warmup(w: torch.Tensor, w_scale: torch.Tensor):
GROUPED_FP8_GEMM_NT_CONTIGUOUS_WARMUP_CACHE.add(w.size())


FP8_FP4_MEGA_MOE_WARMUP_CACHE: set[tuple] = set()


def _get_fp8_fp4_mega_moe_warmup_key(module: "DeepseekV4MoE | KimiMoE") -> tuple:
gate = module.gate
return (
gate.output_size,
getattr(gate, "tid2eid", None) is not None,
gate.e_score_correction_bias is not None,
getattr(module, "image_sentinel_lo", 0),
module.experts.has_fused_shared_experts,
)


def _get_fp8_fp4_mega_moe_m_values(
module: "DeepseekV4MoE | KimiMoE", max_tokens: int
) -> list[int]:
"""Get the largest M per distinct fp8_fp4_mega_moe config and, where used,
bf16_mega_gate config."""
dg = _import_deep_gemm()
gate, experts = module.gate, module.experts
uses_mega_gate = _mega_moe_uses_mega_gate(module)
num_max_tokens_per_rank = experts.get_symm_buffer().num_max_tokens_per_rank
if module.use_sequence_parallel:
max_tokens = cdiv(max_tokens, module.tp_size)

m_values: dict[tuple, int] = {}
for num_tokens in range(1, max_tokens + 1):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My agent flagged a potential issue. Could you double-check whether it’s actually valid? It seems plausible to me, which is exactly why I’m not a fan of these token-based warmups... :))

DeepSeek-V4 only calls bf16_mega_gate above a hardcoded threshold, at model.py:1043-1044:

gate_threshold = 1 if self.gate.weight.shape[0] == 128 else 16
if hidden_states.shape[0] <= gate_threshold:
    router_logits, _ = self.gate(hidden_states)      # GateLinear + fused_topk_bias
    ...
else:
    ... bf16_mega_gate(...)

The warmup side. It picks the smallest token count for each config key, at deep_gemm_warmup.py:367-383:

for num_tokens in range(1, max_tokens + 1):
    gate_config = (dg.get_bf16_mega_gate_config(num_tokens, ...) if uses_mega_gate else {})
    block_m = dg.get_block_m_for_mega_moe(...)
    m_values.setdefault((tuple(gate_config.items()), block_m), num_tokens)

How the gap happens. Suppose a (gate_config, block_m) key spans token counts 1..N with N > 16. Its representative is then 1. The forward at deep_gemm_warmup.py:433 takes the GateLinear branch, so bf16_mega_gate is never compiled for that config. On DSv4-Flash with EP2 (256 experts, top-6), block_m=16 covers about 1–181 tokens. Any gate config that is constant across 16→17 would be missed. CUDA-graph capture probably compiles most of those sizes, so the leftover gap is eager sizes above the capture limit.

Fix. Make “mega-gate is actually reached” part of the key, and stop hardcoding the threshold in two places:

# model.py — expose it
def mega_gate_threshold(self) -> int:
    return 1 if self.gate.weight.shape[0] == 128 else 16

# deep_gemm_warmup.py
threshold = module.mega_gate_threshold() if uses_mega_gate else 0
for num_tokens in range(1, max_tokens + 1):
    uses_gate_kernel = uses_mega_gate and num_tokens > threshold
    gate_config = dg.get_bf16_mega_gate_config(...) if uses_gate_kernel else {}
    ...
    m_values.setdefault((uses_gate_kernel, tuple(gate_config.items()), block_m), num_tokens)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, verified: the mechanism is real. On current DeepGEMM the gate config changes exactly at 17 tokens, so the 256/384-expert models (threshold 16) don't miss anything, but a 128-expert gate (threshold 1, e.g. the DSpark drafter) misses the config used by sizes 2..16, because its representative is M=1 which takes the GateLinear branch.

Rather than exposing the threshold, ba3bd55 keeps the largest M per (gate_config, block_m) key. The forward condition is num_tokens > threshold, so any config the forward actually passes to bf16_mega_gate has its largest M above the threshold. That avoids duplicating the constant and adds no keys. I swept 288 configs (hidden 4096/7168, 128/256/384 experts, top_k 6/8, EP 1–8, SP) against the real heuristics on B300: largest-M misses 0 bf16_mega_gate/fp8_fp4_mega_moe kernels, while smallest-M missed a gate kernel in all 96 128-expert configs. Added test_mega_moe_m_values_reach_mega_gate for it. Agreed that CUDA-graph capture covers most sizes otherwise; this closes the eager gap. Re-validated DSV4 with/without MTP: no MegaMoE JIT after warmup, GSM8K 0.985.

This reply was generated with Claude and reviewed by a human.

gate_config = (
dg.get_bf16_mega_gate_config(
num_tokens, gate.input_size, gate.output_size, experts.top_k
)
if uses_mega_gate
else {}
)
block_m = dg.get_block_m_for_mega_moe(
get_ep_group().world_size,
experts.num_experts,
num_max_tokens_per_rank,
num_tokens,
experts.top_k,
"fp8xfp4",
)
# Keep the largest M: DeepSeek-V4 only calls bf16_mega_gate above a
# small token threshold, and a config's largest M is always above it.
m_values[(tuple(gate_config.items()), block_m)] = num_tokens
return sorted(m_values.values())


def _deepgemm_fp8_fp4_mega_moe_warmup(
module: "DeepseekV4MoE | KimiMoE",
max_tokens: int,
pbar: tqdm | None = None,
):
from vllm.models.deepseek_v4.nvidia.model import (
prepare_mega_gate_routing_metadata,
)

key = _get_fp8_fp4_mega_moe_warmup_key(module)
if key in FP8_FP4_MEGA_MOE_WARMUP_CACHE:
return

m_values = _get_fp8_fp4_mega_moe_m_values(module, max_tokens)
max_m = m_values[-1] # sorted

device = module.experts.get_symm_buffer().x.device
input_ids = torch.zeros(max_m, device=device, dtype=torch.int64)
hidden_states = torch.zeros(
(max_m, module.gate.input_size), device=device, dtype=torch.bfloat16
)
is_padding = torch.zeros(max_m + 1, device=device, dtype=torch.bool)
# With padding skip on, sequence parallel gives ranks > 0 a padding mask
# slice at any alignment.
sp_masked = module.use_sequence_parallel and envs.VLLM_MOE_SKIP_PADDING
offsets = (0, 1) if sp_masked else (0,)

# Run the whole layer so the gate sees the same optional inputs as serving.
for num_tokens in m_values:
inputs: tuple = (hidden_states[:num_tokens],)
if _mega_moe_uses_mega_gate(module):
ids = input_ids[:num_tokens]
inputs += (
ids,
prepare_mega_gate_routing_metadata(
ids,
has_hash_routing=module.gate.tid2eid is not None,
image_sentinel_base_id=module.image_sentinel_lo or None,
),
)
for offset in offsets:
forward_context = ForwardContext(
no_compile_layers={},
attn_metadata={},
slot_mapping={},
is_padding=is_padding[offset : offset + num_tokens],
)
with override_forward_context(forward_context):
module(*inputs)
if pbar is not None:
pbar.update(1)

FP8_FP4_MEGA_MOE_WARMUP_CACHE.add(key)


def deepgemm_fp8_gemm_nt_warmup(
model: torch.nn.Module, max_tokens: int, pbar: tqdm | None = None
):
Expand All @@ -330,11 +463,21 @@ def deepgemm_grouped_fp8_gemm_nt_contiguous_warmup(
)


@torch.inference_mode()
def deepgemm_fp8_fp4_mega_moe_warmup(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warp with torch.inference_mode()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in ba3bd55. deepgemm_fp8_fp4_mega_moe_warmup is now decorated with @torch.inference_mode(), like the other warmups that run a full module forward. Nothing up the call chain enables it, and on Kimi K3 the routed-expert RMSNorm weight requires grad, so the warmup was building autograd graphs that serving never does.

This reply was generated with Claude and reviewed by a human.

model: torch.nn.Module, max_tokens: int, pbar: tqdm | None = None
):
for m in model.modules():
if _mega_moe_may_use_deep_gemm(m):
_deepgemm_fp8_fp4_mega_moe_warmup(m, max_tokens, pbar=pbar)


def _count_warmup_iterations(model: torch.nn.Module, max_tokens: int) -> int:
seen_fp8_sizes: set[torch.Size] = set(FP8_GEMM_NT_WARMUP_CACHE)
seen_grouped_sizes: set[torch.Size] = set(
GROUPED_FP8_GEMM_NT_CONTIGUOUS_WARMUP_CACHE
)
seen_mega_moe_keys: set[tuple] = set(FP8_FP4_MEGA_MOE_WARMUP_CACHE)

total = 0
for m in model.modules():
Expand All @@ -355,6 +498,11 @@ def _count_warmup_iterations(model: torch.nn.Module, max_tokens: int) -> int:
if w2.size() not in seen_grouped_sizes:
total += n_values
seen_grouped_sizes.add(w2.size())
elif _mega_moe_may_use_deep_gemm(m):
key = _get_fp8_fp4_mega_moe_warmup_key(m)
if key not in seen_mega_moe_keys:
total += len(_get_fp8_fp4_mega_moe_m_values(m, max_tokens))

@LopezCastroRoberto LopezCastroRoberto Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think it's cheap, but it seems like the enumeration runs twice per key? _get_fp8_fp4_mega_moe_m_values runs once during counting (here) and again during the warmup itself. A single result could be stored and reused, if it is worth it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, it runs twice (once for the progress-bar total, once for the warmup). I measured ~41 ms per key at 8192 tokens on B300 (~5 µs/token), next to seconds of JIT per kernel. _count_warmup_iterations on main already recomputes the fp8_gemm_nt and grouped GEMM lists the same way, so I kept this path consistent rather than adding a cache for it alone. Happy to memoize all three in a follow-up if you'd like.

This reply was generated with Claude and reviewed by a human.

seen_mega_moe_keys.add(key)
return total


Expand All @@ -369,6 +517,8 @@ def deep_gemm_warmup(model: torch.nn.Module, max_tokens: int):
with tqdm(total=total, desc="DeepGEMM warmup") as pbar:
deepgemm_fp8_gemm_nt_warmup(model, max_tokens, pbar)
deepgemm_grouped_fp8_gemm_nt_contiguous_warmup(model, max_tokens, pbar)
deepgemm_fp8_fp4_mega_moe_warmup(model, max_tokens, pbar)
else:
deepgemm_fp8_gemm_nt_warmup(model, max_tokens, None)
deepgemm_grouped_fp8_gemm_nt_contiguous_warmup(model, max_tokens, None)
deepgemm_fp8_fp4_mega_moe_warmup(model, max_tokens, None)
1 change: 1 addition & 0 deletions vllm/models/kimi_k3/nvidia/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@ def __init__(
)
self.latent_moe_use_norm = config.latent_moe_use_norm
self.tp_size = get_tensor_model_parallel_world_size()
self.use_sequence_parallel = use_sequence_parallel
self.routed_scaling_factor = config.routed_scaling_factor
self.moe_renormalize = moe_renormalize
self.use_grouped_topk = config.use_grouped_topk
Expand Down
Loading