Skip to content

Commit 4664f94

Browse files
committed
Add batch-invariant MoE and Mamba inference
1 parent e9a3184 commit 4664f94

28 files changed

Lines changed: 3333 additions & 86 deletions

‎megatron/core/inference/communication/torch_symm_triton/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@
77
multimem_all_gather_v,
88
multimem_all_gatherv_3tensor,
99
multimem_reduce_scatter_v,
10+
ordered_reduce_scatter_v,
1011
)

‎megatron/core/inference/communication/torch_symm_triton/variable_collectives.py‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,125 @@ def multimem_reduce_scatter_v(
354354
return output_tensor
355355

356356

357+
@triton.jit
358+
def _ordered_reduce_scatter_v_kernel(
359+
local_ptr,
360+
buffer_ptrs_dev,
361+
signal_pad_ptrs,
362+
local_tokens,
363+
rank_token_offset_ptr,
364+
ep_max_tokens_ptr,
365+
input_byte_offset,
366+
HIDDEN_SIZE: tl.constexpr,
367+
BLOCK_SIZE: tl.constexpr,
368+
RANK: tl.constexpr,
369+
WORLD_SIZE: tl.constexpr,
370+
):
371+
"""Variable-count reduce-scatter with explicit rank-order fp32 addition.
372+
373+
This is intentionally not a multimem.ld_reduce kernel. Each rank reads the
374+
same token row from every peer symmetric buffer in rank order and accumulates
375+
in fp32, which gives batch-invariant MoE a defined cross-rank reduction tree.
376+
"""
377+
pid = tl.program_id(axis=0)
378+
379+
ep_max_tokens = tl.load(ep_max_tokens_ptr)
380+
if pid >= ep_max_tokens:
381+
return
382+
383+
symm_mem_sync(
384+
signal_pad_ptrs,
385+
None,
386+
RANK,
387+
WORLD_SIZE,
388+
hasPreviousMemAccess=False,
389+
hasSubsequentMemAccess=True,
390+
)
391+
sync_threads()
392+
393+
tid = tl.arange(0, BLOCK_SIZE)
394+
rank_token_offset = tl.load(rank_token_offset_ptr)
395+
buffer_ptrs = buffer_ptrs_dev.to(tl.pointer_type(tl.uint64))
396+
397+
for token_offset in range(pid, local_tokens, tl.num_programs(axis=0)):
398+
global_token = rank_token_offset + token_offset
399+
400+
for channel_offset in range(0, HIDDEN_SIZE, BLOCK_SIZE):
401+
offsets = channel_offset + tid
402+
mask = offsets < HIDDEN_SIZE
403+
acc = tl.zeros([BLOCK_SIZE], dtype=tl.float32)
404+
405+
for src_rank in tl.range(0, WORLD_SIZE):
406+
peer_base = tl.load(buffer_ptrs + src_rank).to(tl.pointer_type(tl.uint8))
407+
peer_ptr = (peer_base + input_byte_offset).to(tl.pointer_type(tl.float32))
408+
vals = tl.load(peer_ptr + global_token * HIDDEN_SIZE + offsets, mask=mask, other=0.0)
409+
acc += vals
410+
411+
tl.store(local_ptr + token_offset * HIDDEN_SIZE + offsets, acc, mask=mask)
412+
413+
414+
def ordered_reduce_scatter_v(
415+
output_tensor: torch.Tensor,
416+
input_tensor: torch.Tensor,
417+
symm_mem_hdl: _SymmetricMemory,
418+
rank_token_offset: torch.Tensor,
419+
ep_max_tokens: torch.Tensor,
420+
per_rank_max_tokens: int,
421+
input_byte_offset: int = 0,
422+
**kwargs,
423+
) -> torch.Tensor:
424+
"""Variable-count reduce-scatter with fixed rank-order fp32 accumulation.
425+
426+
This is the batch-invariant alternative to multimem_reduce_scatter_v. It
427+
uses symmetric memory for peer visibility, but performs no hardware FP
428+
reduction; each output token is accumulated by explicitly loading peers in
429+
rank order.
430+
"""
431+
assert HAVE_TRITON, "Triton is required for ordered_reduce_scatter_v."
432+
assert (
433+
output_tensor.ndim == 2 and input_tensor.ndim == 2
434+
), "output_tensor and input_tensor must be 2-D [tokens, hidden_size]."
435+
assert is_device_nvls_capable(
436+
output_tensor.device
437+
), "ordered_reduce_scatter_v requires a Hopper+ GPU with NVLink (SM >= 9)."
438+
assert (
439+
output_tensor.dtype == input_tensor.dtype == torch.float32
440+
), "ordered_reduce_scatter_v requires fp32 input and output tensors."
441+
assert (
442+
rank_token_offset.numel() == 1
443+
and rank_token_offset.dtype == torch.int32
444+
and rank_token_offset.is_cuda
445+
), "rank_token_offset must be a scalar int32 CUDA tensor."
446+
447+
hidden_size = output_tensor.shape[1]
448+
assert (
449+
input_tensor.shape[1] == hidden_size
450+
), f"input and output hidden_size mismatch: {input_tensor.shape[1]} vs {hidden_size}"
451+
452+
MAX_NUM_BLOCKS = kwargs.get("max_num_blocks", 128)
453+
MAX_BLOCK_SIZE = 1024
454+
WARP_SIZE = 32
455+
block_size = min(triton.next_power_of_2(hidden_size), MAX_BLOCK_SIZE)
456+
num_warps = max(1, block_size // WARP_SIZE)
457+
num_blocks = min(per_rank_max_tokens, MAX_NUM_BLOCKS)
458+
459+
_ordered_reduce_scatter_v_kernel[(num_blocks, 1, 1)](
460+
output_tensor,
461+
symm_mem_hdl.buffer_ptrs_dev,
462+
symm_mem_hdl.signal_pad_ptrs_dev,
463+
local_tokens=output_tensor.shape[0],
464+
rank_token_offset_ptr=rank_token_offset,
465+
ep_max_tokens_ptr=ep_max_tokens,
466+
input_byte_offset=input_byte_offset,
467+
HIDDEN_SIZE=hidden_size,
468+
BLOCK_SIZE=block_size,
469+
RANK=symm_mem_hdl.rank,
470+
WORLD_SIZE=symm_mem_hdl.world_size,
471+
num_warps=num_warps,
472+
)
473+
return output_tensor
474+
475+
357476
@triton.jit
358477
def _multimem_all_gatherv_3tensor_kernel(
359478
local_ptr_0,

‎megatron/core/inference/contexts/dynamic_context.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC
303303
else:
304304
self.num_attention_heads_per_partition = 1
305305

306+
self.batch_invariant_mode = model_config.batch_invariant_mode
306307
self.num_speculative_tokens = inference_config.num_speculative_tokens
307308
assert self.num_speculative_tokens < inference_config.block_size_tokens, (
308309
f"num_speculative_tokens ({self.num_speculative_tokens}) must be < "
@@ -344,6 +345,12 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC
344345
self.mamba_ssm_states_dtype = mamba_inference_state_config.ssm_states_dtype
345346
self.mamba_chunk_size = mamba_inference_state_config.mamba_chunk_size
346347

348+
if self.batch_invariant_mode:
349+
assert self.num_speculative_tokens == 0, (
350+
"batch_invariant_mode for Mamba dynamic inference only supports "
351+
"one-token decode; set num_speculative_tokens=0."
352+
)
353+
347354
# For hybrid models, the layer map converts the global layer index to the
348355
# corresponding attention layer index or Mamba layer index depending on the
349356
# layer type.
@@ -683,6 +690,13 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC
683690

684691
# Deal with chunked prefill
685692
self.enable_chunked_prefill = inference_config.enable_chunked_prefill
693+
if self.batch_invariant_mode and self.is_hybrid_model and self.enable_chunked_prefill:
694+
# A chunk plus its final token must fit in one step; otherwise a prompt
695+
# of that length can never advance without an invalid one-token tail.
696+
assert self.max_tokens > self.mamba_chunk_size, (
697+
"batch-invariant Mamba chunked prefill requires max_tokens > "
698+
f"mamba_chunk_size ({self.mamba_chunk_size})."
699+
)
686700

687701
# FlashInfer.
688702
if inference_config.use_flashinfer_fused_rope is True:

‎megatron/core/inference/engines/dynamic_engine.py‎

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1467,6 +1467,26 @@ def _find_mamba_match_count(self, req: DynamicInferenceRequest) -> int:
14671467
return i + 1
14681468
return 0
14691469

1470+
def _mamba_batch_invariant_prefill_chunk_length(
1471+
self, req: DynamicInferenceRequest, capacity: int
1472+
) -> int:
1473+
"""Raw prefill length that computes an aligned chunk within ``capacity``.
1474+
1475+
Non-final calls must start and end at Mamba chunk boundaries. The final
1476+
prompt call may be shorter because it seeds the decode replay tail.
1477+
"""
1478+
remaining = len(req.remaining_prompt_tokens)
1479+
if capacity >= remaining:
1480+
return remaining
1481+
1482+
chunk_size = self.context.mamba_chunk_size
1483+
computed_tokens = (capacity // chunk_size) * chunk_size
1484+
if remaining - computed_tokens == 1:
1485+
computed_tokens -= chunk_size
1486+
if computed_tokens <= 0:
1487+
return 0
1488+
return computed_tokens
1489+
14701490
def schedule_waiting_requests(self):
14711491
"""Tries to schedule any requests in the waiting pool."""
14721492
# Keep track of which requests get scheduled.
@@ -1571,6 +1591,9 @@ def schedule_chunked_prefill(self):
15711591
# is_continuing_chunked_prefill is True if we are scheduling next
15721592
# chunk of a existing chunked prefill request
15731593
is_continuing_chunked_prefill = self.context.chunked_prefill_request_id >= 0
1594+
batch_invariant_mamba_prefill = (
1595+
self.context.batch_invariant_mode and self.context.is_hybrid_model
1596+
)
15741597

15751598
# Check for conflicting block hashes.
15761599
if prefix_caching_enabled and not is_continuing_chunked_prefill:
@@ -1630,29 +1653,47 @@ def schedule_chunked_prefill(self):
16301653
not in self.context.kv_block_allocator.kv_hash_to_block_id
16311654
):
16321655
pending_block_hashes.add(block_hash)
1633-
prefill_chunk_length = self.context.max_tokens - self.context.active_token_count
1656+
available_prefill_length = (
1657+
self.context.max_tokens - self.context.active_token_count
1658+
)
1659+
scheduled_prefill_length = available_prefill_length
1660+
1661+
if batch_invariant_mamba_prefill:
1662+
scheduled_prefill_length = self._mamba_batch_invariant_prefill_chunk_length(
1663+
req, available_prefill_length
1664+
)
1665+
# No valid non-final Mamba chunk fits in the remaining token budget.
1666+
if scheduled_prefill_length == 0:
1667+
can_schedule = False
1668+
break
16341669

16351670
# If this chunk would leave exactly 1 token for the final chunk, reduce
16361671
# this chunk by 1 or skip scheduling so the final chunk has 2 tokens.
16371672
# This avoids the edge case where max_seqlen_q=1 which results in a bug
16381673
# with the Flash Attention kernel.
16391674
# See https://github.com/Dao-AILab/flash-attention/issues/1537
1640-
if remaining_len - prefill_chunk_length == 1:
1641-
if prefill_chunk_length > 1:
1642-
prefill_chunk_length -= 1
1675+
# Batch-invariant Mamba handles this in its chunk-length helper.
1676+
if (
1677+
not batch_invariant_mamba_prefill
1678+
and remaining_len - scheduled_prefill_length == 1
1679+
):
1680+
if scheduled_prefill_length > 1:
1681+
scheduled_prefill_length -= 1
16431682
else:
16441683
# We only have space for 1 token, but remaining is 2.
16451684
# Delay scheduling to avoid leaving exactly 1 token for the final chunk.
16461685
can_schedule = False
16471686
break
16481687

1649-
self.context.add_request(req, prefill_chunk_length=prefill_chunk_length)
1688+
self.context.add_request(req, prefill_chunk_length=scheduled_prefill_length)
16501689
self._loop.call_soon_threadsafe(
16511690
self._loop.create_task, self._notify_cond_for_new_request()
16521691
)
16531692
self.context.chunked_prefill_request_id = req.request_id
1654-
req.remaining_prompt_tokens = req.remaining_prompt_tokens[prefill_chunk_length:]
1655-
req.finished_chunk_token_count += prefill_chunk_length
1693+
req.remaining_prompt_tokens = req.remaining_prompt_tokens[
1694+
scheduled_prefill_length:
1695+
]
1696+
req.finished_chunk_token_count += scheduled_prefill_length
16561697
# Still have tokens to prefill, so we break and keep the
16571698
# chunked prefill request at the head of the waiting queue
16581699
# Note that we do not need to continue check the queue, as the tokens are full
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
"""Batch-invariant inference MoE helpers."""
3+
4+
from typing import Optional
5+
from unittest.mock import MagicMock
6+
7+
import torch
8+
9+
from megatron.core.transformer.custom_layers.batch_invariant_kernels import (
10+
grouped_gemm_batch_invariant,
11+
grouped_gemm_batch_invariant_alignment,
12+
is_batch_invariant_mode_enabled,
13+
)
14+
from megatron.core.utils import null_decorator
15+
16+
try:
17+
import triton
18+
import triton.language as tl
19+
20+
HAVE_TRITON = True
21+
except ImportError:
22+
HAVE_TRITON = False
23+
24+
if not HAVE_TRITON:
25+
triton = MagicMock()
26+
triton.jit = null_decorator
27+
tl = MagicMock()
28+
29+
30+
def enabled() -> bool:
31+
"""Return whether global batch-invariant mode is active."""
32+
return is_batch_invariant_mode_enabled()
33+
34+
35+
def grouped_mm(x_bf16: torch.Tensor, weight: torch.Tensor, offs: torch.Tensor) -> torch.Tensor:
36+
"""Batch-invariant BF16 grouped GEMM used by inference fused MoE."""
37+
return grouped_gemm_batch_invariant(
38+
x_bf16,
39+
weight,
40+
offs=offs.to(torch.int32),
41+
m_total=x_bf16.shape[0],
42+
)
43+
44+
45+
def grouped_mm_alignment() -> int:
46+
"""Per-expert row alignment required by the batch-invariant grouped GEMM."""
47+
return grouped_gemm_batch_invariant_alignment()
48+
49+
50+
@triton.jit
51+
def _unpermute_tokens_in_expert_order_kernel(
52+
expert_out_ptr, # [output_size, hidden_dim] bf16 expert outputs
53+
probs_ptr, # [output_size] fp32 routing probabilities
54+
inverse_map_ptr, # [num_tokens, num_local_experts] permuted row or -1
55+
valid_tokens_ptr, # scalar int32 CUDA tensor: number of valid tokens
56+
output_ptr, # [num_tokens, hidden_dim] fp32 output buffer
57+
hidden_dim,
58+
num_local_experts: tl.constexpr,
59+
BLOCK_H: tl.constexpr,
60+
):
61+
"""Token-local batch-invariant unpermute.
62+
63+
Each program owns one output token and one hidden tile. Contributions are
64+
accumulated in fp32 by increasing local expert id, avoiding atomic-add order.
65+
"""
66+
tok = tl.program_id(0)
67+
block_h = tl.program_id(1)
68+
valid_tokens = tl.load(valid_tokens_ptr)
69+
offsets = block_h * BLOCK_H + tl.arange(0, BLOCK_H)
70+
mask_h = offsets < hidden_dim
71+
72+
acc = tl.zeros([BLOCK_H], dtype=tl.float32)
73+
if tok < valid_tokens:
74+
for lid in tl.range(0, num_local_experts):
75+
pos = tl.load(inverse_map_ptr + tok * num_local_experts + lid)
76+
if pos >= 0:
77+
prob = tl.load(probs_ptr + pos)
78+
vals = tl.load(expert_out_ptr + pos * hidden_dim + offsets, mask=mask_h).to(
79+
tl.float32
80+
)
81+
acc += vals * prob
82+
tl.store(output_ptr + tok * hidden_dim + offsets, acc, mask=mask_h)
83+
84+
85+
def unpermute_tokens_in_expert_order(
86+
expert_output: torch.Tensor,
87+
permuted_probs: torch.Tensor,
88+
inverse_map: torch.Tensor,
89+
valid_tokens: torch.Tensor,
90+
out: Optional[torch.Tensor],
91+
) -> torch.Tensor:
92+
"""Reduce local expert contributions token-by-token in fixed expert order."""
93+
_, hidden_dim = expert_output.shape
94+
num_tokens, num_local_experts = inverse_map.shape
95+
if out is None:
96+
out = torch.empty(
97+
num_tokens, hidden_dim, dtype=torch.float32, device=expert_output.device
98+
)
99+
100+
BLOCK_H = min(triton.next_power_of_2(hidden_dim), 1024)
101+
grid = (num_tokens, triton.cdiv(hidden_dim, BLOCK_H))
102+
_unpermute_tokens_in_expert_order_kernel[grid](
103+
expert_output,
104+
permuted_probs,
105+
inverse_map,
106+
valid_tokens,
107+
out,
108+
hidden_dim,
109+
num_local_experts,
110+
BLOCK_H=BLOCK_H,
111+
)
112+
return out

0 commit comments

Comments
 (0)