@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 8c79ad6580
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 3, 16:30 UTC

Revert "[Core] Replace routing replay with device cache and async D2H pipeline" (#39917) (#42434)

Signed-off-by: aoshen02 <aoshen@inferact.ai>

aoshen02 committed May 14, 2026 at 14:49 UTC 8c79ad65804d042dfaf7ce0e9fd28f503f9266a9
15 files changed +614 -1377
docs/training/routed_experts_replay.md deleted
-289
@@ -1,289 +0,0 @@
1 -# Routed Experts Replay
2 -
3 -## Overview
4 -
5 -Routed experts replay captures which MoE (Mixture of Experts) experts process each token during inference and returns this information alongside the generated text. This is essential for **reinforcement learning (RL) training pipelines** (such as GRPO and RLHF) where the training step needs to reconstruct expert routing decisions from the inference pass.
6 -
7 -When enabled, each API response includes:
8 -
9 -- **`prompt_routed_experts`**: A `[prompt_len, num_moe_layers, top_k]` array of expert IDs for the prompt tokens (at the response level, shared across completions).
10 -- **`routed_experts`**: A `[gen_len, num_moe_layers, top_k]` array of expert IDs for the generated tokens (per completion).
11 -
12 -For example, a model with 40 MoE layers and top-22 routing that processes a 100-token prompt and generates 50 tokens would return:
13 -
14 -- `prompt_routed_experts`: shape `[100, 40, 22]`
15 -- `routed_experts`: shape `[50, 40, 22]`
16 -
17 -Each value is an int16 expert ID in the range `[0, num_experts)`.
18 -
19 -## Quickstart
20 -
21 -### OpenAI API Server
22 -
23 -```bash
24 -vllm serve <MODEL> \
25 - --enable-return-routed-experts \
26 - --tensor-parallel-size 4 \
27 - --enable-expert-parallel
28 -```
29 -
30 -Then query the `/v1/completions` endpoint as usual. The response includes routing data:
31 -
32 -```python
33 -import requests
34 -
35 -resp = requests.post("http://localhost:8000/v1/completions", json={
36 - "model": "<MODEL>",
37 - "prompt": "Explain quantum computing.",
38 - "max_tokens": 64,
39 - "temperature": 0.0,
40 -}).json()
41 -
42 -# Generation routing (per completion choice)
43 -gen_routing = resp["choices"][0]["routed_experts"] # [gen_len, layers, top_k]
44 -
45 -# Prompt routing (shared across all choices)
46 -prompt_routing = resp["prompt_routed_experts"] # [prompt_len, layers, top_k]
47 -
48 -print(f"Prompt routing shape: [{len(prompt_routing)}, "
49 - f"{len(prompt_routing[0])}, {len(prompt_routing[0][0])}]")
50 -print(f"Gen routing shape: [{len(gen_routing)}, "
51 - f"{len(gen_routing[0])}, {len(gen_routing[0][0])}]")
52 -```
53 -
54 -### Python SDK (Offline Inference)
55 -
56 -```python
57 -from vllm import LLM, SamplingParams
58 -
59 -llm = LLM(
60 - model="<MODEL>",
61 - enable_return_routed_experts=True,
62 - tensor_parallel_size=4,
63 - enable_expert_parallel=True,
64 -)
65 -
66 -outputs = llm.generate(
67 - ["Explain quantum computing."],
68 - SamplingParams(temperature=0, max_tokens=64),
69 -)
70 -
71 -result = outputs[0]
72 -
73 -# Prompt routing: numpy array, shape [prompt_len, num_moe_layers, top_k]
74 -prompt_routing = result.prompt_routed_experts
75 -print(f"Prompt routing: {prompt_routing.shape}, dtype={prompt_routing.dtype}")
76 -
77 -# Generation routing: numpy array, shape [gen_len, num_moe_layers, top_k]
78 -gen_routing = result.outputs[0].routed_experts
79 -print(f"Gen routing: {gen_routing.shape}, dtype={gen_routing.dtype}")
80 -```
81 -
82 -## Output Format
83 -
84 -### `CompletionOutput.routed_experts`
85 -
86 -- **Type**: `numpy.ndarray` (Python SDK) or `list[list[list[int]]]` (JSON API)
87 -- **Shape**: `[gen_len, num_moe_layers, top_k]`
88 -- **Dtype**: `int16`
89 -- **Content**: Expert IDs for **generated tokens only**. `gen_len` matches the number of generated tokens (i.e., `usage.completion_tokens` or fewer).
90 -
91 -### `RequestOutput.prompt_routed_experts`
92 -
93 -- **Type**: `numpy.ndarray` (Python SDK) or `list[list[list[int]]]` (JSON API)
94 -- **Shape**: `[prompt_len, num_moe_layers, top_k]`
95 -- **Dtype**: `int16`
96 -- **Content**: Expert IDs for **prompt tokens only**. `prompt_len` matches `usage.prompt_tokens`. This field lives on the request-level response (not per-choice), because prompt routing is shared across all completions when `n > 1`.
97 -
98 -### Why Separate Prompt and Generation Routing?
99 -
100 -When a request has multiple completions (`n > 1`), each completion shares the same prompt but produces different generated text. Storing prompt routing once on the `RequestOutput` (rather than duplicating it on every `CompletionOutput`) avoids redundant data. For RL training, the consumer typically needs:
101 -
102 -1. The prompt routing (once) to reconstruct the forward pass for the shared prefix.
103 -2. The per-completion generation routing to reconstruct each completion's forward pass.
104 -
105 -## Architecture
106 -
107 -### Data Flow
108 -
109 -```text
110 -Forward Pass Async D2H Pipeline Output
111 -───────────── ────────────────── ──────
112 -FusedMoE layer After forward pass: On request finish:
113 -writes topk_ids ──────► D2H copy to pinned ──────► Extract from host cache
114 -to device buffer staging buffer Split at prompt_len
115 -(L, N, K) int16 (via CUDA stream) Trim gen to output len
116 - Scatter to per-request Serialize to API response
117 - host cache (numpy)
118 -```
119 -
120 -### Device Cache
121 -
122 -A pre-allocated GPU buffer with layout `(L, N, K)` where:
123 -
124 -- `L` = number of MoE layers
125 -- `N` = `max_num_batched_tokens`
126 -- `K` = `num_experts_per_tok` (top-k)
127 -
128 -The `(L, N, K)` layout ensures that `buffer[layer_id]` gives a contiguous `(N, K)` view per layer. Each `FusedMoE` layer gets a persistent reference to its slice via `module._routing_replay_out = buffer[layer_id]`.
129 -
130 -**Dtype**: `int16` — sufficient for expert IDs (max ~512 experts in practice) and half the memory of `int32`.
131 -
132 -### Host Cache
133 -
134 -Per-request numpy arrays for accumulating routing data across decode steps. Each request gets a lazily allocated `(seq_len, L, K)` int16 buffer that grows as the sequence lengthens. Buffers are freed when a request completes.
135 -
136 -### Async D2H Pipeline
137 -
138 -After each forward pass, the model runner issues a non-blocking device-to-host copy on a dedicated CUDA stream:
139 -
140 -1. **Copy**: `pinned_staging[:, :total_tokens, :].copy_(device_buffer[:, :total_tokens, :])` on a separate stream, recorded with a CUDA event.
141 -2. **Scatter** (deferred to next step): On the *next* forward pass, synchronize the event (effectively free — an entire forward pass has elapsed) and scatter the staging data into per-request host cache buffers using the token positions.
142 -
143 -This design ensures the D2H copy overlaps with the next forward pass, minimizing GPU stall time.
144 -
145 -### CUDA Graph Compatibility
146 -
147 -CUDA graph compatibility requires two mechanisms:
148 -
149 -1. **Persistent tensor attribute**: Each `FusedMoE` layer stores a reference to its buffer slice as `module._routing_replay_out`. Because `torch.compile` captures module attributes by reference, graph replay always writes to the live buffer — not a stale snapshot.
150 -
151 -2. **Static marking**: Both the full `(L, N, K)` buffer and each per-layer `(N, K)` view are marked with `cudagraph_mark_tensor_static()`. This prevents CUDA graphs from snapshot/restore behavior that would zero the buffer on replay.
152 -
153 -### Multi-Node Support
154 -
155 -On multi-node tensor-parallel setups, all TP ranks allocate a device buffer (required for symmetric CUDA graph structure), but only TP rank 0 runs the D2H pipeline and host cache. Routing data flows from the model runner through `ModelRunnerOutput` via Ray DAG to the scheduler — no shared memory or file locks needed.
156 -
157 -### Routing Capture Path
158 -
159 -For the **non-monolithic (Triton) kernel path** (e.g., BF16 MoE), routing is captured after `select_experts()` in the MoE runner:
160 -
161 -```python
162 -routing_replay_out = getattr(layer, "_routing_replay_out", None)
163 -topk_weights, topk_ids = self.router.select_experts(...)
164 -
165 -if routing_replay_out is not None:
166 - routing_replay_out[:topk_ids.shape[0]].copy_(topk_ids.to(torch.int16))
167 -```
168 -
169 -For the **monolithic kernel path** (e.g., FP8/MXFP8 via FlashInfer), `routing_replay_out` is threaded through the `apply_monolithic()` call chain and FlashInfer writes expert IDs directly during routing inside the fused kernel.
170 -
171 -### MTP (Multi-Token Prediction) Handling
172 -
173 -With MTP speculative decoding, the model captures routing for all tokens including speculative ones that may later be rejected. When a request finishes, the generation routing is trimmed to match the actual number of accepted output tokens:
174 -
175 -```python
176 -num_gen = self.detokenizer.num_output_tokens()
177 -if gen_routed_experts.shape[0] > num_gen and num_gen > 0:
178 - gen_routed_experts = gen_routed_experts[:num_gen]
179 -```
180 -
181 -This ensures the routing array length always matches the token IDs in the response.
182 -
183 -## Design Decisions
184 -
185 -### Why Replace SharedMemory with Device Cache?
186 -
187 -The previous implementation used `multiprocessing.SharedMemory` with `fcntl` file locking to transfer routing data from GPU workers to the scheduler. This approach had fundamental problems:
188 -
189 -- **Multi-node**: `SharedMemory` is node-local. On multi-node TP setups (required for 400B+ parameter models), the scheduler on node 0 cannot read shared memory from workers on other nodes.
190 -- **Performance**: Synchronous `.cpu().numpy()` D2H transfers block the GPU. File-based locking adds further overhead.
191 -- **CUDA graphs**: The callback-based capture mechanism bakes tensor references at trace time, causing stale data on graph replay.
192 -
193 -The device cache approach solves all three: data flows through Ray DAG (works multi-node), D2H is async (non-blocking), and persistent tensor attributes work with CUDA graphs.
194 -
195 -### Why `(L, N, K)` Layout Instead of `(N, L, K)`?
196 -
197 -FlashInfer's `routing_replay_out` parameter expects a contiguous `(N, K)` tensor per layer. With `(L, N, K)` layout, `buffer[layer_id]` gives a contiguous `(N, K)` view with zero-copy slicing. The previous `(N, L, K)` layout would require non-contiguous indexing or an explicit copy.
198 -
199 -### Why int16 Instead of int32?
200 -
201 -Expert IDs are small integers (typically 0-255 for models with up to 256 experts). `int16` supports up to 32,767 experts — far more than any current model — while halving GPU memory usage and D2H bandwidth compared to `int32`.
202 -
203 -### Why Split Prompt and Generation Routing?
204 -
205 -RL training pipelines process prompt and generation routing separately:
206 -
207 -- Prompt routing reconstructs the shared forward pass for the input.
208 -- Generation routing reconstructs each sampled trajectory.
209 -
210 -With `n > 1` completions, all completions share the same prompt routing. Duplicating it per completion would waste memory proportional to `n * prompt_len * L * K`. Instead, `prompt_routed_experts` is stored once on `RequestOutput` and shared.
211 -
212 -### Why Async D2H Instead of Synchronous Copy?
213 -
214 -A synchronous `.cpu()` call forces the GPU to drain its command queue before the copy can begin, stalling the pipeline. The async approach:
215 -
216 -1. Issues the copy on a separate CUDA stream (non-blocking to the main compute stream).
217 -2. Defers the host-side scatter to the *next* step, by which time the copy has finished.
218 -
219 -This means the D2H transfer overlaps entirely with the next forward pass, adding near-zero latency to the critical path.
220 -
221 -### Why All TP Ranks Get a Device Buffer?
222 -
223 -CUDA graph capture records the exact sequence of kernel calls and their arguments. If only rank 0 had a device buffer, the `FusedMoE` layer would take a different code path on rank 0 vs. other ranks (one writes to a buffer, others don't). This asymmetry causes different CUDA graph structures across ranks, which can lead to NCCL deadlocks during collective operations inside the graph. Giving all ranks a real buffer ensures symmetric graph structure. Only rank 0 does the D2H copy and host cache management.
224 -
225 -## Performance
226 -
227 -Routing replay adds a small overhead from the device buffer writes and async D2H copies. On tested configurations:
228 -
229 -- **Throughput overhead** (random data, ISL=1024, OSL=1024): **~2%**
230 -- **Memory overhead** (int16 buffer, 40 layers, 8192 tokens, top-22): **~14 MB per GPU**
231 -- **Accuracy impact** (GSM8K): **Zero** (pass@1 identical with and without routing replay)
232 -
233 -The overhead is dominated by the per-layer `.copy_()` during the forward pass. The async D2H pipeline runs entirely in the background.
234 -
235 -## Supported Configurations
236 -
237 -| Configuration | Supported |
238 -|------------------------------------------|-----------------------------------------------------------|
239 -| BF16 Triton MoE (non-monolithic) | Yes |
240 -| FP8/MXFP8 FlashInfer MoE (monolithic) | Yes (requires FlashInfer with `routing_replay_out`) |
241 -| CUDA graphs | Yes |
242 -| Multi-node tensor parallelism | Yes |
243 -| Data parallelism (DP) | Yes |
244 -| Expert parallelism (EP) | Yes |
245 -| Prefix caching | Yes (cached positions marked with `-1` sentinel) |
246 -| MTP speculative decoding | Yes (gen routing trimmed to accepted tokens) |
247 -| `n > 1` (multiple completions) | Yes (prompt routing shared, gen routing per-completion) |
248 -
249 -## Limitations
250 -
251 -- **Streaming**: Routing data is only available when the request finishes (not streamed incrementally).
252 -- **V1 engine only**: Routing replay is implemented for the vLLM V1 engine.
253 -- **Preempted requests**: When a request is preempted by the scheduler (and later resumed via re-prefill), any routing already accumulated in the worker's host cache for that request is dropped without being emitted. The consumer sees `routed_experts=None` for the resumed request with no other signal. Partial-rollout and async-RL pipelines that rely on routing for preempted requests should either disable preemption (`--no-enable-chunked-prefill` / sufficient KV headroom) or reconstruct routing on the resumed prefill.
254 -- **Async scheduling**: Not supported; rejected at config time. The worker-side stop predicate reads `req_state.output_token_ids[-1]`, which under async scheduling is the placeholder `-1` until `AsyncGPUModelRunnerOutput` resolves the real sampled token, so EOS / stop-token finishes would silently drop routing. Use sync scheduling (the default when `--enable-return-routed-experts` is set, or set explicitly with the appropriate scheduler config).
255 -- **Sequence parallelism / naive DP MoE dispatch**: Not supported on the FusedMoE layer; rejected at bind time. SP shards `topk_ids` along dim 0 across the TP group so each rank only captures `1/sp_size` of the rows; naive DP dispatch all-gathers tokens across DP ranks before routing, so `topk_ids.shape[0]` exceeds the per-rank buffer size. Both raise `NotImplementedError` from `bind_routing_capture_to_model`.
256 -- **Pipeline / prefill-context / decode-context parallelism**: Not yet validated; rejected at config time.
257 -
258 -## CLI Reference
259 -
260 -| Flag | Description |
261 -|------------------------------------|------------------------------------------------------------------------|
262 -| `--enable-return-routed-experts` | Enable routing replay capture and return expert IDs in API responses. |
263 -
264 -## API Reference
265 -
266 -### Completions (`/v1/completions`)
267 -
268 -**Response-level field:**
269 -
270 -| Field | Type | Description |
271 -|---------------------------|-------------------------------------|-----------------------------------------------------------------------------|
272 -| `prompt_routed_experts` | `list[list[list[int]]]` or `null` | Expert IDs for prompt tokens. Shape: `[prompt_len, num_moe_layers, top_k]`. |
273 -
274 -**Choice-level field:**
275 -
276 -| Field | Type | Description |
277 -|--------------------|-------------------------------------|-------------------------------------------------------------------------------|
278 -| `routed_experts` | `list[list[list[int]]]` or `null` | Expert IDs for generated tokens. Shape: `[gen_len, num_moe_layers, top_k]`. |
279 -
280 -### Chat Completions (`/v1/chat/completions`)
281 -
282 -Same fields as above on `ChatCompletionResponse` and `ChatCompletionResponseChoice`.
283 -
284 -### Python SDK
285 -
286 -| Object | Field | Type | Description |
287 -|----------------------|---------------------------|--------------------------|-----------------------------|
288 -| `RequestOutput` | `prompt_routed_experts` | `np.ndarray` or `None` | `[prompt_len, L, K]` i16 |
289 -| `CompletionOutput` | `routed_experts` | `np.ndarray` or `None` | `[gen_len, L, K]` int16 |
tests/model_executor/test_routed_experts_capture.py
+199 -117
@@ -1,162 +1,244 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +import types
4 +from types import SimpleNamespace
5 +from unittest.mock import patch
6
7 import pytest
8 import torch
9
10 +from vllm.distributed.eplb.eplb_state import EplbLayerState
11 +from vllm.model_executor.layers.fused_moe.config import RoutingMethodType
12 +from vllm.model_executor.layers.fused_moe.routed_experts_capturer import (
13 + RoutedExpertsCapturer,
14 +)
15 +from vllm.model_executor.layers.fused_moe.router.base_router import BaseRouter
16 +
17 pytestmark = pytest.mark.cpu_test
18
19 +_REC_MODULE = "vllm.model_executor.layers.fused_moe.routed_experts_capturer"
20
10 -def test_bind_routing_capture_to_model_sets_layer_view(monkeypatch):
11 - import vllm.model_executor.layers.fused_moe.layer as fused_moe_layer
12 - import vllm.model_executor.layers.fused_moe.routed_experts_capturer as rec_mod
21
14 - class _DummyMoEConfig:
15 - is_sequence_parallel = False
16 - dp_size = 1
22 +def _capturer_with_buffer(
23 + *,
24 + max_tokens: int = 8,
25 + num_layers: int = 4,
26 + num_experts_per_tok: int = 2,
27 + dp_rank: int = 0,
28 +) -> RoutedExpertsCapturer:
29 + c = RoutedExpertsCapturer()
30 + c.dp_rank = dp_rank
31 + c._device_buffer = torch.full(
32 + (max_tokens, num_layers, num_experts_per_tok),
33 + -1,
34 + dtype=torch.int32,
35 + )
36 + return c
37
18 - class _DummyQuantMethod:
19 - supports_internal_mk = True
38
21 - class DummyFusedMoE:
22 - _routing_replay_out: torch.Tensor
39 +class DummyRouter(BaseRouter):
40 + @property
41 + def routing_method_type(self) -> RoutingMethodType:
42 + return RoutingMethodType.FUSED_TOPK
43
24 - def __init__(self, moe_layer_id):
25 - self.moe_layer_id = moe_layer_id
26 - self.moe_config = _DummyMoEConfig()
27 - self.quant_method = _DummyQuantMethod()
44 + def _compute_routing(
45 + self, hidden_states, router_logits, indices_type, *, input_ids=None
46 + ):
47 + topk_ids = torch.tensor([[1, 2], [3, 4]], dtype=torch.int64)
48 + topk_weights = torch.ones_like(topk_ids, dtype=torch.float32)
49 + return topk_weights, topk_ids
50
29 - monkeypatch.setattr(fused_moe_layer, "FusedMoE", DummyFusedMoE)
51 + def _apply_eplb_mapping(self, topk_ids: torch.Tensor) -> torch.Tensor:
52 + # Make mapping observable without requiring CUDA EPLB path.
53 + return topk_ids + 10
54
31 - num_layers, num_tokens, top_k = 4, 8, 2
32 - buffer = torch.zeros((num_layers, num_tokens, top_k), dtype=torch.int16)
55
34 - class DummyDeviceCache:
35 - def __init__(self, buf):
36 - self.buffer = buf
56 +def _make_router(eplb_state: EplbLayerState | None = None) -> DummyRouter:
57 + return DummyRouter(
58 + top_k=2,
59 + global_num_experts=16,
60 + eplb_state=eplb_state,
61 + indices_type_getter=None,
62 + )
63
38 - class DummyCapturer:
39 - def get_device_cache(self):
40 - return DummyDeviceCache(buffer)
64
42 - monkeypatch.setattr(rec_mod, "get_global_experts_capturer", lambda: DummyCapturer())
65 +def test_base_router_capture_pre_eplb_mapping():
66 + router = _make_router()
67 + captured = []
68
44 - m0 = DummyFusedMoE(moe_layer_id=0)
45 - m2 = DummyFusedMoE(moe_layer_id=2)
69 + def capture_fn(ids):
70 + captured.append(ids.clone())
71
47 - class DummyModel:
48 - def modules(self):
49 - return iter([m0, m2])
72 + router.set_capture_fn(capture_fn)
73 + topk_weights, topk_ids = router.select_experts(
74 + hidden_states=torch.empty(1),
75 + router_logits=torch.empty(1),
76 + )
77
51 - rec_mod.bind_routing_capture_to_model(DummyModel())
78 + assert topk_weights.shape == topk_ids.shape
79 + assert len(captured) == 1
80 + assert torch.equal(captured[0], torch.tensor([[1, 2], [3, 4]]))
81 + assert torch.equal(topk_ids, torch.tensor([[11, 12], [13, 14]]))
82
53 - assert torch.equal(m0._routing_replay_out, buffer[0])
54 - assert torch.equal(m2._routing_replay_out, buffer[2])
83
84 +def test_base_router_capture_with_eplb_enabled():
85 + eplb_state = EplbLayerState()
86 + eplb_state.expert_load_view = torch.zeros(32, dtype=torch.int64)
87 + eplb_state.logical_to_physical_map = torch.arange(32).view(32, 1)
88 + eplb_state.logical_replica_count = torch.ones(32, dtype=torch.int64)
89 + eplb_state.should_record_tensor = torch.ones((), dtype=torch.bool)
90 + router = _make_router(eplb_state=eplb_state)
91
57 -def test_bind_routing_capture_to_model_noop_when_disabled(monkeypatch):
58 - import vllm.model_executor.layers.fused_moe.routed_experts_capturer as rec_mod
92 + captured = []
93
60 - class DummyCapturer:
61 - def get_device_cache(self):
62 - return None
94 + def capture_fn(ids):
95 + captured.append(ids.clone())
96
64 - monkeypatch.setattr(rec_mod, "get_global_experts_capturer", lambda: DummyCapturer())
97 + router.set_capture_fn(capture_fn)
98 + _, topk_ids = router.select_experts(
99 + hidden_states=torch.empty(1),
100 + router_logits=torch.empty(1),
101 + )
102
66 - class DummyModel:
67 - def modules(self):
68 - return iter([])
103 + assert len(captured) == 1
104 + # Capture should see logical ids pre-EPLB mapping.
105 + assert torch.equal(captured[0], torch.tensor([[1, 2], [3, 4]]))
106 + # Our DummyRouter mapping adds +10.
107 + assert torch.equal(topk_ids, torch.tensor([[11, 12], [13, 14]]))
108
70 - rec_mod.bind_routing_capture_to_model(DummyModel())
109
110 +def test_gpu_model_runner_binds_router_capture(monkeypatch):
111 + from vllm.v1.worker import gpu_model_runner as gmr
112
73 -# =========================================================================
74 -# Tests for device-cache routing replay architecture
75 -# =========================================================================
113 + class DummyFusedMoE:
114 + def __init__(self):
115 + self.layer_id = 7
116 + self.router = _make_router()
117
118 + class DummyCapturer:
119 + def __init__(self):
120 + self.calls = []
121
78 -class TestRoutedExpertsDeviceCache:
79 - """Tests for _RoutedExpertsDeviceCache (GPU buffer for routing data)."""
122 + def capture(self, layer_id, topk_ids):
123 + self.calls.append((layer_id, topk_ids))
124
81 - def test_allocation_shape_and_dtype(self):
82 - """Device cache allocates (L, N, K) int16 buffer."""
83 - from vllm.model_executor.layers.fused_moe.routed_experts_capturer import (
84 - _RoutedExpertsDeviceCache,
85 - )
125 + dummy_module = DummyFusedMoE()
126
87 - cache = _RoutedExpertsDeviceCache(
88 - num_hidden_layers=40,
89 - max_num_batched_tokens=8192,
90 - num_experts_per_tok=8,
91 - device="cpu",
92 - )
93 - assert cache.buffer.shape == (40, 8192, 8)
94 - assert cache.buffer.dtype == torch.int16
127 + # Patch the runtime import inside _bind_routed_experts_capturer.
128 + import vllm.model_executor.layers.fused_moe.layer as fused_moe_layer
129
96 - def test_per_layer_view_is_contiguous(self):
97 - """buffer[layer_id] gives contiguous (N, K) view for FlashInfer."""
98 - from vllm.model_executor.layers.fused_moe.routed_experts_capturer import (
99 - _RoutedExpertsDeviceCache,
100 - )
130 + monkeypatch.setattr(fused_moe_layer, "FusedMoE", DummyFusedMoE)
131
102 - cache = _RoutedExpertsDeviceCache(
103 - num_hidden_layers=40,
104 - max_num_batched_tokens=8192,
105 - num_experts_per_tok=8,
106 - device="cpu",
132 + dummy_self = types.SimpleNamespace(
133 + compilation_config=types.SimpleNamespace(
134 + static_forward_context={"dummy": dummy_module}
135 )
108 - layer_view = cache.buffer[0]
109 - assert layer_view.is_contiguous()
110 - assert layer_view.shape == (8192, 8)
136 + )
137
138 + capturer = DummyCapturer()
139 + gmr.GPUModelRunner._bind_routed_experts_capturer(dummy_self, capturer)
140
113 -class TestRoutedExpertsHostCache:
114 - """Tests for _RoutedExpertsHostCache (per-request numpy buffer)."""
141 + assert dummy_module.router.capture_fn is not None
142 + dummy_module.router.capture_fn(torch.tensor([[5, 6]]))
143
116 - def test_sentinel_initialization(self):
117 - """Host cache initializes with zeros by default."""
118 - import numpy as np
144 + assert len(capturer.calls) == 1
145 + layer_id, topk_ids = capturer.calls[0]
146 + assert layer_id == 7
147 + assert torch.equal(topk_ids, torch.tensor([[5, 6]]))
148
120 - from vllm.model_executor.layers.fused_moe.routed_experts_capturer import (
121 - _RoutedExpertsHostCache,
122 - )
149
124 - cache = _RoutedExpertsHostCache(
125 - num_hidden_layers=40,
126 - num_experts_per_tok=8,
127 - max_model_len=1024,
128 - )
129 - buf = cache.get_or_grow_buffer("req1", max_pos=100)
130 - assert buf.dtype == np.int16
131 - assert (buf == 0).all(), "Host cache must initialize with zeros"
132 -
133 - def test_grow_preserves_existing_data(self):
134 - """Growing the buffer preserves previously written data."""
135 - from vllm.model_executor.layers.fused_moe.routed_experts_capturer import (
136 - _RoutedExpertsHostCache,
137 - )
150 +def test_gpu_model_runner_binding_stage(monkeypatch):
151 + from vllm.v1.worker import gpu_model_runner as gmr
152
139 - cache = _RoutedExpertsHostCache(
140 - num_hidden_layers=40,
141 - num_experts_per_tok=8,
142 - max_model_len=1024,
143 - )
144 - buf = cache.get_or_grow_buffer("req1", max_pos=50)
145 - buf[0, 0, 0] = 42
146 - buf2 = cache.get_or_grow_buffer("req1", max_pos=200)
147 - assert buf2[0, 0, 0] == 42, "Data lost during buffer grow"
148 -
149 - def test_free_request_removes_buffer(self):
150 - """Freeing a request removes its buffer."""
151 - from vllm.model_executor.layers.fused_moe.routed_experts_capturer import (
152 - _RoutedExpertsHostCache,
153 - )
153 + class DummyFusedMoE:
154 + def __init__(self):
155 + self.layer_id = 11
156 + self.router = _make_router()
157 +
158 + class DummyCapturer:
159 + def __init__(self):
160 + self.calls = []
161 +
162 + def capture(self, layer_id, topk_ids):
163 + self.calls.append((layer_id, topk_ids))
164 +
165 + dummy_module = DummyFusedMoE()
166 +
167 + import vllm.model_executor.layers.fused_moe.layer as fused_moe_layer
168 +
169 + monkeypatch.setattr(fused_moe_layer, "FusedMoE", DummyFusedMoE)
170
155 - cache = _RoutedExpertsHostCache(
156 - num_hidden_layers=40,
157 - num_experts_per_tok=8,
158 - max_model_len=1024,
171 + dummy_self = types.SimpleNamespace(
172 + compilation_config=types.SimpleNamespace(
173 + static_forward_context={"dummy": dummy_module}
174 )
160 - cache.get_or_grow_buffer("req1", max_pos=50)
161 - cache.free_request("req1")
162 - assert cache.get_buffer("req1") is None
175 + )
176 +
177 + # Before binding, no capture hook.
178 + assert dummy_module.router.capture_fn is None
179 +
180 + capturer = DummyCapturer()
181 + gmr.GPUModelRunner._bind_routed_experts_capturer(dummy_self, capturer)
182 +
183 + # After binding, hook should exist and be callable.
184 + assert callable(dummy_module.router.capture_fn)
185 + dummy_module.router.capture_fn(torch.tensor([[9, 10]]))
186 + assert len(capturer.calls) == 1
187 +
188 +
189 +def test_routed_experts_capturer_single_dp_no_metadata():
190 + """dp_metadata is None: capture writes the full topk_ids rows."""
191 + capturer = _capturer_with_buffer(dp_rank=0)
192 + topk = torch.tensor([[1, 2], [3, 4], [5, 6]], dtype=torch.int32)
193 + ctx = SimpleNamespace(dp_metadata=None)
194 + with patch(f"{_REC_MODULE}.get_forward_context", return_value=ctx):
195 + capturer.capture(layer_id=0, topk_ids=topk)
196 + assert torch.equal(capturer._device_buffer[:3, 0, :], topk)
197 + assert capturer._device_buffer[3, 0, 0].item() == -1
198 +
199 +
200 +def test_routed_experts_capturer_dp_naive_concatenated_all_ranks():
201 + """n == sum(num_tokens_dp): slice this rank's segment from concatenated topk."""
202 + capturer = _capturer_with_buffer(dp_rank=1)
203 + num_tokens_dp = torch.tensor([2, 3], dtype=torch.int32)
204 + ctx = SimpleNamespace(
205 + dp_metadata=SimpleNamespace(num_tokens_across_dp_cpu=num_tokens_dp)
206 + )
207 + # Concatenated order: rank0 rows then rank1 rows.
208 + topk = torch.tensor(
209 + [[0, 1], [2, 3], [10, 11], [12, 13], [14, 15]], dtype=torch.int32
210 + )
211 + with patch(f"{_REC_MODULE}.get_forward_context", return_value=ctx):
212 + capturer.capture(layer_id=0, topk_ids=topk)
213 + want = topk[2:5]
214 + assert torch.equal(capturer._device_buffer[:3, 0, :], want)
215 +
216 +
217 +def test_routed_experts_capturer_dp_modular_local_tokens():
218 + """n == token_num_per_dp: topk is already local to this DP rank."""
219 + capturer = _capturer_with_buffer(dp_rank=1)
220 + num_tokens_dp = torch.tensor([2, 3], dtype=torch.int32)
221 + ctx = SimpleNamespace(
222 + dp_metadata=SimpleNamespace(num_tokens_across_dp_cpu=num_tokens_dp)
223 + )
224 + topk = torch.tensor([[10, 11], [12, 13], [14, 15]], dtype=torch.int32)
225 + with patch(f"{_REC_MODULE}.get_forward_context", return_value=ctx):
226 + capturer.capture(layer_id=0, topk_ids=topk)
227 + assert torch.equal(capturer._device_buffer[:3, 0, :], topk)
228 +
229 +
230 +def test_routed_experts_capturer_dp_unexpected_batch_raises():
231 + """Mismatch between topk batch dim and DP layout: fail fast."""
232 + capturer = _capturer_with_buffer(dp_rank=0)
233 + num_tokens_dp = torch.tensor([2, 3], dtype=torch.int32)
234 + ctx = SimpleNamespace(
235 + dp_metadata=SimpleNamespace(num_tokens_across_dp_cpu=num_tokens_dp)
236 + )
237 + # total=5, local=2: n=1 matches neither naive (5) nor modular (2).
238 + topk = torch.tensor([[1, 2]], dtype=torch.int32)
239 + with (
240 + patch(f"{_REC_MODULE}.get_forward_context", return_value=ctx),
241 + pytest.raises(AssertionError, match="unexpected topk_ids batch dim"),
242 + ):
243 + capturer.capture(layer_id=0, topk_ids=topk)
244 + assert capturer._device_buffer[0, 0, 0].item() == -1
vllm/config/vllm.py
-51
@@ -1212,12 +1212,6 @@ class VllmConfig:
1212 if envs.VLLM_USE_V2_MODEL_RUNNER:
1213 self._validate_v2_model_runner()
1214
1215 - if (
1216 - self.model_config is not None
1217 - and self.model_config.enable_return_routed_experts
1218 - ):
1219 - self._validate_return_routed_experts()
1220 -
1215 # Re-compute compile ranges after platform-specific config updates
1216 # (e.g., XPU may lower max_num_batched_tokens when MLA is enabled)
1217 self._set_compile_ranges()
@@ -1933,51 +1927,6 @@ class VllmConfig:
1927 + ", ".join(unsupported)
1928 )
1929
1936 - def _validate_return_routed_experts(self) -> None:
1937 - """Reject parallelism configurations not yet validated with
1938 - --enable-return-routed-experts.
1939 -
1940 - Validated scope (PR #39917): TP, EP, DP, single-node and multi-node,
1941 - prefix caching, and speculative decoding (MTP validated end-to-end;
1942 - Eagle/Eagle3/Ngram/Medusa supported by construction since the
1943 - routing buffer is bound only to the target model and verified-token
1944 - routing lands at the correct positions during the main forward).
1945 -
1946 - Out-of-scope (block until validated): PP > 1, prefill context
1947 - parallelism (PCP) > 1, decode context parallelism (DCP) > 1,
1948 - async scheduling.
1949 - """
1950 - unsupported: list[str] = []
1951 -
1952 - if self.parallel_config.pipeline_parallel_size > 1:
1953 - unsupported.append(
1954 - "pipeline parallelism "
1955 - f"(pipeline_parallel_size="
1956 - f"{self.parallel_config.pipeline_parallel_size})"
1957 - )
1958 - if self.parallel_config.prefill_context_parallel_size > 1:
1959 - unsupported.append(
1960 - "prefill context parallelism "
1961 - f"(prefill_context_parallel_size="
1962 - f"{self.parallel_config.prefill_context_parallel_size})"
1963 - )
1964 - if self.parallel_config.decode_context_parallel_size > 1:
1965 - unsupported.append(
1966 - "decode context parallelism "
1967 - f"(decode_context_parallel_size="
1968 - f"{self.parallel_config.decode_context_parallel_size})"
1969 - )
1970 - if self.scheduler_config.async_scheduling:
1971 - unsupported.append("async scheduling")
1972 -
1973 - if unsupported:
1974 - raise ValueError(
1975 - "--enable-return-routed-experts is not yet validated with: "
1976 - + ", ".join(unsupported)
1977 - + ". Disable these features or omit "
1978 - "--enable-return-routed-experts."
1979 - )
1980 -
1930 def validate_block_size(self) -> None:
1931 """Validate block_size against DCP and mamba constraints.
1932
vllm/entrypoints/openai/chat_completion/protocol.py
-4
@@ -92,16 +92,12 @@ class ChatCompletionResponseChoice(OpenAIBaseModel):
92 # not part of the OpenAI spec but is useful for tracing the tokens
93 # in agent scenarios
94 token_ids: list[int] | None = None
95 - routed_experts: list[list[list[int]]] | None = None # [gen_len, num_layers, top_k]
95
96
97 class ChatCompletionResponse(OpenAIBaseModel):
98 id: str = Field(default_factory=lambda: f"chatcmpl-{random_uuid()}")
99 object: Literal["chat.completion"] = "chat.completion"
100 created: int = Field(default_factory=lambda: int(time.time()))
102 - prompt_routed_experts: list[list[list[int]]] | None = (
103 - None # [prompt_len, num_layers, top_k]
104 - )
101 model: str
102 choices: list[ChatCompletionResponseChoice]
103 service_tier: Literal["auto", "default", "flex", "scale", "priority"] | None = None
vllm/entrypoints/openai/chat_completion/serving.py
-15
@@ -1101,11 +1101,6 @@ class OpenAIServingChat(OpenAIServing):
1101 token_ids=(
1102 as_list(output.token_ids) if request.return_token_ids else None
1103 ),
1104 - routed_experts=(
1105 - output.routed_experts.tolist()
1106 - if output.routed_experts is not None
1107 - else None
1108 - ),
1104 )
1105 choices.append(choice_data)
1106 continue
@@ -1327,11 +1322,6 @@ class OpenAIServingChat(OpenAIServing):
1322 token_ids=(
1323 as_list(output.token_ids) if request.return_token_ids else None
1324 ),
1330 - routed_experts=(
1331 - output.routed_experts.tolist()
1332 - if output.routed_experts is not None
1333 - else None
1334 - ),
1325 )
1326 choice_data = maybe_filter_parallel_tool_calls(choice_data, request)
1327
@@ -1371,10 +1361,6 @@ class OpenAIServingChat(OpenAIServing):
1361
1362 request_metadata.final_usage_info = usage
1363
1374 - prompt_routed_experts = None
1375 - if final_res.prompt_routed_experts is not None:
1376 - prompt_routed_experts = final_res.prompt_routed_experts.tolist()
1377 -
1364 # ``final_res.prompt`` is the rendered chat-templated prompt text
1365 prompt_text = final_res.prompt if request.return_prompt_text else None
1366
@@ -1391,7 +1377,6 @@ class OpenAIServingChat(OpenAIServing):
1377 ),
1378 prompt_text=prompt_text,
1379 kv_transfer_params=final_res.kv_transfer_params,
1394 - prompt_routed_experts=prompt_routed_experts,
1380 )
1381
1382 # Log complete response if output logging is enabled
vllm/entrypoints/openai/completion/protocol.py
-4
@@ -477,16 +477,12 @@ class CompletionResponseChoice(OpenAIBaseModel):
477 token_ids: list[int] | None = None # For response
478 prompt_logprobs: list[dict[int, Logprob] | None] | None = None
479 prompt_token_ids: list[int] | None = None # For prompt
480 - routed_experts: list[list[list[int]]] | None = None # [gen_len, num_layers, top_k]
480
481
482 class CompletionResponse(OpenAIBaseModel):
483 id: str = Field(default_factory=lambda: f"cmpl-{random_uuid()}")
484 object: Literal["text_completion"] = "text_completion"
485 created: int = Field(default_factory=lambda: int(time.time()))
487 - prompt_routed_experts: list[list[list[int]]] | None = (
488 - None # [prompt_len, num_layers, top_k]
489 - )
486 model: str
487 choices: list[CompletionResponseChoice]
488 service_tier: Literal["auto", "default", "flex", "scale", "priority"] | None = None
vllm/entrypoints/openai/completion/serving.py
-11
@@ -551,11 +551,6 @@ class OpenAIServingCompletion(OpenAIServing):
551 token_ids=(
552 as_list(output.token_ids) if request.return_token_ids else None
553 ),
554 - routed_experts=(
555 - output.routed_experts.tolist()
556 - if output.routed_experts is not None
557 - else None
558 - ),
554 )
555 choices.append(choice_data)
556
@@ -579,13 +574,8 @@ class OpenAIServingCompletion(OpenAIServing):
574 )
575
576 request_metadata.final_usage_info = usage
582 - prompt_routed_experts = None
577 if final_res_batch:
578 kv_transfer_params = final_res_batch[0].kv_transfer_params
585 - pre = final_res_batch[0].prompt_routed_experts
586 - if pre is not None:
587 - prompt_routed_experts = pre.tolist()
588 -
579 return CompletionResponse(
580 id=request_id,
581 created=created_time,
@@ -594,7 +584,6 @@ class OpenAIServingCompletion(OpenAIServing):
584 usage=usage,
585 system_fingerprint=self.system_fingerprint,
586 kv_transfer_params=kv_transfer_params,
597 - prompt_routed_experts=prompt_routed_experts,
587 )
588
589 def _create_completion_logprobs(
vllm/model_executor/layers/fused_moe/layer.py
-7
@@ -100,9 +100,6 @@ class FusedMoE(PluggableLayer):
100 not supported by the router (or the experts).
101 """
102
103 - # Auto-incrementing layer ID for routing replay buffer binding.
104 - _next_moe_layer_id: int = 0
105 -
103 # --8<-- [end:fused_moe]
104
105 def __init__(
@@ -148,10 +145,6 @@ class FusedMoE(PluggableLayer):
145 ):
146 super().__init__()
147
151 - # Assign unique layer ID for routing replay buffer binding.
152 - self.moe_layer_id = FusedMoE._next_moe_layer_id
153 - FusedMoE._next_moe_layer_id += 1
154 -
148 if params_dtype is None:
149 params_dtype = torch.get_default_dtype()
150 self.params_dtype = params_dtype
vllm/model_executor/layers/fused_moe/routed_experts_capturer.py
+298 -760
@@ -1,833 +1,371 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +# Adapted from
4 +# https://github.com/sgl-project/sglang/blob/bed301a5acaa9577c9aa706468bdf242f6a43051/python/sglang/srt/layers/moe/routed_experts_capturer.py
5 +
6 from __future__ import annotations
7
5 -import contextlib
8 +import fcntl
9 import logging
7 -from abc import ABC, abstractmethod
10 +import os
11 +import tempfile
12 +from collections.abc import Generator
13 +from contextlib import contextmanager
14 +from multiprocessing import shared_memory
15 +from unittest.mock import patch
16
17 import numpy as np
18 import torch
11 -import torch.distributed
19
13 -from vllm.config.model import ModelConfig
20 +from vllm.config import VllmConfig
21 +from vllm.distributed import get_tensor_model_parallel_rank
22 +from vllm.forward_context import get_forward_context
23 +from vllm.platforms import current_platform
24
25 logger = logging.getLogger(__name__)
26
27
18 -# ---------------------------------------------------------------------------
19 -# Custom op for routing capture -- traceable by torch.compile / Dynamo.
20 -#
21 -# Registered as a formal custom op so that torch.compile traces through it
22 -# cleanly without graph breaks. ALL TP ranks call this op with a real
23 -# device buffer to ensure identical CUDA graph structure (symmetry).
24 -# Non-rank-0 buffers are written but never read for D2H.
25 -# ---------------------------------------------------------------------------
28 +def _get_num_experts_per_tok(hf_config) -> int:
29 + """Resolve the per-token expert count from the HF config.
30
31 + Different model families store this under different attribute names
32 + (e.g. ``num_experts_per_tok`` for DeepSeek, ``top_k_experts`` for Gemma 4).
33 + """
34 + val = getattr(hf_config, "num_experts_per_tok", None)
35 + if val is None:
36 + val = getattr(hf_config, "top_k_experts", None)
37 + if val is None:
38 + raise ValueError(
39 + "Cannot determine num_experts_per_tok: HF config has neither "
40 + "'num_experts_per_tok' nor 'top_k_experts'"
41 + )
42 + return val
43
28 -@torch.library.custom_op("vllm::capture_routing", mutates_args={"buffer"})
29 -def capture_routing_op(
30 - buffer: torch.Tensor,
31 - topk_ids: torch.Tensor,
32 - layer_id: int,
33 - batch_size: int,
34 -) -> None:
35 - buffer[layer_id, :batch_size, :].copy_(
36 - topk_ids[:batch_size].to(buffer.dtype), non_blocking=True
37 - )
44
45 +# Constants
46 +_TMP_DIR = tempfile.gettempdir()
47 +_LOCK_FILE_PREFIX = os.path.join(_TMP_DIR, "vllm_routed_experts")
48 +_BUFFER_PREFIX = "vllm_routed_experts_buffer"
49
40 -@capture_routing_op.register_fake
41 -def _capture_routing_op_fake(
42 - buffer: torch.Tensor,
43 - topk_ids: torch.Tensor,
44 - layer_id: int,
45 - batch_size: int,
46 -) -> None:
47 - pass
50 +# Global singleton instances
51 +_global_experts_capturer: RoutedExpertsCapturer | None = None
52 +_global_experts_reader: RoutedExpertsReader | None = None
53
54
50 -_MB = 1024 * 1024
55 +@contextmanager
56 +def _file_lock(lock_file: str, mode: str = "wb+") -> Generator[None, None, None]:
57 + """Context manager for file-based locking."""
58 + with open(lock_file, mode) as fp:
59 + fcntl.flock(fp, fcntl.LOCK_EX)
60 + try:
61 + yield
62 + finally:
63 + fcntl.flock(fp, fcntl.LOCK_UN)
64
65
53 -class _RoutedExpertsDeviceCache:
54 - """Per-device (GPU) cache for capturing routed expert IDs during forward
55 - pass. Always writes at row 0 so that CUDA graph replay sees the same
56 - addresses that were recorded at capture time.
57 - """
66 +def _create_or_attach_shared_memory(
67 + name: str, size: int, lock_file: str
68 +) -> shared_memory.SharedMemory:
69 + """Create or attach to shared memory with proper locking."""
70 + # Ensure lock file exists before acquiring lock
71 + with open(lock_file, "wb"):
72 + pass
73
59 - DTYPE = torch.int16
74 + with _file_lock(lock_file):
75 + try:
76 + shm = shared_memory.SharedMemory(name=name, create=True, size=size)
77 + except FileExistsError:
78 + shm = shared_memory.SharedMemory(name=name, create=False, size=size)
79
61 - def __init__(
62 - self,
63 - max_num_batched_tokens: int,
64 - num_hidden_layers: int,
65 - num_experts_per_tok: int,
66 - device: str,
67 - ) -> None:
68 - # Layout: (L, N, K) so that buffer[layer_id] is a contiguous (N, K)
69 - # view — required by the FlashInfer routing-replay kernel which
70 - # writes expert IDs assuming contiguous row-major memory.
71 - self.num_hidden_layers = num_hidden_layers
72 - self.buffer = torch.zeros(
73 - (num_hidden_layers, max_num_batched_tokens, num_experts_per_tok),
74 - dtype=self.DTYPE,
75 - device=device,
76 - )
77 - self._finalize_allocation_log()
78 -
79 - def get_buffer_size_bytes(self):
80 - return self.buffer.nbytes
81 -
82 - def capture_fwd_routed_experts(self, layer_id: int, topk_ids: torch.Tensor):
83 - assert layer_id is not None, "capturing routing experts but get layer_id None"
84 - batch, _ = topk_ids.shape
85 - self.buffer[layer_id, :batch, :].copy_(topk_ids, non_blocking=True)
86 -
87 - def _finalize_allocation_log(self):
88 - buf_mb = self.get_buffer_size_bytes() / _MB
89 - logger.info(
90 - "Routing experts device buffer allocated. shape=%s, size=%.2f MB",
91 - tuple(self.buffer.shape),
92 - buf_mb,
93 - )
80 + if shm.size != size:
81 + logger.warning(
82 + "Shared memory %s size mismatch; recreating",
83 + name,
84 + )
85 + shm.close()
86 + shm.unlink()
87 + try:
88 + shm = shared_memory.SharedMemory(name=name, create=True, size=size)
89 + logger.info("Created shared memory %s", name)
90 + except FileExistsError:
91 + shm = shared_memory.SharedMemory(name=name, create=False, size=size)
92 + logger.info("Linked to existing shared memory %s", name)
93
94 + return shm
95
96 -class _RoutedExpertsHostCache:
97 - """Host (CPU) cache using numpy arrays for per-request routing data.
96
99 - Numpy arrays avoid torch dispatcher overhead for scatter operations.
100 - Lazy per-request allocation avoids a massive up-front buffer.
97 +class RoutedExpertsCapturer:
98 """
99 + Capturer for routed experts with device and optional shared memory buffer.
100
103 - DTYPE = np.int16
104 -
105 - def __init__(
106 - self,
107 - num_hidden_layers: int,
108 - num_experts_per_tok: int,
109 - max_model_len: int,
110 - ) -> None:
111 - self.max_model_len = max_model_len
112 - self.num_hidden_layers = num_hidden_layers
113 - self.num_experts_per_tok = num_experts_per_tok
114 -
115 - self._req_buffers: dict[str, np.ndarray] = {}
116 - self._filled_len: dict[str, int] = {}
117 - self._total_allocated_bytes = 0
118 -
119 - self._finalize_allocation_log()
101 + This class captures expert routing decisions during model forward passes
102 + and optionally stores them in shared memory for cross-process access.
103 + """
104
121 - def get_buffer_size_bytes(self) -> int:
122 - return self._total_allocated_bytes
105 + _instance: RoutedExpertsCapturer | None = None
106
124 - def get_or_grow_buffer(self, req_id: str, max_pos: int) -> np.ndarray:
125 - required_len = max_pos + 1
107 + def __init__(self) -> None:
108 + self._device_buffer: torch.Tensor | None = None
109 + self._shm: shared_memory.SharedMemory | None = None
110 + self._host_buffer_view: np.ndarray | None = None
111 + self._lock_file: str | None = None
112
127 - if req_id not in self._req_buffers:
128 - buf = np.zeros(
129 - (required_len, self.num_hidden_layers, self.num_experts_per_tok),
130 - dtype=self.DTYPE,
131 - )
132 - self._req_buffers[req_id] = buf
133 - self._total_allocated_bytes += buf.nbytes
134 - return buf
135 -
136 - buf = self._req_buffers[req_id]
137 - if buf.shape[0] >= required_len:
138 - return buf
139 -
140 - new_len = min(max(required_len, buf.shape[0] * 2), self.max_model_len)
141 - new_buf = np.zeros(
142 - (new_len, self.num_hidden_layers, self.num_experts_per_tok),
143 - dtype=self.DTYPE,
144 - )
145 - new_buf[: buf.shape[0]] = buf
146 - self._total_allocated_bytes += new_buf.nbytes - buf.nbytes
147 - self._req_buffers[req_id] = new_buf
148 - return new_buf
149 -
150 - def get_buffer(self, req_id: str) -> np.ndarray | None:
151 - return self._req_buffers.get(req_id)
152 -
153 - def update_filled_len(self, req_id: str, max_pos: int) -> None:
154 - new_len = max_pos + 1
155 - self._filled_len[req_id] = max(self._filled_len.get(req_id, 0), new_len)
156 -
157 - def get_filled_len(self, req_id: str) -> int:
158 - return self._filled_len.get(req_id, 0)
159 -
160 - def free_request(self, req_id: str) -> None:
161 - if req_id in self._req_buffers:
162 - self._total_allocated_bytes -= self._req_buffers.pop(req_id).nbytes
163 - self._filled_len.pop(req_id, None)
164 -
165 - def _finalize_allocation_log(self):
166 - logger.info(
167 - "Routing experts host cache initialized (lazy allocation). "
168 - "max_model_len=%s, layers=%s, experts_per_tok=%s",
169 - self.max_model_len,
170 - self.num_hidden_layers,
171 - self.num_experts_per_tok,
172 - )
113 + @classmethod
114 + def create(cls) -> RoutedExpertsCapturer:
115 + """Create a global singleton instance."""
116 + global _global_experts_capturer
117 + if _global_experts_capturer is not None:
118 + raise RuntimeError("Experts capturer already created.")
119
120 + _global_experts_capturer = cls()
121 + return _global_experts_capturer
122
175 -class RoutedExpertsCapturer(ABC):
123 @staticmethod
177 - def create(
178 - enable: bool,
179 - model_config: ModelConfig,
180 - num_fused_shared_experts: int,
181 - max_num_batched_tokens: int,
182 - max_model_len: int,
183 - device: str,
184 - shared_host_cache: _RoutedExpertsHostCache | None = None,
185 - skip_host_cache: bool = False,
186 - ):
187 - if enable:
188 - return _RoutedExpertsCapturerReal(
189 - model_config,
190 - max_num_batched_tokens=max_num_batched_tokens,
191 - num_fused_shared_experts=num_fused_shared_experts,
192 - max_model_len=max_model_len,
193 - device=device,
194 - shared_host_cache=shared_host_cache,
195 - skip_host_cache=skip_host_cache,
196 - )
197 - return _RoutedExpertsCapturerNoop()
198 -
199 - @abstractmethod
200 - def capture(self, layer_id: int, topk_ids: torch.Tensor):
201 - raise NotImplementedError
202 -
203 - def get_routed_experts(
204 - self, req_id: str, seqlen: int | None = None, free_slot: bool = True
205 - ):
206 - raise NotImplementedError
124 + def get_instance() -> RoutedExpertsCapturer | None:
125 + """Get the global singleton instance."""
126 + return _global_experts_capturer
127
208 - def sync_fwd_experts_buffer_DtoH(
128 + def init_buffer(
129 self,
210 - positions: torch.Tensor,
211 - num_scheduled_tokens: dict[str, int],
212 - ):
213 - raise NotImplementedError
214 -
215 - def finalize_pending_copy(self):
216 - raise NotImplementedError
130 + max_num_batched_tokens: int,
131 + max_num_kv_tokens: int,
132 + vllm_config: VllmConfig,
133 + ) -> None:
134 + """
135 + Initialize the device buffer and optionally shared memory buffer.
136
218 - def get_host_cache(self):
219 - raise NotImplementedError
137 + Args:
138 + max_num_batched_tokens: Maximum number of tokens in a batch.
139 + max_num_kv_tokens: Maximum number of KV tokens for shared memory.
140 + vllm_config: vllm configuration containing layer and expert info.
141 + """
142
221 - def get_device_cache(self):
222 - raise NotImplementedError
143 + if self._device_buffer is not None:
144 + raise RuntimeError("Device buffer has already been initialized")
145
146 + hf_config = vllm_config.model_config.hf_text_config
147 + num_layers = hf_config.num_hidden_layers
148 + num_experts_per_tok = _get_num_experts_per_tok(hf_config)
149
225 -def _count_moe_layers(hf_config) -> int:
226 - """Count the number of MoE layers in a model.
150 + # Initialize device buffer
151 + self._device_buffer = torch.zeros(
152 + (max_num_batched_tokens, num_layers, num_experts_per_tok),
153 + dtype=torch.int32,
154 + device=current_platform.device_type,
155 + )
156 + self.dp_rank = vllm_config.parallel_config.data_parallel_rank
157
228 - Resolves three known config shapes:
229 - - Nemotron-style: an explicit ``layers_block_type`` list with "moe" entries.
230 - - Qwen3MoE / DeepSeek-style sparse: ``decoder_sparse_step > 1`` with optional
231 - ``mlp_only_layers`` exclusions.
232 - - Default: every layer is MoE except those listed in ``mlp_only_layers``.
233 - """
234 - layers_block_type = getattr(hf_config, "layers_block_type", None)
235 - if layers_block_type is not None:
236 - return layers_block_type.count("moe")
237 - n = hf_config.num_hidden_layers
238 - mlp_only = getattr(hf_config, "mlp_only_layers", None) or []
239 - step = getattr(hf_config, "decoder_sparse_step", 1) or 1
240 - if step > 1:
241 - return sum(1 for i in range(n) if (i + 1) % step == 0 and i not in mlp_only)
242 - return n - sum(1 for i in mlp_only if 0 <= i < n)
243 -
244 -
245 -class _RoutedExpertsCapturerReal(RoutedExpertsCapturer):
246 - """Capturer with GPU device cache and CPU host cache.
247 -
248 - Performance strategy -- async D2H with optimized host-cache scatter:
249 -
250 - Every decode step we issue a non-blocking D2H copy on a dedicated
251 - CUDA stream. The scatter into per-request host-cache buffers is
252 - deferred to the start of the NEXT step (by which time the copy has
253 - finished). The scatter loop is optimized with direct scalar access
254 - to avoid numpy slice views, int() conversions, and .max() calls.
255 -
256 - At extraction time (when a request finishes), data is already in a
257 - contiguous host buffer -- just a numpy slice, no concatenation.
258 - """
158 + if get_tensor_model_parallel_rank() != 0:
159 + return
160
260 - def __init__(
261 - self,
262 - model_config: ModelConfig,
263 - max_num_batched_tokens: int,
264 - num_fused_shared_experts: int,
265 - max_model_len: int,
266 - device: str,
267 - shared_host_cache: _RoutedExpertsHostCache | None = None,
268 - skip_host_cache: bool = False,
269 - ):
270 - self.num_fused_shared_experts = num_fused_shared_experts
271 - self.num_hidden_layers = _count_moe_layers(model_config.hf_text_config)
272 - self.num_experts_per_tok = model_config.hf_text_config.num_experts_per_tok
273 - self.max_num_batched_tokens = max_num_batched_tokens
274 - self.max_model_len = max_model_len
275 - self._skip_host_cache = skip_host_cache
276 -
277 - if skip_host_cache:
278 - self.host_cache = None
279 - logger.info("Skipping host cache for device %s (non-rank-0)", device)
280 - elif shared_host_cache is not None:
281 - self.host_cache = shared_host_cache
282 - else:
283 - self.host_cache = _RoutedExpertsHostCache(
284 - num_hidden_layers=self.num_hidden_layers,
285 - num_experts_per_tok=self.num_experts_per_tok,
286 - max_model_len=self.max_model_len,
287 - )
161 + # Initialize shared memory
162 + shape = (max_num_kv_tokens, num_layers, num_experts_per_tok)
163 + buffer_size = int(np.prod(shape)) * np.dtype(np.int32).itemsize
164 + instance_id = vllm_config.instance_id
165 + self._lock_file = f"{_LOCK_FILE_PREFIX}_{instance_id}_{self.dp_rank}.lock"
166 + shm_name = f"{_BUFFER_PREFIX}_{instance_id}_{self.dp_rank}"
167
289 - self.device_cache = _RoutedExpertsDeviceCache(
290 - max_num_batched_tokens=self.max_num_batched_tokens,
291 - num_hidden_layers=self.num_hidden_layers,
292 - num_experts_per_tok=self.num_experts_per_tok,
293 - device=device,
168 + self._shm = _create_or_attach_shared_memory(
169 + shm_name, buffer_size, self._lock_file
170 )
171 + self._host_buffer_view = np.ndarray(shape, dtype=np.int32, buffer=self._shm.buf)
172 + self._host_buffer_view.fill(0)
173
296 - # ---- Async D2H pipeline (rank-0 only) ----
297 - # Non-rank-0 workers only need the device buffer for symmetric
298 - # CUDA graph capture; they skip the D2H pipeline entirely.
299 - self._has_pending_copy = False
300 - self._pending_positions: np.ndarray | None = None
301 - self._pending_num_scheduled: dict[str, int] | None = None
302 - self._pending_total_tokens: int = 0
303 -
304 - if not skip_host_cache:
305 - # Same (L, N, K) layout as device_cache.buffer.
306 - self._pinned_staging = torch.zeros(
307 - (
308 - self.num_hidden_layers,
309 - max_num_batched_tokens,
310 - self.num_experts_per_tok,
311 - ),
312 - dtype=_RoutedExpertsDeviceCache.DTYPE,
313 - pin_memory=True,
314 - )
315 - # Private device snapshot: source for the async D2H. Decouples
316 - # the in-flight copy from device_cache.buffer, which the next
317 - # step's MoE writes overwrite in place on main_stream.
318 - self._device_staging = torch.empty_like(self.device_cache.buffer)
319 - self._copy_stream = torch.cuda.Stream(device=device)
320 - self._copy_event = torch.cuda.Event()
321 -
322 - pinned_mb = self._pinned_staging.nbytes / _MB
323 - logger.info(
324 - "Routing experts pinned staging buffer allocated. "
325 - "shape=%s, size=%.2f MB",
326 - tuple(self._pinned_staging.shape),
327 - pinned_mb,
328 - )
329 - else:
330 - self._pinned_staging = None
331 - self._device_staging = None
332 - self._copy_stream = None
333 - self._copy_event = None
334 - logger.info(
335 - "Routing experts device-only capturer (rank != 0). "
336 - "Device buffer shape=%s",
337 - tuple(self.device_cache.buffer.shape),
338 - )
174 + logger.debug(
175 + "Created shared memory buffer '%s' with shape %s",
176 + shm_name,
177 + shape,
178 + )
179
340 - def capture(self, layer_id: int, topk_ids: torch.Tensor):
341 - self.device_cache.capture_fwd_routed_experts(layer_id, topk_ids)
180 + def capture(self, layer_id: int, topk_ids: torch.Tensor) -> None:
181 + """
182 + Capture expert routing decisions for a specific layer.
183
343 - # ------------------------------------------------------------------
344 - # sync_fwd_experts_buffer_DtoH -- called AFTER the forward pass
345 - # ------------------------------------------------------------------
184 + Args:
185 + layer_id: The layer index.
186 + topk_ids: Tensor of shape (batch_size, num_routed_experts).
187 + """
188 + if self._device_buffer is None:
189 + raise RuntimeError("Buffer not initialized. Call init_buffer() first.")
190 +
191 + ctx = get_forward_context()
192 + if ctx.dp_metadata is None: # single dp
193 + start_loc = 0
194 + end_loc = topk_ids.shape[0]
195 + token_num_per_dp = topk_ids.shape[0]
196 + else: # multi dp
197 + num_tokens_dp = ctx.dp_metadata.num_tokens_across_dp_cpu
198 + token_num_per_dp = int(num_tokens_dp[self.dp_rank].item())
199 + total = int(num_tokens_dp.sum().item())
200 + n = topk_ids.shape[0]
201 +
202 + if n == total:
203 + # Naive dispatch: all DP ranks' tokens concatenated before routing.
204 + cumsum = torch.cumsum(num_tokens_dp, dim=0)
205 + end_loc = int(cumsum[self.dp_rank].item())
206 + start_loc = end_loc - token_num_per_dp
207 + elif n == token_num_per_dp:
208 + # Modular-kernel path: DP combine happens inside quant_method.apply;
209 + # select_experts only sees this rank's tokens.
210 + start_loc = 0
211 + end_loc = token_num_per_dp
212 + else:
213 + raise AssertionError(
214 + "RoutedExpertsCapturer: unexpected topk_ids batch dim "
215 + f"{n} (expected {total} or {token_num_per_dp} "
216 + f"for dp_rank={self.dp_rank})"
217 + )
218
347 - def sync_fwd_experts_buffer_DtoH(
348 - self,
349 - positions: torch.Tensor,
350 - num_scheduled_tokens: dict[str, int],
351 - ):
352 - if self.host_cache is None:
219 + if layer_id >= self._device_buffer.shape[1]:
220 return
221
355 - # 1. Finalize previous async copy -- the copy had an entire
356 - # forward pass to complete so event.synchronize() is ~free.
357 - if self._has_pending_copy:
358 - self._copy_event.synchronize()
359 - self._scatter_to_host()
360 - self._has_pending_copy = False
222 + self._device_buffer[:token_num_per_dp, layer_id, :] = topk_ids[
223 + start_loc:end_loc, :
224 + ]
225
362 - total_tokens = sum(num_scheduled_tokens.values())
363 - if total_tokens == 0:
364 - return
226 + def clear_buffer(self) -> None:
227 + """Clear the device buffer."""
228 + if self._device_buffer is not None:
229 + self._device_buffer.zero_()
230
366 - # 2. Snapshot the device buffer on main_stream into a private
367 - # staging buffer, then issue the D2H from the staging buffer
368 - # on a dedicated copy stream. The snapshot serializes after
369 - # the current step's MoE writes (same stream) and is private
370 - # from the next step's MoE writes, so the in-flight D2H is
371 - # not aliased by step N+1's forward under async scheduling.
372 - main_stream = torch.cuda.current_stream(self._copy_stream.device)
373 - self._device_staging[:, :total_tokens, :].copy_(
374 - self.device_cache.buffer[:, :total_tokens, :], non_blocking=True
375 - )
376 - with torch.cuda.stream(self._copy_stream):
377 - self._copy_stream.wait_stream(main_stream)
378 - self._pinned_staging[:, :total_tokens, :].copy_(
379 - self._device_staging[:, :total_tokens, :], non_blocking=True
380 - )
381 - self._copy_event.record()
231 + def save_captured_experts(self, indices: np.ndarray) -> None:
232 + """
233 + Save captured experts from device buffer to shared memory.
234
383 - # 3. Save metadata for deferred scatter.
384 - self._pending_positions = positions.numpy().copy()
385 - self._pending_num_scheduled = num_scheduled_tokens
386 - self._pending_total_tokens = total_tokens
387 - self._has_pending_copy = True
235 + Args:
236 + indices: Array of indices indicating where to store the data.
237 + """
238 + if get_tensor_model_parallel_rank() != 0:
239 + return
240 + if self._lock_file is None:
241 + raise RuntimeError("Shared memory not initialized.")
242 + if self._host_buffer_view is None:
243 + return
244 + if self._device_buffer is None:
245 + raise RuntimeError("Device buffer not initialized.")
246
389 - # ------------------------------------------------------------------
390 - # Optimized scatter into pre-allocated host-cache buffers
391 - # ------------------------------------------------------------------
247 + num_tokens = len(indices)
248 + data = self._device_buffer[:num_tokens, :, :].cpu().numpy()
249
393 - def _scatter_to_host(self):
394 - """Scatter D2H data into per-request host cache buffers.
250 + with _file_lock(self._lock_file):
251 + self._host_buffer_view[indices, :, :] = data
252
396 - Staging layout is (L, N, K). Host cache layout is (seq_len, L, K).
397 - We transpose the staging slice to (N, L, K) before scattering so
398 - that indexing by token position naturally yields (L, K) rows.
399 - """
400 - # Transpose (L, N, K) -> (N, L, K) for the active token range.
401 - host_values = (
402 - self._pinned_staging[:, : self._pending_total_tokens, :]
403 - .numpy()
404 - .transpose(1, 0, 2)
405 - )
406 - positions_np = self._pending_positions
407 - host_cache = self.host_cache
408 - assert self._pending_num_scheduled is not None
409 - assert positions_np is not None
410 - assert host_cache is not None
411 -
412 - offset = 0
413 - for req_id, n_tokens in self._pending_num_scheduled.items():
414 - if n_tokens == 0:
415 - continue
416 -
417 - if n_tokens == 1:
418 - pos_val = int(positions_np[offset])
419 - buf = host_cache.get_or_grow_buffer(req_id, pos_val)
420 - buf[pos_val] = host_values[offset]
421 - host_cache.update_filled_len(req_id, pos_val)
422 - else:
423 - pos = positions_np[offset : offset + n_tokens]
424 - max_pos = int(pos[-1]) if n_tokens > 0 else 0
425 - if n_tokens > 1:
426 - max_pos = int(pos.max())
427 - buf = host_cache.get_or_grow_buffer(req_id, max_pos)
428 - buf[pos] = host_values[offset : offset + n_tokens]
429 - host_cache.update_filled_len(req_id, max_pos)
430 -
431 - offset += n_tokens
432 -
433 - self._pending_positions = None
434 - self._pending_num_scheduled = None
435 - self._pending_total_tokens = 0
436 -
437 - # ------------------------------------------------------------------
438 - # finalize_pending_copy -- call before reading host cache
439 - # ------------------------------------------------------------------
440 -
441 - def finalize_pending_copy(self):
442 - """Ensure the most recent async D2H copy has been scattered into
443 - host cache buffers. Call before get_routed_experts."""
444 - if self._has_pending_copy:
445 - self._copy_event.synchronize()
446 - self._scatter_to_host()
447 - self._has_pending_copy = False
448 -
449 - # ------------------------------------------------------------------
450 - # Extraction -- O(1), just a numpy slice
451 - # ------------------------------------------------------------------
452 -
453 - def get_routed_experts(
454 - self,
455 - req_id: str,
456 - seqlen: int | None = None,
457 - free_slot: bool = True,
458 - ):
459 - if self.host_cache is None:
460 - return None
461 - buf = self.host_cache.get_buffer(req_id)
462 - if buf is None:
463 - return None
464 - filled = self.host_cache.get_filled_len(req_id)
465 - if filled <= 0:
466 - return None
467 - effective_len = min(filled, seqlen) if seqlen is not None else filled
468 - result = buf[:effective_len].copy()
469 - if free_slot:
470 - self.host_cache.free_request(req_id)
471 - return result
472 -
473 - def get_host_cache(self):
474 - return self.host_cache
475 -
476 - def get_device_cache(self):
477 - return self.device_cache
478 -
479 -
480 -class _RoutedExpertsCapturerNoop(RoutedExpertsCapturer):
481 - def __init__(self):
482 - pass
253 + def cleanup(self) -> None:
254 + """Explicitly clean up shared memory resources."""
255 + if self._shm is not None:
256 + try:
257 + self._shm.close()
258 + self._shm.unlink()
259 + except Exception:
260 + logger.debug("Exception during cleanup for capturer", exc_info=True)
261 + finally:
262 + self._shm = None
263
484 - def capture(self, layer_id: int, topk_ids: torch.Tensor):
485 - pass
264 + def __del__(self) -> None:
265 + """Clean up shared memory on destruction."""
266 + self.cleanup()
267
487 - def get_routed_experts(self, req_id: str, seqlen=None, free_slot=True):
488 - return None
268
490 - def sync_fwd_experts_buffer_DtoH(self, positions, num_scheduled_tokens):
491 - pass
269 +class RoutedExpertsReader:
270 + """
271 + Reader for routed experts from shared memory.
272
493 - def finalize_pending_copy(self):
494 - pass
273 + This class attaches to shared memory created by RoutedExpertsCapturer
274 + and reads expert routing decisions.
275 + """
276
496 - def get_host_cache(self):
497 - return None
277 + _instance: RoutedExpertsReader | None = None
278
499 - def get_device_cache(self):
500 - pass
279 + def __init__(self) -> None:
280 + self._shm: shared_memory.SharedMemory | None = None
281 + self._host_buffer_view: np.ndarray | None = None
282 + self._lock_file: str | None = None
283
284 + @classmethod
285 + def create(cls) -> RoutedExpertsReader:
286 + """Create a global singleton instance."""
287 + global _global_experts_reader
288 + if _global_experts_reader is not None:
289 + raise RuntimeError("Experts reader already created.")
290
503 -# Global capturer instance (per-process)
504 -_global_expert_capturer: RoutedExpertsCapturer | None = _RoutedExpertsCapturerNoop()
505 -_shared_host_cache: _RoutedExpertsHostCache | None = None
291 + _global_experts_reader = cls()
292 + return _global_experts_reader
293
294 + @staticmethod
295 + def get_instance() -> RoutedExpertsReader | None:
296 + """Get the global singleton instance."""
297 + if _global_experts_reader is None:
298 + logger.info("Experts reader not initialized.")
299 + return _global_experts_reader
300
508 -def get_global_experts_capturer():
509 - return _global_expert_capturer
301 + def attach_buffer(
302 + self,
303 + max_num_kv_tokens: int,
304 + vllm_config: VllmConfig,
305 + ) -> None:
306 + """
307 + Attach to an existing shared memory buffer.
308
309 + Args:
310 + max_num_kv_tokens: Maximum number of KV tokens.
311 + vllm_config: vllm configuration.
312 + """
313 + if self._shm is not None:
314 + logger.warning("Already attached to shared memory buffer.")
315 + return # Already attached
316 +
317 + hf_config = vllm_config.model_config.hf_text_config
318 + shape = (
319 + max_num_kv_tokens,
320 + hf_config.num_hidden_layers,
321 + _get_num_experts_per_tok(hf_config),
322 + )
323
512 -def set_global_experts_capturer(capturer: RoutedExpertsCapturer):
513 - global _global_expert_capturer
514 - _global_expert_capturer = capturer
324 + self.dp_rank = vllm_config.parallel_config.data_parallel_rank
325 + instance_id = vllm_config.instance_id
326 + self._lock_file = f"{_LOCK_FILE_PREFIX}_{instance_id}_{self.dp_rank}.lock"
327 + shm_name = f"{_BUFFER_PREFIX}_{instance_id}_{self.dp_rank}"
328
329 + with _file_lock(self._lock_file, mode="rb+"):
330 + # Avoid resource_tracker registering the shared memory
331 + with patch(
332 + "multiprocessing.resource_tracker.register",
333 + lambda *args, **kwargs: None,
334 + ):
335 + self._shm = shared_memory.SharedMemory(name=shm_name)
336
517 -def extract_routed_experts_for_current_batch(
518 - req_ids: list[str],
519 - requests: dict,
520 - req_id_to_index: dict[str, int],
521 - num_tokens_no_spec: np.ndarray,
522 - max_model_len: int,
523 -) -> dict[str, np.ndarray] | None:
524 - """Extract routed experts for requests predicted to finish this step.
337 + self._host_buffer_view = np.ndarray(
338 + shape, dtype=np.int32, buffer=self._shm.buf
339 + )
340
526 - Checks all stop conditions the scheduler will check (max_tokens,
527 - EOS token, stop tokens, max_model_len) so that every finished
528 - request gets its routing data attached to the ModelRunnerOutput.
341 + def get_routed_experts(self, indices: np.ndarray) -> np.ndarray:
342 + """
343 + Read routed expert data from shared memory.
344
530 - Args:
531 - req_ids: Ordered request IDs for the current batch.
532 - requests: Map of req_id to CachedRequestState (read-only).
533 - req_id_to_index: Map of req_id to input batch index.
534 - num_tokens_no_spec: Array of total token counts per request index.
535 - max_model_len: Maximum model sequence length.
536 - """
537 - capturer = get_global_experts_capturer()
538 - if capturer is None:
539 - return None
540 - host_cache = capturer.get_host_cache()
541 - if host_cache is None:
542 - return None
543 -
544 - finishing_req_ids: list[str] = []
545 - for req_id in req_ids:
546 - req_state = requests.get(req_id)
547 - if req_state is None:
548 - continue
549 - sp = req_state.sampling_params
550 - if sp is None:
551 - continue
552 - output_ids = req_state.output_token_ids
553 - if not output_ids:
554 - continue
555 - if len(output_ids) < sp.min_tokens:
556 - continue
557 -
558 - finishing = False
559 - last_token = output_ids[-1]
560 -
561 - # EOS token (mirrors check_stop: eos_token_id is None
562 - # when ignore_eos=True, so this naturally respects that)
563 - if last_token == sp.eos_token_id:
564 - finishing = True
565 -
566 - # Explicit stop token IDs
567 - if not finishing and sp.stop_token_ids and last_token in sp.stop_token_ids:
568 - finishing = True
569 -
570 - # max_tokens / max_model_len length cap
571 - if not finishing:
572 - if sp.max_tokens is not None and len(output_ids) >= sp.max_tokens:
573 - finishing = True
574 - else:
575 - req_idx = req_id_to_index.get(req_id)
576 - if req_idx is not None:
577 - total = num_tokens_no_spec[req_idx]
578 - if total >= max_model_len:
579 - finishing = True
580 -
581 - if finishing:
582 - finishing_req_ids.append(req_id)
583 -
584 - if not finishing_req_ids:
585 - return None
586 -
587 - # At least one request is finishing: ensure the latest async D2H
588 - # copy has been scattered into the host cache.
589 - capturer.finalize_pending_copy()
590 -
591 - result: dict[str, np.ndarray] = {}
592 - for req_id in finishing_req_ids:
593 - seqlen = host_cache.get_filled_len(req_id)
594 - if seqlen <= 0:
595 - continue
596 - experts = capturer.get_routed_experts(req_id, seqlen=seqlen, free_slot=False)
597 - if experts is not None:
598 - result[req_id] = experts
599 -
600 - return result if result else None
601 -
602 -
603 -def free_routing_buffers(
604 - finished_req_ids: set[str],
605 - preempted_req_ids: set[str] | None = None,
606 -) -> None:
607 - """Free host cache buffers for finished and preempted requests.
608 -
609 - Finished requests had their routing data extracted in the previous
610 - step.
611 -
612 - Preempted requests are re-prefilled from scratch when they resume,
613 - so their host-cache buffer is freed here. This means any routing
614 - already accumulated in the host cache for the preempted request is
615 - dropped without being emitted on a ``ModelRunnerOutput`` --
616 - consumers see ``routed_experts=None`` for those requests with no
617 - other signal. Partial-rollout / async-RL pipelines that depend on
618 - receiving routing for preempted requests should treat preemption
619 - as a routing-data loss event and either keep preemption disabled
620 - or reconstruct routing on the resumed prefill.
621 - """
622 - capturer = get_global_experts_capturer()
623 - if capturer is None:
624 - return
625 - host_cache = capturer.get_host_cache()
626 - if host_cache is None:
627 - return
628 -
629 - for req_id in finished_req_ids:
630 - host_cache.free_request(req_id)
631 - if preempted_req_ids:
632 - for req_id in preempted_req_ids:
633 - host_cache.free_request(req_id)
634 -
635 -
636 -def issue_routing_d2h_copy(
637 - input_batch_req_ids: list[str],
638 - num_scheduled_tokens: dict[str, int],
639 - positions: torch.Tensor,
640 - positions_cpu: torch.Tensor,
641 -) -> None:
642 - """Issue async D2H copy of routed experts after the forward pass.
643 -
644 - Called EARLY in the execute_model epilogue so the copy overlaps with
645 - eplb, kv_connector finalization, and draft work.
646 - finalize_pending_copy() + get_routed_experts() happen later in
647 - extract_routed_experts_for_current_batch().
648 - """
649 - capturer = get_global_experts_capturer()
650 - if capturer is None:
651 - return
652 -
653 - ordered = {
654 - req_id: num_scheduled_tokens[req_id]
655 - for req_id in input_batch_req_ids
656 - if req_id in num_scheduled_tokens
657 - }
658 - n = sum(ordered.values())
659 - positions_cpu[:n].copy_(positions[:n])
660 - capturer.sync_fwd_experts_buffer_DtoH(
661 - positions=positions_cpu[:n],
662 - num_scheduled_tokens=ordered,
663 - )
664 -
665 -
666 -def split_routed_experts(
667 - routed_experts: np.ndarray,
668 - prompt_len: int,
669 - num_output_tokens: int | None = None,
670 -) -> tuple[np.ndarray | None, np.ndarray | None]:
671 - """Split routing data into prompt and generation portions.
672 -
673 - Args:
674 - routed_experts: Full routing array of shape (seq_len, L, K).
675 - prompt_len: Number of prompt tokens for the request.
676 - num_output_tokens: Actual number of generated tokens (from
677 - detokenizer). When provided, the generation portion is
678 - clipped to this length — necessary with MTP where the model
679 - runner may capture routing for more tokens than the final
680 - output contains.
681 -
682 - Returns:
683 - (prompt_routed_experts, gen_routed_experts) numpy arrays, either
684 - of which may be None if the corresponding portion is empty.
685 - """
686 - prompt_routed_experts = routed_experts[:prompt_len]
687 - gen_routed_experts = routed_experts[prompt_len:]
688 -
689 - # Clip generation routing to match actual output tokens.
690 - if (
691 - num_output_tokens is not None
692 - and gen_routed_experts.shape[0] > num_output_tokens
693 - and num_output_tokens > 0
694 - ):
695 - gen_routed_experts = gen_routed_experts[:num_output_tokens]
696 -
697 - if prompt_routed_experts.size == 0:
698 - prompt_routed_experts = None
699 - if gen_routed_experts.size == 0:
700 - gen_routed_experts = None
701 -
702 - return prompt_routed_experts, gen_routed_experts
703 -
704 -
705 -def get_shared_host_cache() -> _RoutedExpertsHostCache | None:
706 - return _shared_host_cache
707 -
708 -
709 -def create_shared_host_cache(
710 - model_config: ModelConfig,
711 - max_model_len: int,
712 -) -> _RoutedExpertsHostCache:
713 - global _shared_host_cache
714 - num_hidden_layers = _count_moe_layers(model_config.hf_text_config)
715 - num_experts_per_tok = model_config.hf_text_config.num_experts_per_tok
716 - _shared_host_cache = _RoutedExpertsHostCache(
717 - num_hidden_layers=num_hidden_layers,
718 - num_experts_per_tok=num_experts_per_tok,
719 - max_model_len=max_model_len,
720 - )
721 - return _shared_host_cache
722 -
723 -
724 -def init_routed_experts_capturer_with_shared_cache(
725 - enable: bool,
726 - model_config: ModelConfig,
727 - num_fused_shared_experts: int,
728 - max_num_batched_tokens: int,
729 - max_model_len: int,
730 - device: str,
731 - rank: int = 0,
732 - world_size: int = 1,
733 -) -> RoutedExpertsCapturer:
734 - """Initialize capturer with rank-aware handling (only rank 0 captures)."""
735 - if not enable:
736 - capturer = _RoutedExpertsCapturerNoop()
737 - set_global_experts_capturer(capturer)
738 - return capturer
739 -
740 - if world_size > 1 and rank != 0:
741 - # Non-rank-0 workers get a device-only capturer (no host cache,
742 - # no D2H pipeline) so that ALL ranks have a real device buffer.
743 - # This ensures the custom op call in every MoE layer produces
744 - # identical CUDA graph structure across TP ranks.
745 - logger.info("Creating device-only routed experts capturer for rank %s", rank)
746 - capturer = RoutedExpertsCapturer.create(
747 - enable=True,
748 - model_config=model_config,
749 - num_fused_shared_experts=num_fused_shared_experts,
750 - max_num_batched_tokens=max_num_batched_tokens,
751 - max_model_len=max_model_len,
752 - device=device,
753 - skip_host_cache=True,
754 - )
755 - set_global_experts_capturer(capturer)
756 - return capturer
757 -
758 - capturer = RoutedExpertsCapturer.create(
759 - enable=True,
760 - model_config=model_config,
761 - num_fused_shared_experts=num_fused_shared_experts,
762 - max_num_batched_tokens=max_num_batched_tokens,
763 - max_model_len=max_model_len,
764 - device=device,
765 - skip_host_cache=False,
766 - )
767 - set_global_experts_capturer(capturer)
768 - return capturer
769 -
770 -
771 -def bind_routing_capture_to_model(model) -> None:
772 - """Bind routing capture buffers to all FusedMoE layers in the model.
773 -
774 - Must be called AFTER init_routed_experts_capturer_with_shared_cache()
775 - and BEFORE CUDA graph capture. All TP ranks get a real buffer so
776 - that the custom op call produces identical graph structure.
777 - """
778 - from vllm.model_executor.layers.fused_moe.layer import FusedMoE
779 -
780 - capturer = get_global_experts_capturer()
781 - device_cache = capturer.get_device_cache()
782 - if device_cache is None:
783 - return # routing capture not enabled
784 -
785 - buffer = device_cache.buffer
786 -
787 - # Mark the buffer so CUDA graphs do NOT snapshot/restore its contents.
788 - if hasattr(torch.compiler, "cudagraph_mark_tensor_static"):
789 - torch.compiler.cudagraph_mark_tensor_static(buffer)
790 - elif hasattr(torch._C, "_set_static_address_tag"):
791 - torch._C._set_static_address_tag(buffer, True)
792 - with contextlib.suppress(Exception):
793 - torch._dynamo.mark_static_address(buffer)
794 -
795 - bound = 0
796 - for module in model.modules():
797 - if isinstance(module, FusedMoE) and hasattr(module, "moe_layer_id"):
798 - # Per-FusedMoE configurations not yet validated for routing
799 - # capture. These signals are only set after model init, so a
800 - # config-level guard cannot see them.
801 - if module.moe_config.is_sequence_parallel:
802 - raise NotImplementedError(
803 - "routed-experts capture is not yet validated with "
804 - "sequence parallelism on the FusedMoE layer "
805 - "(moe_config.is_sequence_parallel=True)."
806 - )
807 - if (
808 - module.moe_config.dp_size > 1
809 - and not module.quant_method.supports_internal_mk
810 - ):
811 - raise NotImplementedError(
812 - "routed-experts capture is not yet validated with "
813 - "naive DP dispatch (non-modular quant method "
814 - f"{type(module.quant_method).__name__}, "
815 - f"dp_size={module.moe_config.dp_size})."
816 - )
345 + Args:
346 + indices: Array of indices to read.
347
818 - layer_id = module.moe_layer_id
819 - layer_buf = buffer[layer_id] # (N_max, K)
820 - module._routing_replay_out = layer_buf
821 - # Mark each per-layer view as static so CUDA graphs don't
822 - # snapshot/restore or relocate the buffer during replay.
823 - if hasattr(torch.compiler, "cudagraph_mark_tensor_static"):
824 - torch.compiler.cudagraph_mark_tensor_static(layer_buf)
825 - with contextlib.suppress(Exception):
826 - torch._dynamo.mark_static_address(layer_buf)
827 - bound += 1
828 -
829 - logger.info(
830 - "Bound routing capture buffer to %s FusedMoE layers. Buffer shape=%s",
831 - bound,
832 - tuple(buffer.shape),
833 - )
348 + Returns:
349 + Copy of the expert routing data for the given indices.
350 + """
351 + if self._host_buffer_view is None:
352 + raise RuntimeError("Buffer not attached. Call attach_buffer() first.")
353 + if self._lock_file is None:
354 + raise RuntimeError("Lock file not initialized.")
355 +
356 + with _file_lock(self._lock_file, mode="rb+"):
357 + return self._host_buffer_view[indices, :, :].copy()
358 +
359 + def cleanup(self) -> None:
360 + """Explicitly clean up resources (close without unlink)."""
361 + if self._shm is not None:
362 + try:
363 + self._shm.close()
364 + except Exception:
365 + logger.debug("Exception during cleanup for reader", exc_info=True)
366 + finally:
367 + self._shm = None
368 +
369 + def __del__(self) -> None:
370 + """Close shared memory on destruction (do not unlink)."""
371 + self.cleanup()
vllm/model_executor/layers/fused_moe/runner/moe_runner.py
-8
@@ -511,10 +511,6 @@ class MoERunner(MoERunnerInterface):
511 shared_experts_input, SharedExpertsOrder.NO_OVERLAP
512 )
513
514 - # Get routing replay buffer from persistent layer attribute
515 - # (set by bind_routing_capture_to_model during capturer init)
516 - routing_replay_out = getattr(layer, "_routing_replay_out", None)
517 -
514 if self._quant_method.is_monolithic:
515 fused_out = self._quant_method.apply_monolithic(
516 layer=layer,
@@ -529,10 +525,6 @@ class MoERunner(MoERunnerInterface):
525 input_ids=input_ids,
526 )
527
532 - # Write routing data for non-monolithic path (Triton, etc.)
533 - if routing_replay_out is not None:
534 - routing_replay_out[: topk_ids.shape[0]].copy_(topk_ids.to(torch.int16))
535 -
528 # Passing shared_experts_input in case SharedExpertsOrder is
529 # MK_INTERNAL_OVERLAPPED.
530 fused_out = self._quant_method.apply(
vllm/outputs.py
-4
@@ -121,7 +121,6 @@ class RequestOutput:
121 num_cached_tokens: int | None = None,
122 *,
123 kv_transfer_params: dict[str, Any] | None = None,
124 - prompt_routed_experts: np.ndarray | None = None,
124 # Forward compatibility, code that uses args added in new release can
125 # still run with older versions of vLLM without breaking.
126 **kwargs: Any,
@@ -142,15 +141,12 @@ class RequestOutput:
141 self.encoder_prompt_token_ids = encoder_prompt_token_ids
142 self.num_cached_tokens = num_cached_tokens
143 self.kv_transfer_params = kv_transfer_params
145 - self.prompt_routed_experts = prompt_routed_experts
144
145 def add(self, next_output: "RequestOutput", aggregate: bool) -> None:
146 """Merge subsequent RequestOutput into this one"""
147
148 self.finished |= next_output.finished
149 self.kv_transfer_params = next_output.kv_transfer_params
152 - if next_output.prompt_routed_experts is not None:
153 - self.prompt_routed_experts = next_output.prompt_routed_experts
150
151 for next_completion in next_output.outputs:
152 for i, completion in enumerate(self.outputs):
vllm/v1/core/sched/scheduler.py
+70 -7
@@ -7,6 +7,8 @@ from collections.abc import Iterable
7 from dataclasses import replace
8 from typing import Any
9
10 +import numpy as np
11 +
12 from vllm import envs
13 from vllm.compilation.cuda_graph import CUDAGraphStat
14 from vllm.config import VllmConfig
@@ -25,6 +27,9 @@ from vllm.distributed.kv_transfer.kv_connector.v1 import (
27 from vllm.distributed.kv_transfer.kv_connector.v1.base import KVConnectorMetadata
28 from vllm.distributed.kv_transfer.kv_connector.v1.metrics import KVConnectorStats
29 from vllm.logger import init_logger
30 +from vllm.model_executor.layers.fused_moe.routed_experts_capturer import (
31 + RoutedExpertsReader,
32 +)
33 from vllm.multimodal import MULTIMODAL_REGISTRY, MultiModalRegistry
34 from vllm.multimodal.encoder_budget import MultiModalBudget
35 from vllm.v1.core.encoder_cache_manager import (
@@ -47,7 +52,7 @@ from vllm.v1.core.sched.request_queue import (
52 )
53 from vllm.v1.core.sched.utils import check_stop, remove_all
54 from vllm.v1.engine import EngineCoreEventType, EngineCoreOutput, EngineCoreOutputs
50 -from vllm.v1.kv_cache_interface import KVCacheConfig
55 +from vllm.v1.kv_cache_interface import AttentionSpec, KVCacheConfig
56 from vllm.v1.metrics.perf import ModelMetrics, PerfStats
57 from vllm.v1.metrics.stats import PrefixCacheStats, SchedulerStats
58 from vllm.v1.outputs import DraftTokenIds, KVConnectorOutput, ModelRunnerOutput
@@ -253,6 +258,43 @@ class Scheduler(SchedulerInterface):
258 if self.log_stats and vllm_config.observability_config.enable_mfu_metrics:
259 self.perf_metrics = ModelMetrics(vllm_config)
260
261 + if self.vllm_config.model_config.enable_return_routed_experts:
262 + assert self.dcp_world_size == 1 and self.pcp_world_size == 1, (
263 + "enable_return_routed_experts does not support context parallelism "
264 + "(dcp_world_size > 1 or pcp_world_size > 1)"
265 + )
266 +
267 + self.routed_experts_reader = RoutedExpertsReader.create()
268 +
269 + assert len(kv_cache_config.kv_cache_groups) > 0, (
270 + "enable_return_routed_experts requires at least one kv cache group"
271 + )
272 + # Find the attention group for routed experts indexing.
273 + self.routed_experts_attn_gid = 0
274 + for gid, group in enumerate(kv_cache_config.kv_cache_groups):
275 + if isinstance(group.kv_cache_spec, AttentionSpec):
276 + self.routed_experts_attn_gid = gid
277 + break
278 + min_block_size = min(
279 + [
280 + group.kv_cache_spec.block_size
281 + for group in kv_cache_config.kv_cache_groups
282 + ]
283 + )
284 + num_groups = len(kv_cache_config.kv_cache_groups)
285 + self.max_num_kv_tokens = (
286 + kv_cache_config.num_blocks // num_groups
287 + ) * min_block_size
288 + dcp_size = self.vllm_config.parallel_config.decode_context_parallel_size
289 + pcp_size = self.vllm_config.parallel_config.prefill_context_parallel_size
290 + if pcp_size * dcp_size > 1:
291 + self.max_num_kv_tokens *= pcp_size * dcp_size
292 +
293 + self.routed_experts_reader.attach_buffer(
294 + max_num_kv_tokens=self.max_num_kv_tokens,
295 + vllm_config=self.vllm_config,
296 + )
297 +
298 self._pause_state: PauseState = PauseState.UNPAUSED
299
300 def _mamba_block_aligned_split(
@@ -1371,15 +1413,11 @@ class Scheduler(SchedulerInterface):
1413 request.resumable = False
1414 stopped = True
1415
1374 - # Get routing data from ModelRunnerOutput (via worker D2H pipeline)
1416 routed_experts = None
1376 - if (
1377 - model_runner_output.routed_experts_dict is not None
1378 - and req_id in model_runner_output.routed_experts_dict
1379 - ):
1380 - routed_experts = model_runner_output.routed_experts_dict[req_id]
1417 finish_reason = None
1418 if stopped:
1419 + routed_experts = self._get_routed_experts(request)
1420 +
1421 # Capture finish_reason BEFORE _handle_stopped_request, which may
1422 # reset the status to WAITING for streaming requests that continue.
1423 finish_reason = request.get_finished_reason()
@@ -1554,6 +1592,31 @@ class Scheduler(SchedulerInterface):
1592 self._enqueue_waiting_request(request)
1593 return False
1594
1595 + def _get_routed_experts(self, request: Request) -> np.ndarray | None:
1596 + if not self.vllm_config.model_config.enable_return_routed_experts:
1597 + return None
1598 +
1599 + kv_blocks = self.kv_cache_manager.get_blocks(request.request_id)
1600 + block_ids = kv_blocks.get_block_ids()[self.routed_experts_attn_gid]
1601 + num_tokens = request.num_tokens - 1
1602 +
1603 + # compute slot mapping using attention group's block_size
1604 + block_ids_array = np.array(block_ids, dtype=np.int32)
1605 + num_blocks = len(block_ids)
1606 + attn_group = self.kv_cache_config.kv_cache_groups[self.routed_experts_attn_gid]
1607 + block_size = attn_group.kv_cache_spec.block_size
1608 +
1609 + # generate block offsets
1610 + block_offsets = np.arange(0, block_size)
1611 +
1612 + # compute slot mapping: slot = block_id * block_size + offset
1613 + slot_mapping = (
1614 + block_offsets.reshape((1, block_size))
1615 + + block_ids_array.reshape((num_blocks, 1)) * block_size
1616 + ).flatten()[:num_tokens]
1617 +
1618 + return self.routed_experts_reader.get_routed_experts(indices=slot_mapping)
1619 +
1620 def _update_request_with_output(
1621 self, request: Request, new_token_ids: list[int]
1622 ) -> tuple[list[int], bool]:
vllm/v1/engine/output_processor.py
+2 -27
@@ -11,9 +11,6 @@ import numpy as np
11 import torch
12
13 from vllm.lora.request import LoRARequest
14 -from vllm.model_executor.layers.fused_moe.routed_experts_capturer import (
15 - split_routed_experts,
16 -)
14 from vllm.outputs import (
15 STREAM_FINISHED,
16 CompletionOutput,
@@ -317,24 +314,8 @@ class RequestState:
314 finished,
315 )
316
320 - # Split routing data into prompt and generation portions.
321 - # Prompt routing lives on RequestOutput (shared across n>1
322 - # completions); generation routing lives on each CompletionOutput.
323 - prompt_routed_experts = None
324 - gen_routed_experts = None
325 - if routed_experts is not None:
326 - prompt_len = len(self.prompt_token_ids) if self.prompt_token_ids else 0
327 - num_gen = (
328 - self.detokenizer.num_output_tokens()
329 - if self.detokenizer is not None
330 - else None
331 - )
332 - prompt_routed_experts, gen_routed_experts = split_routed_experts(
333 - routed_experts, prompt_len, num_gen
334 - )
335 -
317 output = self._new_completion_output(
337 - new_token_ids, finish_reason, stop_reason, gen_routed_experts
318 + new_token_ids, finish_reason, stop_reason, routed_experts
319 )
320
321 if self.parent_req is None:
@@ -346,11 +327,7 @@ class RequestState:
327 external_req_id = self.parent_req.external_req_id
328
329 return self._new_request_output(
349 - external_req_id,
350 - outputs,
351 - finished,
352 - kv_transfer_params,
353 - prompt_routed_experts,
330 + external_req_id, outputs, finished, kv_transfer_params
331 )
332
333 def _new_request_output(
@@ -359,7 +336,6 @@ class RequestState:
336 outputs: list[CompletionOutput] | list[PoolingOutput],
337 finished: bool,
338 kv_transfer_params: dict[str, Any] | None = None,
362 - prompt_routed_experts: np.ndarray | None = None,
339 ) -> RequestOutput | PoolingRequestOutput:
340 # If prompt embeds were used, put placeholder prompt token ids
341 prompt_token_ids = self.prompt_token_ids
@@ -395,7 +371,6 @@ class RequestState:
371 kv_transfer_params=kv_transfer_params,
372 num_cached_tokens=self.num_cached_tokens,
373 metrics=self.stats,
398 - prompt_routed_experts=prompt_routed_experts,
374 )
375
376 def _new_completion_output(
vllm/v1/outputs.py
-3
@@ -198,9 +198,6 @@ class ModelRunnerOutput:
198 # req_id -> num_nans_in_logits
199 num_nans_in_logits: dict[str, int] | None = None
200
201 - # req_id -> routed experts ndarray of shape (seq_len, num_moe_layers, top_k)
202 - routed_experts_dict: dict[str, np.ndarray] | None = None
203 -
201 # information related to cudagraph execution
202 cudagraph_stats: CUDAGraphStat | None = None
203
vllm/v1/worker/gpu_model_runner.py
+45 -70
@@ -54,11 +54,7 @@ from vllm.lora.layers import LoRAMapping, LoRAMappingType
54 from vllm.model_executor.layers.attention import Attention, MLAAttention
55 from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
56 from vllm.model_executor.layers.fused_moe.routed_experts_capturer import (
57 - extract_routed_experts_for_current_batch,
58 - free_routing_buffers,
59 - get_global_experts_capturer,
60 - init_routed_experts_capturer_with_shared_cache,
61 - issue_routing_d2h_copy,
57 + RoutedExpertsCapturer,
58 )
59 from vllm.model_executor.layers.mamba.ops.ssu_dispatch import (
60 initialize_mamba_ssu_backend,
@@ -1112,12 +1108,6 @@ class GPUModelRunner(
1108 for req_id in scheduler_output.finished_req_ids:
1109 self.input_batch.remove_request(req_id)
1110
1115 - if self.routed_experts_initialized:
1116 - free_routing_buffers(
1117 - scheduler_output.finished_req_ids,
1118 - scheduler_output.preempted_req_ids,
1119 - )
1120 -
1111 # Zero GPU memory for freshly allocated cache blocks to prevent
1112 # stale NaN/data from corrupting attention or SSM computation.
1113 if scheduler_output.new_block_ids_to_zero:
@@ -2201,6 +2191,10 @@ class GPUModelRunner(
2191 block_table_gid_0 = _get_block_table(0)
2192 slot_mapping_gid_0 = slot_mappings[0]
2193
2194 + if self.routed_experts_initialized:
2195 + attn_gid = self.routed_experts_attn_gid
2196 + slot_mapping_attn = slot_mappings[attn_gid]
2197 + self.slot_mapping = slot_mapping_attn[:num_tokens].cpu().numpy()
2198 num_computed_tokens_cpu = self.input_batch.num_computed_tokens_cpu_tensor[
2199 :num_reqs_padded
2200 ]
@@ -3884,9 +3878,11 @@ class GPUModelRunner(
3878 )
3879
3880 if self.routed_experts_initialized:
3887 - capturer = get_global_experts_capturer()
3881 + capturer = RoutedExpertsCapturer.get_instance()
3882 if capturer is not None:
3889 - capturer.finalize_pending_copy()
3883 + capturer.clear_buffer() # noqa
3884 + else:
3885 + logger.error("RoutedExpertsCapturer not initialized.")
3886
3887 # If ngram_gpu is used, we need to copy the scheduler_output to avoid
3888 # the modification has influence on the scheduler_output in engine core process.
@@ -4400,14 +4396,6 @@ class GPUModelRunner(
4396 scheduler_output.total_num_scheduled_tokens,
4397 )
4398
4403 - if self.routed_experts_initialized:
4404 - issue_routing_d2h_copy(
4405 - input_batch_req_ids=self.input_batch.req_ids,
4406 - num_scheduled_tokens=scheduler_output.num_scheduled_tokens,
4407 - positions=self.positions,
4408 - positions_cpu=self._positions_cpu,
4409 - )
4410 -
4399 if propose_drafts_after_bookkeeping:
4400 # ngram and other speculative decoding methods use the sampled
4401 # tokens on the CPU, so they are run after bookkeeping.
@@ -4427,15 +4415,12 @@ class GPUModelRunner(
4415 self.kv_connector_output = None
4416
4417 with record_function_or_nullcontext("gpu_model_runner: ModelRunnerOutput"):
4430 - routed_experts_dict = None
4418 if self.routed_experts_initialized:
4432 - routed_experts_dict = extract_routed_experts_for_current_batch(
4433 - req_ids=req_ids_output_copy,
4434 - requests=self.requests,
4435 - req_id_to_index=self.input_batch.req_id_to_index,
4436 - num_tokens_no_spec=self.input_batch.num_tokens_no_spec,
4437 - max_model_len=self.max_model_len,
4438 - )
4419 + capturer = RoutedExpertsCapturer.get_instance()
4420 + if capturer is not None:
4421 + capturer.save_captured_experts(indices=self.slot_mapping) # noqa
4422 + else:
4423 + logger.error("RoutedExpertsCapturer not initialized.")
4424
4425 output = ModelRunnerOutput(
4426 req_ids=req_ids_output_copy,
@@ -4449,7 +4434,6 @@ class GPUModelRunner(
4434 else None,
4435 num_nans_in_logits=num_nans_in_logits,
4436 cudagraph_stats=cudagraph_stats,
4452 - routed_experts_dict=routed_experts_dict,
4437 )
4438
4439 if not self.use_async_scheduling:
@@ -6242,8 +6226,6 @@ class GPUModelRunner(
6226 "Skipping CUDA graph capture. To turn on CUDA graph capture, "
6227 "ensure `cudagraph_mode` was not manually set to `NONE`"
6228 )
6245 - if self.model_config.enable_return_routed_experts:
6246 - self.init_routed_experts_capturer()
6229 return 0
6230
6231 # Initialize encoder CUDA graph manager if enabled.
@@ -6277,14 +6259,6 @@ class GPUModelRunner(
6259
6260 start_time = time.perf_counter()
6261
6280 - # Initialize the routed experts capturer once before any CUDA graph
6281 - # capture. Must happen before graphs are captured so the buffer
6282 - # address is baked into the graph. Do NOT call this inside
6283 - # _capture_cudagraphs() -- creating the capturer twice replaces
6284 - # the device buffer, causing graphs to write to a dead buffer.
6285 - if self.model_config.enable_return_routed_experts:
6286 - self.init_routed_experts_capturer()
6287 -
6262 # Trigger CUDA graph capture for specific shapes.
6263 # Capture the large shapes first so that the smaller shapes
6264 # can reuse the memory pool allocated for the large shapes.
@@ -7079,44 +7053,45 @@ class GPUModelRunner(
7053 "Initializing routed experts capturer, enable_return_routed_experts: %s",
7054 self.model_config.enable_return_routed_experts,
7055 )
7082 - if not self.model_config.enable_return_routed_experts:
7083 - self.routed_experts_initialized = False
7084 - return
7085 -
7086 - from vllm.distributed import get_tp_group
7087 -
7088 - if hasattr(self.model_config.hf_text_config, "n_shared_experts"):
7089 - num_fused_shared_experts = 1
7090 - else:
7091 - num_fused_shared_experts = 0
7056 + routed_experts_capturer = RoutedExpertsCapturer.create()
7057 + self.routed_experts_attn_gid = self._get_attention_kv_cache_gid()
7058 + min_block_size = min(
7059 + [
7060 + group.kv_cache_spec.block_size
7061 + for group in self.kv_cache_config.kv_cache_groups
7062 + ]
7063 + )
7064 + num_groups = len(self.kv_cache_config.kv_cache_groups)
7065 + self.max_num_kv_tokens = (
7066 + self.kv_cache_config.num_blocks // num_groups
7067 + ) * min_block_size
7068 + dcp_size = self.vllm_config.parallel_config.decode_context_parallel_size
7069 + pcp_size = self.vllm_config.parallel_config.prefill_context_parallel_size
7070 + if pcp_size * dcp_size > 1:
7071 + self.max_num_kv_tokens *= pcp_size * dcp_size
7072
7093 - tp_group = get_tp_group()
7094 - init_routed_experts_capturer_with_shared_cache(
7095 - enable=self.model_config.enable_return_routed_experts,
7096 - model_config=self.model_config,
7097 - num_fused_shared_experts=num_fused_shared_experts,
7073 + routed_experts_capturer.init_buffer(
7074 max_num_batched_tokens=self.scheduler_config.max_num_batched_tokens,
7099 - max_model_len=self.max_model_len,
7100 - device=self.device,
7101 - rank=tp_group.rank_in_group,
7102 - world_size=tp_group.world_size,
7075 + max_num_kv_tokens=self.max_num_kv_tokens,
7076 + vllm_config=self.vllm_config,
7077 )
7104 - self._bind_routed_experts_capturer()
7078 + self._bind_routed_experts_capturer(routed_experts_capturer)
7079 self.routed_experts_initialized = True
7080
7107 - # Pinned CPU buffer for async positions D2H (avoids sync .cpu() call)
7108 - self._positions_cpu = torch.empty(
7109 - self.scheduler_config.max_num_batched_tokens,
7110 - dtype=torch.long,
7111 - pin_memory=True,
7081 + def _bind_routed_experts_capturer(self, capturer: RoutedExpertsCapturer) -> None:
7082 + from vllm.model_executor.layers.fused_moe.layer import FusedMoE
7083 + from vllm.model_executor.layers.fused_moe.router.base_router import (
7084 + BaseRouter,
7085 )
7086
7114 - def _bind_routed_experts_capturer(self) -> None:
7115 - from vllm.model_executor.layers.fused_moe.routed_experts_capturer import (
7116 - bind_routing_capture_to_model,
7117 - )
7087 + for module in self.compilation_config.static_forward_context.values():
7088 + if isinstance(module, FusedMoE) and isinstance(module.router, BaseRouter):
7089 + layer_id = module.layer_id
7090 +
7091 + def _capture_fn(topk_ids, _layer_id=layer_id, _capturer=capturer):
7092 + _capturer.capture(_layer_id, topk_ids)
7093
7119 - bind_routing_capture_to_model(self.model)
7094 + module.router.set_capture_fn(_capture_fn)
7095
7096 def may_add_encoder_only_layers_to_kv_cache_config(self) -> None:
7097 """