Skip to content

Commit f87f092

Browse files
Support routing replay with asynchronous scheduling (#7310)
Signed-off-by: Lawrence McAfee <lmcafee@nvidia.com>
1 parent 6bfec46 commit f87f092

26 files changed

Lines changed: 4180 additions & 370 deletions

File tree

‎examples/inference/README.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ Inference can collect traces two ways.
117117

118118
| Path | Enable with | Captures | CUDA graphs |
119119
|------|-------------|----------|------------|
120-
| **Sink** | `--moe-enable-routing-replay` + legacy scheduling | top-K indices only | on |
120+
| **Sink** | `--moe-enable-routing-replay` | top-K indices only | on |
121121
| **Hook** | no replay + `--cuda-graph-impl none` | indices **+ hidden states + router weights** | must be off |
122122

123123
Only the hook path captures the hidden states and router weights that
@@ -140,14 +140,14 @@ Forward hooks do not fire during CUDA graph replay. MoE cudagraphs must be disab
140140

141141
**Inference — sink** (routing indices only, graphs on):
142142

143-
Routing replay requires legacy scheduling. Because async scheduling is enabled by
144-
default, explicitly select legacy mode when enabling the routing replay sink.
143+
The routing replay sink supports both legacy and async dynamic-batching schedules.
144+
The example below explicitly selects async scheduling.
145145

146146
```bash
147147
--moe-routing-trace-path /path/to/trace_dir
148148
--moe-routing-trace-max-inference-steps 200
149149
--moe-enable-routing-replay
150-
--inference-dynamic-batching-async-sched-mode legacy
150+
--inference-dynamic-batching-async-sched-mode async
151151
```
152152

153153
**Inference — hook** (adds hidden states + weights for predictability):

‎examples/inference/advanced/gpt_dynamic_inference_with_coordinator.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,16 @@ async def main(
9494
num_requests_added = 0
9595
next_suspend_at = args.suspend_resume_interval or 0
9696
cycles_done = 0
97+
batch_boundaries = (
98+
{int(boundary) for boundary in args.batch_boundaries.split(",")}
99+
if args.drain_between_batches and args.batch_boundaries
100+
else set()
101+
)
102+
103+
async def wait_for_batch_boundary():
104+
"""Drain submitted requests before starting the next configured batch."""
105+
if num_requests_added in batch_boundaries and futures:
106+
await asyncio.gather(*futures)
97107

98108
while True:
99109
current_time = time.time_ns() / 10**9
@@ -103,6 +113,7 @@ async def main(
103113
num_requests_added < num_requests_total
104114
and requests[num_requests_added].time_arrival <= current_time
105115
):
116+
await wait_for_batch_boundary()
106117
request = requests[num_requests_added]
107118
# These add-request calls will queue up the request on a zmq socket and return
108119
# instantaneously. They will return an asyncio future which can be awaited for
@@ -123,6 +134,7 @@ async def main(
123134
for i in range(
124135
min(args.incoming_requests_per_step, num_requests_total - num_requests_added)
125136
):
137+
await wait_for_batch_boundary()
126138
# Change sampling parameters to force different generation lengths.
127139
request = requests[num_requests_added]
128140
n = request.sampling_params.num_tokens_to_generate

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
InferenceBatchDimensions,
2020
)
2121
from megatron.core.inference.config import (
22+
AsyncScheduleMode,
2223
InferenceConfig,
2324
KVCacheManagementMode,
2425
PrefixCachingEvictionPolicy,
@@ -2731,7 +2732,10 @@ def initialize_attention_state(
27312732
)
27322733

27332734
if self.moe_enable_routing_replay:
2734-
if self.using_cuda_graph_this_step():
2735+
if (
2736+
self.config.async_sched_mode == AsyncScheduleMode.ASYNC
2737+
or self.using_cuda_graph_this_step()
2738+
):
27352739
self.moe_routing_metadata.enable_static_buffer_recording()
27362740
else:
27372741
self.moe_routing_metadata.disable_static_buffer_recording()

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

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -641,42 +641,39 @@ def evict_lru_blocks(self, num_blocks_needed: int) -> bool:
641641
# Per-block routing storage methods (for MoE routing replay)
642642
# =========================================================================
643643

644-
def store_routing_per_block(self, flat_routing: Optional[np.ndarray]) -> None:
644+
def store_routing_per_block(
645+
self, flat_routing: np.ndarray, block_ids: np.ndarray, positions: np.ndarray
646+
) -> None:
645647
"""Scatter flat routing indices into per-block storage.
646648
647-
Uses the context's token-to-block mapping to distribute each token's
648-
routing data into the appropriate block. Matched (prefix-cached) blocks
649-
already have routing from the original request and are not overwritten
650-
here since their tokens are not in the active token layout.
649+
Matched prefix-cache blocks already contain routing from the original
650+
request and are absent from this forward's token layout.
651651
652652
Args:
653653
flat_routing: ndarray of shape [active_token_count, num_layers, topk]
654-
aligned with the context's active-token layout, or None.
654+
aligned with the supplied token layout.
655+
block_ids: KV-block ID for each routing row.
656+
positions: Position within the KV block for each routing row.
655657
"""
656-
if flat_routing is None:
657-
return
658-
659-
context = self.context
660-
token_count = context.active_token_count
658+
token_count = flat_routing.shape[0]
661659
if token_count == 0:
662660
return
663661

664-
assert (
665-
flat_routing.shape[0] == token_count
666-
), f"Routing token count {flat_routing.shape[0]} != active token count {token_count}"
667-
668-
# Token-to-block mapping for all active tokens
669-
block_ids_np = context.token_to_block_idx[:token_count].cpu().numpy()
670-
positions_np = context.token_to_local_position_within_kv_block[:token_count].cpu().numpy()
662+
assert block_ids.shape == (
663+
token_count,
664+
), f"Routing block IDs shape {block_ids.shape} != ({token_count},)"
665+
assert positions.shape == (
666+
token_count,
667+
), f"Routing positions shape {positions.shape} != ({token_count},)"
671668

672669
dummy = self.dummy_block_idx
673670

674671
# Group tokens by block_id using sort for efficient scatter
675672
unique_blocks, inverse, counts = np.unique(
676-
block_ids_np, return_inverse=True, return_counts=True
673+
block_ids, return_inverse=True, return_counts=True
677674
)
678675
sorted_indices = np.argsort(inverse, kind='stable')
679-
sorted_positions = positions_np[sorted_indices]
676+
sorted_positions = positions[sorted_indices]
680677
sorted_routing = flat_routing[sorted_indices]
681678

682679
offset = 0
@@ -707,19 +704,20 @@ def reconstruct_routing_from_blocks(
707704
708705
Returns:
709706
ndarray [total_routing_tokens, num_layers, topk] or None if any
710-
block is missing routing data.
707+
required block is missing routing data.
711708
"""
712709
block_size = self.context.block_size_tokens
713710
routing_parts = []
714711
tokens_collected = 0
715712

716713
for bid in block_ids:
717-
routing = self.get_block_routing(bid)
718-
if routing is None:
719-
return None # Missing routing data for this block
714+
# Speculative preparation can add a trailing block the result does not need.
720715
remaining = total_routing_tokens - tokens_collected
721716
if remaining <= 0:
722717
break
718+
routing = self.get_block_routing(bid)
719+
if routing is None:
720+
return None # Missing routing data for this block
723721
take = min(block_size, remaining)
724722
routing_parts.append(routing[:take])
725723
tokens_collected += take

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

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22

3-
from typing import TYPE_CHECKING, Optional
3+
from typing import TYPE_CHECKING, Optional, cast
44

55
import torch
66

7+
from megatron.core.inference.config import AsyncScheduleMode
8+
79
if TYPE_CHECKING:
810
from megatron.core.inference.contexts.dynamic_context import DynamicInferenceContext
911

@@ -23,6 +25,12 @@ class RoutingMetadata:
2325
"""
2426

2527
def __init__(self, context: 'DynamicInferenceContext', moe_router_topk: int):
28+
"""Initialize lazy routing storage.
29+
30+
Args:
31+
context (DynamicInferenceContext): Context owning the recorded token layout.
32+
moe_router_topk (int): Number of selected experts per token.
33+
"""
2634
self.context = context
2735
self.max_tokens = context.max_tokens
2836
self.moe_router_topk = moe_router_topk
@@ -31,6 +39,8 @@ def __init__(self, context: 'DynamicInferenceContext', moe_router_topk: int):
3139
# Static buffer allocated lazily in _ensure_buffer_allocated().
3240
# We defer allocation because RouterReplay instances don't exist yet at init time.
3341
self.routing_indices_buffer: Optional[torch.Tensor] = None
42+
self.buffer_index_cuda: Optional[torch.Tensor] = None
43+
self.layer_indices_cuda: Optional[list[torch.Tensor]] = None
3444
self.num_moe_layers: Optional[int] = None
3545

3646
def _ensure_buffer_allocated(self) -> None:
@@ -41,29 +51,40 @@ def _ensure_buffer_allocated(self) -> None:
4151
if self.routing_indices_buffer is not None:
4252
return
4353

44-
self.num_moe_layers = len(RouterReplay.global_router_replay_instances)
54+
self.num_moe_layers = len(RouterReplay.get_instances(is_mtp_layer=False))
4555

4656
if self.num_moe_layers == 0:
4757
return
4858

49-
# Static buffer for CUDA graph compatibility.
50-
# Shape: [max_tokens, num_moe_layers, moe_router_topk]
51-
self.routing_indices_buffer = torch.empty(
52-
(self.max_tokens, self.num_moe_layers, self.moe_router_topk),
53-
dtype=torch.int32,
54-
device=self.device,
55-
)
56-
57-
def get_routing_indices(self) -> Optional[torch.Tensor]:
59+
shape = (self.max_tokens, self.num_moe_layers, self.moe_router_topk)
60+
if self.context.config.async_sched_mode == AsyncScheduleMode.ASYNC:
61+
shape = (2, *shape)
62+
self.buffer_index_cuda = torch.zeros(1, dtype=torch.int64, device=self.device)
63+
# Separate scalars keep every compiled input aligned, unlike slices of arange.
64+
self.layer_indices_cuda = [
65+
torch.tensor([i], dtype=torch.int64, device=self.device)
66+
for i in range(self.num_moe_layers)
67+
]
68+
self.routing_indices_buffer = torch.empty(shape, dtype=torch.int32, device=self.device)
69+
70+
def get_routing_indices(self, buffer_index: int | None = None) -> Optional[torch.Tensor]:
5871
"""Get the recorded routing indices.
5972
60-
Automatically uses the static buffer when CUDA graphs are active,
61-
otherwise retrieves from RouterReplay utility.
73+
Async scheduling reads the selected bank in both eager and graphed execution.
74+
Legacy execution reads static graph storage or stacks eager router outputs.
75+
76+
Args:
77+
buffer_index (int | None): Bank written by the async base forward, or
78+
``None`` for legacy recording.
6279
6380
Returns:
6481
Tensor of shape [num_tokens, num_moe_layers, topk] or None if not available.
6582
"""
66-
if self.context.using_cuda_graph_this_step():
83+
if buffer_index is not None:
84+
if self.routing_indices_buffer is None:
85+
return None
86+
return self.routing_indices_buffer[buffer_index, : self.context.active_token_count]
87+
elif self.context.using_cuda_graph_this_step():
6788
# Return view of static buffer up to current token count.
6889
if self.routing_indices_buffer is None:
6990
return None
@@ -72,13 +93,13 @@ def get_routing_indices(self) -> Optional[torch.Tensor]:
7293
return self.routing_indices_buffer[: self.context.active_token_count]
7394
else:
7495
# Get from RouterReplay and stack into [num_tokens, num_layers, topk].
75-
recorded_data = RouterReplay.get_recorded_data()
96+
recorded_data = RouterReplay.get_recorded_data(is_mtp_layer=False)
7697
if recorded_data is None or len(recorded_data) == 0:
7798
return None
7899
if recorded_data[0] is None:
79100
return None
80101
# Stack: list of [num_tokens, topk] -> [num_tokens, num_layers, topk]
81-
return torch.stack(recorded_data, dim=1)
102+
return torch.stack(cast(list[torch.Tensor], recorded_data), dim=1)
82103

83104
def enable_static_buffer_recording(self) -> None:
84105
"""Enable recording into the static buffer for CUDA graph compatibility.
@@ -89,8 +110,13 @@ def enable_static_buffer_recording(self) -> None:
89110
"""
90111
self._ensure_buffer_allocated()
91112
if self.routing_indices_buffer is not None:
92-
RouterReplay.set_global_static_buffers(self.routing_indices_buffer)
113+
RouterReplay.set_global_static_buffers(
114+
self.routing_indices_buffer,
115+
is_mtp_layer=False,
116+
buffer_index=self.buffer_index_cuda,
117+
layer_indices=self.layer_indices_cuda,
118+
)
93119

94120
def disable_static_buffer_recording(self) -> None:
95121
"""Disable static buffer recording, reverting to normal tensor assignment."""
96-
RouterReplay.clear_global_static_buffers()
122+
RouterReplay.clear_global_static_buffers(is_mtp_layer=False)

0 commit comments

Comments
 (0)