@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / c7560af424
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

[RFC] Replace shared-memory routed experts with ModelRunnerOutput transfer and HTTP support (#39568)

Signed-off-by: xhx1022 <1737006628@qq.com> Signed-off-by: arlenxu <arlenxu@tencent.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: arlenxu <arlenxu@tencent.com> Co-authored-by: Junjie Zhang <junj.jay.zhang@gmail.com>

Hongxin Xu committed May 14, 2026 at 22:12 UTC c7560af42487b1570c4e6f4cea5df1605a4d59fc
12 files changed +610 -374
tests/model_executor/test_routed_experts_capture.py
+12 -7
@@ -25,10 +25,15 @@ def _capturer_with_buffer(
25 num_layers: int = 4,
26 num_experts_per_tok: int = 2,
27 dp_rank: int = 0,
28 + tp_size: int = 1,
29 ) -> RoutedExpertsCapturer:
29 - c = RoutedExpertsCapturer()
30 + # Bypass __init__ so the test can use a CPU buffer and skip the
31 + # VllmConfig dependency. The CUDA device-tensor allocation in the
32 + # real constructor is not what we are exercising here.
33 + c = RoutedExpertsCapturer.__new__(RoutedExpertsCapturer)
34 c.dp_rank = dp_rank
31 - c._device_buffer = torch.full(
35 + c.tp_size = tp_size
36 + c.device_buffer = torch.full(
37 (max_tokens, num_layers, num_experts_per_tok),
38 -1,
39 dtype=torch.int32,
@@ -193,8 +198,8 @@ def test_routed_experts_capturer_single_dp_no_metadata():
198 ctx = SimpleNamespace(dp_metadata=None)
199 with patch(f"{_REC_MODULE}.get_forward_context", return_value=ctx):
200 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
201 + assert torch.equal(capturer.device_buffer[:3, 0, :], topk)
202 + assert capturer.device_buffer[3, 0, 0].item() == -1
203
204
205 def test_routed_experts_capturer_dp_naive_concatenated_all_ranks():
@@ -211,7 +216,7 @@ def test_routed_experts_capturer_dp_naive_concatenated_all_ranks():
216 with patch(f"{_REC_MODULE}.get_forward_context", return_value=ctx):
217 capturer.capture(layer_id=0, topk_ids=topk)
218 want = topk[2:5]
214 - assert torch.equal(capturer._device_buffer[:3, 0, :], want)
219 + assert torch.equal(capturer.device_buffer[:3, 0, :], want)
220
221
222 def test_routed_experts_capturer_dp_modular_local_tokens():
@@ -224,7 +229,7 @@ def test_routed_experts_capturer_dp_modular_local_tokens():
229 topk = torch.tensor([[10, 11], [12, 13], [14, 15]], dtype=torch.int32)
230 with patch(f"{_REC_MODULE}.get_forward_context", return_value=ctx):
231 capturer.capture(layer_id=0, topk_ids=topk)
227 - assert torch.equal(capturer._device_buffer[:3, 0, :], topk)
232 + assert torch.equal(capturer.device_buffer[:3, 0, :], topk)
233
234
235 def test_routed_experts_capturer_dp_unexpected_batch_raises():
@@ -241,4 +246,4 @@ def test_routed_experts_capturer_dp_unexpected_batch_raises():
246 pytest.raises(AssertionError, match="unexpected topk_ids batch dim"),
247 ):
248 capturer.capture(layer_id=0, topk_ids=topk)
244 - assert capturer._device_buffer[0, 0, 0].item() == -1
249 + assert capturer.device_buffer[0, 0, 0].item() == -1
tests/v1/core/test_async_scheduler.py
+1
@@ -282,6 +282,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
282 scheduler.finished_req_ids_dict = None
283 scheduler.vllm_config = Mock()
284 scheduler.vllm_config.model_config.enable_return_routed_experts = False
285 + scheduler.enable_return_routed_experts = False
286 scheduler.recompute_kv_load_failures = False
287 scheduler.make_stats = Mock(return_value=None)
288 scheduler.max_model_len = 128
tests/v1/core/test_scheduler.py
+1
@@ -2545,6 +2545,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
2545 scheduler.finished_req_ids_dict = None
2546 scheduler.vllm_config = Mock()
2547 scheduler.vllm_config.model_config.enable_return_routed_experts = False
2548 + scheduler.enable_return_routed_experts = False
2549 scheduler.recompute_kv_load_failures = False
2550 scheduler.make_stats = Mock(return_value=None)
2551 scheduler.max_model_len = 128
vllm/config/vllm.py
+24
@@ -785,6 +785,30 @@ class VllmConfig:
785
786 self.parallel_config.is_moe_model = self.model_config.is_moe
787
788 + if (
789 + self.model_config is not None
790 + and self.model_config.enable_return_routed_experts
791 + ):
792 + if self.parallel_config.pipeline_parallel_size > 1:
793 + raise ValueError(
794 + "--enable-return-routed-experts is incompatible with "
795 + "pipeline parallelism (PP > 1)."
796 + )
797 +
798 + # Incompatible with any KV connector — covers both PD disaggregation
799 + # (kv_producer/kv_consumer: routing captured on P can't reach D) and
800 + # single-instance KV offload/sharing (kv_both: slot_mapping semantics
801 + # change when KV blocks live outside local GPU memory, breaking the
802 + # slot-indexed routed_experts buffer).
803 + if (
804 + self.kv_transfer_config is not None
805 + and self.kv_transfer_config.is_kv_transfer_instance
806 + ):
807 + raise ValueError(
808 + "--enable-return-routed-experts is incompatible with KV "
809 + "connectors (PD disaggregation, KV cache offload)."
810 + )
811 +
812 if self.lora_config is not None:
813 self.lora_config.verify_with_model_config(self.model_config)
814
vllm/entrypoints/serve/disagg/protocol.py
+10
@@ -160,6 +160,16 @@ class GenerateResponseChoice(BaseModel):
160 # per OpenAI spec this is the default
161 finish_reason: str | None = "stop"
162 token_ids: list[int] | None = None
163 + # Per-token expert routing decisions, base64-encoded ``.npy`` bytes
164 + # (numpy serialization). Shape after decode:
165 + # (num_tokens - 1, num_layers, num_experts_per_tok) dtype uint8/uint16
166 + # ``num_tokens - 1`` because the last sampled token has not been
167 + # forwarded yet and therefore has no routing data.
168 + # Decode:
169 + # np.load(io.BytesIO(base64.b64decode(s)))
170 + # ``None`` if (a) the request was aborted before any forward pass,
171 + # or (b) ``enable_return_routed_experts`` is off server-side.
172 + routed_experts: str | None = None
173
174
175 class GenerateResponseStreamChoice(BaseModel):
vllm/entrypoints/serve/disagg/serving.py
+16
@@ -3,10 +3,13 @@
3
4
5 import asyncio
6 +import io
7 import time
8 from collections.abc import AsyncGenerator
9 from collections.abc import Sequence as GenericSequence
10
11 +import numpy as np
12 +import pybase64 as base64
13 from fastapi import Request
14
15 from vllm.engine.protocol import EngineClient
@@ -255,11 +258,24 @@ class ServingTokens(OpenAIServing):
258 else:
259 logprobs = None
260
261 + # Encode routed_experts for transport. JSON can't carry raw
262 + # bytes, so we write the ndarray as a ``.npy`` byte stream
263 + # and base64-encode it. ``pybase64`` is ~3x faster than the
264 + # stdlib ``base64`` on large payloads thanks to SIMD.
265 + # This is the only base64 hop in the pipeline -- the
266 + # engine<->API-server link is binary msgpack + zmq.
267 + routed_experts_b64 = None
268 + if output.routed_experts is not None:
269 + buf = io.BytesIO()
270 + np.save(buf, output.routed_experts)
271 + routed_experts_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
272 +
273 choice_data = GenerateResponseChoice(
274 index=output.index,
275 logprobs=logprobs,
276 finish_reason=output.finish_reason if output.finish_reason else "stop",
277 token_ids=as_list(output.token_ids),
278 + routed_experts=routed_experts_b64,
279 )
280
281 choices.append(choice_data)
vllm/model_executor/layers/fused_moe/routed_experts_capturer.py
+242 -264
@@ -5,22 +5,16 @@
5
6 from __future__ import annotations
7
8 -import fcntl
8 import logging
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
9
10 import numpy as np
11 import torch
12
13 from vllm.config import VllmConfig
21 -from vllm.distributed import get_tensor_model_parallel_rank
14 +from vllm.distributed.parallel_state import get_tp_group
15 from vllm.forward_context import get_forward_context
16 from vllm.platforms import current_platform
17 +from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig
18
19 logger = logging.getLogger(__name__)
20
@@ -42,151 +36,104 @@ def _get_num_experts_per_tok(hf_config) -> int:
36 return val
37
38
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 -
50 -# Global singleton instances
51 -_global_experts_capturer: RoutedExpertsCapturer | None = None
52 -_global_experts_reader: RoutedExpertsReader | None = None
53 -
54 -
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 -
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 -
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 -
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
39 +def get_num_experts(hf_config) -> int:
40 + """Resolve ``num_experts`` across HuggingFace config naming conventions.
41
96 -
97 -class RoutedExpertsCapturer:
98 - """
99 - Capturer for routed experts with device and optional shared memory buffer.
100 -
101 - This class captures expert routing decisions during model forward passes
102 - and optionally stores them in shared memory for cross-process access.
42 + Different MoE model families expose this under different keys:
43 + - ``num_experts``: Mixtral, Qwen2-MoE, Qwen3-MoE
44 + - ``n_routed_experts``: DeepSeek-V2/V3
45 + - ``num_local_experts``: Mixtral (older exports)
46 """
47 + for key in ("num_experts", "n_routed_experts", "num_local_experts"):
48 + val = getattr(hf_config, key, None)
49 + if val is not None:
50 + return val
51 + raise ValueError(
52 + "Could not resolve num_experts from model config. "
53 + "Expected one of 'num_experts', 'n_routed_experts', "
54 + "or 'num_local_experts'."
55 + )
56
105 - _instance: RoutedExpertsCapturer | None = None
57
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 -
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 -
123 - @staticmethod
124 - def get_instance() -> RoutedExpertsCapturer | None:
125 - """Get the global singleton instance."""
126 - return _global_experts_capturer
58 +class RoutedExpertsCapturer:
59 + """Worker-side capturer for routed experts, lives on GPU.
60 +
61 + Layer-level hooks call :meth:`capture` from inside the forward pass
62 + with the per-layer ``topk_ids`` tensor. The tensor is sliced to the
63 + tokens owned by this DP rank and written into a preallocated device
64 + buffer. At the end of the step, :class:`GPUModelRunner` reads the
65 + device buffer, issues a D2H copy into a pinned CPU buffer, and hands
66 + the result to the scheduler via :class:`RoutedExpertsLists`.
67 +
68 + The device / pinned-CPU transit buffers use ``torch.int32`` (not a
69 + narrow ``uint8``/``uint16`` sized by ``num_experts``). This keeps the
70 + SP all-gather path free of dtype casts, matches the router's native
71 + ``topk_ids`` indices dtype more closely, and costs only a few MB per
72 + worker (``max_num_batched_tokens * num_layers * top_k * 4`` bytes).
73 + The scheduler-side slot buffer
74 + (``RoutedExpertsManager.routed_experts_by_slot``) still uses the
75 + narrow dtype -- numpy fancy-index assignment in ``store_batch``
76 + narrows the data on the way in.
77 +
78 + Invariants:
79 + - One instance per worker; shape is fixed at init and covers the
80 + worst-case step (``max_num_batched_tokens`` tokens).
81 + - :meth:`clear_buffer` is called at the start of every step, so
82 + unused slots stay zero.
83 + - ``device_buffer.dtype`` is ``torch.int32``.
84 + """
85
128 - def init_buffer(
86 + def __init__(
87 self,
88 max_num_batched_tokens: int,
131 - max_num_kv_tokens: int,
89 vllm_config: VllmConfig,
90 ) -> None:
134 - """
135 - Initialize the device buffer and optionally shared memory buffer.
136 -
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 -
143 - if self._device_buffer is not None:
144 - raise RuntimeError("Device buffer has already been initialized")
145 -
91 hf_config = vllm_config.model_config.hf_text_config
147 - num_layers = hf_config.num_hidden_layers
92 num_experts_per_tok = _get_num_experts_per_tok(hf_config)
149 -
150 - # Initialize device buffer
151 - self._device_buffer = torch.zeros(
152 - (max_num_batched_tokens, num_layers, num_experts_per_tok),
93 + self.device_buffer = torch.zeros(
94 + (
95 + max_num_batched_tokens,
96 + hf_config.num_hidden_layers,
97 + num_experts_per_tok,
98 + ),
99 + # Use int32 for the device / host transit buffers: it
100 + # matches the router's native topk_ids dtype, is universally
101 + # supported by NCCL (uint8/uint16 are version-dependent),
102 + # and the extra bytes are small (few MB per worker). The
103 + # big scheduler-side slot buffer stays narrow.
104 dtype=torch.int32,
105 device=current_platform.device_type,
106 )
107 self.dp_rank = vllm_config.parallel_config.data_parallel_rank
157 -
158 - if get_tensor_model_parallel_rank() != 0:
159 - return
160 -
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 -
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 -
174 - logger.debug(
175 - "Created shared memory buffer '%s' with shape %s",
176 - shm_name,
177 - shape,
178 - )
108 + self.tp_size = vllm_config.parallel_config.tensor_parallel_size
109
110 def capture(self, layer_id: int, topk_ids: torch.Tensor) -> None:
181 - """
182 - Capture expert routing decisions for a specific layer.
111 + """Capture expert routing decisions for a specific layer.
112 +
113 + Under data parallelism, ``topk_ids`` may have three different batch
114 + layouts depending on where the DP combine happens and whether
115 + Sequence Parallelism (SP) is active for the MoE layer:
116 + - ``n == total`` (naive dispatch): all DP ranks' tokens are
117 + concatenated before routing; we slice out this rank's span
118 + using the cumulative per-rank counts.
119 + - ``n == token_num_per_dp`` (modular-kernel path): DP combine
120 + happens inside ``quant_method.apply``; ``select_experts`` only
121 + ever sees this rank's tokens, so we take the whole tensor.
122 + - ``n == ceil(token_num_per_dp / tp_size)`` (SP + modular-kernel
123 + path): tokens were split along dim=0 across the TP group by
124 + ``_sequence_parallel_context``
125 + (``moe_runner_base.py:_sequence_parallel_context``), so each
126 + TP rank only sees its shard. We all-gather along dim=0 to
127 + reconstruct this DP rank's full routing tensor. SP pads with
128 + ceil-div (see ``_compute_sp_num_tokens`` in
129 + ``forward_context.py``), so the gathered tensor may contain a
130 + few trailing padding rows which are trimmed by the downstream
131 + ``[:token_num_per_dp]`` slice.
132
133 Args:
134 layer_id: The layer index.
135 topk_ids: Tensor of shape (batch_size, num_routed_experts).
136 """
188 - if self._device_buffer is None:
189 - raise RuntimeError("Buffer not initialized. Call init_buffer() first.")
137
138 ctx = get_forward_context()
139 if ctx.dp_metadata is None: # single dp
@@ -200,172 +147,203 @@ class RoutedExpertsCapturer:
147 n = topk_ids.shape[0]
148
149 if n == total:
203 - # Naive dispatch: all DP ranks' tokens concatenated before routing.
150 + # Naive dispatch: all DP ranks' tokens concatenated
151 + # before routing. This rank owns tokens
152 + # [end_loc - token_num_per_dp, end_loc).
153 cumsum = torch.cumsum(num_tokens_dp, dim=0)
154 end_loc = int(cumsum[self.dp_rank].item())
155 start_loc = end_loc - token_num_per_dp
156 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.
157 + # Modular-kernel path: DP combine happens inside
158 + # quant_method.apply; select_experts only sees this
159 + # rank's tokens, take the whole tensor.
160 + start_loc = 0
161 + end_loc = token_num_per_dp
162 + elif (
163 + self.tp_size > 1
164 + and n != token_num_per_dp
165 + and n == (token_num_per_dp + self.tp_size - 1) // self.tp_size
166 + ):
167 + # SP + modular-kernel path. All-gather across the TP
168 + # group along dim=0 to reconstruct the full per-DP-rank
169 + # tensor; keep only the first ``token_num_per_dp`` rows
170 + # (trailing rows are SP ceil-div padding). The TP group
171 + # is always initialized on real rollout workers, and
172 + # every rank in the group reaches this branch in
173 + # lockstep (bind is per-FusedMoE layer, SP is a global
174 + # condition), so a bare all_gather here will not
175 + # deadlock -- let it raise if the precondition is
176 + # violated rather than skip silently.
177 + #
178 + # ``topk_ids`` is already whatever the router produced
179 + # (typically int32/int64, both supported by NCCL); the
180 + # downstream ``device_buffer[...] = topk_ids[...]``
181 + # setitem narrows into int32 automatically.
182 + topk_ids = get_tp_group().all_gather(topk_ids, dim=0)
183 start_loc = 0
184 end_loc = token_num_per_dp
185 else:
186 + sp_expected = (
187 + (token_num_per_dp + self.tp_size - 1) // self.tp_size
188 + if self.tp_size > 0
189 + else -1
190 + )
191 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})"
192 + "RoutedExpertsCapturer: unexpected topk_ids batch "
193 + f"dim {n} (expected {total}, {token_num_per_dp}, "
194 + f"or {sp_expected} for dp_rank={self.dp_rank}, "
195 + f"tp_size={self.tp_size})"
196 )
197
219 - if layer_id >= self._device_buffer.shape[1]:
198 + # Defensive: model may expose more layers than the capture buffer
199 + # was sized for (unusual, but guards against miss-config).
200 + if layer_id >= self.device_buffer.shape[1]:
201 return
202
222 - self._device_buffer[:token_num_per_dp, layer_id, :] = topk_ids[
203 + self.device_buffer[:token_num_per_dp, layer_id, :] = topk_ids[
204 start_loc:end_loc, :
205 ]
206
207 def clear_buffer(self) -> None:
227 - """Clear the device buffer."""
228 - if self._device_buffer is not None:
229 - self._device_buffer.zero_()
230 -
231 - def save_captured_experts(self, indices: np.ndarray) -> None:
208 + """Zero the device buffer. Called at the start of every step so
209 + slots belonging to finished / preempted tokens don't leak into
210 + the next step.
211 """
233 - Save captured experts from device buffer to shared memory.
212 + self.device_buffer.zero_()
213
235 - Args:
236 - indices: Array of indices indicating where to store the data.
214 + def get_device_buffer(self) -> torch.Tensor:
215 + """Return the underlying device buffer so the model runner can
216 + issue the D2H copy. The tensor is shared; callers must either
217 + clone or fully drain it before the next forward pass runs
218 + :meth:`clear_buffer`.
219 """
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 -
247 - num_tokens = len(indices)
248 - data = self._device_buffer[:num_tokens, :, :].cpu().numpy()
249 -
250 - with _file_lock(self._lock_file):
251 - self._host_buffer_view[indices, :, :] = data
252 -
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 -
264 - def __del__(self) -> None:
265 - """Clean up shared memory on destruction."""
266 - self.cleanup()
267 -
268 -
269 -class RoutedExpertsReader:
270 - """
271 - Reader for routed experts from shared memory.
272 -
273 - This class attaches to shared memory created by RoutedExpertsCapturer
274 - and reads expert routing decisions.
220 + return self.device_buffer
221 +
222 +
223 +class RoutedExpertsManager:
224 + """Scheduler-side slot-indexed buffer for routed experts.
225 +
226 + Lives on CPU in the scheduler process. Each slot corresponds to
227 + ``block_id * block_size + offset_in_block`` where ``block_id`` is
228 + drawn from the physical KV-cache block pool, so routing data is
229 + tied to physical blocks and naturally survives preemption for
230 + prefix-cached blocks (prefix hits re-expose the same slots).
231 +
232 + Data flow per step:
233 + 1. Worker D2Hs its device capture buffer into
234 + :class:`RoutedExpertsLists` and returns it via
235 + :class:`ModelRunnerOutput`.
236 + 2. Scheduler calls :meth:`store_batch` with that step's
237 + ``(routing_data, slot_mapping)`` — a single CPU->CPU
238 + fancy-index assign, ~few MB per step.
239 + 3. On request completion / abort / preemption, the scheduler
240 + calls :meth:`get` with the request's block IDs to recover
241 + the full per-token routing.
242 +
243 + Memory: ``routed_experts_by_slot`` is sized for the whole block
244 + pool (``num_blocks * block_size`` slots). For large block pools
245 + this can reach multiple GB; see the init log for the exact size.
246 """
247
277 - _instance: RoutedExpertsReader | None = None
278 -
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 -
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 -
301 - def attach_buffer(
248 + def __init__(
249 self,
303 - max_num_kv_tokens: int,
250 vllm_config: VllmConfig,
251 + kv_cache_config: KVCacheConfig,
252 ) -> 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
253 + # Pick the attention group for block/slot mapping. We require
254 + # a FullAttentionSpec group rather than any AttentionSpec to
255 + # stay consistent with the worker-side lookup in
256 + # ``GPUModelRunner._get_attention_kv_cache_gid``; hybrid models
257 + # (Mamba / linear attention) also have other AttentionSpec
258 + # groups whose slot layout differs.
259 + self.attn_gid = next(
260 + gid
261 + for gid, g in enumerate(kv_cache_config.kv_cache_groups)
262 + if isinstance(g.kv_cache_spec, FullAttentionSpec)
263 + )
264 + attn_group = kv_cache_config.kv_cache_groups[self.attn_gid]
265 + self.block_size = attn_group.kv_cache_spec.block_size
266
267 + # All kv_cache_groups share the same physical block pool, so
268 + # block IDs span [0, num_blocks) regardless of how many groups
269 + # exist. Sizing to the full pool avoids index-out-of-range
270 + # when different groups happen to land on the same block.
271 hf_config = vllm_config.model_config.hf_text_config
318 - shape = (
319 - max_num_kv_tokens,
272 + num_experts = get_num_experts(hf_config)
273 + num_experts_per_tok = _get_num_experts_per_tok(hf_config)
274 + max_num_slots = kv_cache_config.num_blocks * self.block_size
275 + # Expert IDs are 0..num_experts-1; uint8 fits 256 distinct
276 + # values so the boundary is ``<= 256`` (NOT ``< 256``). Keeping
277 + # this narrow matters because the slot buffer is sized for the
278 + # whole block pool and can reach multiple GB.
279 + expert_id_dtype = np.uint8 if num_experts <= 256 else np.uint16
280 + self.routed_experts_by_slot = np.zeros(
281 + (
282 + max_num_slots,
283 + hf_config.num_hidden_layers,
284 + num_experts_per_tok,
285 + ),
286 + dtype=expert_id_dtype,
287 + )
288 + logger.info(
289 + "RoutedExpertsManager CPU buffer: %.2f GB "
290 + "(slots=%d, layers=%d, top_k=%d, dtype=%s)",
291 + self.routed_experts_by_slot.nbytes / 1e9,
292 + max_num_slots,
293 hf_config.num_hidden_layers,
321 - _get_num_experts_per_tok(hf_config),
294 + hf_config.num_experts_per_tok,
295 + self.routed_experts_by_slot.dtype.name,
296 )
297
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)
298 + def store_batch(self, data: np.ndarray, slot_mapping: np.ndarray) -> None:
299 + """Persist one step's routed experts into the slot buffer.
300
337 - self._host_buffer_view = np.ndarray(
338 - shape, dtype=np.int32, buffer=self._shm.buf
339 - )
340 -
341 - def get_routed_experts(self, indices: np.ndarray) -> np.ndarray:
301 + Equivalent to ``slot_buffer[slot_mapping] = data``; numpy fancy
302 + indexing handles repeated / out-of-order indices. Called once
303 + per scheduler step in ``update_from_output``.
304 """
343 - Read routed expert data from shared memory.
305 + self.routed_experts_by_slot[slot_mapping] = data
306 +
307 + def get(
308 + self,
309 + block_ids: list[int],
310 + num_tokens: int,
311 + token_start: int = 0,
312 + ) -> np.ndarray:
313 + """Read routed experts data for a completed / preempted request.
314 +
315 + Reconstructs a per-token slot_mapping from the request's block
316 + IDs and returns the routing slice. Because numpy fancy indexing
317 + returns a **copy** (not a view), the returned ndarray is safe
318 + to hold across subsequent :meth:`store_batch` calls — do not
319 + replace the fancy index with a slice without re-verifying.
320
321 Args:
346 - indices: Array of indices to read.
322 + block_ids: Block IDs from the attention KV-cache group.
323 + num_tokens: Number of tokens that have gone through a forward
324 + pass and therefore have routing data written to their
325 + slots (typically ``request.num_tokens - 1``; the last
326 + sampled token has not been forwarded yet). Slots beyond
327 + ``request.num_computed_tokens`` are zero-initialized.
328 + token_start: Skip the first ``token_start`` tokens from the
329 + result. The slot_mapping is sliced before the fancy-index
330 + read, so only the requested slots are fetched — no large
331 + intermediate array is allocated. Clamped to
332 + ``[0, num_tokens]`` automatically.
333
334 Returns:
349 - Copy of the expert routing data for the given indices.
335 + Array of shape (num_tokens - token_start, num_layers,
336 + num_experts_per_tok).
337 """
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()
338 + bs = self.block_size
339 + block_ids_array = np.array(block_ids, dtype=np.int32)
340 + block_offsets = np.arange(bs)
341 + # slot = block_id * block_size + offset_in_block; flatten the
342 + # (num_blocks, block_size) grid and trim to num_tokens, then
343 + # skip the first token_start entries so only the requested
344 + # range is fetched in a single fancy-index read.
345 + slot_mapping = (
346 + block_ids_array.reshape(-1, 1) * bs + block_offsets.reshape(1, -1)
347 + ).flatten()[:num_tokens]
348 + slot_mapping = slot_mapping[token_start:]
349 + return self.routed_experts_by_slot[slot_mapping]
vllm/sampling_params.py
+7
@@ -294,6 +294,13 @@ class SamplingParams(
294 """Arbitrary additional args, that can be used by custom sampling
295 implementations, plugins, etc. Not used by any in-tree sampling
296 implementations."""
297 + routed_experts_prompt_start: int = 0
298 + """When enable_return_routed_experts is active, skip the first
299 + routed_experts_prompt_start prompt tokens from the returned routing
300 + data. In multi-turn agent scenarios, set this to the length of the
301 + already-returned prefix to avoid duplicating routing for prompt tokens
302 + covered by earlier turns. Default 0 returns routing for all prompt
303 + tokens."""
304
305 # Fields used for bad words
306 bad_words: list[str] | None = None
vllm/v1/core/sched/scheduler.py
+91 -61
@@ -7,8 +7,6 @@ from collections.abc import Iterable
7 from dataclasses import replace
8 from typing import Any
9
10 -import numpy as np
11 -
10 from vllm import envs
11 from vllm.compilation.cuda_graph import CUDAGraphStat
12 from vllm.config import VllmConfig
@@ -28,7 +26,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.base import KVConnectorMetadat
26 from vllm.distributed.kv_transfer.kv_connector.v1.metrics import KVConnectorStats
27 from vllm.logger import init_logger
28 from vllm.model_executor.layers.fused_moe.routed_experts_capturer import (
31 - RoutedExpertsReader,
29 + RoutedExpertsManager,
30 )
31 from vllm.multimodal import MULTIMODAL_REGISTRY, MultiModalRegistry
32 from vllm.multimodal.encoder_budget import MultiModalBudget
@@ -52,7 +50,7 @@ from vllm.v1.core.sched.request_queue import (
50 )
51 from vllm.v1.core.sched.utils import check_stop, remove_all
52 from vllm.v1.engine import EngineCoreEventType, EngineCoreOutput, EngineCoreOutputs
55 -from vllm.v1.kv_cache_interface import AttentionSpec, KVCacheConfig
53 +from vllm.v1.kv_cache_interface import KVCacheConfig
54 from vllm.v1.metrics.perf import ModelMetrics, PerfStats
55 from vllm.v1.metrics.stats import PrefixCacheStats, SchedulerStats
56 from vllm.v1.outputs import DraftTokenIds, KVConnectorOutput, ModelRunnerOutput
@@ -258,42 +256,24 @@ class Scheduler(SchedulerInterface):
256 if self.log_stats and vllm_config.observability_config.enable_mfu_metrics:
257 self.perf_metrics = ModelMetrics(vllm_config)
258
261 - if self.vllm_config.model_config.enable_return_routed_experts:
259 + self.enable_return_routed_experts = (
260 + vllm_config.model_config.enable_return_routed_experts
261 + )
262 +
263 + if self.enable_return_routed_experts:
264 assert self.dcp_world_size == 1 and self.pcp_world_size == 1, (
265 "enable_return_routed_experts does not support context parallelism "
266 "(dcp_world_size > 1 or pcp_world_size > 1)"
267 )
268
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,
269 + self.routed_experts_mgr = RoutedExpertsManager(
270 + vllm_config=vllm_config,
271 + kv_cache_config=kv_cache_config,
272 )
273 + # Block-ID snapshot taken at schedule time (before forward),
274 + # so update_from_output can read slot data even if a later
275 + # schedule() frees the blocks (async scheduling race).
276 + self._re_block_ids: dict[str, list[int]] = {}
277
278 self._pause_state: PauseState = PauseState.UNPAUSED
279
@@ -990,6 +970,22 @@ class Scheduler(SchedulerInterface):
970 request.use_structured_output and not request.is_prefill_chunk
971 )
972
973 + # Snapshot block IDs for routed experts before forward starts.
974 + # A concurrent schedule() may preempt requests and free blocks
975 + # before update_from_output runs; the snapshot survives that.
976 + # Use update() to preserve entries from the previous step that
977 + # have not yet been consumed by update_from_output (async
978 + # scheduling may call _update_after_schedule again before the
979 + # prior update_from_output runs).
980 + if self.enable_return_routed_experts:
981 + gid = self.routed_experts_mgr.attn_gid
982 + self._re_block_ids.update(
983 + {
984 + rid: self.kv_cache_manager.get_blocks(rid).get_block_ids()[gid]
985 + for rid in num_scheduled_tokens
986 + }
987 + )
988 +
989 # Clear the finished request IDs.
990 # NOTE: We shouldn't do self.finished_req_ids.clear() here because
991 # it will also affect the scheduler output.
@@ -1323,6 +1319,27 @@ class Scheduler(SchedulerInterface):
1319 num_scheduled_tokens,
1320 )
1321
1322 + # Persist per-step routed experts into the scheduler-side slot
1323 + # buffer (CPU->CPU fancy-index assign; ~few MB per step).
1324 + # MUST precede the per-request routing reads below: stopped
1325 + # requests may terminate on tokens generated in this very step,
1326 + # whose routing was just D2H'd into model_runner_output.
1327 + routing_data = None
1328 + routing_offsets: dict[str, int] = {}
1329 + if model_runner_output.routed_experts is not None:
1330 + re = model_runner_output.routed_experts
1331 + self.routed_experts_mgr.store_batch(re.routing_data, re.slot_mapping)
1332 + routing_data = re.routing_data.astype(
1333 + self.routed_experts_mgr.routed_experts_by_slot.dtype,
1334 + copy=False,
1335 + )
1336 + # Build offset map using model runner's request order
1337 + # (input_batch ordering), NOT scheduler dict order.
1338 + offset = 0
1339 + for rid in model_runner_output.req_ids:
1340 + routing_offsets[rid] = offset
1341 + offset += num_scheduled_tokens[rid]
1342 +
1343 # NOTE(woosuk): As len(num_scheduled_tokens) can be up to 1K or more,
1344 # the below loop can be a performance bottleneck. We should do our best
1345 # to avoid expensive operations inside the loop.
@@ -1385,6 +1402,7 @@ class Scheduler(SchedulerInterface):
1402 pooler_output = pooler_outputs[req_index] if pooler_outputs else None
1403 kv_transfer_params = None
1404 status_before_stop = request.status
1405 + num_output_tokens_before = len(request._output_token_ids)
1406
1407 # Check for stop and update request status.
1408 if new_token_ids:
@@ -1414,10 +1432,47 @@ class Scheduler(SchedulerInterface):
1432 stopped = True
1433
1434 routed_experts = None
1435 + if (
1436 + self.enable_return_routed_experts
1437 + and routing_data is not None
1438 + and new_token_ids
1439 + ):
1440 + req_offset = routing_offsets[req_id]
1441 + end = req_offset + num_tokens_scheduled
1442 + block_ids = self._re_block_ids.pop(req_id, [])
1443 + if num_output_tokens_before == 0:
1444 + # Prefill completed: read full prompt routing from
1445 + # slot buffer using the block-ID snapshot taken at
1446 + # schedule time (immune to async preemption).
1447 + if (
1448 + request.sampling_params is not None
1449 + and request.sampling_params.routed_experts_prompt_start
1450 + is not None
1451 + ):
1452 + prompt_start = (
1453 + request.sampling_params.routed_experts_prompt_start
1454 + )
1455 + assert prompt_start < request.num_prompt_tokens
1456 + else:
1457 + prompt_start = 0
1458 + routed_experts = self.routed_experts_mgr.get(
1459 + block_ids,
1460 + request.num_prompt_tokens,
1461 + token_start=prompt_start,
1462 + )
1463 + else:
1464 + if scheduled_spec_token_ids:
1465 + # Spec decode: accepted tokens at the START of
1466 + # the scheduled range, rejected at the end.
1467 + routed_experts = routing_data[
1468 + req_offset : req_offset + len(new_token_ids)
1469 + ]
1470 + else:
1471 + # Normal decode / re-prefill: token(s) at the END.
1472 + routed_experts = routing_data[end - len(new_token_ids) : end]
1473 +
1474 finish_reason = None
1475 if stopped:
1419 - routed_experts = self._get_routed_experts(request)
1420 -
1476 # Capture finish_reason BEFORE _handle_stopped_request, which may
1477 # reset the status to WAITING for streaming requests that continue.
1478 finish_reason = request.get_finished_reason()
@@ -1592,31 +1647,6 @@ class Scheduler(SchedulerInterface):
1647 self._enqueue_waiting_request(request)
1648 return False
1649
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 -
1650 def _update_request_with_output(
1651 self, request: Request, new_token_ids: list[int]
1652 ) -> tuple[list[int], bool]:
vllm/v1/engine/output_processor.py
+13 -7
@@ -175,6 +175,9 @@ class RequestState:
175
176 self.stats = RequestStateStats(arrival_time=arrival_time) if log_stats else None
177
178 + # Routed experts accumulation (prompt + sample chunks)
179 + self.routed_experts_chunks: list[np.ndarray] = []
180 +
181 # Stream Interval
182 self.stream_interval = stream_interval
183 self.sent_tokens_offset = 0 # Offset of sent tokens
@@ -273,7 +276,6 @@ class RequestState:
276 finish_reason: FinishReason | None,
277 stop_reason: int | str | None,
278 kv_transfer_params: dict[str, Any] | None = None,
276 - routed_experts: np.ndarray | None = None,
279 ) -> RequestOutput | PoolingRequestOutput | None:
280 finished = finish_reason is not None
281 final_only = self.output_kind == RequestOutputKind.FINAL_ONLY
@@ -314,9 +316,7 @@ class RequestState:
316 finished,
317 )
318
317 - output = self._new_completion_output(
318 - new_token_ids, finish_reason, stop_reason, routed_experts
319 - )
319 + output = self._new_completion_output(new_token_ids, finish_reason, stop_reason)
320
321 if self.parent_req is None:
322 outputs = [output]
@@ -378,7 +378,6 @@ class RequestState:
378 token_ids: list[int],
379 finish_reason: FinishReason | None,
380 stop_reason: int | str | None,
381 - routed_experts: np.ndarray | None = None,
381 ) -> CompletionOutput:
382 assert self.detokenizer is not None
383 assert self.logprobs_processor is not None
@@ -395,6 +394,11 @@ class RequestState:
394 if delta and logprobs:
395 logprobs = logprobs[-len(token_ids) :]
396
397 + # Concatenate routed experts on finish
398 + routed_experts = None
399 + if finished and self.routed_experts_chunks:
400 + routed_experts = np.concatenate(self.routed_experts_chunks, axis=0)
401 +
402 return CompletionOutput(
403 index=self.request_index,
404 text=text,
@@ -616,7 +620,10 @@ class OutputProcessor:
620 finish_reason = engine_core_output.finish_reason
621 stop_reason = engine_core_output.stop_reason
622 kv_transfer_params = engine_core_output.kv_transfer_params
619 - routed_experts = engine_core_output.routed_experts
623 + if engine_core_output.routed_experts is not None:
624 + req_state.routed_experts_chunks.append(
625 + engine_core_output.routed_experts
626 + )
627
628 if req_state.is_prefilling:
629 if engine_core_output.prefill_stats is not None:
@@ -647,7 +654,6 @@ class OutputProcessor:
654 finish_reason,
655 stop_reason,
656 kv_transfer_params,
650 - routed_experts,
657 ):
658 if req_state.streaming_input:
659 request_output.finished = False
vllm/v1/outputs.py
+78
@@ -109,6 +109,73 @@ class LogprobsTensors(NamedTuple):
109 )
110
111
112 +class RoutedExpertsTensors(NamedTuple):
113 + """Device-side snapshot of routed experts data, pending async D2H.
114 +
115 + Produced by :class:`GPUModelRunner` at the end of each async-scheduled
116 + step. The copy stream waits on the default stream, then issues
117 + non-blocking D2H via :meth:`to_cpu_nonblocking` into a pinned CPU
118 + buffer; :class:`AsyncGPUModelRunnerOutput.get_output` synchronizes
119 + the copy before the scheduler reads it.
120 +
121 + Sliced to ``total_num_scheduled_tokens`` (step-level, across all
122 + requests — NOT per-request). Both ``routing_data`` and
123 + ``slot_mapping`` must be private clones when sourced from shared
124 + capturer / prepare-input buffers, so the next forward pass /
125 + ``_prepare_inputs`` on the default stream does not race with a
126 + D2H still pending on the copy stream.
127 + """
128 +
129 + # (num_scheduled_tokens, num_layers, num_experts_per_tok)
130 + routing_data: torch.Tensor
131 + # (num_scheduled_tokens,)
132 + slot_mapping: torch.Tensor
133 +
134 + def to_cpu_nonblocking(self) -> "RoutedExpertsTensors":
135 + """Issue non-blocking D2H on the current stream.
136 +
137 + NOTE: ``non_blocking=True`` only delivers true overlap when the
138 + CPU target is pinned. The current fallback here allocates a
139 + new pageable CPU tensor per call, which silently degrades to a
140 + synchronous copy; acceptable because the sync happens on the
141 + dedicated copy stream, not the default stream.
142 + """
143 + if self.routing_data.device.type == "cpu":
144 + return self
145 + return RoutedExpertsTensors(
146 + self.routing_data.to("cpu", non_blocking=True),
147 + self.slot_mapping.to("cpu", non_blocking=True),
148 + )
149 +
150 + def tolists(self) -> "RoutedExpertsLists":
151 + """Convert to the numpy-backed form consumed by the scheduler.
152 +
153 + ``.cpu()`` is a no-op when the tensor is already on CPU, so this
154 + is cheap for the post-D2H case; for raw device tensors it will
155 + synchronously block, which is only reached in tests.
156 + """
157 + return RoutedExpertsLists(
158 + self.routing_data.cpu().numpy(),
159 + self.slot_mapping.cpu().numpy(),
160 + )
161 +
162 +
163 +class RoutedExpertsLists(NamedTuple):
164 + """CPU-side routed experts, the form :meth:`RoutedExpertsManager.store_batch`
165 + consumes.
166 +
167 + Batched per scheduler step: the leading dim is the number of tokens
168 + scheduled across all requests in this step (``total_num_scheduled_tokens``),
169 + not per-request tokens. ``slot_mapping[i]`` tells the scheduler which
170 + physical KV-cache slot row ``i`` of ``routing_data`` belongs to.
171 + """
172 +
173 + # (num_scheduled_tokens, num_layers, num_experts_per_tok)
174 + routing_data: np.ndarray
175 + # (num_scheduled_tokens,)
176 + slot_mapping: np.ndarray
177 +
178 +
179 # [num_reqs, <dynamic>]
180 # The shape of each element depends on the pooler used
181 PoolerOutput: TypeAlias = torch.Tensor | list[torch.Tensor] | list[torch.Tensor | None]
@@ -201,6 +268,17 @@ class ModelRunnerOutput:
268 # information related to cudagraph execution
269 cudagraph_stats: CUDAGraphStat | None = None
270
271 + # Per-step routed experts data captured by the worker.
272 + # ``routing_data`` shape: (num_scheduled_tokens, num_layers,
273 + # num_experts_per_tok); expert IDs as uint8/uint16.
274 + # ``slot_mapping`` shape: (num_scheduled_tokens,); physical KV-cache
275 + # slot for each row of routing_data.
276 + # ``num_scheduled_tokens`` is step-level (total across all requests
277 + # in this step), not per-request. The scheduler persists this into
278 + # its slot buffer via ``slot_buffer[slot_mapping] = routing_data``.
279 + # ``None`` when ``enable_return_routed_experts`` is off.
280 + routed_experts: RoutedExpertsLists | None = None
281 +
282
283 # ModelRunnerOutput wrapper for async scheduling.
284 class AsyncModelRunnerOutput(ABC):
vllm/v1/worker/gpu_model_runner.py
+115 -35
@@ -157,6 +157,8 @@ from vllm.v1.outputs import (
157 LogprobsTensors,
158 ModelRunnerOutput,
159 PoolerOutput,
160 + RoutedExpertsLists,
161 + RoutedExpertsTensors,
162 SamplerOutput,
163 make_empty_encoder_model_runner_output,
164 )
@@ -236,6 +238,7 @@ class AsyncGPUModelRunnerOutput(AsyncModelRunnerOutput):
238 invalid_req_indices: list[int],
239 async_output_copy_stream: torch.cuda.Stream,
240 vocab_size: int,
241 + routed_experts: RoutedExpertsTensors | None = None,
242 ):
243 self._model_runner_output = model_runner_output
244 self._invalid_req_indices = invalid_req_indices
@@ -248,6 +251,7 @@ class AsyncGPUModelRunnerOutput(AsyncModelRunnerOutput):
251 self._sampled_token_ids = sampled_token_ids
252 self.vocab_size = vocab_size
253 self._logprobs_tensors = logprobs_tensors
254 + self._routed_experts = routed_experts
255
256 # Initiate the copy on a separate stream, but do not synchronize it.
257 default_stream = torch.cuda.current_stream()
@@ -261,6 +265,11 @@ class AsyncGPUModelRunnerOutput(AsyncModelRunnerOutput):
265 if self._logprobs_tensors
266 else None
267 )
268 + self._routed_experts_cpu = (
269 + self._routed_experts.to_cpu_nonblocking()
270 + if self._routed_experts is not None
271 + else None
272 + )
273 self.async_copy_ready_event.record()
274
275 def get_output(self) -> ModelRunnerOutput:
@@ -292,6 +301,11 @@ class AsyncGPUModelRunnerOutput(AsyncModelRunnerOutput):
301 output = self._model_runner_output
302 output.sampled_token_ids = valid_sampled_token_ids
303 output.logprobs = logprobs_lists
304 +
305 + if self._routed_experts_cpu is not None:
306 + output.routed_experts = self._routed_experts_cpu.tolists()
307 + del self._routed_experts
308 +
309 return output
310
311
@@ -2192,9 +2206,18 @@ class GPUModelRunner(
2206 slot_mapping_gid_0 = slot_mappings[0]
2207
2208 if self.routed_experts_initialized:
2209 + # Copy this step's attention slot_mapping into our private
2210 + # device buffer. The shared ``slot_mappings[attn_gid]`` is
2211 + # owned by the attention block table and will be overwritten
2212 + # by the next ``_prepare_inputs``; we need a stable snapshot
2213 + # because the async D2H may still be in flight on the copy
2214 + # stream when the next step runs.
2215 attn_gid = self.routed_experts_attn_gid
2216 slot_mapping_attn = slot_mappings[attn_gid]
2197 - self.slot_mapping = slot_mapping_attn[:num_tokens].cpu().numpy()
2217 + self.routed_experts_slot_mapping_device[:num_tokens].copy_(
2218 + slot_mapping_attn[:num_tokens]
2219 + )
2220 +
2221 num_computed_tokens_cpu = self.input_batch.num_computed_tokens_cpu_tensor[
2222 :num_reqs_padded
2223 ]
@@ -3478,6 +3501,21 @@ class GPUModelRunner(
3501 invalid_req_indices = []
3502 logprobs_lists = None
3503 if not self.use_async_scheduling:
3504 + # Sync scheduling: issue routed experts D2H into the pinned
3505 + # CPU buffer BEFORE ``_to_list`` below. ``_to_list`` does
3506 + # ``event.synchronize()`` on the async copy stream which
3507 + # waits for every D2H queued on the default stream since
3508 + # the last sync, so this enqueue is naturally covered
3509 + # without requiring its own synchronize.
3510 + if self.routed_experts_initialized:
3511 + buf = self.routed_experts_capturer.get_device_buffer()
3512 + total = scheduler_output.total_num_scheduled_tokens
3513 + self.routed_experts_cpu[:total].copy_(buf[:total], non_blocking=True)
3514 + self.routed_experts_slot_mapping_cpu[:total].copy_(
3515 + self.routed_experts_slot_mapping_device[:total],
3516 + non_blocking=True,
3517 + )
3518 +
3519 # Get the valid generated tokens.
3520 max_gen_len = sampled_token_ids.shape[-1]
3521 if max_gen_len == 1:
@@ -3878,11 +3916,7 @@ class GPUModelRunner(
3916 )
3917
3918 if self.routed_experts_initialized:
3881 - capturer = RoutedExpertsCapturer.get_instance()
3882 - if capturer is not None:
3883 - capturer.clear_buffer() # noqa
3884 - else:
3885 - logger.error("RoutedExpertsCapturer not initialized.")
3919 + self.routed_experts_capturer.clear_buffer()
3920
3921 # If ngram_gpu is used, we need to copy the scheduler_output to avoid
3922 # the modification has influence on the scheduler_output in engine core process.
@@ -4415,13 +4449,6 @@ class GPUModelRunner(
4449 self.kv_connector_output = None
4450
4451 with record_function_or_nullcontext("gpu_model_runner: ModelRunnerOutput"):
4418 - if self.routed_experts_initialized:
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 -
4452 output = ModelRunnerOutput(
4453 req_ids=req_ids_output_copy,
4454 req_id_to_index=req_id_to_index_output_copy,
@@ -4434,14 +4461,47 @@ class GPUModelRunner(
4461 else None,
4462 num_nans_in_logits=num_nans_in_logits,
4463 cudagraph_stats=cudagraph_stats,
4464 + routed_experts=None,
4465 )
4466
4467 if not self.use_async_scheduling:
4468 + if self.routed_experts_initialized:
4469 + # Sync path: D2H was issued in ``_bookkeeping_sync`` and
4470 + # synchronized by ``_to_list``'s event.synchronize(), so
4471 + # the pinned buffers are ready to be wrapped as numpy.
4472 + total = scheduler_output.total_num_scheduled_tokens
4473 + output.routed_experts = RoutedExpertsLists(
4474 + routing_data=self.routed_experts_cpu[:total].numpy(),
4475 + slot_mapping=self.routed_experts_slot_mapping_cpu[:total].numpy(),
4476 + )
4477 return output
4478
4479 with record_function_or_nullcontext(
4480 "gpu_model_runner: AsyncGPUModelRunnerOutput"
4481 ):
4482 + # Async path: produce a device-side snapshot that the async
4483 + # copy stream can D2H later. Both tensors must be private
4484 + # clones because:
4485 + # - ``routing_data`` source is the shared capturer buffer,
4486 + # which is ``clear_buffer()``-ed at the start of the
4487 + # next step on the default stream.
4488 + # - ``slot_mapping`` source is our own
4489 + # ``routed_experts_slot_mapping_device``, which the
4490 + # next ``_prepare_inputs`` overwrites on the default
4491 + # stream while the D2H is still pending on the copy
4492 + # stream.
4493 + # Without clones, the copy stream would read torn data.
4494 + routed_experts_snapshot = None
4495 + if self.routed_experts_initialized:
4496 + buf = self.routed_experts_capturer.get_device_buffer()
4497 + total = scheduler_output.total_num_scheduled_tokens
4498 + routed_experts_snapshot = RoutedExpertsTensors(
4499 + routing_data=buf[:total].clone(),
4500 + slot_mapping=self.routed_experts_slot_mapping_device[
4501 + :total
4502 + ].clone(),
4503 + )
4504 +
4505 async_output = AsyncGPUModelRunnerOutput(
4506 model_runner_output=output,
4507 sampled_token_ids=sampler_output.sampled_token_ids,
@@ -4449,6 +4509,7 @@ class GPUModelRunner(
4509 invalid_req_indices=invalid_req_indices,
4510 async_output_copy_stream=self._get_or_create_async_output_copy_stream(),
4511 vocab_size=self.input_batch.vocab_size,
4512 + routed_experts=routed_experts_snapshot,
4513 )
4514 with record_function_or_nullcontext(
4515 "gpu_model_runner: set_async_sampled_token_ids"
@@ -7042,9 +7103,16 @@ class GPUModelRunner(
7103 kv_transfer_group.set_host_xfer_buffer_ops(copy_kv_blocks)
7104
7105 def _get_attention_kv_cache_gid(self) -> int:
7045 - """Find the KV cache group index for attention layers."""
7106 + """Find the KV cache group index for attention layers.
7107 +
7108 + Must match :attr:`RoutedExpertsManager.attn_gid` in the scheduler:
7109 + both pick the first ``FullAttentionSpec`` group so hybrid models
7110 + (Mamba / linear-attention layers that use other AttentionSpec
7111 + subclasses) end up indexing the same slot layout on both sides.
7112 + Falls back to 0 only for legacy single-group configs.
7113 + """
7114 for gid, group in enumerate(self.kv_cache_config.kv_cache_groups):
7047 - if isinstance(group.kv_cache_spec, AttentionSpec):
7115 + if isinstance(group.kv_cache_spec, FullAttentionSpec):
7116 return gid
7117 return 0
7118
@@ -7053,29 +7121,41 @@ class GPUModelRunner(
7121 "Initializing routed experts capturer, enable_return_routed_experts: %s",
7122 self.model_config.enable_return_routed_experts,
7123 )
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 -
7073 - routed_experts_capturer.init_buffer(
7124 + self.routed_experts_capturer = RoutedExpertsCapturer(
7125 max_num_batched_tokens=self.scheduler_config.max_num_batched_tokens,
7075 - max_num_kv_tokens=self.max_num_kv_tokens,
7126 vllm_config=self.vllm_config,
7127 )
7078 - self._bind_routed_experts_capturer(routed_experts_capturer)
7128 + self.routed_experts_attn_gid = self._get_attention_kv_cache_gid()
7129 + self._bind_routed_experts_capturer(self.routed_experts_capturer)
7130 +
7131 + # Pinned CPU buffer for non-blocking D2H of ``routing_data`` on
7132 + # the sync scheduling path. Shape / dtype mirror the device
7133 + # capturer exactly so ``copy_`` is a straight memcpy.
7134 + self.routed_experts_cpu = torch.empty(
7135 + self.routed_experts_capturer.device_buffer.shape,
7136 + dtype=self.routed_experts_capturer.device_buffer.dtype,
7137 + device="cpu",
7138 + pin_memory=self.pin_memory,
7139 + )
7140 + # ``slot_mapping`` dtype is fixed to int64 by
7141 + # ``block_table.slot_mapping``; we mirror that here.
7142 + max_tokens = self.scheduler_config.max_num_batched_tokens
7143 + self.routed_experts_slot_mapping_cpu = torch.empty(
7144 + (max_tokens,),
7145 + dtype=torch.int64,
7146 + device="cpu",
7147 + pin_memory=self.pin_memory,
7148 + )
7149 + # Private device buffer so the shared ``block_table.slot_mapping``
7150 + # can be overwritten by the next ``_prepare_inputs`` while the
7151 + # D2H is still pending on the copy stream. Written in
7152 + # ``_prepare_inputs``, read in ``_bookkeeping_sync`` (sync path)
7153 + # or cloned into a snapshot (async path).
7154 + self.routed_experts_slot_mapping_device = torch.empty(
7155 + (max_tokens,),
7156 + dtype=torch.int64,
7157 + device=self.device,
7158 + )
7159 self.routed_experts_initialized = True
7160
7161 def _bind_routed_experts_capturer(self, capturer: RoutedExpertsCapturer) -> None: