[AuxOutput] Add block-keyed storage for routed-expert outputs (#45635)
Signed-off-by: aoshen02 <aoshen@inferact.ai> Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: aoshen02 <aoshen@inferact.ai> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: Roger Wang <hey@rogerw.io> Co-authored-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Codex <noreply@openai.com>
Hongxin Xu committed
Sep 21, 2026 at 18:25 UTC
0b7f11a1eece3dc9c689f1b5ab90d60c03b10793
32 files changed
+3600
-873
tests/config/test_aux_output_config.py
new
+119
@@ -0,0 +1,119 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from types import SimpleNamespace
5
+from typing import Any
6
+
7
+import pytest
8
+
9
+from vllm.config import AuxOutputConfig, VllmConfig
10
+from vllm.engine.arg_utils import EngineArgs
11
+
12
+pytestmark = pytest.mark.cpu_test
13
+
14
+
15
+def _config(
16
+ *,
17
+ use_v2: bool = True,
18
+ pp: int = 1,
19
+ dcp: int = 1,
20
+ pcp: int = 1,
21
+ connector: str | None = None,
22
+ runner_type: str = "generate",
23
+ is_moe: bool = True,
24
+ sliding_window: int | None = None,
25
+ attention_chunk_size: int | None = None,
26
+ enable_prefix_caching: bool = True,
27
+ adaptive_verification: bool = False,
28
+):
29
+ return SimpleNamespace(
30
+ model_config=SimpleNamespace(
31
+ runner_type=runner_type,
32
+ is_moe=is_moe,
33
+ get_sliding_window=lambda: sliding_window,
34
+ attention_chunk_size=attention_chunk_size,
35
+ ),
36
+ use_v2_model_runner=use_v2,
37
+ parallel_config=SimpleNamespace(
38
+ pipeline_parallel_size=pp,
39
+ decode_context_parallel_size=dcp,
40
+ prefill_context_parallel_size=pcp,
41
+ ),
42
+ aux_output_config=AuxOutputConfig(enable_return_routed_experts=True),
43
+ cache_config=SimpleNamespace(
44
+ enable_prefix_caching=enable_prefix_caching,
45
+ ),
46
+ speculative_config=(
47
+ SimpleNamespace(enable_adaptive_verification=True)
48
+ if adaptive_verification
49
+ else None
50
+ ),
51
+ kv_transfer_config=(
52
+ None
53
+ if connector is None
54
+ else SimpleNamespace(
55
+ is_kv_transfer_instance=True,
56
+ kv_connector=connector,
57
+ )
58
+ ),
59
+ )
60
+
61
+
62
+def test_aux_output_config_defaults():
63
+ config = AuxOutputConfig()
64
+
65
+ assert not config.enabled
66
+ assert not config.enable_return_routed_experts
67
+ assert config.max_bytes is None
68
+
69
+
70
+def test_aux_output_capture_changes_compilation_hash():
71
+ disabled = AuxOutputConfig()
72
+ enabled = AuxOutputConfig(enable_return_routed_experts=True)
73
+
74
+ assert disabled.compute_hash() != enabled.compute_hash()
75
+
76
+
77
+def test_legacy_routed_experts_flag_updates_aux_output_config():
78
+ args = EngineArgs(enable_return_routed_experts=True)
79
+
80
+ assert args.aux_output_config.enabled
81
+ assert args.aux_output_config.enable_return_routed_experts
82
+
83
+
84
+@pytest.mark.parametrize(
85
+ ("kwargs", "error"),
86
+ [
87
+ ({"use_v2": False}, "requires Model Runner V2"),
88
+ ({"runner_type": "pooling"}, "only supports generate runners"),
89
+ ({"is_moe": False}, "only supports MoE models"),
90
+ ({"enable_prefix_caching": False}, "requires prefix caching"),
91
+ (
92
+ {"adaptive_verification": True},
93
+ "adaptive speculative verification",
94
+ ),
95
+ ({"pp": 2}, "pipeline parallelism"),
96
+ ({"dcp": 2}, "context parallelism"),
97
+ ({"pcp": 2}, "context parallelism"),
98
+ ({"connector": "MooncakeConnector"}, "incompatible with KV connectors"),
99
+ ],
100
+)
101
+def test_aux_output_connector_rejects_unsupported_configuration(kwargs, error):
102
+ with pytest.raises(ValueError, match=error):
103
+ VllmConfig._verify_aux_output_compatibility(_config(**kwargs))
104
+
105
+
106
+@pytest.mark.parametrize(
107
+ "kwargs",
108
+ [{"sliding_window": 4096}, {"attention_chunk_size": 4096}],
109
+)
110
+def test_aux_output_config_defers_attention_layout_to_kv_config(kwargs):
111
+ VllmConfig._verify_aux_output_compatibility(_config(**kwargs))
112
+
113
+
114
+def test_aux_output_guards_are_inactive_when_capture_is_disabled():
115
+ config: Any = VllmConfig.__new__(VllmConfig)
116
+ config.model_config = SimpleNamespace()
117
+ config.aux_output_config = AuxOutputConfig()
118
+
119
+ config._verify_aux_output_compatibility()
tests/distributed/aux_output_connector/test_store.py
new
+1854
@@ -0,0 +1,1854 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import threading
5
+from contextlib import nullcontext
6
+from dataclasses import dataclass, field
7
+from types import SimpleNamespace
8
+from unittest.mock import Mock
9
+
10
+import numpy as np
11
+import pytest
12
+import torch
13
+
14
+from vllm.distributed.aux_output_connector.connector import (
15
+ AuxOutputConnectorMetadata,
16
+ AuxOutputSchedulerConnector,
17
+ AuxRequestOutput,
18
+ PackedBlockHashes,
19
+)
20
+from vllm.distributed.aux_output_connector.routed_experts import (
21
+ RoutedExpertsBuffer,
22
+ materialize_routed_experts,
23
+ publish_routed_experts,
24
+ routed_experts_keys,
25
+)
26
+from vllm.distributed.aux_output_connector.store import (
27
+ BackgroundBlockObjectStore,
28
+ BlockObject,
29
+ BlockObjectStore,
30
+ BlockObjectStoreError,
31
+)
32
+from vllm.distributed.aux_output_connector.worker import (
33
+ AuxOutputWorkerConnector,
34
+)
35
+from vllm.v1.core.sched.output import CachedRequestData, SchedulerOutput
36
+from vllm.v1.outputs import ModelRunnerOutput
37
+from vllm.v1.worker.gpu import async_utils
38
+from vllm.v1.worker.gpu.sample.output import SamplerOutput
39
+
40
+pytestmark = pytest.mark.cpu_test
41
+
42
+_SHAPE = (3, 2)
43
+_DTYPE = np.dtype("uint8")
44
+_BLOCK_SIZE = 4
45
+
46
+
47
+def test_background_store_publishes_without_blocking_caller():
48
+ started = threading.Event()
49
+ release = threading.Event()
50
+ underlying = Mock()
51
+
52
+ def put(*_args, **_kwargs):
53
+ started.set()
54
+ release.wait()
55
+
56
+ underlying.put.side_effect = put
57
+ store = BackgroundBlockObjectStore(underlying, max_pending_batches=1)
58
+
59
+ store.put([BlockObject("key", b"value")])
60
+ assert started.wait(timeout=1)
61
+ release.set()
62
+ store.close()
63
+
64
+ underlying.close.assert_called_once_with()
65
+
66
+
67
+def _make_connector():
68
+ return AuxOutputSchedulerConnector()
69
+
70
+
71
+@dataclass
72
+class _Execution:
73
+ request_id: str
74
+ token_start: int
75
+ num_tokens: int
76
+ emit_start: int
77
+ block_hashes: list[bytes] | PackedBlockHashes
78
+
79
+
80
+@dataclass
81
+class _WorkerStep:
82
+ metadata: AuxOutputConnectorMetadata
83
+ executions: list[_Execution]
84
+
85
+
86
+@dataclass
87
+class _SchedulerRequest:
88
+ request_id: str
89
+ block_hashes: list[bytes]
90
+ num_tokens: int
91
+ num_output_tokens: int = 1
92
+ num_computed_tokens: int = 0
93
+ num_in_flight_tokens: int = 0
94
+ finished: bool = False
95
+ sampling_params: SimpleNamespace = field(
96
+ default_factory=lambda: SimpleNamespace(routed_experts_prompt_start=0)
97
+ )
98
+
99
+ def is_finished(self) -> bool:
100
+ return self.finished
101
+
102
+
103
+def _request_metadata(
104
+ request_id: str,
105
+ token_start: int,
106
+ num_tokens: int,
107
+ emit_start: int,
108
+ block_hashes,
109
+) -> _Execution:
110
+ return _Execution(request_id, token_start, num_tokens, emit_start, block_hashes)
111
+
112
+
113
+def _metadata(
114
+ generation: int,
115
+ requests: list[_Execution],
116
+ finished_requests: dict[str, list[bytes] | PackedBlockHashes],
117
+ hash_updates: dict[str, list[bytes] | PackedBlockHashes] | None = None,
118
+) -> _WorkerStep:
119
+ block_hashes = {
120
+ request.request_id: request.block_hashes
121
+ for request in requests
122
+ if request.block_hashes
123
+ }
124
+ if hash_updates:
125
+ block_hashes.update(
126
+ (request_id, hashes)
127
+ for request_id, hashes in hash_updates.items()
128
+ if hashes
129
+ )
130
+ block_hashes.update(
131
+ (request_id, hashes)
132
+ for request_id, hashes in finished_requests.items()
133
+ if hashes
134
+ )
135
+ return _WorkerStep(
136
+ AuxOutputConnectorMetadata(
137
+ generation,
138
+ {request.request_id: request.emit_start for request in requests},
139
+ block_hashes,
140
+ tuple(finished_requests),
141
+ ),
142
+ requests,
143
+ )
144
+
145
+
146
+def _execution_ranges(metadata, request_ids):
147
+ by_request = {request.request_id: request for request in metadata.executions}
148
+ token_starts = tuple(
149
+ by_request[request_id].token_start for request_id in request_ids
150
+ )
151
+ num_tokens = tuple(by_request[request_id].num_tokens for request_id in request_ids)
152
+ return token_starts, num_tokens
153
+
154
+
155
+def _begin_step(worker: AuxOutputWorkerConnector, step: _WorkerStep) -> None:
156
+ worker.begin_step(step.metadata)
157
+
158
+
159
+def _make_worker(
160
+ max_num_seqs: int,
161
+ max_num_batched_tokens: int | None = None,
162
+ max_concurrent_batches: int = 2,
163
+ max_store_blocks: int | None = None,
164
+) -> AuxOutputWorkerConnector:
165
+ object_nbytes = _BLOCK_SIZE * int(np.prod(_SHAPE))
166
+ store = _make_store(
167
+ max_bytes=(
168
+ 1 << 20 if max_store_blocks is None else max_store_blocks * object_nbytes
169
+ ),
170
+ object_nbytes=object_nbytes,
171
+ )
172
+ worker = object.__new__(AuxOutputWorkerConnector)
173
+ worker._store = store
174
+ worker._buffer = RoutedExpertsBuffer(
175
+ _DTYPE,
176
+ _SHAPE,
177
+ _BLOCK_SIZE,
178
+ max_num_seqs,
179
+ max_num_batched_tokens or max_num_seqs * _BLOCK_SIZE,
180
+ max_concurrent_batches,
181
+ )
182
+ worker._requests = {}
183
+ worker._generation = 0
184
+ worker._step_metadata = None
185
+ worker._pending_outputs = []
186
+ worker._lock = threading.Lock()
187
+ worker._max_concurrent_batches = max_concurrent_batches
188
+ return worker
189
+
190
+
191
+def test_worker_rejects_metadata_generation_rollback():
192
+ worker = _make_worker(1)
193
+ _begin_step(worker, _metadata(1, [], {}))
194
+
195
+ with pytest.raises(AssertionError, match="generation moved backwards"):
196
+ _begin_step(worker, _metadata(0, [], {}))
197
+
198
+ worker.close()
199
+
200
+
201
+def test_worker_rejects_run_and_finish_in_one_step():
202
+ worker = _make_worker(1)
203
+ request = _request_metadata("request", 0, 1, 0, [])
204
+ metadata = _metadata(
205
+ 0,
206
+ [request],
207
+ {"request": []},
208
+ )
209
+
210
+ with pytest.raises(AssertionError, match="cannot run and finish"):
211
+ _begin_step(worker, metadata)
212
+
213
+ worker.close()
214
+
215
+
216
+def _input_batch(request_ids, token_starts, query_start_loc):
217
+ return SimpleNamespace(
218
+ req_ids=request_ids,
219
+ num_computed_tokens_np=token_starts,
220
+ query_start_loc_np=query_start_loc,
221
+ )
222
+
223
+
224
+def test_non_output_rank_skips_capture_snapshot():
225
+ worker = object.__new__(AuxOutputWorkerConnector)
226
+ worker._store = None
227
+ worker._buffer = None
228
+ worker._capturer = Mock()
229
+ worker._step_metadata = Mock()
230
+ assert worker.prepare_output(_input_batch([], np.array([]), np.array([]))) is None
231
+ worker._capturer.snapshot_routing_data.assert_not_called()
232
+
233
+
234
+def test_worker_skips_aux_outputs_for_internal_warmup_step():
235
+ worker = _make_worker(1)
236
+ worker._capturer = Mock()
237
+
238
+ worker.begin_step(None)
239
+
240
+ assert worker.prepare_output(_input_batch([], np.array([]), np.array([]))) is None
241
+ worker._capturer.snapshot_routing_data.assert_not_called()
242
+ worker.close()
243
+
244
+
245
+def test_next_step_does_not_consume_pending_output(monkeypatch):
246
+ """begin_step must not wait for or consume an unconsumed step output."""
247
+ event = Mock()
248
+ monkeypatch.setattr(torch.cuda, "Event", lambda **kwargs: event)
249
+ monkeypatch.setattr(async_utils, "stream", lambda *args: nullcontext())
250
+ monkeypatch.setattr(
251
+ async_utils, "async_copy_to_np", lambda tensor: tensor.numpy().copy()
252
+ )
253
+ worker = _make_worker(1)
254
+ worker.begin_step(
255
+ _metadata(0, [_request_metadata("request", 0, 1, 0, [])], {}).metadata
256
+ )
257
+ rows = torch.ones((1, *_SHAPE), dtype=torch.uint8)
258
+ worker._capturer = Mock()
259
+ worker._capturer.snapshot_routing_data.return_value = rows
260
+ process_output = Mock(wraps=worker.process_output)
261
+ monkeypatch.setattr(worker, "process_output", process_output)
262
+ pending = worker.prepare_output(
263
+ _input_batch(["request"], np.array([0]), np.array([0, 1]))
264
+ )
265
+ assert pending is not None
266
+ output = async_utils.AsyncOutput(
267
+ ModelRunnerOutput(["request"], {"request": 0}),
268
+ SamplerOutput(
269
+ torch.tensor([[7]]), None, None, None, num_rejected=torch.tensor([0])
270
+ ),
271
+ torch.tensor([1]),
272
+ Mock(),
273
+ Mock(),
274
+ check_ep_fault=False,
275
+ pending_aux_output=pending,
276
+ )
277
+ event.record.assert_called_once()
278
+ assert worker._pending_outputs == [pending]
279
+
280
+ worker.begin_step(_metadata(0, [], {}).metadata)
281
+ process_output.assert_not_called()
282
+ assert worker._pending_outputs == [pending]
283
+
284
+ result = output.get_output()
285
+
286
+ assert result.sampled_token_ids == [[7]]
287
+ np.testing.assert_array_equal(
288
+ result.aux_output_connector_output["request"].rows, rows.numpy()
289
+ )
290
+ process_output.assert_called_once()
291
+ assert worker._pending_outputs == []
292
+ worker.close()
293
+
294
+
295
+@pytest.mark.parametrize("num_pending", [1, 2])
296
+def test_finished_request_teardown_waits_for_pending_output(num_pending):
297
+ """Terminal cleanup waits for every outstanding output, not just the first."""
298
+ worker = _make_worker(1)
299
+ rows = np.arange(4 * 3 * 2, dtype=np.uint8).reshape(4, *_SHAPE)
300
+ worker._capturer = Mock()
301
+ worker._capturer.snapshot_routing_data.return_value = torch.from_numpy(rows)
302
+ pending_outputs = []
303
+ for i in range(num_pending):
304
+ request = _request_metadata("request", 4 * i, 4, 4 * i, [bytes([i]) * 4])
305
+ worker.begin_step(_metadata(0, [request], {}).metadata)
306
+ pending = worker.prepare_output(
307
+ _input_batch(["request"], np.array([4 * i]), np.array([0, 4]))
308
+ )
309
+ assert pending is not None
310
+ pending.enqueue_cpu_copy(np.array([1]), np.array([0]))
311
+ pending_outputs.append(pending)
312
+
313
+ # The next step finishes the request while its output is unconsumed.
314
+ worker.begin_step(_metadata(0, [], {"request": []}).metadata)
315
+ assert "request" in worker._requests
316
+
317
+ for i, pending in enumerate(pending_outputs):
318
+ output = worker.process_output(pending)
319
+ np.testing.assert_array_equal(output["request"].rows, rows)
320
+ if i + 1 < num_pending:
321
+ assert "request" in worker._requests
322
+ assert worker._store._references
323
+ assert "request" not in worker._requests
324
+ # The finished request's store keys must be released after commit.
325
+ assert not worker._store._references
326
+ worker.close()
327
+
328
+
329
+def test_pending_outputs_bounded_by_max_concurrent_batches():
330
+ worker = _make_worker(1, max_concurrent_batches=2)
331
+ worker._capturer = Mock()
332
+ worker._capturer.snapshot_routing_data.side_effect = lambda num_rows: torch.zeros(
333
+ (num_rows, *_SHAPE), dtype=torch.uint8
334
+ )
335
+ metadata = _metadata(0, [_request_metadata("request", 0, 1, 0, [])], {}).metadata
336
+ batch = _input_batch(["request"], np.array([0]), np.array([0, 1]))
337
+ for _ in range(2):
338
+ worker.begin_step(metadata)
339
+ assert worker.prepare_output(batch) is not None
340
+
341
+ worker.begin_step(metadata)
342
+ with pytest.raises(AssertionError, match="not consumed in order"):
343
+ worker.prepare_output(batch)
344
+
345
+ worker.close()
346
+
347
+
348
+def test_worker_rejects_invalid_rejected_token_count():
349
+ worker = _make_worker(1)
350
+ metadata = _metadata(
351
+ 0,
352
+ [_request_metadata("request", 0, 1, 0, [])],
353
+ {},
354
+ )
355
+ with pytest.raises(AssertionError, match="rejected-token count is invalid"):
356
+ _process_output(
357
+ worker,
358
+ metadata,
359
+ np.zeros((1, *_SHAPE), dtype=_DTYPE),
360
+ ["request"],
361
+ np.array([-1]),
362
+ token_starts=(0,),
363
+ num_tokens=(1,),
364
+ )
365
+ assert worker._pending_outputs == []
366
+
367
+ worker.close()
368
+
369
+
370
+def _process_output(
371
+ worker,
372
+ step,
373
+ rows,
374
+ request_ids,
375
+ num_rejected,
376
+ num_sampled=None,
377
+ *,
378
+ token_starts=None,
379
+ num_tokens=None,
380
+ query_start_loc=None,
381
+):
382
+ if num_sampled is None:
383
+ num_sampled = np.ones(len(request_ids), dtype=np.int32)
384
+ if token_starts is None or num_tokens is None:
385
+ assert isinstance(step, _WorkerStep)
386
+ token_starts, num_tokens = _execution_ranges(step, request_ids)
387
+ worker._capturer = Mock()
388
+ worker._capturer.snapshot_routing_data.return_value = torch.from_numpy(rows)
389
+ metadata = step.metadata if isinstance(step, _WorkerStep) else step
390
+ worker.begin_step(metadata)
391
+ if query_start_loc is None:
392
+ query_start_loc = np.concatenate(
393
+ (np.zeros(1, dtype=np.int32), np.cumsum(num_tokens, dtype=np.int32))
394
+ )
395
+ pending = worker.prepare_output(
396
+ _input_batch(request_ids, np.asarray(token_starts), query_start_loc)
397
+ )
398
+ assert pending is not None
399
+ pending.enqueue_cpu_copy(num_sampled, num_rejected)
400
+ return worker.process_output(pending)
401
+
402
+
403
+def test_worker_ignores_cudagraph_query_padding():
404
+ worker = _make_worker(2)
405
+ logical = np.arange(2 * 3 * 2, dtype=np.uint8).reshape(2, 3, 2)
406
+ metadata = _metadata(
407
+ 0,
408
+ [
409
+ _request_metadata("first", 0, 1, 0, []),
410
+ _request_metadata("second", 0, 1, 0, []),
411
+ ],
412
+ {},
413
+ )
414
+
415
+ output = _process_output(
416
+ worker,
417
+ metadata,
418
+ logical,
419
+ ["first", "second"],
420
+ np.zeros(2, dtype=np.int32),
421
+ query_start_loc=np.array([0, 1, 2, 2], dtype=np.int32),
422
+ )
423
+
424
+ np.testing.assert_array_equal(output["first"].rows, logical[:1])
425
+ np.testing.assert_array_equal(output["second"].rows, logical[1:])
426
+ worker.close()
427
+
428
+
429
+def test_worker_rejects_scheduler_emit_cursor_ahead():
430
+ worker = _make_worker(1)
431
+ _begin_step(
432
+ worker,
433
+ _metadata(
434
+ 0,
435
+ [_request_metadata("request", 0, 1, 0, [])],
436
+ {},
437
+ ),
438
+ )
439
+
440
+ with pytest.raises(AssertionError, match="Scheduler emit cursor moved ahead"):
441
+ _begin_step(
442
+ worker,
443
+ _metadata(
444
+ 0,
445
+ [_request_metadata("request", 1, 1, 1, [])],
446
+ {},
447
+ ),
448
+ )
449
+
450
+ worker.close()
451
+
452
+
453
+@pytest.mark.parametrize(
454
+ "routing",
455
+ [
456
+ np.zeros((1, _SHAPE[0], _SHAPE[1] + 1), dtype=_DTYPE),
457
+ np.zeros((1, *_SHAPE), dtype=np.int32),
458
+ ],
459
+ ids=["shape", "dtype"],
460
+)
461
+def test_worker_rejects_mismatched_capture_profile(routing):
462
+ worker = _make_worker(1)
463
+ metadata = _metadata(
464
+ 0,
465
+ [_request_metadata("request", 0, 1, 0, [])],
466
+ {},
467
+ )
468
+
469
+ with pytest.raises(AssertionError, match="capture profile changed"):
470
+ _process_output(
471
+ worker,
472
+ metadata,
473
+ routing,
474
+ ["request"],
475
+ np.array([0]),
476
+ )
477
+ worker.close()
478
+
479
+
480
+@pytest.mark.parametrize(
481
+ ("initial_start", "initial_rows", "invalid_start"),
482
+ [
483
+ (4, [4, 5, 6], 6),
484
+ (0, [0], 2),
485
+ (None, [], 3),
486
+ ],
487
+ ids=["overlap", "gap", "unaligned-initial-capture"],
488
+)
489
+def test_logical_buffer_rejects_noncontiguous_capture(
490
+ initial_start, initial_rows, invalid_start
491
+):
492
+ buffer = RoutedExpertsBuffer(np.dtype("uint8"), (1,), 4, 1, 4, 1)
493
+ if initial_start is not None:
494
+ rows = np.asarray(initial_rows, dtype=np.uint8).reshape(-1, 1)
495
+ assert buffer.capture("request", initial_start, rows) == []
496
+
497
+ with pytest.raises(AssertionError, match="not contiguous"):
498
+ buffer.capture(
499
+ "request", invalid_start, np.array([[invalid_start]], dtype=np.uint8)
500
+ )
501
+
502
+
503
+def test_logical_buffer_captures_one_row_per_decode_step():
504
+ buffer = RoutedExpertsBuffer(_DTYPE, _SHAPE, _BLOCK_SIZE, 1, 8, 2)
505
+ logical = np.arange(_BLOCK_SIZE * 3 * 2, dtype=_DTYPE).reshape(_BLOCK_SIZE, 3, 2)
506
+
507
+ completed = []
508
+ for step in range(_BLOCK_SIZE):
509
+ completed += buffer.capture("request", step, logical[step : step + 1])
510
+
511
+ assert len(completed) == 1
512
+ np.testing.assert_array_equal(completed[0][1], logical)
513
+
514
+
515
+def test_logical_buffer_copies_borrowed_block_when_retained():
516
+ buffer = RoutedExpertsBuffer(_DTYPE, _SHAPE, _BLOCK_SIZE, 1, 4, 2)
517
+ logical = np.arange(_BLOCK_SIZE * 3 * 2, dtype=_DTYPE).reshape(_BLOCK_SIZE, *_SHAPE)
518
+
519
+ expected = logical.copy()
520
+ completed = buffer.capture("request", 0, logical)
521
+ retained = buffer.retain_block(completed[0][1])
522
+ logical[:] = 0
523
+
524
+ assert not np.shares_memory(retained, logical)
525
+ np.testing.assert_array_equal(retained, expected)
526
+ buffer.release_block(retained)
527
+
528
+
529
+def _make_store(
530
+ *,
531
+ max_bytes: int = 1 << 20,
532
+ object_nbytes: int = 4,
533
+):
534
+ return BlockObjectStore(
535
+ max_bytes=max_bytes,
536
+ object_nbytes=object_nbytes,
537
+ )
538
+
539
+
540
+def test_publish_routed_experts_publishes_full_blocks():
541
+ store = _make_store(object_nbytes=_BLOCK_SIZE * int(np.prod(_SHAPE)))
542
+ buffer = RoutedExpertsBuffer(_DTYPE, _SHAPE, _BLOCK_SIZE, 1, 8, 2)
543
+ logical = np.arange(8 * 3 * 2, dtype=np.uint8).reshape(8, 3, 2)
544
+ hashes = [b"a" * 32, b"b" * 32]
545
+ keys = routed_experts_keys(hashes, "0")
546
+ blocks = buffer.capture("request", 0, logical)
547
+ publish_routed_experts(
548
+ store,
549
+ batches=[(keys, blocks)],
550
+ block_size=_BLOCK_SIZE,
551
+ )
552
+ expected = logical.copy()
553
+ logical[:] = 0
554
+
555
+ np.testing.assert_array_equal(
556
+ materialize_routed_experts(
557
+ store,
558
+ keys,
559
+ shape_per_token=_SHAPE,
560
+ dtype=_DTYPE,
561
+ ),
562
+ expected,
563
+ )
564
+ store.close()
565
+
566
+
567
+def test_worker_data_plane_publishes_blocks_and_reuses_prefix():
568
+ worker = _make_worker(2)
569
+
570
+ hashes = [b"a" * 32, b"b" * 32, b"c" * 32]
571
+ logical = np.arange(10 * 3 * 2, dtype=np.uint8).reshape(10, 3, 2)
572
+ first = _metadata(
573
+ generation=0,
574
+ requests=[_request_metadata("first", 0, 8, 0, hashes)],
575
+ finished_requests={},
576
+ )
577
+ output = _process_output(worker, first, logical[:8], ["first"], np.array([0]))
578
+ assert output is not None
579
+ np.testing.assert_array_equal(output["first"].rows, logical[:8])
580
+
581
+ second = _metadata(
582
+ generation=0,
583
+ requests=[_request_metadata("second", 8, 2, 0, hashes)],
584
+ finished_requests={"first": []},
585
+ )
586
+ output = _process_output(worker, second, logical[8:], ["second"], np.array([0]))
587
+ assert output is not None
588
+ np.testing.assert_array_equal(output["second"].rows, logical)
589
+ worker.close()
590
+
591
+
592
+def test_worker_matches_kv_eviction_order_after_requests_finish():
593
+ worker = _make_worker(2, max_store_blocks=3)
594
+ hashes = [bytes([value]) * 32 for value in range(5)]
595
+ blocks = [
596
+ np.full((_BLOCK_SIZE, *_SHAPE), value, dtype=_DTYPE) for value in range(5)
597
+ ]
598
+
599
+ def publish_and_finish(request_id, block_indexes):
600
+ rows = np.concatenate([blocks[index] for index in block_indexes])
601
+ step = _metadata(
602
+ 0,
603
+ [
604
+ _request_metadata(
605
+ request_id,
606
+ 0,
607
+ len(rows),
608
+ 0,
609
+ [hashes[index] for index in block_indexes],
610
+ )
611
+ ],
612
+ {},
613
+ )
614
+ _process_output(worker, step, rows, [request_id], np.array([0]))
615
+ _begin_step(worker, _metadata(0, [], {request_id: []}))
616
+
617
+ publish_and_finish("s", [0])
618
+ publish_and_finish("ab", [1, 2])
619
+ publish_and_finish("c", [3])
620
+ publish_and_finish("d", [4])
621
+
622
+ assert worker._store is not None
623
+ np.testing.assert_array_equal(
624
+ materialize_routed_experts(
625
+ worker._store,
626
+ routed_experts_keys([hashes[1], hashes[3], hashes[4]], "0"),
627
+ shape_per_token=_SHAPE,
628
+ dtype=_DTYPE,
629
+ ),
630
+ np.concatenate([blocks[1], blocks[3], blocks[4]]),
631
+ )
632
+ with pytest.raises(BlockObjectStoreError, match="does not exist"):
633
+ materialize_routed_experts(
634
+ worker._store,
635
+ routed_experts_keys([hashes[2]], "0"),
636
+ shape_per_token=_SHAPE,
637
+ dtype=_DTYPE,
638
+ )
639
+ worker.close()
640
+
641
+
642
+def test_worker_rejects_unbacked_capture_gap():
643
+ worker = _make_worker(1)
644
+ logical = np.arange(4 * 3 * 2, dtype=np.uint8).reshape(4, 3, 2)
645
+ first = _metadata(
646
+ 0,
647
+ [_request_metadata("request", 0, 1, 0, [])],
648
+ {},
649
+ )
650
+ _process_output(
651
+ worker,
652
+ first,
653
+ logical[:1],
654
+ ["request"],
655
+ np.array([0]),
656
+ np.zeros(1, dtype=np.int32),
657
+ )
658
+ second = _metadata(
659
+ 0,
660
+ [_request_metadata("request", 2, 1, 0, [])],
661
+ {},
662
+ )
663
+
664
+ with pytest.raises(AssertionError, match="unbacked token gap"):
665
+ _process_output(
666
+ worker,
667
+ second,
668
+ logical[2:3],
669
+ ["request"],
670
+ np.array([0]),
671
+ np.zeros(1, dtype=np.int32),
672
+ )
673
+ worker.close()
674
+
675
+
676
+def test_worker_replays_rejected_speculative_gap():
677
+ worker = _make_worker(1)
678
+ logical = np.arange(5 * 3 * 2, dtype=np.uint8).reshape(5, 3, 2)
679
+ first = _metadata(
680
+ 0,
681
+ [_request_metadata("request", 0, 5, 0, [])],
682
+ {},
683
+ )
684
+ first_output = _process_output(
685
+ worker, first, logical[:5], ["request"], np.array([4])
686
+ )
687
+
688
+ queued = _metadata(
689
+ 0,
690
+ [_request_metadata("request", 5, 4, 0, [])],
691
+ {},
692
+ )
693
+ queued_output = _process_output(
694
+ worker,
695
+ queued,
696
+ logical[1:],
697
+ ["request"],
698
+ np.array([3]),
699
+ )
700
+
701
+ rolled_back = _metadata(
702
+ 0,
703
+ [_request_metadata("request", 6, 3, 0, [b"a" * 32])],
704
+ {},
705
+ )
706
+
707
+ rolled_back_output = _process_output(
708
+ worker,
709
+ rolled_back,
710
+ logical[2:],
711
+ ["request"],
712
+ np.array([0]),
713
+ )
714
+
715
+ np.testing.assert_array_equal(first_output["request"].rows, logical[:1])
716
+ assert queued_output["request"].token_start == 1
717
+ np.testing.assert_array_equal(
718
+ queued_output["request"].rows,
719
+ logical[1:2],
720
+ )
721
+ np.testing.assert_array_equal(
722
+ rolled_back_output["request"].rows,
723
+ logical[2:],
724
+ )
725
+ assert worker._requests["request"].capture_cursor == len(logical)
726
+ worker.close()
727
+
728
+
729
+def test_worker_rejects_overlapping_capture_rows():
730
+ worker = _make_worker(1)
731
+ logical = np.arange(4 * 3 * 2, dtype=np.uint8).reshape(4, 3, 2)
732
+ first = _metadata(
733
+ 0,
734
+ [_request_metadata("request", 0, 3, 0, [])],
735
+ {},
736
+ )
737
+ _process_output(
738
+ worker,
739
+ first,
740
+ logical[:3],
741
+ ["request"],
742
+ np.array([0]),
743
+ np.zeros(1, dtype=np.int32),
744
+ )
745
+ stale_overlap = logical[2:].copy()
746
+ stale_overlap[0] += 100
747
+ second = _metadata(
748
+ 0,
749
+ [_request_metadata("request", 2, 2, 0, [b"a" * 32])],
750
+ {},
751
+ )
752
+ with pytest.raises(AssertionError, match="capture moved backwards"):
753
+ _process_output(
754
+ worker,
755
+ second,
756
+ stale_overlap,
757
+ ["request"],
758
+ np.array([0]),
759
+ )
760
+ worker.close()
761
+
762
+
763
+def test_worker_publishes_entire_batch_before_materializing_prefix():
764
+ worker = _make_worker(2)
765
+ hashes = [b"a" * 32, b"b" * 32]
766
+ logical = np.arange(8 * 3 * 2, dtype=np.uint8).reshape(8, 3, 2)
767
+ metadata = _metadata(
768
+ generation=0,
769
+ requests=[
770
+ _request_metadata("consumer", 4, 4, 0, hashes),
771
+ _request_metadata("producer", 0, 4, 0, hashes),
772
+ ],
773
+ finished_requests={},
774
+ )
775
+
776
+ output = _process_output(
777
+ worker,
778
+ metadata,
779
+ np.concatenate((logical[4:], logical[:4])),
780
+ ["consumer", "producer"],
781
+ np.array([0, 0]),
782
+ )
783
+
784
+ assert output is not None
785
+ np.testing.assert_array_equal(output["consumer"].rows, logical)
786
+ np.testing.assert_array_equal(output["producer"].rows, logical[:4])
787
+ worker.close()
788
+
789
+
790
+def test_worker_defers_full_block_until_kv_hash_arrives():
791
+ worker = _make_worker(1)
792
+ logical = np.arange(5 * 3 * 2, dtype=np.uint8).reshape(5, 3, 2)
793
+ first = _metadata(
794
+ 0,
795
+ [_request_metadata("request", 0, 4, 0, [])],
796
+ {},
797
+ )
798
+
799
+ output = _process_output(
800
+ worker,
801
+ first,
802
+ logical[:4],
803
+ ["request"],
804
+ np.array([0]),
805
+ )
806
+
807
+ assert output is not None
808
+ np.testing.assert_array_equal(output["request"].rows, logical[:4])
809
+ assert worker._requests["request"].pending_blocks[0][0] == 0
810
+
811
+ block_hash = b"a" * 32
812
+ second = _metadata(
813
+ 0,
814
+ [_request_metadata("request", 4, 1, 4, [block_hash])],
815
+ {},
816
+ )
817
+ output = _process_output(worker, second, logical[4:], ["request"], np.array([0]))
818
+
819
+ assert output is not None
820
+ np.testing.assert_array_equal(output["request"].rows, logical[4:])
821
+ assert not worker._requests["request"].pending_blocks
822
+ assert worker._store is not None
823
+ np.testing.assert_array_equal(
824
+ materialize_routed_experts(
825
+ worker._store,
826
+ routed_experts_keys([block_hash], "0"),
827
+ shape_per_token=_SHAPE,
828
+ dtype=_DTYPE,
829
+ ),
830
+ logical[:4],
831
+ )
832
+ worker.close()
833
+
834
+
835
+def test_worker_releases_newly_keyed_blocks_before_capture():
836
+ worker = _make_worker(1, max_num_batched_tokens=16)
837
+ connector = _make_connector()
838
+ block_hashes = [b"a" * 32]
839
+ request = _scheduler_request("request", block_hashes, num_tokens=100)
840
+ logical = np.arange(52 * 3 * 2, dtype=np.uint8).reshape(52, 3, 2)
841
+
842
+ for token_start, num_tokens in [(0, 4), (4, 16), (20, 16)]:
843
+ request.num_computed_tokens = token_start
844
+ metadata = connector.build_connector_meta(
845
+ _step_output([request.request_id], [token_start], [num_tokens]),
846
+ {request.request_id: request},
847
+ )
848
+ _process_output(
849
+ worker,
850
+ metadata,
851
+ logical[token_start : token_start + num_tokens],
852
+ [request.request_id],
853
+ np.array([0]),
854
+ token_starts=(token_start,),
855
+ num_tokens=(num_tokens,),
856
+ )
857
+
858
+ block_hashes.extend(bytes([value]) * 32 for value in range(1, 5))
859
+ request.num_computed_tokens = 36
860
+ metadata = connector.build_connector_meta(
861
+ _step_output([request.request_id], [36], [16]),
862
+ {request.request_id: request},
863
+ )
864
+ _process_output(
865
+ worker,
866
+ metadata,
867
+ logical[36:52],
868
+ [request.request_id],
869
+ np.array([0]),
870
+ token_starts=(36,),
871
+ num_tokens=(16,),
872
+ )
873
+
874
+ state = worker._requests[request.request_id]
875
+ assert [start for start, _ in state.pending_blocks] == [
876
+ 20,
877
+ 24,
878
+ 28,
879
+ 32,
880
+ 36,
881
+ 40,
882
+ 44,
883
+ 48,
884
+ ]
885
+ worker.close()
886
+
887
+
888
+def test_worker_publishes_pending_block_without_forward():
889
+ worker = _make_worker(1)
890
+ _begin_step(
891
+ worker,
892
+ _metadata(
893
+ 0,
894
+ [_request_metadata("request", 0, 1, 0, [])],
895
+ {},
896
+ ),
897
+ )
898
+ logical = np.arange(_BLOCK_SIZE * 3 * 2, dtype=np.uint8).reshape(_BLOCK_SIZE, 3, 2)
899
+ assert worker._buffer is not None
900
+ worker._requests["request"].pending_blocks = [
901
+ (0, worker._buffer.retain_block(logical))
902
+ ]
903
+ block_hash = b"a" * 32
904
+
905
+ _begin_step(worker, _metadata(0, [], {}, {"request": [block_hash]}))
906
+
907
+ assert not worker._requests["request"].pending_blocks
908
+ assert worker._store is not None
909
+ np.testing.assert_array_equal(
910
+ materialize_routed_experts(
911
+ worker._store,
912
+ routed_experts_keys([block_hash], "0"),
913
+ shape_per_token=_SHAPE,
914
+ dtype=_DTYPE,
915
+ ),
916
+ logical,
917
+ )
918
+ worker.close()
919
+
920
+
921
+def test_worker_releases_unscheduled_request_blocks_before_capture():
922
+ worker = _make_worker(2, 20, max_concurrent_batches=1)
923
+ logical = np.arange(48 * 3 * 2, dtype=np.uint8).reshape(48, 3, 2)
924
+ prompt_hashes = [b"a" * 32, b"b" * 32]
925
+ prompt = _metadata(
926
+ 0,
927
+ [
928
+ _request_metadata("first", 0, 4, 0, prompt_hashes[:1]),
929
+ _request_metadata("second", 0, 4, 0, prompt_hashes[1:]),
930
+ ],
931
+ {},
932
+ )
933
+ _process_output(
934
+ worker,
935
+ prompt,
936
+ logical[:8],
937
+ ["first", "second"],
938
+ np.zeros(2),
939
+ np.zeros(2, dtype=np.int32),
940
+ )
941
+
942
+ first_decode = _metadata(
943
+ 0,
944
+ [_request_metadata("first", 4, 20, 0, [])],
945
+ {},
946
+ )
947
+ _process_output(
948
+ worker,
949
+ first_decode,
950
+ logical[8:28],
951
+ ["first"],
952
+ np.zeros(1),
953
+ np.zeros(1, dtype=np.int32),
954
+ )
955
+ assert len(worker._requests["first"].pending_blocks) == 5
956
+
957
+ first_hashes = [bytes([value]) * 32 for value in range(2, 7)]
958
+ second_decode = _metadata(
959
+ 0,
960
+ [_request_metadata("second", 4, 20, 0, [])],
961
+ {},
962
+ {"first": first_hashes},
963
+ )
964
+ _process_output(
965
+ worker,
966
+ second_decode,
967
+ logical[28:48],
968
+ ["second"],
969
+ np.zeros(1),
970
+ np.zeros(1, dtype=np.int32),
971
+ )
972
+
973
+ assert not worker._requests["first"].pending_blocks
974
+ assert len(worker._requests["second"].pending_blocks) == 5
975
+ worker.close()
976
+
977
+
978
+def test_worker_does_not_rematerialize_emitted_rows():
979
+ worker = _make_worker(1)
980
+ hashes = [b"a" * 32, b"b" * 32]
981
+ logical = np.arange(8 * 3 * 2, dtype=np.uint8).reshape(8, 3, 2)
982
+
983
+ first = _metadata(
984
+ 0,
985
+ [_request_metadata("request", 0, 4, 0, hashes)],
986
+ {},
987
+ )
988
+ output = _process_output(worker, first, logical[:4], ["request"], np.array([0]))
989
+ assert output is not None
990
+
991
+ assert worker._store is not None
992
+ worker._store.get_concatenated = Mock(wraps=worker._store.get_concatenated)
993
+ second = _metadata(
994
+ 0,
995
+ # Async scheduling may build this before the scheduler consumes first.
996
+ [_request_metadata("request", 4, 4, 0, [])],
997
+ {},
998
+ )
999
+ output = _process_output(worker, second, logical[4:], ["request"], np.array([0]))
1000
+
1001
+ assert output is not None
1002
+ np.testing.assert_array_equal(output["request"].rows, logical[4:])
1003
+ worker._store.get_concatenated.assert_not_called()
1004
+ worker.close()
1005
+
1006
+
1007
+def test_worker_resumes_from_scheduler_emit_boundary():
1008
+ worker = _make_worker(1)
1009
+ logical = np.arange(5 * 3 * 2, dtype=np.uint8).reshape(5, 3, 2)
1010
+ resumed = _metadata(
1011
+ 0,
1012
+ [_request_metadata("request", 4, 1, 4, [b"a" * 32])],
1013
+ {},
1014
+ )
1015
+
1016
+ output = _process_output(worker, resumed, logical[4:], ["request"], np.array([0]))
1017
+
1018
+ assert output["request"].token_start == 4
1019
+ np.testing.assert_array_equal(output["request"].rows, logical[4:])
1020
+ assert worker._requests["request"].emit_cursor == 5
1021
+ worker.close()
1022
+
1023
+
1024
+def test_worker_emits_mid_block_chunked_prefill():
1025
+ worker = _make_worker(1)
1026
+ logical = np.arange(3 * 3 * 2, dtype=np.uint8).reshape(3, 3, 2)
1027
+
1028
+ first = _metadata(
1029
+ 0,
1030
+ [_request_metadata("request", 0, 2, 0, [])],
1031
+ {},
1032
+ )
1033
+ output = _process_output(
1034
+ worker,
1035
+ first,
1036
+ logical[:2],
1037
+ ["request"],
1038
+ np.array([0]),
1039
+ np.zeros(1, dtype=np.int32),
1040
+ )
1041
+ assert output is not None and not output
1042
+
1043
+ second = _metadata(
1044
+ 0,
1045
+ [_request_metadata("request", 2, 1, 0, [])],
1046
+ {},
1047
+ )
1048
+ output = _process_output(worker, second, logical[2:], ["request"], np.array([0]))
1049
+
1050
+ np.testing.assert_array_equal(output["request"].rows, logical)
1051
+ worker.close()
1052
+
1053
+
1054
+def test_worker_emits_published_block_and_mid_block_tail():
1055
+ worker = _make_worker(1)
1056
+ logical = np.arange(7 * 3 * 2, dtype=np.uint8).reshape(7, 3, 2)
1057
+
1058
+ first = _metadata(
1059
+ 0,
1060
+ [_request_metadata("request", 0, 6, 0, [b"a" * 32])],
1061
+ {},
1062
+ )
1063
+ output = _process_output(
1064
+ worker,
1065
+ first,
1066
+ logical[:6],
1067
+ ["request"],
1068
+ np.array([0]),
1069
+ np.zeros(1, dtype=np.int32),
1070
+ )
1071
+ assert output is not None and not output
1072
+
1073
+ second = _metadata(
1074
+ 0,
1075
+ [_request_metadata("request", 6, 1, 0, [])],
1076
+ {},
1077
+ )
1078
+ output = _process_output(worker, second, logical[6:], ["request"], np.array([0]))
1079
+
1080
+ np.testing.assert_array_equal(output["request"].rows, logical)
1081
+ worker.close()
1082
+
1083
+
1084
+def test_worker_output_does_not_alias_released_tail_buffer():
1085
+ worker = _make_worker(1)
1086
+ logical = np.arange(4 * 3 * 2, dtype=np.uint8).reshape(4, 3, 2)
1087
+ first = _metadata(
1088
+ 0,
1089
+ [_request_metadata("request", 0, 2, 0, [b"a" * 32])],
1090
+ {},
1091
+ )
1092
+ _process_output(
1093
+ worker,
1094
+ first,
1095
+ logical[:2],
1096
+ ["request"],
1097
+ np.array([0]),
1098
+ np.zeros(1, dtype=np.int32),
1099
+ )
1100
+ second = _metadata(
1101
+ 0,
1102
+ [_request_metadata("request", 2, 2, 0, [])],
1103
+ {},
1104
+ )
1105
+
1106
+ output = _process_output(worker, second, logical[2:], ["request"], np.array([0]))
1107
+ emitted = output["request"].rows
1108
+ expected = logical.copy()
1109
+
1110
+ replacement = np.full_like(logical, 99)
1111
+ reuse = _metadata(
1112
+ 0,
1113
+ [_request_metadata("reuse", 0, 4, 0, [b"b" * 32])],
1114
+ {},
1115
+ )
1116
+ _process_output(
1117
+ worker,
1118
+ reuse,
1119
+ replacement,
1120
+ ["reuse"],
1121
+ np.array([0]),
1122
+ np.zeros(1, dtype=np.int32),
1123
+ )
1124
+
1125
+ np.testing.assert_array_equal(emitted, expected)
1126
+ worker.close()
1127
+
1128
+
1129
+def test_worker_merges_block_hash_deltas():
1130
+ worker = _make_worker(1)
1131
+ _begin_step(
1132
+ worker,
1133
+ _metadata(
1134
+ 0,
1135
+ [_request_metadata("request", 0, 1, 0, [b"a" * 32, b"b" * 32])],
1136
+ {},
1137
+ ),
1138
+ )
1139
+ _begin_step(
1140
+ worker,
1141
+ _metadata(
1142
+ 0,
1143
+ [
1144
+ _request_metadata(
1145
+ "request",
1146
+ 1,
1147
+ 1,
1148
+ 0,
1149
+ [b"c" * 32],
1150
+ )
1151
+ ],
1152
+ {},
1153
+ ),
1154
+ )
1155
+
1156
+ assert worker._requests["request"].aux_output_keys == [
1157
+ "vllm-artifact/0/" + (b"a" * 32).hex(),
1158
+ "vllm-artifact/0/" + (b"b" * 32).hex(),
1159
+ "vllm-artifact/0/" + (b"c" * 32).hex(),
1160
+ ]
1161
+ worker.close()
1162
+
1163
+
1164
+def test_worker_finish_publishes_keyed_block_and_discards_request_state():
1165
+ worker = _make_worker(1)
1166
+ logical = np.arange(2 * _BLOCK_SIZE * 3 * 2, dtype=np.uint8).reshape(
1167
+ 2 * _BLOCK_SIZE, 3, 2
1168
+ )
1169
+ running = _metadata(
1170
+ 0,
1171
+ [_request_metadata("request", 0, len(logical), 0, [])],
1172
+ {},
1173
+ )
1174
+ _process_output(
1175
+ worker,
1176
+ running,
1177
+ logical,
1178
+ ["request"],
1179
+ np.array([0]),
1180
+ np.array([0]),
1181
+ )
1182
+ block_hash = b"a" * 32
1183
+
1184
+ _begin_step(
1185
+ worker,
1186
+ _metadata(
1187
+ 0,
1188
+ [],
1189
+ {"request": [block_hash]},
1190
+ ),
1191
+ )
1192
+
1193
+ assert "request" not in worker._requests
1194
+ assert worker._buffer is not None
1195
+ assert "request" not in worker._buffer._requests
1196
+ assert worker._store is not None
1197
+ np.testing.assert_array_equal(
1198
+ materialize_routed_experts(
1199
+ worker._store,
1200
+ routed_experts_keys([block_hash], "0"),
1201
+ shape_per_token=_SHAPE,
1202
+ dtype=_DTYPE,
1203
+ ),
1204
+ logical[:_BLOCK_SIZE],
1205
+ )
1206
+ worker.close()
1207
+
1208
+
1209
+def test_worker_fails_when_cached_aux_output_is_missing():
1210
+ worker = _make_worker(1)
1211
+ worker._generation = 0
1212
+ metadata = _metadata(
1213
+ 0,
1214
+ [
1215
+ _request_metadata(
1216
+ "request",
1217
+ 8,
1218
+ 1,
1219
+ 0,
1220
+ [b"a" * 32, b"b" * 32],
1221
+ )
1222
+ ],
1223
+ {},
1224
+ )
1225
+
1226
+ with pytest.raises(BlockObjectStoreError, match="does not exist"):
1227
+ _process_output(
1228
+ worker,
1229
+ metadata,
1230
+ np.zeros((1, *_SHAPE), dtype=_DTYPE),
1231
+ ["request"],
1232
+ np.array([0]),
1233
+ )
1234
+ worker.close()
1235
+
1236
+
1237
+def test_store_rejects_oversized_batch_without_partial_write():
1238
+ store = _make_store(max_bytes=6, object_nbytes=3)
1239
+ store.put([BlockObject("retained", b"rrr")])
1240
+
1241
+ with pytest.raises(BlockObjectStoreError, match="cannot retain"):
1242
+ store.put(
1243
+ [
1244
+ BlockObject("first", b"111"),
1245
+ BlockObject("second", b"222"),
1246
+ BlockObject("third", b"333"),
1247
+ ]
1248
+ )
1249
+
1250
+ assert store.get_concatenated(["retained"]) == b"rrr"
1251
+ with pytest.raises(BlockObjectStoreError, match="does not exist"):
1252
+ store.get_concatenated(["first"])
1253
+ store.close()
1254
+
1255
+
1256
+def test_store_lru_and_immutable_put():
1257
+ store = _make_store(max_bytes=8)
1258
+ store.put([BlockObject("first", b"1111"), BlockObject("second", b"2222")])
1259
+ assert store.get_concatenated(["first"]) == b"1111"
1260
+
1261
+ store.put([BlockObject("first", b"1111"), BlockObject("third", b"3333")])
1262
+
1263
+ assert store.get_concatenated(["first", "third"]) == b"11113333"
1264
+ with pytest.raises(BlockObjectStoreError, match="Increase aux_output_config"):
1265
+ store.get_concatenated(["second"])
1266
+ store.close()
1267
+
1268
+
1269
+def test_store_does_not_evict_referenced_aux_outputs():
1270
+ store = _make_store(max_bytes=8)
1271
+ store.put([BlockObject("first", b"1111"), BlockObject("second", b"2222")])
1272
+ store.put([], retain_keys=["first"])
1273
+ store.put([], retain_keys=["first"], release_keys=["first"])
1274
+
1275
+ store.put([BlockObject("third", b"3333")])
1276
+
1277
+ assert store.get_concatenated(["first", "third"]) == b"11113333"
1278
+ with pytest.raises(BlockObjectStoreError, match="does not exist"):
1279
+ store.get_concatenated(["second"])
1280
+ store.put([], release_keys=["first"])
1281
+ store.close()
1282
+
1283
+
1284
+def test_store_terminal_put_preserves_tail_first_eviction_order():
1285
+ store = _make_store(max_bytes=8)
1286
+ keys = ["first", "second", "third"]
1287
+ store.put(
1288
+ [BlockObject("first", b"1111"), BlockObject("second", b"2222")],
1289
+ retain_keys=keys,
1290
+ )
1291
+
1292
+ store.put(
1293
+ [BlockObject("third", b"3333")],
1294
+ release_keys=reversed(keys),
1295
+ )
1296
+
1297
+ assert store.get_concatenated(keys[:2]) == b"11112222"
1298
+ with pytest.raises(BlockObjectStoreError, match="does not exist"):
1299
+ store.get_concatenated(["third"])
1300
+ store.close()
1301
+
1302
+
1303
+def test_store_terminal_put_orders_survivors_for_later_eviction():
1304
+ store = _make_store(max_bytes=12)
1305
+ keys = ["first", "second", "third"]
1306
+ store.put(
1307
+ [BlockObject("first", b"1111"), BlockObject("second", b"2222")],
1308
+ retain_keys=keys,
1309
+ )
1310
+
1311
+ store.put(
1312
+ [BlockObject("third", b"3333")],
1313
+ release_keys=reversed(keys),
1314
+ )
1315
+ store.put([BlockObject("fourth", b"4444")])
1316
+
1317
+ assert store.get_concatenated(["first", "second", "fourth"]) == b"111122224444"
1318
+ with pytest.raises(BlockObjectStoreError, match="does not exist"):
1319
+ store.get_concatenated(["third"])
1320
+ store.close()
1321
+
1322
+
1323
+def test_store_terminal_put_keeps_a_shared_incoming_key():
1324
+ store = _make_store(max_bytes=8)
1325
+ keys = ["first", "second", "third"]
1326
+ store.put(
1327
+ [BlockObject("first", b"1111"), BlockObject("second", b"2222")],
1328
+ retain_keys=(*keys, "third"),
1329
+ )
1330
+
1331
+ store.put(
1332
+ [BlockObject("third", b"3333")],
1333
+ release_keys=reversed(keys),
1334
+ )
1335
+
1336
+ assert store.get_concatenated(["first", "third"]) == b"11113333"
1337
+ with pytest.raises(BlockObjectStoreError, match="does not exist"):
1338
+ store.get_concatenated(["second"])
1339
+ store.put([], release_keys=["third"])
1340
+ store.close()
1341
+
1342
+
1343
+def test_store_rejects_access_after_close():
1344
+ store = _make_store(max_bytes=8)
1345
+ store.close()
1346
+
1347
+ with pytest.raises(RuntimeError, match="closed"):
1348
+ store.put([BlockObject("first", b"1111")])
1349
+ with pytest.raises(RuntimeError, match="closed"):
1350
+ store.get_concatenated(["first"])
1351
+
1352
+
1353
+def test_store_reuses_evicted_slot_without_moving_live_objects():
1354
+ store = _make_store(max_bytes=12)
1355
+ store.put(
1356
+ [
1357
+ BlockObject("first", b"1111"),
1358
+ BlockObject("second", b"2222"),
1359
+ BlockObject("third", b"3333"),
1360
+ ]
1361
+ )
1362
+ assert store.get_concatenated(["second"]) == b"2222"
1363
+ store.put([BlockObject("fourth", b"4444")])
1364
+
1365
+ assert store.get_concatenated(["second", "third", "fourth"]) == b"222233334444"
1366
+ store.close()
1367
+
1368
+
1369
+def _scheduler_request(
1370
+ request_id: str,
1371
+ block_hashes: list[bytes],
1372
+ *,
1373
+ num_tokens: int = 10,
1374
+ num_output_tokens: int = 1,
1375
+ prompt_start: int = 0,
1376
+):
1377
+ return _SchedulerRequest(
1378
+ request_id=request_id,
1379
+ block_hashes=block_hashes,
1380
+ num_tokens=num_tokens,
1381
+ num_output_tokens=num_output_tokens,
1382
+ num_computed_tokens=num_tokens,
1383
+ sampling_params=SimpleNamespace(routed_experts_prompt_start=prompt_start),
1384
+ )
1385
+
1386
+
1387
+def _step_output(
1388
+ request_ids: list[str], token_starts: list[int], token_counts: list[int]
1389
+) -> SchedulerOutput:
1390
+ output = SchedulerOutput.make_empty()
1391
+ output.scheduled_cached_reqs = CachedRequestData(
1392
+ req_ids=request_ids,
1393
+ resumed_req_ids=set(),
1394
+ new_token_ids=[[] for _ in request_ids],
1395
+ all_token_ids={},
1396
+ new_block_ids=[None for _ in request_ids],
1397
+ num_computed_tokens=token_starts,
1398
+ num_output_tokens=[0 for _ in request_ids],
1399
+ )
1400
+ output.num_scheduled_tokens = dict(zip(request_ids, token_counts, strict=True))
1401
+ output.total_num_scheduled_tokens = sum(token_counts)
1402
+ return output
1403
+
1404
+
1405
+def test_scheduler_connector_builds_worker_metadata_and_forwards_output():
1406
+ connector = _make_connector()
1407
+ request = _scheduler_request("request", [b"a" * 32], num_tokens=5)
1408
+ request.num_computed_tokens = 0
1409
+ scheduler_output = _step_output([request.request_id], [0], [4])
1410
+
1411
+ metadata = connector.build_connector_meta(
1412
+ scheduler_output, {request.request_id: request}
1413
+ )
1414
+
1415
+ assert metadata.generation == 0
1416
+ assert metadata.requests == {request.request_id: 4}
1417
+ assert list(metadata.block_hashes[request.request_id]) == [b"a" * 32]
1418
+
1419
+ routing = np.arange(4 * 3 * 2, dtype=np.uint8).reshape(4, 3, 2)
1420
+ output = {"request": AuxRequestOutput(0, routing)}
1421
+ request.num_computed_tokens = 4
1422
+ np.testing.assert_array_equal(connector.take_output(request, output), routing)
1423
+
1424
+
1425
+def test_scheduler_starts_worker_output_at_requested_prompt_token():
1426
+ connector = _make_connector()
1427
+ request = _scheduler_request(
1428
+ "request",
1429
+ [b"a" * 32],
1430
+ num_tokens=4,
1431
+ num_output_tokens=0,
1432
+ prompt_start=2,
1433
+ )
1434
+
1435
+ metadata = connector.build_connector_meta(
1436
+ _step_output([request.request_id], [0], [4]),
1437
+ {request.request_id: request},
1438
+ )
1439
+
1440
+ assert metadata.requests == {request.request_id: 2}
1441
+
1442
+
1443
+def test_scheduler_connector_preserves_request_finish_order():
1444
+ connector = _make_connector()
1445
+ first = _scheduler_request("first", [b"a" * 32])
1446
+ second = _scheduler_request("second", [b"b" * 32])
1447
+ connector.build_connector_meta(
1448
+ _step_output([first.request_id, second.request_id], [0, 0], [0, 0]),
1449
+ {first.request_id: first, second.request_id: second},
1450
+ )
1451
+ connector.request_finished(second)
1452
+ connector.request_finished(first)
1453
+
1454
+ metadata = connector.build_connector_meta(_step_output([], [], []), {})
1455
+
1456
+ assert metadata.finished_requests == (second.request_id, first.request_id)
1457
+ assert not metadata.block_hashes
1458
+
1459
+
1460
+def test_worker_preemption_terminal_keeps_inflight_hashes_for_publish():
1461
+ worker = _make_worker(1)
1462
+ block_hashes = [b"a" * 32, b"b" * 32]
1463
+ logical = np.arange(2 * _BLOCK_SIZE * 3 * 2, dtype=np.uint8).reshape(
1464
+ 2 * _BLOCK_SIZE, 3, 2
1465
+ )
1466
+ first = _metadata(
1467
+ 0,
1468
+ [
1469
+ _request_metadata(
1470
+ "request",
1471
+ 0,
1472
+ _BLOCK_SIZE,
1473
+ 0,
1474
+ block_hashes[:1],
1475
+ )
1476
+ ],
1477
+ {},
1478
+ )
1479
+ _process_output(
1480
+ worker,
1481
+ first,
1482
+ logical[:_BLOCK_SIZE],
1483
+ ["request"],
1484
+ np.array([0]),
1485
+ )
1486
+ inflight = _metadata(
1487
+ 0,
1488
+ [
1489
+ _request_metadata(
1490
+ "request",
1491
+ _BLOCK_SIZE,
1492
+ _BLOCK_SIZE,
1493
+ 0,
1494
+ block_hashes[1:],
1495
+ )
1496
+ ],
1497
+ {},
1498
+ )
1499
+ _process_output(
1500
+ worker,
1501
+ inflight,
1502
+ logical[_BLOCK_SIZE:],
1503
+ ["request"],
1504
+ np.array([0]),
1505
+ np.zeros(1, dtype=np.int32),
1506
+ token_starts=(_BLOCK_SIZE,),
1507
+ num_tokens=(_BLOCK_SIZE,),
1508
+ )
1509
+ _begin_step(
1510
+ worker,
1511
+ _metadata(
1512
+ 0,
1513
+ [],
1514
+ {"request": []},
1515
+ ),
1516
+ )
1517
+
1518
+ assert worker._store is not None
1519
+ np.testing.assert_array_equal(
1520
+ materialize_routed_experts(
1521
+ worker._store,
1522
+ routed_experts_keys(block_hashes[:1], "0"),
1523
+ shape_per_token=_SHAPE,
1524
+ dtype=_DTYPE,
1525
+ ),
1526
+ logical[:_BLOCK_SIZE],
1527
+ )
1528
+ np.testing.assert_array_equal(
1529
+ materialize_routed_experts(
1530
+ worker._store,
1531
+ routed_experts_keys(block_hashes[1:], "0"),
1532
+ shape_per_token=_SHAPE,
1533
+ dtype=_DTYPE,
1534
+ ),
1535
+ logical[_BLOCK_SIZE:],
1536
+ )
1537
+ worker.close()
1538
+
1539
+
1540
+def test_worker_releases_terminal_pins_before_same_step_publish():
1541
+ worker = _make_worker(2, max_store_blocks=1)
1542
+ victim_hash = b"a" * 32
1543
+ producer_hash = b"b" * 32
1544
+ victim_rows = np.zeros((_BLOCK_SIZE, *_SHAPE), dtype=_DTYPE)
1545
+ producer_rows = np.ones((_BLOCK_SIZE, *_SHAPE), dtype=_DTYPE)
1546
+ running = _metadata(
1547
+ 0,
1548
+ [
1549
+ _request_metadata("victim", 0, _BLOCK_SIZE, 0, [victim_hash]),
1550
+ _request_metadata("producer", 0, _BLOCK_SIZE, 0, []),
1551
+ ],
1552
+ {},
1553
+ )
1554
+ _process_output(
1555
+ worker,
1556
+ running,
1557
+ np.concatenate((victim_rows, producer_rows)),
1558
+ ["victim", "producer"],
1559
+ np.zeros(2, dtype=np.int32),
1560
+ np.zeros(2, dtype=np.int32),
1561
+ )
1562
+
1563
+ _begin_step(
1564
+ worker,
1565
+ _metadata(
1566
+ 0,
1567
+ [],
1568
+ {"victim": []},
1569
+ {"producer": [producer_hash]},
1570
+ ),
1571
+ )
1572
+
1573
+ assert worker._store is not None
1574
+ np.testing.assert_array_equal(
1575
+ materialize_routed_experts(
1576
+ worker._store,
1577
+ routed_experts_keys([producer_hash], "0"),
1578
+ shape_per_token=_SHAPE,
1579
+ dtype=_DTYPE,
1580
+ ),
1581
+ producer_rows,
1582
+ )
1583
+ with pytest.raises(BlockObjectStoreError, match="does not exist"):
1584
+ worker._store.get_concatenated(routed_experts_keys([victim_hash], "0"))
1585
+ worker.close()
1586
+
1587
+
1588
+def test_terminal_request_publishes_hash_discovered_after_last_schedule():
1589
+ connector = _make_connector()
1590
+ worker = _make_worker(1)
1591
+ block_hashes: list[bytes] = []
1592
+ request = _scheduler_request("request", block_hashes, num_tokens=5)
1593
+ request.num_computed_tokens = 0
1594
+ metadata = connector.build_connector_meta(
1595
+ _step_output([request.request_id], [0], [4]),
1596
+ {request.request_id: request},
1597
+ )
1598
+ logical = np.arange(_BLOCK_SIZE * 3 * 2, dtype=np.uint8).reshape(_BLOCK_SIZE, 3, 2)
1599
+
1600
+ _process_output(
1601
+ worker,
1602
+ metadata,
1603
+ logical,
1604
+ [request.request_id],
1605
+ np.array([0]),
1606
+ token_starts=(0,),
1607
+ num_tokens=(_BLOCK_SIZE,),
1608
+ )
1609
+ assert worker._requests[request.request_id].pending_blocks
1610
+
1611
+ block_hash = b"a" * 32
1612
+ block_hashes.append(block_hash)
1613
+ request.num_computed_tokens = _BLOCK_SIZE
1614
+ connector.request_finished(request)
1615
+ cleanup = connector.build_connector_meta(_step_output([], [], []), {})
1616
+ worker.begin_step(cleanup)
1617
+
1618
+ assert worker._store is not None
1619
+ np.testing.assert_array_equal(
1620
+ materialize_routed_experts(
1621
+ worker._store,
1622
+ routed_experts_keys([block_hash], "0"),
1623
+ shape_per_token=_SHAPE,
1624
+ dtype=_DTYPE,
1625
+ ),
1626
+ logical,
1627
+ )
1628
+ worker.close()
1629
+
1630
+
1631
+def test_scheduler_connector_recreates_preempted_request_state():
1632
+ connector = _make_connector()
1633
+ request = _scheduler_request("request", [b"a" * 32], num_tokens=5)
1634
+ connector.build_connector_meta(
1635
+ _step_output([request.request_id], [0], [4]),
1636
+ {request.request_id: request},
1637
+ )
1638
+ connector.request_finished(request)
1639
+ cleanup = connector.build_connector_meta(
1640
+ _step_output([], [], []),
1641
+ {request.request_id: request},
1642
+ )
1643
+ assert request.request_id in cleanup.finished_requests
1644
+ assert request.request_id not in cleanup.block_hashes
1645
+
1646
+ resumed = connector.build_connector_meta(
1647
+ _step_output([request.request_id], [0], [4]),
1648
+ {request.request_id: request},
1649
+ )
1650
+ assert list(resumed.block_hashes[request.request_id]) == [b"a" * 32]
1651
+
1652
+
1653
+def test_scheduler_consumes_ordered_stale_aux_outputs():
1654
+ connector = _make_connector()
1655
+ request = _scheduler_request("request", [], num_tokens=6)
1656
+ connector.build_connector_meta(
1657
+ _step_output([request.request_id], [0], [4]),
1658
+ {request.request_id: request},
1659
+ )
1660
+ first_rows = np.arange(4 * 3 * 2, dtype=np.uint8).reshape(4, 3, 2)
1661
+ second_rows = np.arange(4 * 3 * 2, dtype=np.uint8).reshape(4, 3, 2) + 40
1662
+ first = {"request": AuxRequestOutput(0, first_rows)}
1663
+ second = {"request": AuxRequestOutput(4, second_rows)}
1664
+ connector.request_finished(request)
1665
+
1666
+ request.num_tokens = 5
1667
+ np.testing.assert_array_equal(connector.take_output(request, first), first_rows)
1668
+ request.num_tokens = 6
1669
+ np.testing.assert_array_equal(
1670
+ connector.take_output(request, second), second_rows[:1]
1671
+ )
1672
+
1673
+
1674
+def test_scheduler_rejects_stale_output_without_aux_outputs():
1675
+ connector = _make_connector()
1676
+ request = _scheduler_request("request", [], num_tokens=1)
1677
+ connector.build_connector_meta(
1678
+ _step_output([request.request_id], [0], [1]),
1679
+ {request.request_id: request},
1680
+ )
1681
+
1682
+ with pytest.raises(
1683
+ AssertionError, match="auxiliary output worker output is missing"
1684
+ ):
1685
+ connector.take_output(
1686
+ request,
1687
+ {},
1688
+ )
1689
+
1690
+
1691
+def test_scheduler_rejects_missing_accepted_aux_output_rows():
1692
+ connector = _make_connector()
1693
+ request = _scheduler_request("request", [], num_tokens=2)
1694
+ connector.build_connector_meta(
1695
+ _step_output([request.request_id], [0], [1]),
1696
+ {request.request_id: request},
1697
+ )
1698
+ output = {
1699
+ "request": AuxRequestOutput(
1700
+ 0,
1701
+ np.empty((0, *_SHAPE), dtype=_DTYPE),
1702
+ )
1703
+ }
1704
+
1705
+ with pytest.raises(AssertionError, match="invalid token range"):
1706
+ connector.take_output(request, output)
1707
+
1708
+
1709
+def test_scheduler_rejects_aux_output_past_finished_request():
1710
+ connector = _make_connector()
1711
+ request = _scheduler_request("request", [], num_tokens=1)
1712
+ request.finished = True
1713
+ connector.build_connector_meta(
1714
+ _step_output([request.request_id], [0], [1]),
1715
+ {request.request_id: request},
1716
+ )
1717
+ output = {
1718
+ "request": AuxRequestOutput(
1719
+ 1,
1720
+ np.empty((0, *_SHAPE), dtype=_DTYPE),
1721
+ )
1722
+ }
1723
+
1724
+ with pytest.raises(
1725
+ AssertionError, match="finished auxiliary output output has no accepted"
1726
+ ):
1727
+ connector.take_output(request, output)
1728
+
1729
+
1730
+@pytest.mark.parametrize("chunk_size", [2, 4])
1731
+@pytest.mark.parametrize("max_tokens", [1, 2])
1732
+def test_prompt_end_offset_through_worker_and_scheduler(chunk_size, max_tokens):
1733
+ """Omit prompt R3 without losing the empty first output or later decode rows."""
1734
+ connector = _make_connector()
1735
+ worker = _make_worker(1)
1736
+ request = _scheduler_request(
1737
+ "request", [b"a" * 32], num_tokens=4, num_output_tokens=0, prompt_start=4
1738
+ )
1739
+ rows = np.arange(5 * 3 * 2, dtype=_DTYPE).reshape(5, *_SHAPE)
1740
+ try:
1741
+ for start in range(0, 4, chunk_size):
1742
+ metadata = connector.build_connector_meta(
1743
+ _step_output([request.request_id], [start], [chunk_size]),
1744
+ {request.request_id: request},
1745
+ )
1746
+ output = _process_output(
1747
+ worker,
1748
+ metadata,
1749
+ rows[start : start + chunk_size],
1750
+ [request.request_id],
1751
+ np.array([0]),
1752
+ num_sampled=np.array([int(start + chunk_size == 4)]),
1753
+ token_starts=(start,),
1754
+ num_tokens=(chunk_size,),
1755
+ )
1756
+ if start + chunk_size < 4:
1757
+ assert output == {}
1758
+ request.num_tokens = 5
1759
+ request.num_output_tokens = 1
1760
+ request.finished = max_tokens == 1
1761
+ result = connector.take_output(request, output)
1762
+ np.testing.assert_array_equal(result, rows[:0])
1763
+
1764
+ if max_tokens == 2:
1765
+ metadata = connector.build_connector_meta(
1766
+ _step_output([request.request_id], [4], [1]),
1767
+ {request.request_id: request},
1768
+ )
1769
+ output = _process_output(
1770
+ worker,
1771
+ metadata,
1772
+ rows[4:],
1773
+ [request.request_id],
1774
+ np.array([0]),
1775
+ token_starts=(4,),
1776
+ num_tokens=(1,),
1777
+ )
1778
+ request.num_tokens = 6
1779
+ request.finished = True
1780
+ np.testing.assert_array_equal(
1781
+ connector.take_output(request, output), rows[4:]
1782
+ )
1783
+ finally:
1784
+ worker.close()
1785
+
1786
+
1787
+def test_scheduler_connector_sends_each_block_hash_once():
1788
+ connector = _make_connector()
1789
+ request = _scheduler_request("request", [b"a" * 32, b"b" * 32], num_tokens=12)
1790
+ scheduler_output = _step_output([request.request_id], [0], [4])
1791
+
1792
+ first = connector.build_connector_meta(
1793
+ scheduler_output, {request.request_id: request}
1794
+ )
1795
+ request.block_hashes.append(b"c" * 32)
1796
+ second = connector.build_connector_meta(
1797
+ _step_output([request.request_id], [4], [4]),
1798
+ {request.request_id: request},
1799
+ )
1800
+ third = connector.build_connector_meta(
1801
+ _step_output([request.request_id], [8], [4]),
1802
+ {request.request_id: request},
1803
+ )
1804
+
1805
+ assert list(first.block_hashes[request.request_id]) == [b"a" * 32, b"b" * 32]
1806
+ assert list(second.block_hashes[request.request_id]) == [b"c" * 32]
1807
+ assert request.request_id not in third.block_hashes
1808
+
1809
+
1810
+def test_scheduler_connector_sends_unscheduled_hash_update():
1811
+ connector = _make_connector()
1812
+ request = _scheduler_request("request", [b"a" * 32], num_tokens=4)
1813
+ connector.build_connector_meta(
1814
+ _step_output([request.request_id], [0], [4]),
1815
+ {request.request_id: request},
1816
+ )
1817
+
1818
+ request.block_hashes.append(b"b" * 32)
1819
+ request.num_tokens = 9
1820
+ request.num_computed_tokens = 8
1821
+ metadata = connector.build_connector_meta(
1822
+ _step_output([], [], []),
1823
+ {request.request_id: request},
1824
+ )
1825
+
1826
+ assert not metadata.requests
1827
+ assert list(metadata.block_hashes[request.request_id]) == [b"b" * 32]
1828
+
1829
+
1830
+def test_scheduler_connector_reset_derives_emit_start_from_request():
1831
+ connector = _make_connector()
1832
+ request = _scheduler_request("request", [b"a" * 32], num_tokens=5)
1833
+ request.num_computed_tokens = 0
1834
+ before_reset = connector.build_connector_meta(
1835
+ _step_output([request.request_id], [0], [4]),
1836
+ {request.request_id: request},
1837
+ )
1838
+ assert list(before_reset.block_hashes[request.request_id]) == [b"a" * 32]
1839
+
1840
+ connector.reset()
1841
+ empty = connector.build_connector_meta(
1842
+ _step_output([], [], []),
1843
+ {request.request_id: request},
1844
+ )
1845
+ assert not empty.block_hashes
1846
+ request.num_computed_tokens = 4
1847
+ scheduler_output = _step_output([request.request_id], [4], [1])
1848
+ metadata = connector.build_connector_meta(
1849
+ scheduler_output, {request.request_id: request}
1850
+ )
1851
+
1852
+ assert metadata.generation == 1
1853
+ assert metadata.requests[request.request_id] == 4
1854
+ assert list(metadata.block_hashes[request.request_id]) == [b"a" * 32]
tests/entrypoints/openai/test_return_routed_experts.py
+13
-6
@@ -12,15 +12,13 @@ from ...utils import RemoteOpenAIServer
12
MODEL_NAME = "TitanML/tiny-mixtral"
13
14
# tiny-mixtral config: 8 local experts, top-2 routing, 2 hidden layers.
15
-# The published config has sliding_window=4096, which produces
16
-# SlidingWindowSpec kv-cache groups; RoutedExpertsManager requires a
17
-# FullAttentionSpec group, so we override sliding_window=null below.
15
+# Its published sliding window is incompatible with AuxOutput retention.
16
NUM_LOCAL_EXPERTS = 8
17
NUM_EXPERTS_PER_TOK = 2
18
NUM_HIDDEN_LAYERS = 2
19
20
23
-def assert_valid_routed_experts(encoded: str | None) -> None:
21
+def decode_routed_experts(encoded: str | None) -> np.ndarray:
22
assert encoded is not None
23
routed_experts = np.load(io.BytesIO(base64.b64decode(encoded)))
24
assert routed_experts.ndim == 3
@@ -30,6 +28,7 @@ def assert_valid_routed_experts(encoded: str | None) -> None:
28
assert topk == NUM_EXPERTS_PER_TOK
29
assert (routed_experts >= 0).all()
30
assert (routed_experts < NUM_LOCAL_EXPERTS).all()
31
+ return routed_experts
32
33
34
@pytest.fixture(scope="module")
@@ -44,7 +43,11 @@ def server():
43
"--hf-overrides",
44
'{"sliding_window": null}',
45
]
47
- with RemoteOpenAIServer(MODEL_NAME, args) as remote_server:
46
+ with RemoteOpenAIServer(
47
+ MODEL_NAME,
48
+ args,
49
+ env_dict={"VLLM_USE_V2_MODEL_RUNNER": "1"},
50
+ ) as remote_server:
51
yield remote_server
52
53
@@ -63,4 +66,8 @@ async def test_routed_experts(server):
66
choice = result.model_dump()["choices"][0]
67
68
assert choice["token_ids"] is not None
66
- assert_valid_routed_experts(choice["routed_experts"])
69
+ assert result.usage is not None
70
+ routed_experts = decode_routed_experts(choice["routed_experts"])
71
+ assert len(routed_experts) == (
72
+ result.usage.prompt_tokens + len(choice["token_ids"]) - 1
73
+ )
tests/entrypoints/scale_out/token_in_token_out/test_return_routed_experts.py
+11
-5
@@ -15,9 +15,7 @@ MODEL_NAME = "TitanML/tiny-mixtral"
15
GEN_ENDPOINT = "/inference/v1/generate"
16
17
# tiny-mixtral config: 8 local experts, top-2 routing, 2 hidden layers.
18
-# The published config has sliding_window=4096, which produces
19
-# SlidingWindowSpec kv-cache groups; RoutedExpertsManager requires a
20
-# FullAttentionSpec group, so we override sliding_window=null below.
18
+# Its published sliding window is incompatible with AuxOutput retention.
19
NUM_LOCAL_EXPERTS = 8
20
NUM_EXPERTS_PER_TOK = 2
21
NUM_HIDDEN_LAYERS = 2
@@ -36,7 +34,13 @@ def server():
34
'{"sliding_window": null}',
35
"--enable-scale-out",
36
]
39
- with RemoteOpenAIServer(MODEL_NAME, args) as remote_server:
37
+ with RemoteOpenAIServer(
38
+ MODEL_NAME,
39
+ args,
40
+ env_dict={
41
+ "VLLM_USE_V2_MODEL_RUNNER": "1",
42
+ },
43
+ ) as remote_server:
44
yield remote_server
45
46
@@ -56,9 +60,10 @@ async def client(server: RemoteOpenAIServer):
60
@pytest.mark.asyncio
61
async def test_generate_routed_experts(client):
62
"""Test that /inference/v1/generate returns routed_experts when enabled."""
63
+ prompt_token_ids = [1, 2, 3]
64
payload = {
65
"model": MODEL_NAME,
61
- "token_ids": [1, 2, 3],
66
+ "token_ids": prompt_token_ids,
67
"sampling_params": {"max_tokens": 10, "temperature": 0.0},
68
"stream": False,
69
}
@@ -80,3 +85,4 @@ async def test_generate_routed_experts(client):
85
assert topk == NUM_EXPERTS_PER_TOK
86
assert (routed_experts >= 0).all()
87
assert (routed_experts < NUM_LOCAL_EXPERTS).all()
88
+ assert num_tokens == len(prompt_token_ids) + len(choice["token_ids"]) - 1
tests/model_executor/test_routed_experts_capture.py
+218
-194
@@ -7,26 +7,19 @@ from unittest.mock import Mock, patch
7
import pytest
8
import torch
9
10
-from vllm.config import ModelConfig, VllmConfig
10
+from vllm.config import VllmConfig
11
from vllm.config.compilation import CompilationMode
12
from vllm.distributed.eplb.eplb_state import EplbLayerState
13
from vllm.model_executor.layers.fused_moe.config import RoutingMethodType
14
from vllm.model_executor.layers.fused_moe.routed_experts_capturer import (
15
RoutedExpertsCapturer,
16
- RoutedExpertsManager,
16
bind_routed_experts_capturer,
18
- get_routed_experts_attn_gid,
17
)
18
from vllm.model_executor.layers.fused_moe.router.base_router import BaseRouter
21
-from vllm.transformers_utils.model_arch_config_convertor import (
22
- ModelArchConfigConvertorBase,
23
-)
19
from vllm.v1.kv_cache_interface import (
20
FullAttentionSpec,
26
- KVCacheConfig,
21
KVCacheGroupSpec,
22
MLAAttentionSpec,
29
- SlidingWindowMLASpec,
23
UniformTypeKVCacheSpecs,
24
)
25
@@ -42,6 +35,7 @@ def _capturer_with_buffer(
35
num_experts_per_tok: int = 2,
36
dp_rank: int = 0,
37
tp_size: int = 1,
38
+ dtype: torch.dtype = torch.int32,
39
) -> RoutedExpertsCapturer:
40
# Bypass __init__ so the test can use a CPU buffer and skip the
41
# VllmConfig dependency. The CUDA device-tensor allocation in the
@@ -49,10 +43,11 @@ def _capturer_with_buffer(
43
c = RoutedExpertsCapturer.__new__(RoutedExpertsCapturer)
44
c.dp_rank = dp_rank
45
c.tp_size = tp_size
46
+ c.output_dtype = torch.uint8
47
c.device_buffer = torch.full(
48
(max_tokens, num_layers, num_experts_per_tok),
49
-1,
55
- dtype=torch.int32,
50
+ dtype=dtype,
51
)
52
return c
53
@@ -88,74 +83,36 @@ def _make_modular_routed_experts():
83
)
84
85
91
-def _make_model_config(hf_config):
92
- num_experts_per_token = ModelArchConfigConvertorBase(
93
- hf_config, hf_config
94
- ).get_num_experts_per_token()
95
- model_config = SimpleNamespace(
96
- hf_text_config=hf_config,
97
- model_arch_config=SimpleNamespace(
98
- num_experts_per_token=num_experts_per_token,
99
- ),
100
- )
101
- model_config.get_num_experts = lambda: hf_config.num_experts
102
- model_config.get_num_experts_per_tok = lambda: (
103
- ModelConfig.get_num_experts_per_tok(model_config)
104
- )
105
- model_config.get_total_num_hidden_layers = lambda: hf_config.num_hidden_layers
106
- return model_config
107
-
108
-
109
-def test_routed_experts_manager_uses_gemma4_top_k_experts():
110
- hf_config = SimpleNamespace(
111
- num_experts=8,
112
- top_k_experts=2,
113
- num_hidden_layers=3,
114
- )
115
- vllm_config = SimpleNamespace(model_config=_make_model_config(hf_config))
116
- kv_cache_spec = FullAttentionSpec(
117
- block_size=4,
118
- num_kv_heads=1,
119
- head_size=1,
120
- dtype=torch.float32,
121
- )
122
- kv_cache_config = KVCacheConfig(
123
- num_blocks=2,
124
- kv_cache_tensors=[],
125
- kv_cache_groups=[KVCacheGroupSpec(["layer"], kv_cache_spec)],
126
- )
127
-
128
- manager = RoutedExpertsManager(vllm_config, kv_cache_config)
129
-
130
- assert manager.routed_experts_by_slot.shape == (8, 3, 2)
131
-
132
-
133
-def test_routed_experts_manager_uses_kimi_k3_experts_per_token():
134
- hf_config = SimpleNamespace(
135
- num_experts=8,
136
- num_experts_per_token=2,
137
- num_hidden_layers=3,
138
- )
139
- vllm_config = SimpleNamespace(model_config=_make_model_config(hf_config))
140
- kv_cache_spec = FullAttentionSpec(
141
- block_size=4,
86
+def _full_attention_kv_group(
87
+ spec_type: type[FullAttentionSpec] = FullAttentionSpec,
88
+) -> KVCacheGroupSpec:
89
+ full_attention = spec_type(
90
+ block_size=16,
91
num_kv_heads=1,
92
head_size=1,
93
dtype=torch.float32,
94
)
146
- kv_cache_config = KVCacheConfig(
147
- num_blocks=2,
148
- kv_cache_tensors=[],
149
- kv_cache_groups=[KVCacheGroupSpec(["layer"], kv_cache_spec)],
95
+ return KVCacheGroupSpec(
96
+ ["layer"],
97
+ UniformTypeKVCacheSpecs(
98
+ block_size=16,
99
+ kv_cache_specs={"layer": full_attention},
100
+ ),
101
)
102
152
- manager = RoutedExpertsManager(vllm_config, kv_cache_config)
153
-
154
- assert manager.routed_experts_by_slot.shape == (8, 3, 2)
103
104
+@pytest.mark.parametrize("eplb_enabled", [False, True])
105
+def test_base_router_capture_pre_eplb_mapping(eplb_enabled):
106
+ eplb_state = None
107
+ if eplb_enabled:
108
+ eplb_state = EplbLayerState()
109
+ eplb_state.expert_load_view = torch.zeros(32, dtype=torch.int64)
110
+ eplb_state.logical_to_physical_map = torch.arange(32).view(32, 1)
111
+ eplb_state.logical_replica_count = torch.ones(32, dtype=torch.int64)
112
+ eplb_state.should_record_tensor = torch.ones((), dtype=torch.bool)
113
+ eplb_state.num_unpadded_tokens_tensors = [torch.tensor(0, dtype=torch.int32)]
114
+ router = _make_router(eplb_state)
115
157
-def test_base_router_capture_pre_eplb_mapping():
158
- router = _make_router()
116
captured = []
117
118
def capture_fn(ids):
@@ -173,34 +130,7 @@ def test_base_router_capture_pre_eplb_mapping():
130
assert torch.equal(topk_ids, torch.tensor([[11, 12], [13, 14]]))
131
132
176
-def test_base_router_capture_with_eplb_enabled():
177
- eplb_state = EplbLayerState()
178
- eplb_state.expert_load_view = torch.zeros(32, dtype=torch.int64)
179
- eplb_state.logical_to_physical_map = torch.arange(32).view(32, 1)
180
- eplb_state.logical_replica_count = torch.ones(32, dtype=torch.int64)
181
- eplb_state.should_record_tensor = torch.ones((), dtype=torch.bool)
182
- eplb_state.num_unpadded_tokens_tensors = [torch.tensor(0, dtype=torch.int32)]
183
- router = _make_router(eplb_state=eplb_state)
184
-
185
- captured = []
186
-
187
- def capture_fn(ids):
188
- captured.append(ids.clone())
189
-
190
- router.set_capture_fn(capture_fn)
191
- _, topk_ids = router.select_experts(
192
- hidden_states=torch.empty(1),
193
- router_logits=torch.empty(1),
194
- )
195
-
196
- assert len(captured) == 1
197
- # Capture should see logical ids pre-EPLB mapping.
198
- assert torch.equal(captured[0], torch.tensor([[1, 2], [3, 4]]))
199
- # Our DummyRouter mapping adds +10.
200
- assert torch.equal(topk_ids, torch.tensor([[11, 12], [13, 14]]))
201
-
202
-
203
-def test_public_binding_only_visits_target_model(monkeypatch):
133
+def test_public_binding_binds_target_model_router(monkeypatch):
134
class DummyFusedMoE:
135
def __init__(self, layer_id):
136
self.layer_id = layer_id
@@ -208,7 +138,6 @@ def test_public_binding_only_visits_target_model(monkeypatch):
138
self._quant_method = _make_modular_routed_experts().quant_method
139
140
target_module = DummyFusedMoE(layer_id=7)
211
- draft_module = DummyFusedMoE(layer_id=0)
141
142
import vllm.model_executor.layers.fused_moe.layer as fused_moe_layer
143
@@ -221,12 +150,30 @@ def test_public_binding_only_visits_target_model(monkeypatch):
150
)
151
152
assert target_module.router.capture_fn is not None
224
- assert draft_module.router.capture_fn is None
153
topk_ids = torch.tensor([[5, 6]])
154
target_module.router.capture_fn(topk_ids)
155
assert calls == [(7, topk_ids)]
156
157
158
+def test_public_binding_supports_direct_capture_source():
159
+ class DirectCaptureSource(torch.nn.Module):
160
+ def __init__(self):
161
+ super().__init__()
162
+ self.layer_id = 3
163
+ self.capture_fn = None
164
+
165
+ source = DirectCaptureSource()
166
+ calls = []
167
+ capturer = types.SimpleNamespace(capture=lambda *args: calls.append(args))
168
+
169
+ bind_routed_experts_capturer(source, capturer)
170
+
171
+ assert source.capture_fn is not None
172
+ topk_ids = torch.tensor([[1, 2]])
173
+ source.capture_fn(topk_ids)
174
+ assert calls == [(3, topk_ids)]
175
+
176
+
177
def test_public_binding_rejects_monolithic_without_replay_support(monkeypatch):
178
class DummyFusedMoE:
179
def __init__(self):
@@ -277,6 +224,51 @@ def test_routed_experts_capturer_single_dp_no_metadata():
224
assert capturer.device_buffer[3, 0, 0].item() == -1
225
226
227
+@pytest.mark.parametrize(
228
+ ("num_experts", "dtype_name", "torch_dtype"),
229
+ [(256, "uint8", torch.uint8), (257, "uint16", torch.uint16)],
230
+)
231
+def test_routed_experts_capturer_exposes_output_profile(
232
+ monkeypatch, num_experts, dtype_name, torch_dtype
233
+):
234
+ import vllm.model_executor.layers.fused_moe.routed_experts_capturer as module
235
+
236
+ monkeypatch.setattr(module, "current_platform", SimpleNamespace(device_type="cpu"))
237
+ config = SimpleNamespace(
238
+ model_config=SimpleNamespace(
239
+ get_total_num_hidden_layers=lambda: 3,
240
+ get_num_experts=lambda: num_experts,
241
+ get_num_experts_per_tok=lambda: 2,
242
+ hf_text_config=SimpleNamespace(model_type="test"),
243
+ ),
244
+ parallel_config=SimpleNamespace(data_parallel_rank=0, tensor_parallel_size=1),
245
+ )
246
+
247
+ capturer = RoutedExpertsCapturer(8, config)
248
+
249
+ assert capturer.shape_per_token == (3, 2)
250
+ assert capturer.output_dtype_name == dtype_name
251
+ assert capturer.output_dtype == torch_dtype
252
+ assert capturer.device_buffer.shape == (8, 3, 2)
253
+
254
+
255
+@pytest.mark.parametrize("output_dtype", [torch.uint8, torch.uint16])
256
+def test_routed_experts_capturer_narrows_snapshot(output_dtype):
257
+ capturer = _capturer_with_buffer(dtype=torch.int32)
258
+ capturer.output_dtype = output_dtype
259
+ topk = torch.tensor([[1, 2], [254, 255]], dtype=torch.int64)
260
+ ctx = SimpleNamespace(dp_metadata=None)
261
+ with patch(f"{_REC_MODULE}.get_forward_context", return_value=ctx):
262
+ capturer.capture(layer_id=0, topk_ids=topk)
263
+
264
+ output = capturer.snapshot_routing_data(2)
265
+ assert capturer.device_buffer.dtype == torch.int32
266
+ assert capturer.device_buffer[:2, 0, :].tolist() == topk.tolist()
267
+ assert output.dtype == output_dtype
268
+ assert output[:, 0, :].tolist() == topk.tolist()
269
+ assert output.data_ptr() != capturer.device_buffer.data_ptr()
270
+
271
+
272
def test_routed_experts_capturer_dp_naive_concatenated_all_ranks():
273
"""N == sum(num_tokens_dp): slice this rank's segment from concatenated topk."""
274
capturer = _capturer_with_buffer(dp_rank=1)
@@ -307,6 +299,47 @@ def test_routed_experts_capturer_dp_modular_local_tokens():
299
assert torch.equal(capturer.device_buffer[:3, 0, :], topk)
300
301
302
+def test_routed_experts_capturer_dp_ep_gathered_shards():
303
+ capturer = _capturer_with_buffer(dp_rank=1, tp_size=2)
304
+ num_tokens_dp = torch.tensor([3, 2], dtype=torch.int32)
305
+ ctx = SimpleNamespace(
306
+ dp_metadata=SimpleNamespace(
307
+ num_tokens_across_dp_cpu=num_tokens_dp,
308
+ local_sizes=[2, 2, 1, 1],
309
+ )
310
+ )
311
+ topk = torch.tensor(
312
+ [[0, 1], [2, 3], [4, 5], [-1, -1], [10, 11], [12, 13]],
313
+ dtype=torch.int32,
314
+ )
315
+
316
+ with patch(f"{_REC_MODULE}.get_forward_context", return_value=ctx):
317
+ capturer.capture(layer_id=0, topk_ids=topk)
318
+
319
+ assert torch.equal(capturer.device_buffer[:2, 0], topk[4:])
320
+
321
+
322
+def test_routed_experts_capturer_sp_modular_gathers_tp_shards():
323
+ capturer = _capturer_with_buffer(dp_rank=1, tp_size=2)
324
+ num_tokens_dp = torch.tensor([2, 3], dtype=torch.int32)
325
+ ctx = SimpleNamespace(
326
+ dp_metadata=SimpleNamespace(num_tokens_across_dp_cpu=num_tokens_dp)
327
+ )
328
+ local_shard = torch.tensor([[10, 11], [12, 13]], dtype=torch.int32)
329
+ gathered = torch.tensor([[10, 11], [12, 13], [14, 15], [-1, -1]], dtype=torch.int32)
330
+ tp_group = Mock()
331
+ tp_group.all_gather.return_value = gathered
332
+
333
+ with (
334
+ patch(f"{_REC_MODULE}.get_forward_context", return_value=ctx),
335
+ patch(f"{_REC_MODULE}.get_tp_group", return_value=tp_group),
336
+ ):
337
+ capturer.capture(layer_id=0, topk_ids=local_shard)
338
+
339
+ tp_group.all_gather.assert_called_once_with(local_shard, dim=0)
340
+ assert torch.equal(capturer.device_buffer[:3, 0], gathered[:3])
341
+
342
+
343
def test_routed_experts_capturer_dp_unexpected_batch_raises():
344
"""Mismatch between topk batch dim and DP layout: fail fast."""
345
capturer = _capturer_with_buffer(dp_rank=0)
@@ -324,128 +357,119 @@ def test_routed_experts_capturer_dp_unexpected_batch_raises():
357
assert capturer.device_buffer[0, 0, 0].item() == -1
358
359
327
-def test_routed_experts_attention_group_is_shared_and_fail_closed():
328
- """Both sides key routing data by this gid, so it must skip non-full-attention
329
- groups rather than defaulting to 0, and fail closed when none exists."""
330
- common = dict(num_kv_heads=1, head_size=1, dtype=torch.float32)
331
- config = SimpleNamespace(
332
- kv_cache_groups=[
333
- KVCacheGroupSpec(
334
- ["swa_layer"],
335
- SlidingWindowMLASpec(block_size=4, sliding_window=8, **common),
336
- ),
337
- KVCacheGroupSpec(["full_layer"], FullAttentionSpec(block_size=4, **common)),
338
- ]
339
- )
340
- assert get_routed_experts_attn_gid(config) == 1
341
-
342
- with pytest.raises(ValueError, match="requires a full-attention KV cache group"):
343
- get_routed_experts_attn_gid(SimpleNamespace(kv_cache_groups=[]))
344
-
345
-
346
-def test_routed_experts_attention_group_unwraps_uniform_type_specs():
347
- """DeepSeek-V4-shaped groups wrap their specs in ``UniformTypeKVCacheSpecs``.
360
+def test_get_aux_output_connector_passes_config(monkeypatch):
361
+ import vllm.distributed.aux_output_connector.worker as aux_output_worker
362
349
- The wrapper is not a ``FullAttentionSpec``, so a bare isinstance check finds
350
- no group and fails closed on every worker. Unwrap semantics themselves are
351
- covered by ``test_is_full_attention_spec_*`` in tests/v1/core.
352
- """
353
- common = dict(num_kv_heads=1, head_size=1, dtype=torch.float32)
354
- swa_spec = SlidingWindowMLASpec(block_size=4, sliding_window=8, **common)
355
- mla_spec = MLAAttentionSpec(block_size=4, **common)
363
+ connector = Mock()
364
+ constructor = Mock(return_value=connector)
365
+ monkeypatch.setattr(aux_output_worker, "AuxOutputWorkerConnector", constructor)
366
config = SimpleNamespace(
357
- kv_cache_groups=[
358
- KVCacheGroupSpec(
359
- ["swa_layer"],
360
- UniformTypeKVCacheSpecs(
361
- block_size=4, kv_cache_specs={"swa_layer": swa_spec}
362
- ),
363
- ),
364
- KVCacheGroupSpec(
365
- ["mla_layer"],
366
- UniformTypeKVCacheSpecs(
367
- block_size=4, kv_cache_specs={"mla_layer": mla_spec}
368
- ),
369
- ),
370
- ]
367
+ scheduler_config=SimpleNamespace(max_num_batched_tokens=32)
368
)
369
+ model = Mock()
370
+ kv_cache_config = Mock()
371
373
- assert get_routed_experts_attn_gid(config) == 1
372
+ result = aux_output_worker.get_aux_output_connector(model, config, kv_cache_config)
373
375
- swa_only = SimpleNamespace(kv_cache_groups=[config.kv_cache_groups[0]])
376
- with pytest.raises(ValueError, match="requires a full-attention KV cache group"):
377
- get_routed_experts_attn_gid(swa_only)
378
-
379
-
380
-@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
381
-def test_mrv2_async_output_returns_existing_routed_experts_field():
382
- from vllm.v1.outputs import ModelRunnerOutput, RoutedExpertsTensors
383
- from vllm.v1.worker.gpu.async_utils import AsyncOutput
384
- from vllm.v1.worker.gpu.sample.output import SamplerOutput
385
-
386
- routed_experts = RoutedExpertsTensors(
387
- routing_data=torch.arange(6, dtype=torch.int32, device="cuda").reshape(3, 1, 2),
388
- slot_mapping=torch.tensor([11, 12, 13], device="cuda"),
389
- )
390
- num_sampled = torch.tensor([1], dtype=torch.int32, device="cuda")
391
- sampler_output = SamplerOutput(
392
- sampled_token_ids=torch.tensor([[1]], device="cuda"),
393
- logprobs_tensors=None,
394
- num_nans=None,
395
- num_sampled=num_sampled,
396
- num_rejected=torch.tensor([0], dtype=torch.int32, device="cuda"),
374
+ constructor.assert_called_once_with(
375
+ model=model,
376
+ kv_cache_config=kv_cache_config,
377
+ vllm_config=config,
378
)
398
- output = AsyncOutput(
399
- model_runner_output=ModelRunnerOutput(req_ids=["req"], req_id_to_index={}),
400
- sampler_output=sampler_output,
401
- num_sampled_tokens=num_sampled,
402
- main_stream=torch.cuda.current_stream(),
403
- copy_stream=torch.cuda.Stream(),
404
- routed_experts=routed_experts,
405
- ).get_output()
406
-
407
- assert output.routed_experts is not None
408
- assert output.routed_experts.routing_data[:, 0, 0].tolist() == [0, 2, 4]
409
- assert output.routed_experts.slot_mapping.tolist() == [11, 12, 13]
379
+ assert result is connector
380
381
412
-@pytest.mark.parametrize("rank", [0, 1])
413
-def test_all_tp_ranks_initialize_capture(monkeypatch, rank):
414
- pytest.importorskip("vllm.vllm_flash_attn", exc_type=ImportError)
415
- import vllm.v1.worker.gpu.model_runner as model_runner
382
+def test_aux_output_worker_connector_binds_capture_on_non_output_rank(monkeypatch):
383
+ import vllm.distributed.aux_output_connector.worker as aux_output_worker
384
385
capturer = Mock()
386
constructor = Mock(return_value=capturer)
387
bind = Mock()
420
- monkeypatch.setattr(model_runner, "RoutedExpertsCapturer", constructor)
421
- monkeypatch.setattr(model_runner, "bind_routed_experts_capturer", bind)
422
-
423
- runner = model_runner.GPUModelRunner.__new__(model_runner.GPUModelRunner)
424
- runner.max_num_tokens = 32
425
- runner.vllm_config = SimpleNamespace(parallel_config=SimpleNamespace(rank=rank))
426
- runner.kv_cache_config = SimpleNamespace()
427
- runner.model = Mock()
388
+ monkeypatch.setattr(aux_output_worker, "RoutedExpertsCapturer", constructor)
389
+ monkeypatch.setattr(aux_output_worker, "bind_routed_experts_capturer", bind)
390
+ monkeypatch.setattr(
391
+ aux_output_worker,
392
+ "get_tp_group",
393
+ lambda: SimpleNamespace(is_first_rank=False, world_size=1),
394
+ )
395
429
- runner.init_routed_experts_capturer()
396
+ config = SimpleNamespace(
397
+ aux_output_config=SimpleNamespace(enable_return_routed_experts=True),
398
+ kv_transfer_config=None,
399
+ max_concurrent_batches=2,
400
+ scheduler_config=SimpleNamespace(max_num_batched_tokens=32),
401
+ )
402
+ model = Mock()
403
+ connector = aux_output_worker.AuxOutputWorkerConnector(
404
+ vllm_config=config,
405
+ model=model,
406
+ kv_cache_config=SimpleNamespace(kv_cache_groups=[_full_attention_kv_group()]),
407
+ )
408
409
constructor.assert_called_once_with(
410
max_num_batched_tokens=32,
433
- vllm_config=runner.vllm_config,
434
- kv_cache_config=runner.kv_cache_config,
411
+ vllm_config=config,
412
+ )
413
+ bind.assert_called_once_with(model, capturer)
414
+ connector.begin_step(Mock())
415
+ assert connector.prepare_output(Mock()) is None
416
+ capturer.snapshot_routing_data.assert_not_called()
417
+
418
+
419
+def test_aux_output_worker_connector_default_capacity(monkeypatch):
420
+ import vllm.distributed.aux_output_connector.worker as aux_output_worker
421
+
422
+ tp_group = SimpleNamespace(is_first_rank=True, world_size=1)
423
+ store_constructor = Mock()
424
+ background_store_constructor = Mock(side_effect=lambda store, **_: store)
425
+ capturer = SimpleNamespace(shape_per_token=(2,), output_dtype_name="int32")
426
+ monkeypatch.setattr(aux_output_worker, "get_tp_group", lambda: tp_group)
427
+ monkeypatch.setattr(
428
+ aux_output_worker, "RoutedExpertsCapturer", Mock(return_value=capturer)
429
+ )
430
+ monkeypatch.setattr(aux_output_worker, "bind_routed_experts_capturer", Mock())
431
+ monkeypatch.setattr(
432
+ aux_output_worker,
433
+ "resolve_kv_cache_block_sizes",
434
+ lambda *_: (32, 16),
435
+ )
436
+ monkeypatch.setattr(aux_output_worker, "BlockObjectStore", store_constructor)
437
+ monkeypatch.setattr(
438
+ aux_output_worker, "BackgroundBlockObjectStore", background_store_constructor
439
)
436
- bind.assert_called_once_with(runner.model, capturer)
437
- assert runner.routed_experts_capturer is capturer
440
+ monkeypatch.setattr(aux_output_worker, "RoutedExpertsBuffer", Mock())
441
+
442
+ config = SimpleNamespace(
443
+ aux_output_config=SimpleNamespace(max_bytes=None),
444
+ kv_transfer_config=None,
445
+ cache_config=SimpleNamespace(enable_prefix_caching=True),
446
+ scheduler_config=SimpleNamespace(max_num_seqs=8, max_num_batched_tokens=32),
447
+ max_concurrent_batches=2,
448
+ )
449
+ kwargs = dict(
450
+ vllm_config=config,
451
+ model=Mock(),
452
+ kv_cache_config=SimpleNamespace(
453
+ num_blocks=10,
454
+ kv_cache_groups=[_full_attention_kv_group(MLAAttentionSpec)],
455
+ ),
456
+ )
457
+
458
+ aux_output_worker.AuxOutputWorkerConnector(**kwargs)
459
+ assert store_constructor.call_args.kwargs["max_bytes"] == 2560
460
+ assert store_constructor.call_args.kwargs["object_nbytes"] == 128
461
+ assert background_store_constructor.call_args.kwargs["max_pending_batches"] == 16
462
463
464
def test_v2_model_runner_accepts_routed_experts(monkeypatch):
465
monkeypatch.setattr("importlib.metadata.entry_points", lambda **_: ())
466
config = SimpleNamespace(
467
model_config=SimpleNamespace(
444
- enable_return_routed_experts=True,
468
use_mla=False,
469
logits_processors=None,
470
enable_prompt_embeds=False,
471
),
472
+ aux_output_config=SimpleNamespace(enable_return_routed_experts=True),
473
speculative_config=None,
474
parallel_config=SimpleNamespace(
475
prefill_context_parallel_size=1,
tests/v1/core/test_async_scheduler.py
+1
-2
@@ -354,8 +354,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
354
scheduler.finished_req_ids_dict = None
355
scheduler.grammar_compile_error_reqs = set()
356
scheduler.vllm_config = Mock()
357
- scheduler.vllm_config.model_config.enable_return_routed_experts = False
358
- scheduler.enable_return_routed_experts = False
357
+ scheduler.aux_output_connector = None
358
scheduler.return_sampling_mask = False
359
scheduler.recompute_kv_load_failures = False
360
scheduler.defer_block_free = False
tests/v1/core/test_scheduler.py
+71
-23
@@ -19,6 +19,10 @@ from vllm.config import (
19
SpeculativeConfig,
20
VllmConfig,
21
)
22
+from vllm.distributed.aux_output_connector.connector import (
23
+ AuxOutputSchedulerConnector,
24
+ AuxRequestOutput,
25
+)
26
from vllm.distributed.ec_transfer.ec_connector.metrics import ECConnectorStats
27
from vllm.distributed.kv_transfer.kv_connector.v1.hisparse.connector import (
28
HiSparseConnector,
@@ -55,7 +59,6 @@ from vllm.v1.outputs import (
59
ECConnectorOutput,
60
KVConnectorOutput,
61
ModelRunnerOutput,
58
- RoutedExpertsLists,
62
SamplingMaskLists,
63
make_empty_encoder_model_runner_output,
64
)
@@ -136,21 +139,18 @@ def test_add_requests():
139
assert len(scheduler.waiting) == i + 1
140
141
139
-def test_routed_experts_prompt_start_at_prompt_end():
142
+@pytest.mark.parametrize("max_tokens", [1, 2])
143
+def test_routed_experts_prompt_start_at_prompt_end(max_tokens):
144
"""A prompt-end offset should return empty routing data, not crash."""
145
scheduler = create_scheduler()
142
- (request,) = create_requests(num_requests=1)
146
+ (request,) = create_requests(num_requests=1, max_tokens=max_tokens)
147
request.sampling_params.routed_experts_prompt_start = request.num_prompt_tokens
148
+ scheduler.aux_output_connector = AuxOutputSchedulerConnector()
149
scheduler.add_request(request)
150
scheduler_output = scheduler.schedule()
146
- num_tokens = scheduler_output.num_scheduled_tokens[request.request_id]
147
-
148
- manager = Mock()
149
- manager.routed_experts_by_slot = np.empty((0, 1, 1), dtype=np.uint8)
150
- manager.get.return_value = np.empty((0, 1, 1), dtype=np.uint8)
151
- scheduler.enable_return_routed_experts = True
152
- scheduler.routed_experts_mgr = manager
153
- scheduler._re_block_ids = {request.request_id: []}
151
+ assert scheduler_output.aux_output_connector_metadata.requests == {
152
+ request.request_id: request.num_prompt_tokens
153
+ }
154
155
outputs = scheduler.update_from_output(
156
scheduler_output,
@@ -161,21 +161,23 @@ def test_routed_experts_prompt_start_at_prompt_end():
161
logprobs=None,
162
prompt_logprobs_dict={},
163
pooler_output=[],
164
- routed_experts=RoutedExpertsLists(
165
- routing_data=np.zeros((num_tokens, 1, 1), dtype=np.uint8),
166
- slot_mapping=np.arange(num_tokens),
167
- ),
164
+ aux_output_connector_output={
165
+ request.request_id: AuxRequestOutput(
166
+ request.num_prompt_tokens,
167
+ np.empty((0, 1, 1), dtype=np.uint8),
168
+ )
169
+ },
170
),
171
)
172
171
- manager.get.assert_called_once_with(
172
- [], request.num_prompt_tokens, token_start=request.num_prompt_tokens
173
- )
174
- assert outputs[request.client_index].outputs[0].routed_experts.shape == (0, 1, 1)
173
+ output = outputs[request.client_index].outputs[0]
174
+ assert output.new_token_ids == [0]
175
+ assert output.routed_experts.shape == (0, 1, 1)
176
177
178
def test_finish_request():
179
scheduler = create_scheduler()
180
+ scheduler.aux_output_connector = Mock()
181
requests = create_requests(num_requests=10)
182
for request in requests:
183
scheduler.add_request(request)
@@ -184,6 +186,7 @@ def test_finish_request():
186
scheduler.finish_requests(request.request_id, RequestStatus.FINISHED_ABORTED)
187
assert request.request_id not in scheduler.requests
188
assert len(scheduler.waiting) == 9 - i
189
+ scheduler.aux_output_connector.request_finished.assert_called_with(request)
190
191
192
def test_get_num_unfinished_requests():
@@ -1373,6 +1376,31 @@ def test_preemption_re_records_prefix_cache_query():
1376
assert stats.preempted_requests == 1
1377
1378
1379
+def test_preemption_processes_stale_aux_output():
1380
+ scheduler = create_scheduler(enable_prefix_caching=True)
1381
+ request = create_requests(num_requests=1)[0]
1382
+ scheduler.add_request(request)
1383
+ scheduler_output = scheduler.schedule()
1384
+ scheduler.running.remove(request)
1385
+ scheduler.aux_output_connector = Mock()
1386
+ scheduler._preempt_request(request, 0.0)
1387
+ scheduler.aux_output_connector.request_finished.assert_called_once_with(request)
1388
+
1389
+ scheduler.update_from_output(
1390
+ scheduler_output,
1391
+ ModelRunnerOutput(
1392
+ req_ids=[request.request_id],
1393
+ req_id_to_index={request.request_id: 0},
1394
+ sampled_token_ids=[[1000]],
1395
+ logprobs=None,
1396
+ prompt_logprobs_dict={},
1397
+ pooler_output=[],
1398
+ ),
1399
+ )
1400
+
1401
+ scheduler.aux_output_connector.take_output.assert_called_once_with(request, None)
1402
+
1403
+
1404
def test_prefix_cache_stats_not_recorded_when_caching_disabled():
1405
"""With prefix caching off there is no local lookup, so admitting a request
1406
records no phantom miss."""
@@ -1461,6 +1489,7 @@ def test_prefix_cache_stats_counted_once_for_retried_then_scheduled_request():
1489
1490
def test_scheduler_reset_prefix_cache():
1491
scheduler = create_scheduler(enable_prefix_caching=True)
1492
+ scheduler.aux_output_connector = Mock()
1493
requests = create_requests(num_requests=10)
1494
for request in requests:
1495
scheduler.add_request(request)
@@ -1477,10 +1506,20 @@ def test_scheduler_reset_prefix_cache():
1506
# Reset prefix cache should fail since there are still running requests
1507
# and they are taking KV cache
1508
assert not scheduler.reset_prefix_cache()
1509
+ scheduler.aux_output_connector.reset.assert_not_called()
1510
+
1511
+ with pytest.raises(RuntimeError, match=r"pause\(mode='keep'\)"):
1512
+ scheduler.reset_prefix_cache(reset_running_requests=True)
1513
+
1514
+ # pause(mode="keep") also waits for scheduled model outputs to drain.
1515
+ scheduler.set_pause_state(PauseState.PAUSED_ALL)
1516
+ with pytest.raises(RuntimeError, match="model output is in flight"):
1517
+ scheduler.reset_prefix_cache(reset_running_requests=True)
1518
+ for request in requests:
1519
+ request.num_in_flight_tokens = 0
1520
1481
- # Reset prefix cache with reset_running_requests=True. All running requests
1482
- # Should be pushed back to the waiting queue and kv cache should be freed
1521
assert scheduler.reset_prefix_cache(reset_running_requests=True)
1522
+ scheduler.aux_output_connector.reset.assert_called_once_with()
1523
1524
# Verify requests moved from running to waiting
1525
assert len(scheduler.waiting) == len(requests)
@@ -1490,6 +1529,16 @@ def test_scheduler_reset_prefix_cache():
1529
assert scheduler.waiting[i] == request
1530
1531
1532
+@pytest.mark.parametrize("reset_successful", [False, True])
1533
+def test_aux_output_reset_follows_kv_reset_result(reset_successful: bool):
1534
+ scheduler = create_scheduler(enable_prefix_caching=True)
1535
+ scheduler.aux_output_connector = Mock()
1536
+ scheduler.kv_cache_manager.reset_prefix_cache = Mock(return_value=reset_successful)
1537
+
1538
+ assert scheduler.reset_prefix_cache() is reset_successful
1539
+ assert scheduler.aux_output_connector.reset.call_count == int(reset_successful)
1540
+
1541
+
1542
def test_kv_cache_release_after_keep_pause_preserves_requests():
1543
"""A completed keep pause permits release and recomputation of live requests."""
1544
scheduler = create_scheduler(max_num_seqs=1, enable_prefix_caching=True)
@@ -3791,10 +3840,9 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
3840
scheduler.kv_event_publisher = Mock()
3841
scheduler.finished_req_ids = set()
3842
scheduler.finished_req_ids_dict = None
3843
+ scheduler.aux_output_connector = None
3844
scheduler.grammar_compile_error_reqs = set()
3845
scheduler.vllm_config = Mock()
3796
- scheduler.vllm_config.model_config.enable_return_routed_experts = False
3797
- scheduler.enable_return_routed_experts = False
3846
scheduler.return_sampling_mask = False
3847
scheduler.recompute_kv_load_failures = False
3848
scheduler.defer_block_free = False
tests/v1/e2e/general/test_mamba_prefix_cache.py
+1
@@ -1122,6 +1122,7 @@ def _run_mamba_prefix_cache_mrv2(
1122
logprobs_tensors=None,
1123
num_nans=None,
1124
num_sampled=num_sampled,
1125
+ num_rejected=num_rejected,
1126
)
1127
return sampler_output, num_sampled, num_rejected
1128
tests/v1/engine/test_output_processor.py
+47
-1
@@ -3,8 +3,9 @@
3
4
import math
5
import time
6
-from unittest.mock import MagicMock
6
+from unittest.mock import MagicMock, Mock
7
8
+import numpy as np
9
import pytest
10
11
from tests.v1.engine.utils import (
@@ -1307,6 +1308,7 @@ def test_lora_request_tracking(log_stats: bool, dummy_test_vectors):
1308
async def test_request_output_collector():
1309
NUM_REQS = 3
1310
TEXT = "a"
1311
+ routed_experts = np.arange(12, dtype=np.uint8).reshape(2, 3, 2)
1312
1313
def make_outputs() -> list[RequestOutput]:
1314
return [
@@ -1322,6 +1324,9 @@ async def test_request_output_collector():
1324
token_ids=[idx],
1325
cumulative_logprob=(idx + 1 * 1.0),
1326
logprobs=[{"a": idx, "b": idx}],
1327
+ routed_experts=(
1328
+ routed_experts if idx == NUM_REQS - 1 else None
1329
+ ),
1330
finish_reason="length" if (idx == NUM_REQS - 1) else None,
1331
)
1332
],
@@ -1374,6 +1379,7 @@ async def test_request_output_collector():
1379
1380
assert output.finished
1381
assert output.outputs[0].finish_reason == "length"
1382
+ np.testing.assert_array_equal(output.outputs[0].routed_experts, routed_experts)
1383
# Text, token_ids, and logprobs should get merged.
1384
assert output.outputs[0].text == TEXT * num_to_put
1385
for tok_0, tok_1 in zip(output.outputs[0].token_ids, list(range(num_to_put))):
@@ -1385,6 +1391,46 @@ async def test_request_output_collector():
1391
assert output.outputs[0].cumulative_logprob == cumulative_logprob_expected
1392
1393
1394
+def test_routed_experts_are_accumulated_until_finish():
1395
+ state = RequestState(
1396
+ request_id="request-int",
1397
+ external_req_id="request",
1398
+ parent_req=None,
1399
+ request_index=0,
1400
+ lora_request=None,
1401
+ output_kind=RequestOutputKind.DELTA,
1402
+ prompt="prompt",
1403
+ prompt_token_ids=[1, 2, 3, 4],
1404
+ prompt_embeds=None,
1405
+ logprobs_processor=Mock(
1406
+ logprobs=None,
1407
+ cumulative_logprob=0.0,
1408
+ pop_prompt_logprobs=Mock(return_value=None),
1409
+ ),
1410
+ detokenizer=Mock(get_next_output_text=Mock(return_value="")),
1411
+ max_tokens_param=2,
1412
+ arrival_time=0.0,
1413
+ queue=None,
1414
+ log_stats=False,
1415
+ stream_interval=1,
1416
+ )
1417
+ prompt_chunk = np.arange(24, dtype=np.uint8).reshape(4, 3, 2)
1418
+ decode_chunk = np.arange(12, dtype=np.uint8).reshape(2, 3, 2) + 24
1419
+ state.routed_experts_chunks.append(prompt_chunk)
1420
+
1421
+ partial = state.make_request_output([5], None, None, None)
1422
+ assert partial is not None
1423
+ assert partial.outputs[0].routed_experts is None
1424
+
1425
+ state.routed_experts_chunks.append(decode_chunk)
1426
+ finished = state.make_request_output([6], None, FinishReason.LENGTH, None)
1427
+ assert finished is not None
1428
+ np.testing.assert_array_equal(
1429
+ finished.outputs[0].routed_experts,
1430
+ np.concatenate((prompt_chunk, decode_chunk)),
1431
+ )
1432
+
1433
+
1434
@pytest.mark.asyncio
1435
async def test_cumulative_output_collector_n():
1436
"""Test collector correctly handles multiple outputs by index."""
tests/v1/executor/test_ray_utils.py
+12
-17
@@ -3,12 +3,14 @@
3
4
import numpy as np
5
6
+from vllm.distributed.aux_output_connector.connector import (
7
+ AuxRequestOutput,
8
+)
9
from vllm.v1.executor.ray_utils import detach_zero_copy_from_model_runner_output
10
from vllm.v1.outputs import (
11
LogprobsLists,
12
LogprobsTensors,
13
ModelRunnerOutput,
11
- RoutedExpertsLists,
14
)
15
16
@@ -59,26 +61,19 @@ def test_detach_zero_copy_from_model_runner_output_copies_only_numpy_views():
61
assert output.prompt_logprobs_dict["req-0"] is prompt_logprobs
62
63
62
-def test_detach_zero_copy_routed_experts_without_logprobs():
64
+def test_detach_zero_copy_aux_output_without_logprobs():
65
+ rows = _make_readonly(np.arange(12, dtype=np.uint8).reshape(2, 3, 2))
66
output = ModelRunnerOutput(
67
req_ids=["req-0"],
68
req_id_to_index={"req-0": 0},
66
- routed_experts=RoutedExpertsLists(
67
- routing_data=_make_readonly(np.arange(12, dtype=np.int32).reshape(2, 3, 2)),
68
- slot_mapping=_make_readonly(np.array([7, 8], dtype=np.int64)),
69
- ),
69
+ aux_output_connector_output={"req-0": AuxRequestOutput(0, rows)},
70
)
71
- original = output.routed_experts
72
- assert output.logprobs is None
71
72
detach_zero_copy_from_model_runner_output(output)
73
76
- detached = output.routed_experts
77
- assert detached is not None
78
- assert detached is not original
79
- assert detached.routing_data is not original.routing_data
80
- assert detached.slot_mapping is not original.slot_mapping
81
- assert detached.routing_data.flags.writeable
82
- assert detached.slot_mapping.flags.writeable
83
- np.testing.assert_array_equal(detached.routing_data, original.routing_data)
84
- np.testing.assert_array_equal(detached.slot_mapping, original.slot_mapping)
74
+ aux_output = output.aux_output_connector_output
75
+ assert aux_output is not None
76
+ detached = aux_output["req-0"].rows
77
+ assert detached is not rows
78
+ assert detached.flags.writeable
79
+ np.testing.assert_array_equal(detached, rows)
vllm/config/__init__.py
+3
@@ -2,6 +2,7 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
from vllm.config.attention import AttentionConfig, HiSparseConfig
5
+from vllm.config.aux_output import AuxOutputConfig
6
from vllm.config.cache import CacheConfig
7
from vllm.config.compilation import (
8
CompilationConfig,
@@ -66,6 +67,8 @@ from vllm.config.weight_transfer import WeightTransferConfig
67
# __all__ should only contain classes and functions.
68
# Types and globals should be imported from their respective modules.
69
__all__ = [
70
+ # From vllm.config.aux_output
71
+ "AuxOutputConfig",
72
# From vllm.config.attention
73
"AttentionConfig",
74
"HiSparseConfig",
vllm/config/aux_output.py
new
+31
@@ -0,0 +1,31 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Configuration for execution auxiliary outputs."""
4
+
5
+from pydantic import Field
6
+
7
+from vllm.config.utils import config
8
+
9
+
10
+@config
11
+class AuxOutputConfig:
12
+ """Configuration for auxiliary-output delivery."""
13
+
14
+ enable_return_routed_experts: bool = False
15
+ """Capture and return routed-experts auxiliary outputs."""
16
+
17
+ max_bytes: int | None = Field(default=None, gt=0)
18
+ """LRU capacity, or ``None`` to derive it from the KV cache capacity."""
19
+
20
+ @property
21
+ def enabled(self) -> bool:
22
+ """Whether any execution auxiliary output is enabled."""
23
+ return self.enable_return_routed_experts
24
+
25
+ def compute_hash(self) -> str:
26
+ """Hash AuxOutput settings that alter the model forward graph."""
27
+ from vllm.config.utils import hash_factors
28
+
29
+ return hash_factors(
30
+ {"enable_return_routed_experts": self.enable_return_routed_experts}
31
+ )
vllm/config/model.py
-2
@@ -247,8 +247,6 @@ class ModelConfig:
247
248
NOTE: This disables both `torch.compile` and CUDA graphs, and is
249
equivalent to setting `-cc.mode=none -cc.cudagraph_mode=none`."""
250
- enable_return_routed_experts: bool = False
251
- """Whether to return routed experts."""
250
return_sampling_mask: bool = False
251
"""Whether to return the post-processing token support for each sample."""
252
max_logprobs: int = Field(default=20, ge=-1)
vllm/config/vllm.py
+53
-33
@@ -28,6 +28,7 @@ from vllm.utils import random_uuid
28
from vllm.utils.hashing import safe_hash
29
30
from .attention import AttentionConfig, HiSparseConfig
31
+from .aux_output import AuxOutputConfig
32
from .cache import CacheConfig
33
from .compilation import CompilationConfig, CompilationMode, CUDAGraphMode
34
from .device import DeviceConfig
@@ -376,6 +377,8 @@ class VllmConfig:
377
"""Model weight offloading configuration."""
378
attention_config: AttentionConfig = Field(default_factory=AttentionConfig)
379
"""Attention configuration."""
380
+ aux_output_config: AuxOutputConfig = Field(default_factory=AuxOutputConfig)
381
+ """Execution auxiliary output configuration."""
382
engram_config: EngramConfig | None = None
383
"""N-gram embedding storage and sharding settings."""
384
mamba_config: MambaConfig = Field(default_factory=MambaConfig)
@@ -554,6 +557,7 @@ class VllmConfig:
557
vllm_factors.append(self.ec_transfer_config.compute_hash())
558
else:
559
vllm_factors.append("None")
560
+ vllm_factors.append(self.aux_output_config.compute_hash())
561
if self.additional_config:
562
if isinstance(additional_config := self.additional_config, dict):
563
additional_config_hash = safe_hash(
@@ -1084,6 +1088,53 @@ class VllmConfig:
1088
# This is the same for all backends
1089
self.kv_transfer_config.kv_role = "kv_both"
1090
1091
+ def _verify_aux_output_compatibility(self) -> None:
1092
+ """Reject configurations unsupported by enabled auxiliary outputs."""
1093
+ if not self.aux_output_config.enabled:
1094
+ return
1095
+ if not self.use_v2_model_runner:
1096
+ raise ValueError(
1097
+ "AuxOutput Connector requires Model Runner V2; set "
1098
+ "VLLM_USE_V2_MODEL_RUNNER=1."
1099
+ )
1100
+ if self.model_config.runner_type != "generate":
1101
+ raise ValueError("AuxOutput Connector only supports generate runners.")
1102
+ if not self.model_config.is_moe:
1103
+ raise ValueError("AuxOutput Connector only supports MoE models.")
1104
+ if not self.cache_config.enable_prefix_caching:
1105
+ raise ValueError("AuxOutput Connector requires prefix caching.")
1106
+ if (
1107
+ self.speculative_config is not None
1108
+ and self.speculative_config.enable_adaptive_verification
1109
+ ):
1110
+ raise ValueError(
1111
+ "--enable-return-routed-experts is incompatible with "
1112
+ "adaptive speculative verification."
1113
+ )
1114
+ if self.parallel_config.pipeline_parallel_size > 1:
1115
+ raise ValueError(
1116
+ "--enable-return-routed-experts is incompatible with "
1117
+ "pipeline parallelism (PP > 1)."
1118
+ )
1119
+ if (
1120
+ self.parallel_config.decode_context_parallel_size > 1
1121
+ or self.parallel_config.prefill_context_parallel_size > 1
1122
+ ):
1123
+ raise ValueError(
1124
+ "--enable-return-routed-experts is incompatible with "
1125
+ "context parallelism (DCP/PCP > 1)."
1126
+ )
1127
+
1128
+ kv_transfer_config = self.kv_transfer_config
1129
+ if (
1130
+ kv_transfer_config is not None
1131
+ and kv_transfer_config.is_kv_transfer_instance
1132
+ ):
1133
+ raise ValueError(
1134
+ "--enable-return-routed-experts is incompatible with KV "
1135
+ "connectors (PD disaggregation and KV cache offload)."
1136
+ )
1137
+
1138
def _verify_kv_transfer_compat(self) -> None:
1139
"""Reject configurations that silently corrupt KV transfers."""
1140
if (
@@ -1294,38 +1345,6 @@ class VllmConfig:
1345
1346
self.parallel_config.is_moe_model = self.model_config.is_moe
1347
1297
- if (
1298
- self.model_config is not None
1299
- and self.model_config.enable_return_routed_experts
1300
- ):
1301
- if self.parallel_config.pipeline_parallel_size > 1:
1302
- raise ValueError(
1303
- "--enable-return-routed-experts is incompatible with "
1304
- "pipeline parallelism (PP > 1)."
1305
- )
1306
- if (
1307
- self.parallel_config.decode_context_parallel_size > 1
1308
- or self.parallel_config.prefill_context_parallel_size > 1
1309
- ):
1310
- raise ValueError(
1311
- "--enable-return-routed-experts is incompatible with context "
1312
- "parallelism (DCP > 1 or PCP > 1)."
1313
- )
1314
-
1315
- # Incompatible with any KV connector — covers both PD disaggregation
1316
- # (kv_producer/kv_consumer: routing captured on P can't reach D) and
1317
- # single-instance KV offload/sharing (kv_both: slot_mapping semantics
1318
- # change when KV blocks live outside local GPU memory, breaking the
1319
- # slot-indexed routed_experts buffer).
1320
- if (
1321
- self.kv_transfer_config is not None
1322
- and self.kv_transfer_config.is_kv_transfer_instance
1323
- ):
1324
- raise ValueError(
1325
- "--enable-return-routed-experts is incompatible with KV "
1326
- "connectors (PD disaggregation, KV cache offload)."
1327
- )
1328
-
1348
if (
1349
self.model_config is not None
1350
and self.model_config.multimodal_config is not None
@@ -2032,6 +2051,7 @@ class VllmConfig:
2051
# Resolve kv_offloading-derived connector name into kv_transfer_config
2052
# before the HMA check below, which inspects the connector class.
2053
self._post_init_kv_transfer_config()
2054
+ self._verify_aux_output_compatibility()
2055
2056
if self.is_mm_encoder_only and self.cache_config.enable_prefix_caching:
2057
# Such an instance publishes encoder embeddings and runs no language
@@ -2689,7 +2709,7 @@ class VllmConfig:
2709
f"quantization={self.model_config.quantization}, "
2710
f"quantization_config={self.model_config.quantization_config}, " # noqa
2711
f"enforce_eager={self.model_config.enforce_eager}, "
2692
- f"enable_return_routed_experts={self.model_config.enable_return_routed_experts}, " # noqa
2712
+ f"aux_output_config={self.aux_output_config!r}, "
2713
f"kv_cache_dtype={self.cache_config.cache_dtype}, "
2714
f"device_config={self.device_config.device}, "
2715
f"structured_outputs_config={self.structured_outputs_config!r}, "
vllm/distributed/aux_output_connector/__init__.py
new
+2
@@ -0,0 +1,2 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
vllm/distributed/aux_output_connector/connector.py
new
+157
@@ -0,0 +1,157 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Scheduler-side control plane for execution auxiliary outputs."""
4
+
5
+from __future__ import annotations
6
+
7
+from collections.abc import Iterator, Sequence
8
+from dataclasses import dataclass
9
+from typing import TYPE_CHECKING
10
+
11
+import numpy as np
12
+
13
+if TYPE_CHECKING:
14
+ from vllm.v1.core.sched.output import SchedulerOutput
15
+ from vllm.v1.request import Request
16
+
17
+
18
+@dataclass
19
+class PackedBlockHashes:
20
+ """Contiguous, self-contained block hashes for scheduler-worker IPC."""
21
+
22
+ data: bytes
23
+ item_size: int
24
+
25
+ def __iter__(self) -> Iterator[bytes]:
26
+ for start in range(0, len(self.data), self.item_size):
27
+ yield self.data[start : start + self.item_size]
28
+
29
+
30
+@dataclass
31
+class AuxOutputConnectorMetadata:
32
+ generation: int
33
+ requests: dict[str, int]
34
+ block_hashes: dict[str, PackedBlockHashes]
35
+ finished_requests: tuple[str, ...]
36
+
37
+
38
+@dataclass
39
+class AuxRequestOutput:
40
+ token_start: int
41
+ rows: np.ndarray
42
+
43
+
44
+class AuxOutputSchedulerConnector:
45
+ """Build worker metadata without owning auxiliary output payloads or stores."""
46
+
47
+ def __init__(self) -> None:
48
+ # Number of hashes already sent to the worker for each active request.
49
+ self._sent_hash_counts: dict[str, int] = {}
50
+ # Terminal events are delivered with the next connector metadata.
51
+ self._finished_requests: dict[str, PackedBlockHashes | None] = {}
52
+ self._generation = 0
53
+
54
+ def build_connector_meta(
55
+ self,
56
+ scheduler_output: SchedulerOutput,
57
+ requests: dict[str, Request],
58
+ ) -> AuxOutputConnectorMetadata:
59
+ """Build one step's incremental worker metadata."""
60
+ scheduled_requests: dict[str, int] = {}
61
+ block_hashes_by_request: dict[str, PackedBlockHashes] = {}
62
+ for request_id in scheduler_output.num_scheduled_tokens:
63
+ num_sent = self._sent_hash_counts.setdefault(request_id, 0)
64
+ request = requests[request_id]
65
+ packed = self._pack_new_hashes(request.block_hashes, num_sent)
66
+ if packed is not None:
67
+ block_hashes_by_request[request_id] = packed
68
+ self._sent_hash_counts[request_id] = len(request.block_hashes)
69
+ assert request.sampling_params is not None
70
+ scheduled_requests[request_id] = max(
71
+ request.sampling_params.routed_experts_prompt_start,
72
+ 0 if request.num_output_tokens == 0 else request.num_tokens - 1,
73
+ )
74
+ # A settled token can complete a hash block after the next async schedule
75
+ # was built. Send a hash-only update if the request was not rescheduled.
76
+ if len(self._sent_hash_counts) != len(scheduled_requests):
77
+ for request_id, num_sent in self._sent_hash_counts.items():
78
+ if request_id in scheduled_requests:
79
+ continue
80
+ request = requests[request_id]
81
+ packed = self._pack_new_hashes(request.block_hashes, num_sent)
82
+ if packed is None:
83
+ continue
84
+ block_hashes_by_request[request_id] = packed
85
+ self._sent_hash_counts[request_id] = len(request.block_hashes)
86
+ # Sending transfers ownership of these one-shot events.
87
+ finished_requests = tuple(self._finished_requests)
88
+ block_hashes_by_request.update(
89
+ (request_id, block_hashes)
90
+ for request_id, block_hashes in self._finished_requests.items()
91
+ if block_hashes is not None
92
+ )
93
+ self._finished_requests = {}
94
+ return AuxOutputConnectorMetadata(
95
+ self._generation,
96
+ scheduled_requests,
97
+ block_hashes_by_request,
98
+ finished_requests,
99
+ )
100
+
101
+ def take_output(
102
+ self,
103
+ request: Request,
104
+ output: dict[str, AuxRequestOutput] | None,
105
+ ) -> np.ndarray | None:
106
+ """Return the accepted R3 rows for one scheduled request."""
107
+ request_id = request.request_id
108
+ assert output is not None and request_id in output, (
109
+ f"auxiliary output worker output is missing {request_id}"
110
+ )
111
+ request_output = output[request_id]
112
+ token_end = request.num_tokens - 1
113
+ local_end = token_end - request_output.token_start
114
+ if local_end < 0:
115
+ assert not request.is_finished(), (
116
+ "finished auxiliary output output has no accepted token range: "
117
+ f"request={request_id}, token_end={token_end}, "
118
+ f"output_start={request_output.token_start}, "
119
+ "output_end="
120
+ f"{request_output.token_start + len(request_output.rows)}"
121
+ )
122
+ return None
123
+ assert local_end <= len(request_output.rows), (
124
+ "auxiliary output worker output has an invalid token range: "
125
+ f"request={request_id}, token_end={token_end}, "
126
+ f"output_start={request_output.token_start}, "
127
+ f"output_end={request_output.token_start + len(request_output.rows)}"
128
+ )
129
+ return request_output.rows[:local_end]
130
+
131
+ def request_finished(self, request: Request) -> None:
132
+ """Queue a request's terminal event and final block hashes."""
133
+ request_id = request.request_id
134
+ num_sent = self._sent_hash_counts.pop(request_id, None)
135
+ if num_sent is None:
136
+ return
137
+ # The next metadata delivers this terminal event to the worker.
138
+ self._finished_requests[request_id] = self._pack_new_hashes(
139
+ request.block_hashes, num_sent
140
+ )
141
+
142
+ @staticmethod
143
+ def _pack_new_hashes(
144
+ block_hashes: Sequence[bytes], num_sent: int
145
+ ) -> PackedBlockHashes | None:
146
+ assert num_sent <= len(block_hashes), "KV block-hash history shrank"
147
+ new_hashes = block_hashes[num_sent:]
148
+ if not new_hashes:
149
+ return None
150
+ return PackedBlockHashes(b"".join(new_hashes), len(new_hashes[0]))
151
+
152
+ def reset(self) -> None:
153
+ """Start a new auxiliary output namespace after a prefix-cache reset."""
154
+ # The worker drops temporary state on generation changes; resend hashes.
155
+ self._sent_hash_counts.clear()
156
+ self._finished_requests.clear()
157
+ self._generation += 1
vllm/distributed/aux_output_connector/routed_experts.py
new
+219
@@ -0,0 +1,219 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Routed-experts auxiliary output keys, publication, and materialization."""
4
+
5
+from __future__ import annotations
6
+
7
+from collections.abc import Hashable, Iterable, Sequence
8
+from dataclasses import dataclass
9
+from typing import Any
10
+
11
+import numpy as np
12
+
13
+from vllm.distributed.aux_output_connector.store import (
14
+ BackgroundBlockObjectStore,
15
+ BlockObject,
16
+ BlockObjectStore,
17
+)
18
+
19
+
20
+@dataclass
21
+class _RequestTail:
22
+ slot: int
23
+ block_start: int
24
+ length: int = 0
25
+
26
+
27
+class RoutedExpertsBuffer:
28
+ """Own incomplete and unkeyed full routed-experts blocks."""
29
+
30
+ def __init__(
31
+ self,
32
+ dtype: np.dtype[Any],
33
+ shape_per_token: tuple[int, ...],
34
+ block_size: int,
35
+ max_num_seqs: int,
36
+ max_num_batched_tokens: int,
37
+ max_concurrent_batches: int,
38
+ ) -> None:
39
+ self.dtype = dtype
40
+ self.shape_per_token = shape_per_token
41
+ self.block_size = block_size
42
+ max_step_blocks = (
43
+ max_num_batched_tokens + block_size - 1
44
+ ) // block_size + max_num_seqs
45
+ # In-flight full blocks plus one incomplete tail per active request.
46
+ max_blocks = max_concurrent_batches * max_step_blocks + max_num_seqs
47
+ self._rows: np.ndarray = np.empty(
48
+ (max_blocks, block_size, *shape_per_token), dtype=dtype
49
+ )
50
+ self._free_slots = list(range(len(self._rows) - 1, -1, -1))
51
+ self._owned_slots: dict[int, int] = {}
52
+ self._requests: dict[Hashable, _RequestTail] = {}
53
+
54
+ def _tail(self, request_id: Hashable, block_start: int) -> _RequestTail:
55
+ tail = self._requests.get(request_id)
56
+ if tail is not None:
57
+ assert tail.block_start == block_start, (
58
+ "auxiliary output capture skipped an incomplete block: "
59
+ f"request={request_id}, expected={tail.block_start}, "
60
+ f"actual={block_start}"
61
+ )
62
+ return tail
63
+ assert self._free_slots, "auxiliary output block pool is exhausted"
64
+ tail = _RequestTail(self._free_slots.pop(), block_start)
65
+ self._requests[request_id] = tail
66
+ return tail
67
+
68
+ def capture(
69
+ self, request_id: Hashable, token_start: int, rows: np.ndarray
70
+ ) -> list[tuple[int, np.ndarray]]:
71
+ """Stage rows and return completed blocks without retaining them."""
72
+ rows = np.asarray(rows)
73
+ assert rows.shape[1:] == self.shape_per_token and rows.dtype == self.dtype, (
74
+ "routed-experts capture profile changed"
75
+ )
76
+ if token_start < 0:
77
+ raise ValueError("auxiliary output token start must be non-negative")
78
+
79
+ completed: list[tuple[int, np.ndarray]] = []
80
+ offset = 0
81
+ while offset < len(rows):
82
+ position = token_start + offset
83
+ block_start = position // self.block_size * self.block_size
84
+ local_start = position - block_start
85
+
86
+ # Full aligned input blocks do not need tail staging.
87
+ if (
88
+ request_id not in self._requests
89
+ and local_start == 0
90
+ and len(rows) - offset >= self.block_size
91
+ ):
92
+ completed.append((block_start, rows[offset : offset + self.block_size]))
93
+ offset += self.block_size
94
+ continue
95
+
96
+ tail = self._tail(request_id, block_start)
97
+ assert local_start == tail.length, (
98
+ "auxiliary output capture is not contiguous: "
99
+ f"request={request_id}, expected={block_start + tail.length}, "
100
+ f"actual={position}"
101
+ )
102
+ count = min(self.block_size - local_start, len(rows) - offset)
103
+ if count == 1:
104
+ self._rows[tail.slot, local_start] = rows[offset]
105
+ else:
106
+ self._rows[tail.slot, local_start : local_start + count] = rows[
107
+ offset : offset + count
108
+ ]
109
+ tail.length += count
110
+ offset += count
111
+ if tail.length == self.block_size:
112
+ block = self._rows[tail.slot]
113
+ del self._requests[request_id]
114
+ self._owned_slots[id(block)] = tail.slot
115
+ completed.append((block_start, block))
116
+
117
+ return completed
118
+
119
+ def read(
120
+ self, request_id: Hashable, token_start: int, token_end: int
121
+ ) -> np.ndarray:
122
+ tail = self._requests.get(request_id)
123
+ assert tail is not None, (
124
+ f"auxiliary output buffer is missing request {request_id}"
125
+ )
126
+ local_start = token_start - tail.block_start
127
+ local_end = token_end - tail.block_start
128
+ assert 0 <= local_start < local_end <= tail.length, (
129
+ "auxiliary output range is unavailable: "
130
+ f"request={request_id}, range=[{token_start}, {token_end}), "
131
+ f"available=[{tail.block_start}, {tail.block_start + tail.length})"
132
+ )
133
+ return self._rows[tail.slot, local_start:local_end].copy()
134
+
135
+ def retain_block(self, rows: np.ndarray) -> np.ndarray:
136
+ """Retain one unkeyed block after the current capture call."""
137
+ if id(rows) in self._owned_slots:
138
+ return rows
139
+ assert self._free_slots, "auxiliary output block pool is exhausted"
140
+ slot = self._free_slots.pop()
141
+ retained = self._rows[slot]
142
+ retained[...] = rows
143
+ self._owned_slots[id(retained)] = slot
144
+ return retained
145
+
146
+ def release_block(self, rows: np.ndarray) -> None:
147
+ slot = self._owned_slots.pop(id(rows), None)
148
+ if slot is not None:
149
+ self._free_slots.append(slot)
150
+
151
+ def discard(self, request_id: Hashable) -> None:
152
+ tail = self._requests.pop(request_id, None)
153
+ if tail is not None:
154
+ self._free_slots.append(tail.slot)
155
+
156
+ def reset(self) -> None:
157
+ self._requests.clear()
158
+ self._owned_slots.clear()
159
+ self._free_slots[:] = range(len(self._rows) - 1, -1, -1)
160
+
161
+
162
+def routed_experts_keys(
163
+ block_hashes: Iterable[bytes], aux_output_namespace: str
164
+) -> list[str]:
165
+ prefix = f"vllm-artifact/{aux_output_namespace}/"
166
+ return [prefix + block_hash.hex() for block_hash in block_hashes]
167
+
168
+
169
+def materialize_routed_experts(
170
+ store: BackgroundBlockObjectStore | BlockObjectStore,
171
+ aux_output_keys: list[str],
172
+ *,
173
+ shape_per_token: tuple[int, ...],
174
+ dtype: np.dtype[Any],
175
+) -> np.ndarray:
176
+ if not aux_output_keys:
177
+ raise ValueError("routed-experts auxiliary output key list must not be empty")
178
+ payload = store.get_concatenated(aux_output_keys)
179
+ return np.frombuffer(payload, dtype=dtype).reshape((-1, *shape_per_token))
180
+
181
+
182
+def publish_routed_experts(
183
+ store: BackgroundBlockObjectStore | BlockObjectStore,
184
+ *,
185
+ batches: Sequence[tuple[Sequence[str], list[tuple[int, np.ndarray]]]],
186
+ block_size: int,
187
+ retain_keys: Sequence[str] = (),
188
+ release_keys: Sequence[str] = (),
189
+) -> None:
190
+ """Publish immutable full R3 blocks."""
191
+ objects = []
192
+ for aux_output_keys, blocks in batches:
193
+ for block_start, array in blocks:
194
+ if block_start < 0 or block_start % block_size:
195
+ raise ValueError(
196
+ "auxiliary output block start is not hash-block aligned"
197
+ )
198
+ block_index = block_start // block_size
199
+ if block_index >= len(aux_output_keys):
200
+ raise ValueError(
201
+ "auxiliary output block has no corresponding key: "
202
+ f"start={block_start}, index={block_index}, "
203
+ f"keys={len(aux_output_keys)}, block_size={block_size}"
204
+ )
205
+ if len(array) != block_size:
206
+ raise ValueError(
207
+ "auxiliary output block length does not match hash block size"
208
+ )
209
+ objects.append(
210
+ BlockObject(
211
+ key=aux_output_keys[block_index],
212
+ payload=array.tobytes(order="C"),
213
+ )
214
+ )
215
+ store.put(
216
+ objects,
217
+ retain_keys=retain_keys,
218
+ release_keys=release_keys,
219
+ )
vllm/distributed/aux_output_connector/store.py
new
+234
@@ -0,0 +1,234 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""In-process auxiliary-output storage."""
4
+
5
+from __future__ import annotations
6
+
7
+import mmap
8
+import queue
9
+import threading
10
+from collections import OrderedDict
11
+from collections.abc import Iterable
12
+from dataclasses import dataclass
13
+
14
+
15
+@dataclass(frozen=True)
16
+class BlockObject:
17
+ """One immutable auxiliary output object."""
18
+
19
+ key: str
20
+ payload: bytes
21
+
22
+
23
+class BlockObjectStoreError(RuntimeError):
24
+ """AuxOutput storage or retrieval failed."""
25
+
26
+
27
+class BackgroundBlockObjectStore:
28
+ """Serialize store mutations on a background thread."""
29
+
30
+ def __init__(self, store: BlockObjectStore, *, max_pending_batches: int) -> None:
31
+ self._store = store
32
+ self._queue: queue.Queue[
33
+ tuple[list[BlockObject], tuple[str, ...], tuple[str, ...]] | None
34
+ ] = queue.Queue(maxsize=max_pending_batches)
35
+ self._error: BaseException | None = None
36
+ self._closed = False
37
+ self._thread = threading.Thread(
38
+ target=self._run,
39
+ daemon=True,
40
+ name="vllm-aux-output-writer",
41
+ )
42
+ self._thread.start()
43
+
44
+ def _run(self) -> None:
45
+ while True:
46
+ update = self._queue.get()
47
+ try:
48
+ if update is None:
49
+ return
50
+ if self._error is None:
51
+ objects, retain_keys, release_keys = update
52
+ self._store.put(
53
+ objects,
54
+ retain_keys=retain_keys,
55
+ release_keys=release_keys,
56
+ )
57
+ except BaseException as error:
58
+ self._error = error
59
+ finally:
60
+ self._queue.task_done()
61
+
62
+ def _raise_if_failed(self) -> None:
63
+ if self._error is not None:
64
+ raise BlockObjectStoreError(
65
+ "auxiliary output publication failed"
66
+ ) from self._error
67
+
68
+ def put(
69
+ self,
70
+ objects: list[BlockObject],
71
+ *,
72
+ retain_keys: Iterable[str] = (),
73
+ release_keys: Iterable[str] = (),
74
+ ) -> None:
75
+ retains = tuple(retain_keys)
76
+ releases = tuple(release_keys)
77
+ if not objects and not retains and not releases:
78
+ return
79
+ if self._closed:
80
+ raise RuntimeError("auxiliary output store is closed")
81
+ self._queue.put((objects, retains, releases))
82
+ self._raise_if_failed()
83
+
84
+ def get_concatenated(self, keys: list[str]) -> bytes:
85
+ self._queue.join()
86
+ self._raise_if_failed()
87
+ return self._store.get_concatenated(keys)
88
+
89
+ def close(self) -> None:
90
+ if self._closed:
91
+ return
92
+ self._closed = True
93
+ self._queue.join()
94
+ self._queue.put(None)
95
+ self._thread.join()
96
+ self._store.close()
97
+ self._raise_if_failed()
98
+
99
+
100
+class BlockObjectStore:
101
+ """Single-owner bounded store that fails closed after an eviction."""
102
+
103
+ _UNALLOCATED_SLOT = -1
104
+
105
+ def __init__(
106
+ self,
107
+ *,
108
+ max_bytes: int,
109
+ object_nbytes: int,
110
+ ) -> None:
111
+ if object_nbytes <= 0:
112
+ raise ValueError("auxiliary output object size must be positive")
113
+ if max_bytes < object_nbytes:
114
+ raise ValueError("auxiliary output store must fit at least one object")
115
+ self.object_nbytes = object_nbytes
116
+ self.num_slots = max_bytes // object_nbytes
117
+ self._lru: OrderedDict[str, int] = OrderedDict()
118
+ self._references: dict[str, int] = {}
119
+ self._free_slots: list[int] = []
120
+ self._next_slot = 0
121
+ self._arena: mmap.mmap | None = mmap.mmap(
122
+ -1, self.num_slots * self.object_nbytes
123
+ )
124
+
125
+ def _evict_to_fit(self, protected: set[str]) -> None:
126
+ excess = len(self._lru) - self.num_slots
127
+ if excess <= 0:
128
+ return
129
+ victims = []
130
+ for key in self._lru:
131
+ if key not in self._references and key not in protected:
132
+ victims.append(key)
133
+ if len(victims) == excess:
134
+ break
135
+ if len(victims) != excess:
136
+ raise BlockObjectStoreError(
137
+ "auxiliary output store cannot retain the requested batch: "
138
+ f"limit={self.num_slots} objects"
139
+ )
140
+ for victim in victims:
141
+ slot = self._lru.pop(victim)
142
+ if slot != self._UNALLOCATED_SLOT:
143
+ self._free_slots.append(slot)
144
+
145
+ def _allocate_slot(self) -> int:
146
+ if self._free_slots:
147
+ return self._free_slots.pop()
148
+ slot = self._next_slot
149
+ self._next_slot += 1
150
+ return slot
151
+
152
+ def put(
153
+ self,
154
+ objects: list[BlockObject],
155
+ *,
156
+ retain_keys: Iterable[str] = (),
157
+ release_keys: Iterable[str] = (),
158
+ ) -> None:
159
+ arena = self._arena
160
+ if arena is None:
161
+ raise RuntimeError("auxiliary output store is closed")
162
+ unique = {obj.key: obj for obj in objects}
163
+ self._retain(retain_keys)
164
+ terminal_order = self._release(release_keys)
165
+ for key in unique:
166
+ self._lru.setdefault(key, self._UNALLOCATED_SLOT)
167
+ self._lru.move_to_end(key)
168
+ for key in terminal_order:
169
+ if key in self._lru:
170
+ self._lru.move_to_end(key)
171
+ try:
172
+ self._evict_to_fit(set(unique) - set(terminal_order))
173
+ except BlockObjectStoreError:
174
+ for key in unique:
175
+ if self._lru.get(key) == self._UNALLOCATED_SLOT:
176
+ del self._lru[key]
177
+ raise
178
+ for object_id, obj in unique.items():
179
+ slot = self._lru.get(object_id)
180
+ if slot is None:
181
+ continue
182
+ if slot != self._UNALLOCATED_SLOT:
183
+ continue
184
+ slot = self._allocate_slot()
185
+ offset = slot * self.object_nbytes
186
+ arena[offset : offset + self.object_nbytes] = obj.payload
187
+ self._lru[object_id] = slot
188
+
189
+ def _retain(self, keys: Iterable[str]) -> None:
190
+ for key in keys:
191
+ references = self._references.get(key, 0)
192
+ self._references[key] = references + 1
193
+
194
+ def _release(self, keys: Iterable[str]) -> list[str]:
195
+ terminal_order = []
196
+ for key in keys:
197
+ references = self._references[key] - 1
198
+ if references:
199
+ self._references[key] = references
200
+ continue
201
+ del self._references[key]
202
+ terminal_order.append(key)
203
+ return terminal_order
204
+
205
+ def get_concatenated(self, keys: list[str]) -> bytes:
206
+ arena_obj = self._arena
207
+ if arena_obj is None:
208
+ raise RuntimeError("auxiliary output store is closed")
209
+ try:
210
+ entries = [self._lru[key] for key in keys]
211
+ except KeyError as error:
212
+ raise BlockObjectStoreError(
213
+ "auxiliary output object does not exist; the object may have been "
214
+ f"evicted (used={len(self._lru)}, "
215
+ f"limit={self.num_slots} objects). Increase "
216
+ "aux_output_config.max_bytes when a KV cache hit requires it."
217
+ ) from error
218
+ arena = memoryview(arena_obj)
219
+ try:
220
+ payload = b"".join(
221
+ arena[slot * self.object_nbytes : (slot + 1) * self.object_nbytes]
222
+ for slot in entries
223
+ )
224
+ finally:
225
+ arena.release()
226
+ for key in keys:
227
+ self._lru.move_to_end(key)
228
+ return payload
229
+
230
+ def close(self) -> None:
231
+ arena = self._arena
232
+ if arena is not None:
233
+ self._arena = None
234
+ arena.close()
vllm/distributed/aux_output_connector/worker.py
new
+412
@@ -0,0 +1,412 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Worker-side auxiliary-output data plane."""
4
+
5
+from __future__ import annotations
6
+
7
+from collections.abc import Iterable, Sequence
8
+from dataclasses import dataclass, field
9
+from threading import Lock
10
+from typing import TYPE_CHECKING, Any
11
+
12
+import numpy as np
13
+import torch
14
+
15
+from vllm.config import VllmConfig
16
+from vllm.distributed.aux_output_connector.connector import (
17
+ AuxOutputConnectorMetadata,
18
+ AuxRequestOutput,
19
+)
20
+from vllm.distributed.aux_output_connector.routed_experts import (
21
+ RoutedExpertsBuffer,
22
+ materialize_routed_experts,
23
+ publish_routed_experts,
24
+ routed_experts_keys,
25
+)
26
+from vllm.distributed.aux_output_connector.store import (
27
+ BackgroundBlockObjectStore,
28
+ BlockObjectStore,
29
+)
30
+from vllm.distributed.parallel_state import get_tp_group
31
+from vllm.model_executor.layers.fused_moe.routed_experts_capturer import (
32
+ RoutedExpertsCapturer,
33
+ bind_routed_experts_capturer,
34
+)
35
+from vllm.v1.core.kv_cache_utils import resolve_kv_cache_block_sizes
36
+
37
+if TYPE_CHECKING:
38
+ from vllm.v1.kv_cache_interface import KVCacheConfig
39
+ from vllm.v1.worker.gpu.input_batch import InputBatch
40
+
41
+
42
+@dataclass
43
+class _WorkerRequestState:
44
+ aux_output_keys: list[str] = field(default_factory=list)
45
+ pending_blocks: list[tuple[int, np.ndarray]] = field(default_factory=list)
46
+ capture_cursor: int | None = None
47
+ scheduled_cursor: int = 0
48
+ emit_cursor: int = 0
49
+ pending_outputs: int = 0
50
+ # Terminal event received; teardown waits for in-flight step outputs.
51
+ finished: bool = False
52
+
53
+
54
+@dataclass
55
+class PendingAuxOutput:
56
+ """Own one step's R3 snapshot until its asynchronous copy is consumed."""
57
+
58
+ connector: AuxOutputWorkerConnector
59
+ request_ids: list[str]
60
+ token_starts: np.ndarray
61
+ query_start_loc: np.ndarray
62
+ # GPU snapshot taken on the main stream.
63
+ routed_experts_gpu: torch.Tensor
64
+ # Filled by enqueue_cpu_copy on the output copy stream.
65
+ routed_experts: np.ndarray | None = None
66
+ num_sampled: np.ndarray | None = None
67
+ num_rejected: np.ndarray | None = None
68
+
69
+ def enqueue_cpu_copy(
70
+ self, num_sampled: np.ndarray, num_rejected: np.ndarray
71
+ ) -> None:
72
+ """Enqueue asynchronous D2H copies; call on the output copy stream."""
73
+ self.num_sampled = num_sampled
74
+ self.num_rejected = num_rejected
75
+ self.routed_experts = self.routed_experts_gpu.to(
76
+ "cpu", non_blocking=True
77
+ ).numpy()
78
+
79
+ def process_output(self) -> dict[str, AuxRequestOutput]:
80
+ return self.connector.process_output(self)
81
+
82
+
83
+class AuxOutputWorkerConnector:
84
+ """Own capture, request tails, and backend resources on the output worker."""
85
+
86
+ def __init__(
87
+ self,
88
+ *,
89
+ vllm_config: VllmConfig,
90
+ model: torch.nn.Module,
91
+ kv_cache_config: KVCacheConfig,
92
+ ) -> None:
93
+ max_num_batched_tokens = vllm_config.scheduler_config.max_num_batched_tokens
94
+ capturer = RoutedExpertsCapturer(
95
+ max_num_batched_tokens=max_num_batched_tokens, vllm_config=vllm_config
96
+ )
97
+ bind_routed_experts_capturer(model, capturer)
98
+ self._capturer = capturer
99
+ self._store: BackgroundBlockObjectStore | None = None
100
+ self._buffer: RoutedExpertsBuffer | None = None
101
+ self._requests: dict[str, _WorkerRequestState] = {}
102
+ self._generation = 0
103
+ self._step_metadata: AuxOutputConnectorMetadata | None = None
104
+ # Steps whose asynchronous copy has not been consumed yet. The engine
105
+ # consumes step outputs in order, so at most max_concurrent_batches
106
+ # are outstanding; guarded by _lock against the output thread.
107
+ self._pending_outputs: list[PendingAuxOutput] = []
108
+ self._lock = Lock()
109
+ self._max_concurrent_batches = vllm_config.max_concurrent_batches
110
+ # Every TP rank participates in capture collectives, but only the
111
+ # executor output rank owns the auxiliary output data plane.
112
+ if not get_tp_group().is_first_rank:
113
+ return
114
+
115
+ shape_per_token = self._capturer.shape_per_token
116
+ dtype: np.dtype[Any] = np.dtype(self._capturer.output_dtype_name)
117
+ scheduler_block_size, hash_block_size = resolve_kv_cache_block_sizes(
118
+ kv_cache_config, vllm_config
119
+ )
120
+ hashes_per_kv_block = scheduler_block_size // hash_block_size
121
+ block_nbytes = hash_block_size * int(np.prod(shape_per_token)) * dtype.itemsize
122
+ max_bytes = vllm_config.aux_output_config.max_bytes
123
+ if max_bytes is None:
124
+ max_bytes = kv_cache_config.num_blocks * hashes_per_kv_block * block_nbytes
125
+ self._store = BackgroundBlockObjectStore(
126
+ BlockObjectStore(max_bytes=max_bytes, object_nbytes=block_nbytes),
127
+ max_pending_batches=2 * vllm_config.scheduler_config.max_num_seqs,
128
+ )
129
+ self._buffer = RoutedExpertsBuffer(
130
+ dtype,
131
+ shape_per_token,
132
+ hash_block_size,
133
+ vllm_config.scheduler_config.max_num_seqs,
134
+ max_num_batched_tokens,
135
+ vllm_config.max_concurrent_batches,
136
+ )
137
+
138
+ def prepare_output(self, input_batch: InputBatch) -> PendingAuxOutput | None:
139
+ """Snapshot one step's R3 tensor for asynchronous CPU transfer."""
140
+ if self._buffer is None or self._step_metadata is None:
141
+ return None
142
+
143
+ request_ids = list(input_batch.req_ids)
144
+ query_start_loc = input_batch.query_start_loc_np[: len(request_ids) + 1]
145
+ num_rows = int(query_start_loc[-1])
146
+ pending_output = PendingAuxOutput(
147
+ connector=self,
148
+ request_ids=request_ids,
149
+ token_starts=input_batch.num_computed_tokens_np,
150
+ query_start_loc=query_start_loc,
151
+ routed_experts_gpu=self._capturer.snapshot_routing_data(num_rows),
152
+ )
153
+ with self._lock:
154
+ assert len(self._pending_outputs) < self._max_concurrent_batches, (
155
+ "auxiliary output step outputs are not consumed in order"
156
+ )
157
+ self._pending_outputs.append(pending_output)
158
+ for request_id in pending_output.request_ids:
159
+ self._requests[request_id].pending_outputs += 1
160
+ return pending_output
161
+
162
+ def process_output(self, pending: PendingAuxOutput) -> dict[str, AuxRequestOutput]:
163
+ """Commit one consumed R3 snapshot and build request outputs.
164
+
165
+ Request teardown deferred by begin_step is completed here once no
166
+ in-flight step covers the request.
167
+ """
168
+ with self._lock:
169
+ teardown = []
170
+ release_keys: list[str] = []
171
+ try:
172
+ outputs = self._commit_output(pending)
173
+ finally:
174
+ self._pending_outputs.remove(pending)
175
+ for request_id in pending.request_ids:
176
+ state = self._requests[request_id]
177
+ state.pending_outputs -= 1
178
+ if state.pending_outputs == 0 and state.finished:
179
+ teardown.append(request_id)
180
+ release_keys.extend(reversed(state.aux_output_keys))
181
+ if teardown:
182
+ self._publish_blocks([], release_keys=release_keys)
183
+ self._teardown(teardown)
184
+ return outputs
185
+
186
+ def _commit_output(self, pending: PendingAuxOutput) -> dict[str, AuxRequestOutput]:
187
+ buffer = self._buffer
188
+ store = self._store
189
+ assert buffer is not None and store is not None
190
+ block_size = buffer.block_size
191
+
192
+ routed_experts = pending.routed_experts
193
+ num_sampled = pending.num_sampled
194
+ num_rejected = pending.num_rejected
195
+ assert (
196
+ routed_experts is not None
197
+ and num_sampled is not None
198
+ and num_rejected is not None
199
+ ), "auxiliary output CPU copy was not enqueued"
200
+
201
+ # Publish the whole batch before materializing any consumer output.
202
+ materialize_outputs: list[tuple[str, int, int]] = []
203
+ block_batches = []
204
+ outputs: dict[str, AuxRequestOutput] = {}
205
+
206
+ # Use the ModelRunner's actual batch boundaries rather than rebuilding them.
207
+ for request_id, token_start, start, end, sampled, rejected in zip(
208
+ pending.request_ids,
209
+ pending.token_starts,
210
+ pending.query_start_loc[:-1],
211
+ pending.query_start_loc[1:],
212
+ num_sampled,
213
+ num_rejected,
214
+ strict=True,
215
+ ):
216
+ request_num_tokens = end - start
217
+ assert request_num_tokens > 0, (
218
+ "auxiliary output request token count must be positive"
219
+ )
220
+ state = self._requests[request_id]
221
+
222
+ # Capture precedes speculative acceptance, so discard the rejected
223
+ # suffix. Batch boundaries still span the full executed range.
224
+ rejected = int(rejected)
225
+ assert 0 <= rejected <= request_num_tokens, (
226
+ "auxiliary output rejected-token count is invalid"
227
+ )
228
+ rows = routed_experts[start : end - rejected]
229
+
230
+ capture_start = token_start
231
+ capture_cursor = state.capture_cursor
232
+ if capture_cursor is None:
233
+ capture_cursor = capture_start
234
+
235
+ assert capture_start >= capture_cursor, (
236
+ "auxiliary output capture moved backwards"
237
+ )
238
+ if capture_start > capture_cursor:
239
+ # Reattach after an optimistically scheduled suffix was rejected.
240
+ assert capture_cursor < state.scheduled_cursor, (
241
+ "auxiliary output capture has an unbacked token gap"
242
+ )
243
+ capture_start = capture_cursor
244
+
245
+ emit_start = state.emit_cursor
246
+ # Complete blocks without keys remain pending until a hash update.
247
+ completed = buffer.capture(request_id, capture_start, rows)
248
+ token_end = capture_start + len(rows)
249
+ state.capture_cursor = token_end
250
+ state.scheduled_cursor = token_start + request_num_tokens
251
+ block_batches.append((state, completed))
252
+
253
+ if sampled > 0 and emit_start <= token_end:
254
+ if emit_start >= capture_start:
255
+ outputs[request_id] = AuxRequestOutput(
256
+ emit_start, rows[emit_start - capture_start :]
257
+ )
258
+ state.emit_cursor = token_end
259
+ else:
260
+ materialize_outputs.append((request_id, emit_start, token_end))
261
+
262
+ # A consumer may reuse a block produced earlier in the same batch.
263
+ self._publish_blocks(block_batches)
264
+
265
+ for request_id, emit_start, token_end in materialize_outputs:
266
+ state = self._requests[request_id]
267
+ stored_end = (
268
+ min(token_end // block_size, len(state.aux_output_keys)) * block_size
269
+ )
270
+ if emit_start < stored_end:
271
+ first_block = emit_start // block_size
272
+ stored = materialize_routed_experts(
273
+ store,
274
+ state.aux_output_keys[first_block : stored_end // block_size],
275
+ shape_per_token=buffer.shape_per_token,
276
+ dtype=buffer.dtype,
277
+ )
278
+ local_start = emit_start % block_size
279
+ rows = stored[local_start : local_start + stored_end - emit_start]
280
+ if stored_end < token_end:
281
+ rows = np.concatenate(
282
+ (rows, buffer.read(request_id, stored_end, token_end))
283
+ )
284
+ else:
285
+ rows = buffer.read(request_id, emit_start, token_end)
286
+ outputs[request_id] = AuxRequestOutput(emit_start, rows)
287
+ state.emit_cursor = token_end
288
+ return outputs
289
+
290
+ def _publish_blocks(
291
+ self,
292
+ batches: list[tuple[_WorkerRequestState, list[tuple[int, np.ndarray]]]],
293
+ retain_keys: Sequence[str] = (),
294
+ release_keys: Sequence[str] = (),
295
+ ) -> None:
296
+ store = self._store
297
+ buffer = self._buffer
298
+ assert store is not None and buffer is not None
299
+ ready_batches = []
300
+ for state, completed in batches:
301
+ blocks = state.pending_blocks + completed
302
+ keyed_end = len(state.aux_output_keys) * buffer.block_size
303
+ ready = [(start, rows) for start, rows in blocks if start < keyed_end]
304
+ state.pending_blocks = [
305
+ (start, buffer.retain_block(rows))
306
+ for start, rows in blocks
307
+ if start >= keyed_end
308
+ ]
309
+ if ready:
310
+ ready_batches.append((state.aux_output_keys, ready))
311
+ if ready_batches or retain_keys or release_keys:
312
+ publish_routed_experts(
313
+ store,
314
+ batches=ready_batches,
315
+ block_size=buffer.block_size,
316
+ retain_keys=retain_keys,
317
+ release_keys=release_keys,
318
+ )
319
+ for _, blocks in ready_batches:
320
+ for _, rows in blocks:
321
+ buffer.release_block(rows)
322
+
323
+ def _teardown(self, request_ids: Iterable[str]) -> None:
324
+ """Drop finished requests' temporary state after publishing key releases."""
325
+ buffer = self._buffer
326
+ assert buffer is not None
327
+ for request_id in request_ids:
328
+ state = self._requests.pop(request_id)
329
+ for _, rows in state.pending_blocks:
330
+ buffer.release_block(rows)
331
+ buffer.discard(request_id)
332
+
333
+ def begin_step(self, metadata: AuxOutputConnectorMetadata | None) -> None:
334
+ """Apply one scheduler step's request and block-hash updates.
335
+
336
+ While an unconsumed step covers a finished request, its teardown is
337
+ deferred to that step's process_output.
338
+ """
339
+ self._step_metadata = metadata
340
+ if self._buffer is None or metadata is None:
341
+ return
342
+ assert not metadata.requests.keys() & metadata.finished_requests, (
343
+ "auxiliary output request cannot run and finish in one step"
344
+ )
345
+ assert metadata.generation >= self._generation, (
346
+ "auxiliary output metadata generation moved backwards"
347
+ )
348
+ with self._lock:
349
+ release_keys: list[str] = []
350
+ if metadata.generation > self._generation:
351
+ # A generation change follows a prefix-cache reset, which the
352
+ # scheduler only performs once all model output is consumed.
353
+ assert not self._pending_outputs, (
354
+ "auxiliary output generation changed with output in flight"
355
+ )
356
+ release_keys.extend(
357
+ key
358
+ for state in self._requests.values()
359
+ for key in reversed(state.aux_output_keys)
360
+ )
361
+ self._buffer.reset()
362
+ self._requests.clear()
363
+ self._generation = metadata.generation
364
+ for request_id, emit_start in metadata.requests.items():
365
+ state = self._requests.setdefault(
366
+ request_id, _WorkerRequestState(emit_cursor=emit_start)
367
+ )
368
+ if state.pending_outputs == 0:
369
+ # In-flight steps leave the worker cursor behind the
370
+ # scheduler's optimistic view; only check settled requests.
371
+ assert emit_start <= state.emit_cursor, (
372
+ "auxiliary output Scheduler emit cursor moved ahead"
373
+ )
374
+ block_batches: list[
375
+ tuple[_WorkerRequestState, list[tuple[int, np.ndarray]]]
376
+ ] = []
377
+ retained_keys: list[str] = []
378
+ for request_id, block_hashes in metadata.block_hashes.items():
379
+ state = self._requests[request_id]
380
+ keys = routed_experts_keys(block_hashes, str(self._generation))
381
+ state.aux_output_keys.extend(keys)
382
+ retained_keys.extend(keys)
383
+ block_batches.append((state, []))
384
+ finished_now: list[str] = []
385
+ for request_id in metadata.finished_requests:
386
+ state = self._requests[request_id]
387
+ state.finished = True
388
+ if state.pending_outputs:
389
+ continue
390
+ finished_now.append(request_id)
391
+ release_keys.extend(reversed(state.aux_output_keys))
392
+ self._publish_blocks(block_batches, retained_keys, release_keys)
393
+ self._teardown(finished_now)
394
+
395
+ def close(self) -> None:
396
+ with self._lock:
397
+ self._pending_outputs.clear()
398
+ if self._store is not None:
399
+ try:
400
+ self._store.close()
401
+ finally:
402
+ self._store = None
403
+
404
+
405
+def get_aux_output_connector(
406
+ model: torch.nn.Module, vllm_config: VllmConfig, kv_cache_config: KVCacheConfig
407
+) -> AuxOutputWorkerConnector:
408
+ return AuxOutputWorkerConnector(
409
+ model=model,
410
+ kv_cache_config=kv_cache_config,
411
+ vllm_config=vllm_config,
412
+ )
vllm/engine/arg_utils.py
+15
-3
@@ -35,6 +35,7 @@ from typing_extensions import TypeIs
35
import vllm.envs as envs
36
from vllm.config import (
37
AttentionConfig,
38
+ AuxOutputConfig,
39
CacheConfig,
40
CompilationConfig,
41
ConfigType,
@@ -447,7 +448,9 @@ class EngineArgs:
448
"""Arguments for vLLM engine."""
449
450
model: str = ModelConfig.model
450
- enable_return_routed_experts: bool = ModelConfig.enable_return_routed_experts
451
+ # Public compatibility argument. Canonical runtime state lives in
452
+ # AuxOutputConfig.
453
+ enable_return_routed_experts: bool = False
454
return_sampling_mask: bool = ModelConfig.return_sampling_mask
455
model_weights: str = ModelConfig.model_weights
456
served_model_name: str | list[str] | None = ModelConfig.served_model_name
@@ -674,6 +677,7 @@ class EngineArgs:
677
structured_outputs_config: StructuredOutputsConfig = get_field(
678
VllmConfig, "structured_outputs_config"
679
)
680
+ aux_output_config: AuxOutputConfig = get_field(VllmConfig, "aux_output_config")
681
reasoning_parser: str = StructuredOutputsConfig.reasoning_parser
682
reasoning_parser_plugin: str | None = None
683
@@ -816,6 +820,10 @@ class EngineArgs:
820
self.compilation_config = CompilationConfig(**self.compilation_config)
821
if isinstance(self.attention_config, dict):
822
self.attention_config = AttentionConfig(**self.attention_config)
823
+ if isinstance(self.aux_output_config, dict):
824
+ self.aux_output_config = AuxOutputConfig(**self.aux_output_config)
825
+ if self.enable_return_routed_experts:
826
+ self.aux_output_config.enable_return_routed_experts = True
827
if isinstance(self.engram_config, dict):
828
self.engram_config = EngramConfig(**self.engram_config)
829
if isinstance(self.mamba_config, dict):
@@ -884,6 +892,7 @@ class EngineArgs:
892
"""Shared CLI arguments for vLLM engine."""
893
# Model arguments
894
model_kwargs = get_kwargs(ModelConfig)
895
+ aux_output_kwargs = get_kwargs(AuxOutputConfig)
896
model_group = parser.add_argument_group(
897
title="ModelConfig",
898
description=ModelConfig.__doc__,
@@ -923,7 +932,7 @@ class EngineArgs:
932
model_group.add_argument("--enforce-eager", **model_kwargs["enforce_eager"])
933
model_group.add_argument(
934
"--enable-return-routed-experts",
926
- **model_kwargs["enable_return_routed_experts"],
935
+ **aux_output_kwargs["enable_return_routed_experts"],
936
)
937
model_group.add_argument(
938
"--return-sampling-mask",
@@ -1749,6 +1758,9 @@ class EngineArgs:
1758
vllm_group.add_argument(
1759
"--structured-outputs-config", **vllm_kwargs["structured_outputs_config"]
1760
)
1761
+ vllm_group.add_argument(
1762
+ "--aux-output-config", **vllm_kwargs["aux_output_config"]
1763
+ )
1764
vllm_group.add_argument("--profiler-config", **vllm_kwargs["profiler_config"])
1765
vllm_group.add_argument(
1766
"--optimization-level", **vllm_kwargs["optimization_level"]
@@ -1856,7 +1868,6 @@ class EngineArgs:
1868
quantization_config=self.quantization_config,
1869
allow_deprecated_quantization=self.allow_deprecated_quantization,
1870
enforce_eager=self.enforce_eager,
1859
- enable_return_routed_experts=self.enable_return_routed_experts,
1871
return_sampling_mask=self.return_sampling_mask,
1872
max_logprobs=self.max_logprobs,
1873
logprobs_mode=self.logprobs_mode,
@@ -2669,6 +2680,7 @@ class EngineArgs:
2680
load_config=load_config,
2681
offload_config=offload_config,
2682
attention_config=attention_config,
2683
+ aux_output_config=self.aux_output_config,
2684
engram_config=self.engram_config,
2685
mamba_config=mamba_config,
2686
kernel_config=kernel_config,
vllm/model_executor/layers/fused_moe/routed_experts_capturer.py
+37
-237
@@ -10,15 +10,12 @@ from collections.abc import Callable
10
from functools import partial
11
from typing import Protocol, runtime_checkable
12
13
-import numpy as np
13
import torch
14
15
from vllm.config import VllmConfig
16
from vllm.distributed.parallel_state import get_tp_group
17
from vllm.forward_context import get_forward_context
18
from vllm.platforms import current_platform
20
-from vllm.v1.kv_cache_interface import KVCacheConfig, is_full_attention_spec
21
-from vllm.v1.outputs import RoutedExpertsTensors
19
20
logger = logging.getLogger(__name__)
21
@@ -46,37 +43,29 @@ def _get_routed_experts_shape(vllm_config: VllmConfig) -> tuple[int, int, int]:
43
class RoutedExpertsCapturer:
44
"""Worker-side capturer for routed experts, lives on GPU.
45
49
- Layer-level hooks call :meth:`capture` from inside the forward pass
50
- with the per-layer ``topk_ids`` tensor. The tensor is sliced to the
51
- tokens owned by this DP rank and written into a preallocated device
52
- buffer. At the end of the step, :class:`GPUModelRunner` reads the
53
- device buffer, issues a D2H copy into a pinned CPU buffer, and hands
54
- the result to the scheduler via :class:`RoutedExpertsLists`.
46
+ Layer-level hooks call :meth:`capture` inside the forward pass. Routing
47
+ rows owned by this DP rank are written into a preallocated device buffer.
48
56
- The device / pinned-CPU transit buffers use ``torch.int32`` (not a
57
- narrow ``uint8``/``uint16`` sized by ``num_experts``). This keeps the
58
- SP all-gather path free of dtype casts, matches the router's native
59
- ``topk_ids`` indices dtype more closely, and costs only a few MB per
60
- worker (``max_num_batched_tokens * num_layers * top_k * 4`` bytes).
61
- The scheduler-side slot buffer
62
- (``RoutedExpertsManager.routed_experts_by_slot``) still uses the
63
- narrow dtype -- numpy fancy-index assignment in ``store_batch``
64
- narrows the data on the way in.
49
+ The device buffer uses ``int32``. Stable snapshots use the narrowest dtype
50
+ that can represent every logical expert ID.
51
52
Invariants:
53
- One instance per worker; shape is fixed at init and covers the
54
worst-case step (``max_num_batched_tokens`` tokens).
55
- Every routed layer overwrites the current step's token rows.
70
- - ``device_buffer.dtype`` is ``torch.int32``.
56
"""
57
58
def __init__(
59
self,
60
max_num_batched_tokens: int,
61
vllm_config: VllmConfig,
77
- kv_cache_config: KVCacheConfig,
62
) -> None:
79
- num_layers, _, num_experts_per_tok = _get_routed_experts_shape(vllm_config)
63
+ num_layers, num_experts, num_experts_per_tok = _get_routed_experts_shape(
64
+ vllm_config
65
+ )
66
+ self.shape_per_token = (num_layers, num_experts_per_tok)
67
+ self.output_dtype_name = "uint8" if num_experts <= 256 else "uint16"
68
+ self.output_dtype = getattr(torch, self.output_dtype_name)
69
logger.info(
70
"RoutedExpertsCapturer: allocating buffer with "
71
"max_tokens=%d, num_layers=%d, num_experts_per_tok=%d "
@@ -87,23 +76,12 @@ class RoutedExpertsCapturer:
76
vllm_config.model_config.hf_text_config.model_type,
77
)
78
self.device_buffer = torch.zeros(
90
- (
91
- max_num_batched_tokens,
92
- num_layers,
93
- num_experts_per_tok,
94
- ),
95
- # Use int32 for the device / host transit buffers: it
96
- # matches the router's native topk_ids dtype, is universally
97
- # supported by NCCL (uint8/uint16 are version-dependent),
98
- # and the extra bytes are small (few MB per worker). The
99
- # big scheduler-side slot buffer stays narrow.
79
+ (max_num_batched_tokens, *self.shape_per_token),
80
dtype=torch.int32,
81
device=current_platform.device_type,
82
)
83
self.dp_rank = vllm_config.parallel_config.data_parallel_rank
84
self.tp_size = vllm_config.parallel_config.tensor_parallel_size
105
- # KV cache group whose slot layout the routing data is keyed by.
106
- self.attn_gid = get_routed_experts_attn_gid(kv_cache_config)
85
86
def capture(self, layer_id: int, topk_ids: torch.Tensor) -> None:
87
"""Capture expert routing decisions for a specific layer.
@@ -139,31 +117,21 @@ class RoutedExpertsCapturer:
117
118
"""
119
ctx = get_forward_context()
142
- if ctx.dp_metadata is None: # single dp
143
- start_loc = 0
144
- end_loc = topk_ids.shape[0]
145
- token_num_per_dp = topk_ids.shape[0]
146
- else: # multi dp
120
+ if ctx.dp_metadata is None:
121
+ local_topk_ids = topk_ids
122
+ else:
123
num_tokens_dp = ctx.dp_metadata.num_tokens_across_dp_cpu
148
- token_num_per_dp = int(num_tokens_dp[self.dp_rank].item())
124
+ num_local_tokens = int(num_tokens_dp[self.dp_rank].item())
125
total = int(num_tokens_dp.sum().item())
126
n = topk_ids.shape[0]
127
shard_sizes = getattr(ctx.dp_metadata, "local_sizes", None)
128
gathered_size = sum(shard_sizes) if shard_sizes is not None else -1
129
130
if n == total:
155
- # Naive dispatch: all DP ranks' tokens concatenated
156
- # before routing. This rank owns tokens
157
- # [end_loc - token_num_per_dp, end_loc).
158
- cumsum = torch.cumsum(num_tokens_dp, dim=0)
159
- end_loc = int(cumsum[self.dp_rank].item())
160
- start_loc = end_loc - token_num_per_dp
161
- elif n == token_num_per_dp:
162
- # Modular-kernel path: DP combine happens inside
163
- # quant_method.apply; select_experts only sees this
164
- # rank's tokens, take the whole tensor.
165
- start_loc = 0
166
- end_loc = token_num_per_dp
131
+ end = int(num_tokens_dp[: self.dp_rank + 1].sum().item())
132
+ local_topk_ids = topk_ids[end - num_local_tokens : end]
133
+ elif n == num_local_tokens:
134
+ local_topk_ids = topk_ids
135
elif shard_sizes is not None and n == gathered_size:
136
# Naive DP+EP dispatch gathers the sequence-parallel shards in
137
# flattened DP-then-TP order. ``local_sizes`` is the exact
@@ -173,12 +141,11 @@ class RoutedExpertsCapturer:
141
assert len(shard_sizes) % num_dp_ranks == 0
142
shards_per_dp_rank = len(shard_sizes) // num_dp_ranks
143
first_shard = self.dp_rank * shards_per_dp_rank
176
- start_loc = sum(shard_sizes[:first_shard])
177
- end_loc = start_loc + token_num_per_dp
144
+ start = sum(shard_sizes[:first_shard])
145
+ local_topk_ids = topk_ids[start : start + num_local_tokens]
146
elif (
147
self.tp_size > 1
180
- and n != token_num_per_dp
181
- and n == (token_num_per_dp + self.tp_size - 1) // self.tp_size
148
+ and n == (num_local_tokens + self.tp_size - 1) // self.tp_size
149
):
150
# SP + modular-kernel path. All-gather across the TP
151
# group along dim=0 to reconstruct the full per-DP-rank
@@ -193,60 +160,30 @@ class RoutedExpertsCapturer:
160
#
161
# ``topk_ids`` is already whatever the router produced
162
# (typically int32/int64, both supported by NCCL); the
196
- # downstream ``device_buffer[...] = topk_ids[...]``
197
- # setitem narrows into int32 automatically.
198
- topk_ids = get_tp_group().all_gather(topk_ids, dim=0)
199
- start_loc = 0
200
- end_loc = token_num_per_dp
163
+ # downstream buffer assignment narrows to the capture dtype.
164
+ local_topk_ids = get_tp_group().all_gather(topk_ids, dim=0)[
165
+ :num_local_tokens
166
+ ]
167
else:
202
- sp_expected = (
203
- (token_num_per_dp + self.tp_size - 1) // self.tp_size
204
- if self.tp_size > 0
205
- else -1
206
- )
168
+ sp_expected = (num_local_tokens + self.tp_size - 1) // self.tp_size
169
raise AssertionError(
170
"RoutedExpertsCapturer: unexpected topk_ids batch "
209
- f"dim {n} (expected {total}, {token_num_per_dp}, "
171
+ f"dim {n} (expected {total}, {num_local_tokens}, "
172
f"{gathered_size}, or {sp_expected} for "
173
f"dp_rank={self.dp_rank}, tp_size={self.tp_size})"
174
)
175
214
- if layer_id >= self.device_buffer.shape[1]:
176
+ if not 0 <= layer_id < self.device_buffer.shape[1]:
177
raise IndexError(
178
f"routed-experts layer {layer_id} exceeds capture buffer "
179
f"layer count {self.device_buffer.shape[1]}"
180
)
181
220
- self.device_buffer[:token_num_per_dp, layer_id, :] = topk_ids[
221
- start_loc:end_loc, :
222
- ]
223
-
224
- def get_device_buffer(self) -> torch.Tensor:
225
- """Return the underlying device buffer so the model runner can
226
- issue the D2H copy. The tensor is shared; callers must either
227
- clone or fully drain it before the next forward pass overwrites it.
228
- """
229
- return self.device_buffer
230
-
231
- def get_routed_experts(
232
- self, slot_mappings: torch.Tensor, num_tokens: int
233
- ) -> RoutedExpertsTensors:
234
- """Snapshot this step's routing data and its attention slot mapping.
235
-
236
- Both tensors are cloned since the capture buffer and the shared
237
- ``slot_mappings`` are overwritten by the next step, which may race
238
- with an in-flight D2H copy.
182
+ self.device_buffer[: len(local_topk_ids), layer_id] = local_topk_ids
183
240
- Args:
241
- slot_mappings: Per-KV-cache-group slot mappings for this step,
242
- shape ``(num_kv_cache_groups, max_num_batched_tokens)``.
243
- num_tokens: Total number of tokens scheduled in this step.
244
-
245
- """
246
- return RoutedExpertsTensors(
247
- routing_data=self.device_buffer[:num_tokens].clone(),
248
- slot_mapping=slot_mappings[self.attn_gid, :num_tokens].clone(),
249
- )
184
+ def snapshot_routing_data(self, num_tokens: int) -> torch.Tensor:
185
+ """Return a stable snapshot of the current routing data."""
186
+ return self.device_buffer[:num_tokens].to(self.output_dtype)
187
188
189
def bind_routed_experts_capturer(
@@ -268,20 +205,12 @@ def bind_routed_experts_capturer(
205
continue
206
if not isinstance(module, MoERunner):
207
continue
271
- layer_id = module.layer_id
272
-
273
- def capture_fn(
274
- topk_ids: torch.Tensor,
275
- layer_id: int = layer_id,
276
- capturer: RoutedExpertsCapturer = capturer,
277
- ) -> None:
278
- capturer.capture(layer_id, topk_ids)
279
-
208
+ capture_fn = partial(capturer.capture, module.layer_id)
209
quant_method = module._quant_method
281
- moe_kernel = getattr(quant_method, "moe_kernel", None)
282
- impl = getattr(moe_kernel, "impl", None)
283
- fused_experts = getattr(impl, "fused_experts", None)
210
if quant_method.is_monolithic:
211
+ moe_kernel = getattr(quant_method, "moe_kernel", None)
212
+ impl = getattr(moe_kernel, "impl", None)
213
+ fused_experts = getattr(impl, "fused_experts", None)
214
if not (
215
isinstance(fused_experts, FusedMoEExpertsMonolithic)
216
and fused_experts.supports_routing_replay_capture()
@@ -303,132 +232,3 @@ def bind_routed_experts_capturer(
232
233
if num_bound == 0:
234
raise ValueError("No supported MoE router found for routed-experts capture.")
306
-
307
-
308
-def get_routed_experts_attn_gid(kv_cache_config: KVCacheConfig) -> int:
309
- """Return the full-attention KV cache group used for routed experts."""
310
- for gid, group in enumerate(kv_cache_config.kv_cache_groups):
311
- if is_full_attention_spec(group.kv_cache_spec):
312
- return gid
313
- raise ValueError("Routed-experts capture requires a full-attention KV cache group.")
314
-
315
-
316
-class RoutedExpertsManager:
317
- """Scheduler-side slot-indexed buffer for routed experts.
318
-
319
- Lives on CPU in the scheduler process. Each slot corresponds to
320
- ``block_id * block_size + offset_in_block`` where ``block_id`` is
321
- drawn from the physical KV-cache block pool, so routing data is
322
- tied to physical blocks and naturally survives preemption for
323
- prefix-cached blocks (prefix hits re-expose the same slots).
324
-
325
- Data flow per step:
326
- 1. Worker D2Hs its device capture buffer into
327
- :class:`RoutedExpertsLists` and returns it via
328
- :class:`ModelRunnerOutput`.
329
- 2. Scheduler calls :meth:`store_batch` with that step's
330
- ``(routing_data, slot_mapping)`` — a single CPU->CPU
331
- fancy-index assign, ~few MB per step.
332
- 3. On request completion / abort / preemption, the scheduler
333
- calls :meth:`get` with the request's block IDs to recover
334
- the full per-token routing.
335
-
336
- Memory: ``routed_experts_by_slot`` is sized for the whole block
337
- pool (``num_blocks * block_size`` slots). For large block pools
338
- this can reach multiple GB; see the init log for the exact size.
339
- """
340
-
341
- def __init__(
342
- self,
343
- vllm_config: VllmConfig,
344
- kv_cache_config: KVCacheConfig,
345
- ) -> None:
346
- # Hybrid models also have KV groups whose slot layout differs.
347
- self.attn_gid = get_routed_experts_attn_gid(kv_cache_config)
348
- attn_group = kv_cache_config.kv_cache_groups[self.attn_gid]
349
- self.block_size = attn_group.kv_cache_spec.block_size
350
-
351
- # All kv_cache_groups share the same physical block pool, so
352
- # block IDs span [0, num_blocks) regardless of how many groups
353
- # exist. Sizing to the full pool avoids index-out-of-range
354
- # when different groups happen to land on the same block.
355
- num_layers, num_experts, num_experts_per_tok = _get_routed_experts_shape(
356
- vllm_config
357
- )
358
- max_num_slots = kv_cache_config.num_blocks * self.block_size
359
- # Expert IDs are 0..num_experts-1; uint8 fits 256 distinct
360
- # values so the boundary is ``<= 256`` (NOT ``< 256``). Keeping
361
- # this narrow matters because the slot buffer is sized for the
362
- # whole block pool and can reach multiple GB.
363
- expert_id_dtype = np.uint8 if num_experts <= 256 else np.uint16
364
- self.routed_experts_by_slot = np.zeros(
365
- (
366
- max_num_slots,
367
- num_layers,
368
- num_experts_per_tok,
369
- ),
370
- dtype=expert_id_dtype,
371
- )
372
- logger.info(
373
- "RoutedExpertsManager CPU buffer: %.2f GB "
374
- "(slots=%d, layers=%d, top_k=%d, dtype=%s)",
375
- self.routed_experts_by_slot.nbytes / 1e9,
376
- max_num_slots,
377
- num_layers,
378
- num_experts_per_tok,
379
- self.routed_experts_by_slot.dtype.name,
380
- )
381
-
382
- def store_batch(self, data: np.ndarray, slot_mapping: np.ndarray) -> None:
383
- """Persist one step's routed experts into the slot buffer.
384
-
385
- Equivalent to ``slot_buffer[slot_mapping] = data``; numpy fancy
386
- indexing handles repeated / out-of-order indices. Called once
387
- per scheduler step in ``update_from_output``.
388
- """
389
- self.routed_experts_by_slot[slot_mapping] = data
390
-
391
- def get(
392
- self,
393
- block_ids: list[int],
394
- num_tokens: int,
395
- token_start: int = 0,
396
- ) -> np.ndarray:
397
- """Read routed experts data for a completed / preempted request.
398
-
399
- Reconstructs a per-token slot_mapping from the request's block
400
- IDs and returns the routing slice. Because numpy fancy indexing
401
- returns a **copy** (not a view), the returned ndarray is safe
402
- to hold across subsequent :meth:`store_batch` calls — do not
403
- replace the fancy index with a slice without re-verifying.
404
-
405
- Args:
406
- block_ids: Block IDs from the attention KV-cache group.
407
- num_tokens: Number of tokens that have gone through a forward
408
- pass and therefore have routing data written to their
409
- slots (typically ``request.num_tokens - 1``; the last
410
- sampled token has not been forwarded yet). Slots beyond
411
- ``request.num_computed_tokens`` are zero-initialized.
412
- token_start: Skip the first ``token_start`` tokens from the
413
- result. The slot_mapping is sliced before the fancy-index
414
- read, so only the requested slots are fetched — no large
415
- intermediate array is allocated. Clamped to
416
- ``[0, num_tokens]`` automatically.
417
-
418
- Returns:
419
- Array of shape (num_tokens - token_start, num_layers,
420
- num_experts_per_tok).
421
-
422
- """
423
- bs = self.block_size
424
- block_ids_array = np.array(block_ids, dtype=np.int32)
425
- block_offsets = np.arange(bs)
426
- # slot = block_id * block_size + offset_in_block; flatten the
427
- # (num_blocks, block_size) grid and trim to num_tokens, then
428
- # skip the first token_start entries so only the requested
429
- # range is fetched in a single fancy-index read.
430
- slot_mapping = (
431
- block_ids_array.reshape(-1, 1) * bs + block_offsets.reshape(1, -1)
432
- ).flatten()[:num_tokens]
433
- slot_mapping = slot_mapping[token_start:]
434
- return self.routed_experts_by_slot[slot_mapping]
vllm/outputs.py
+4
@@ -194,6 +194,10 @@ class RequestOutput:
194
completion.cumulative_logprob = (
195
next_completion.cumulative_logprob
196
)
197
+ # R3 is returned on the terminal output and must survive
198
+ # aggregation with earlier chunks that have no R3.
199
+ if next_completion.routed_experts is not None:
200
+ completion.routed_experts = next_completion.routed_experts
201
completion.finish_reason = next_completion.finish_reason
202
completion.stop_reason = next_completion.stop_reason
203
else:
vllm/v1/core/sched/output.py
+7
@@ -14,6 +14,9 @@ if TYPE_CHECKING:
14
import numpy.typing as npt
15
import torch
16
17
+ from vllm.distributed.aux_output_connector.connector import (
18
+ AuxOutputConnectorMetadata,
19
+ )
20
from vllm.distributed.ec_transfer.ec_connector.base import ECConnectorMetadata
21
from vllm.distributed.kv_transfer.kv_connector.v1.base import KVConnectorMetadata
22
from vllm.lora.request import LoRARequest
@@ -23,6 +26,7 @@ if TYPE_CHECKING:
26
from vllm.v1.core.kv_cache_utils import KVCacheBlockCopy
27
from vllm.v1.request import Request
28
else:
29
+ AuxOutputConnectorMetadata = object
30
ECConnectorMetadata = object
31
KVConnectorMetadata = object
32
KVCacheBlockCopy = object
@@ -285,6 +289,9 @@ class SchedulerOutput:
289
# synchronously during this step (load_async=False).
290
has_sync_kv_loads: bool = False
291
292
+ # Execution-auxiliary output control metadata consumed by the worker connector.
293
+ aux_output_connector_metadata: AuxOutputConnectorMetadata | None = None
294
+
295
# EC Cache Connector metadata
296
ec_connector_metadata: ECConnectorMetadata | None = None
297
# EC Cache Manager metadata
vllm/v1/core/sched/scheduler.py
+33
-98
@@ -9,6 +9,7 @@ from typing import Any
9
10
from vllm.compilation.cuda_graph import CUDAGraphStat
11
from vllm.config import KVEventsConfig, VllmConfig
12
+from vllm.distributed.aux_output_connector.connector import AuxOutputSchedulerConnector
13
from vllm.distributed.ec_transfer.ec_connector.base import (
14
ECConnectorBase,
15
ECConnectorMetadata,
@@ -26,9 +27,6 @@ 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
29
-from vllm.model_executor.layers.fused_moe.routed_experts_capturer import (
30
- RoutedExpertsManager,
31
-)
30
from vllm.multimodal import MULTIMODAL_REGISTRY, MultiModalRegistry
31
from vllm.multimodal.encoder_budget import MultiModalBudget
32
from vllm.multimodal.utils import get_mm_features_in_window
@@ -393,26 +391,13 @@ class Scheduler(SchedulerInterface):
391
if self.log_stats and vllm_config.observability_config.enable_mfu_metrics:
392
self.perf_metrics = ModelMetrics(vllm_config)
393
396
- self.enable_return_routed_experts = (
397
- vllm_config.model_config.enable_return_routed_experts
394
+ self.aux_output_connector = (
395
+ AuxOutputSchedulerConnector()
396
+ if vllm_config.aux_output_config.enabled
397
+ else None
398
)
399
self.return_sampling_mask = vllm_config.model_config.return_sampling_mask
400
401
- if self.enable_return_routed_experts:
402
- assert self.dcp_world_size == 1 and self.pcp_world_size == 1, (
403
- "enable_return_routed_experts does not support context parallelism "
404
- "(dcp_world_size > 1 or pcp_world_size > 1)"
405
- )
406
-
407
- self.routed_experts_mgr = RoutedExpertsManager(
408
- vllm_config=vllm_config,
409
- kv_cache_config=kv_cache_config,
410
- )
411
- # Block-ID snapshot taken at schedule time (before forward),
412
- # so update_from_output can read slot data even if a later
413
- # schedule() frees the blocks (async scheduling race).
414
- self._re_block_ids: dict[str, list[int]] = {}
415
-
401
self._pause_state: PauseState = PauseState.UNPAUSED
402
403
# In-flight requests still prefilling (prefill chunks + in-progress
@@ -1487,6 +1472,13 @@ class Scheduler(SchedulerInterface):
1472
meta = self._build_kv_connector_meta(self.connector, scheduler_output)
1473
scheduler_output.kv_connector_metadata = meta
1474
1475
+ if self.aux_output_connector is not None:
1476
+ scheduler_output.aux_output_connector_metadata = (
1477
+ self.aux_output_connector.build_connector_meta(
1478
+ scheduler_output, self.requests
1479
+ )
1480
+ )
1481
+
1482
# Build the connector meta for ECConnector
1483
if self.ec_connector is not None:
1484
ec_meta: ECConnectorMetadata = self.ec_connector.build_connector_meta(
@@ -1541,6 +1533,8 @@ class Scheduler(SchedulerInterface):
1533
assert request.status == RequestStatus.RUNNING, (
1534
"Only running requests can be preempted"
1535
)
1536
+ if self.aux_output_connector is not None:
1537
+ self.aux_output_connector.request_finished(request)
1538
self._free_request_blocks(request)
1539
self.encoder_cache_manager.free(request)
1540
self._inflight_prefills.discard(request)
@@ -1596,22 +1590,6 @@ class Scheduler(SchedulerInterface):
1590
if not request.is_prefill_chunk:
1591
self._inflight_prefills.discard(request)
1592
1599
- # Snapshot block IDs for routed experts before forward starts.
1600
- # A concurrent schedule() may preempt requests and free blocks
1601
- # before update_from_output runs; the snapshot survives that.
1602
- # Use update() to preserve entries from the previous step that
1603
- # have not yet been consumed by update_from_output (async
1604
- # scheduling may call _update_after_schedule again before the
1605
- # prior update_from_output runs).
1606
- if self.enable_return_routed_experts:
1607
- gid = self.routed_experts_mgr.attn_gid
1608
- self._re_block_ids.update(
1609
- {
1610
- rid: self.kv_cache_manager.get_blocks(rid).get_block_ids()[gid]
1611
- for rid in num_scheduled_tokens
1612
- }
1613
- )
1614
-
1593
# Clear the finished and preempted request IDs.
1594
# NOTE: We shouldn't just clear() here because it will also affect
1595
# the scheduler output.
@@ -1962,28 +1940,6 @@ class Scheduler(SchedulerInterface):
1940
num_scheduled_tokens,
1941
)
1942
)
1965
-
1966
- # Persist per-step routed experts into the scheduler-side slot
1967
- # buffer (CPU->CPU fancy-index assign; ~few MB per step).
1968
- # MUST precede the per-request routing reads below: stopped
1969
- # requests may terminate on tokens generated in this very step,
1970
- # whose routing was just D2H'd into model_runner_output.
1971
- routing_data = None
1972
- routing_offsets: dict[str, int] = {}
1973
- if model_runner_output.routed_experts is not None:
1974
- re = model_runner_output.routed_experts
1975
- self.routed_experts_mgr.store_batch(re.routing_data, re.slot_mapping)
1976
- routing_data = re.routing_data.astype(
1977
- self.routed_experts_mgr.routed_experts_by_slot.dtype,
1978
- copy=False,
1979
- )
1980
- # Build offset map using model runner's request order
1981
- # (input_batch ordering), NOT scheduler dict order.
1982
- offset = 0
1983
- for rid in model_runner_output.req_ids:
1984
- routing_offsets[rid] = offset
1985
- offset += num_scheduled_tokens[rid]
1986
-
1943
# NOTE(woosuk): As len(num_scheduled_tokens) can be up to 1K or more,
1944
# the below loop can be a performance bottleneck. We should do our best
1945
# to avoid expensive operations inside the loop.
@@ -2076,7 +2032,6 @@ class Scheduler(SchedulerInterface):
2032
ec_transfer_params = None
2033
prefill_stats = None
2034
status_before_stop = request.status
2079
- num_output_tokens_before = len(request._output_token_ids)
2035
2036
# Check for stop and update request status.
2037
if new_token_ids:
@@ -2113,48 +2068,13 @@ class Scheduler(SchedulerInterface):
2068
stopped = True
2069
2070
routed_experts = None
2116
- if (
2117
- self.enable_return_routed_experts
2118
- and routing_data is not None
2119
- and new_token_ids
2120
- ):
2121
- req_offset = routing_offsets[req_id]
2122
- end = req_offset + num_tokens_scheduled
2123
- block_ids = self._re_block_ids.pop(req_id, [])
2124
- if num_output_tokens_before == 0:
2125
- # Prefill completed: read full prompt routing from
2126
- # slot buffer using the block-ID snapshot taken at
2127
- # schedule time (immune to async preemption).
2128
- if (
2129
- request.sampling_params is not None
2130
- and request.sampling_params.routed_experts_prompt_start
2131
- is not None
2132
- ):
2133
- prompt_start = (
2134
- request.sampling_params.routed_experts_prompt_start
2135
- )
2136
- assert prompt_start <= request.num_prompt_tokens
2137
- else:
2138
- prompt_start = 0
2139
- routed_experts = self.routed_experts_mgr.get(
2140
- block_ids,
2141
- request.num_prompt_tokens,
2142
- token_start=prompt_start,
2143
- )
2144
- else:
2145
- if scheduled_spec_token_ids:
2146
- # Spec decode: accepted tokens at the START of
2147
- # the scheduled range, rejected at the end.
2148
- routed_experts = routing_data[
2149
- req_offset : req_offset + len(new_token_ids)
2150
- ]
2151
- else:
2152
- # Normal decode / re-prefill: token(s) at the END.
2153
- routed_experts = routing_data[end - len(new_token_ids) : end]
2154
-
2071
should_emit_output = bool(
2072
new_token_ids or pooler_output is not None or stopped
2073
)
2074
+ if self.aux_output_connector is not None and should_emit_output:
2075
+ routed_experts = self.aux_output_connector.take_output(
2076
+ request, model_runner_output.aux_output_connector_output
2077
+ )
2078
if should_emit_output:
2079
prefill_stats = request.take_prefill_stats()
2080
if prefill_stats is not None:
@@ -2641,6 +2561,8 @@ class Scheduler(SchedulerInterface):
2561
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
2562
assert request.is_finished()
2563
2564
+ if self.aux_output_connector is not None:
2565
+ self.aux_output_connector.request_finished(request)
2566
self._inflight_prefills.discard(request)
2567
connector_delay_free_blocks, kv_xfer_params = self._connector_finished(request)
2568
@@ -2773,6 +2695,16 @@ class Scheduler(SchedulerInterface):
2695
Otherwise, this method will only reset the KV prefix cache when there
2696
is no running requests taking KV cache.
2697
"""
2698
+ if reset_running_requests and self.aux_output_connector is not None:
2699
+ if self._pause_state != PauseState.PAUSED_ALL:
2700
+ raise RuntimeError(
2701
+ "AuxOutput Connector only supports resetting running requests "
2702
+ "after pause(mode='keep')."
2703
+ )
2704
+ if any(request.num_in_flight_tokens for request in self.requests.values()):
2705
+ raise RuntimeError(
2706
+ "AuxOutput Connector cannot reset while model output is in flight."
2707
+ )
2708
if reset_running_requests:
2709
# For logging.
2710
timestamp = time.monotonic()
@@ -2803,6 +2735,9 @@ class Scheduler(SchedulerInterface):
2735
if reset_connector:
2736
reset_successful = self.reset_connector_cache() and reset_successful
2737
2738
+ if reset_successful and self.aux_output_connector is not None:
2739
+ self.aux_output_connector.reset()
2740
+
2741
return reset_successful
2742
2743
def reset_connector_cache(self) -> bool:
vllm/v1/executor/ray_utils.py
+4
-8
@@ -233,14 +233,10 @@ def detach_zero_copy_from_model_runner_output(output: "ModelRunnerOutput") -> No
233
token_ids_c, logprobs_c, ranks_c, cu_num_generated_tokens
234
)
235
236
- if output.routed_experts is not None:
237
- routing_data, slot_mapping = output.routed_experts
238
- routing_data_c = _copy_if_readonly(routing_data)
239
- slot_mapping_c = _copy_if_readonly(slot_mapping)
240
- if routing_data_c is not routing_data or slot_mapping_c is not slot_mapping:
241
- output.routed_experts = type(output.routed_experts)(
242
- routing_data_c, slot_mapping_c
243
- )
236
+ aux_output = output.aux_output_connector_output
237
+ if aux_output is not None:
238
+ for request_output in aux_output.values():
239
+ request_output.rows = _copy_if_readonly(request_output.rows)
240
241
242
class FutureWrapper(Future):
vllm/v1/outputs.py
+3
-77
@@ -15,6 +15,7 @@ from vllm.utils.torch_utils import PIN_MEMORY
15
from vllm.v1.core.sched.output import SchedulerOutput
16
17
if TYPE_CHECKING:
18
+ from vllm.distributed.aux_output_connector.connector import AuxRequestOutput
19
from vllm.distributed.ec_transfer.ec_connector.base import ECConnectorWorkerMetadata
20
from vllm.distributed.ec_transfer.ec_connector.metrics import ECConnectorStats
21
from vllm.distributed.kv_events import KVConnectorKVEvents
@@ -23,6 +24,7 @@ if TYPE_CHECKING:
24
)
25
from vllm.distributed.kv_transfer.kv_connector.v1.metrics import KVConnectorStats
26
else:
27
+ AuxRequestOutput = object
28
KVConnectorStats = object
29
KVConnectorWorkerMetadata = object
30
KVConnectorKVEvents = object
@@ -188,73 +190,6 @@ class LogprobsTensors(NamedTuple):
190
)
191
192
191
-class RoutedExpertsTensors(NamedTuple):
192
- """Device-side snapshot of routed experts data, pending async D2H.
193
-
194
- Produced by :class:`GPUModelRunner` at the end of each async-scheduled
195
- step. The copy stream waits on the default stream, then issues
196
- non-blocking D2H via :meth:`to_cpu_nonblocking` into a pinned CPU
197
- buffer; :class:`AsyncGPUModelRunnerOutput.get_output` synchronizes
198
- the copy before the scheduler reads it.
199
-
200
- Sliced to ``total_num_scheduled_tokens`` (step-level, across all
201
- requests — NOT per-request). Both ``routing_data`` and
202
- ``slot_mapping`` must be private clones when sourced from shared
203
- capturer / prepare-input buffers, so the next forward pass /
204
- ``_prepare_inputs`` on the default stream does not race with a
205
- D2H still pending on the copy stream.
206
- """
207
-
208
- # (num_scheduled_tokens, num_layers, num_experts_per_tok)
209
- routing_data: torch.Tensor
210
- # (num_scheduled_tokens,)
211
- slot_mapping: torch.Tensor
212
-
213
- def to_cpu_nonblocking(self) -> "RoutedExpertsTensors":
214
- """Issue non-blocking D2H on the current stream.
215
-
216
- NOTE: ``non_blocking=True`` only delivers true overlap when the
217
- CPU target is pinned. The current fallback here allocates a
218
- new pageable CPU tensor per call, which silently degrades to a
219
- synchronous copy; acceptable because the sync happens on the
220
- dedicated copy stream, not the default stream.
221
- """
222
- if self.routing_data.device.type == "cpu":
223
- return self
224
- return RoutedExpertsTensors(
225
- self.routing_data.to("cpu", non_blocking=True),
226
- self.slot_mapping.to("cpu", non_blocking=True),
227
- )
228
-
229
- def tolists(self) -> "RoutedExpertsLists":
230
- """Convert to the numpy-backed form consumed by the scheduler.
231
-
232
- ``.cpu()`` is a no-op when the tensor is already on CPU, so this
233
- is cheap for the post-D2H case; for raw device tensors it will
234
- synchronously block, which is only reached in tests.
235
- """
236
- return RoutedExpertsLists(
237
- self.routing_data.cpu().numpy(),
238
- self.slot_mapping.cpu().numpy(),
239
- )
240
-
241
-
242
-class RoutedExpertsLists(NamedTuple):
243
- """CPU-side routed experts, the form :meth:`RoutedExpertsManager.store_batch`
244
- consumes.
245
-
246
- Batched per scheduler step: the leading dim is the number of tokens
247
- scheduled across all requests in this step (``total_num_scheduled_tokens``),
248
- not per-request tokens. ``slot_mapping[i]`` tells the scheduler which
249
- physical KV-cache slot row ``i`` of ``routing_data`` belongs to.
250
- """
251
-
252
- # (num_scheduled_tokens, num_layers, num_experts_per_tok)
253
- routing_data: np.ndarray
254
- # (num_scheduled_tokens,)
255
- slot_mapping: np.ndarray
256
-
257
-
193
# [num_reqs, <dynamic>]
194
# The shape of each element depends on the pooler used
195
PoolerOutput: TypeAlias = torch.Tensor | list[torch.Tensor] | list[torch.Tensor | None]
@@ -361,16 +296,7 @@ class ModelRunnerOutput:
296
# information related to cudagraph execution
297
cudagraph_stats: CUDAGraphStat | None = None
298
364
- # Per-step routed experts data captured by the worker.
365
- # ``routing_data`` shape: (num_scheduled_tokens, num_layers,
366
- # num_experts_per_tok); expert IDs as uint8/uint16.
367
- # ``slot_mapping`` shape: (num_scheduled_tokens,); physical KV-cache
368
- # slot for each row of routing_data.
369
- # ``num_scheduled_tokens`` is step-level (total across all requests
370
- # in this step), not per-request. The scheduler persists this into
371
- # its slot buffer via ``slot_buffer[slot_mapping] = routing_data``.
372
- # ``None`` when ``enable_return_routed_experts`` is off.
373
- routed_experts: RoutedExpertsLists | None = None
299
+ aux_output_connector_output: dict[str, AuxRequestOutput] | None = None
300
301
# ``None`` when ``return_sampling_mask`` is off.
302
sampling_masks: SamplingMaskLists | None = None
vllm/v1/worker/gpu/async_utils.py
+13
-9
@@ -15,12 +15,12 @@ from vllm.v1.outputs import (
15
LogprobsTensors,
16
ModelRunnerOutput,
17
PoolerOutput,
18
- RoutedExpertsTensors,
18
)
19
from vllm.v1.worker.gpu.sample.output import SamplerOutput, SamplingMaskTensors
20
from vllm.v1.worker.utils import raise_if_nan_logits
21
22
if TYPE_CHECKING:
23
+ from vllm.distributed.aux_output_connector.worker import PendingAuxOutput
24
from vllm.v1.worker.gpu.input_batch import InputBatch
25
26
@@ -120,8 +120,8 @@ class AsyncOutput(AsyncModelRunnerOutput):
120
num_sampled_tokens: torch.Tensor,
121
main_stream: torch.cuda.Stream,
122
copy_stream: torch.cuda.Stream,
123
- check_ep_fault: bool = False,
124
- routed_experts: RoutedExpertsTensors | None = None,
123
+ check_ep_fault: bool,
124
+ pending_aux_output: "PendingAuxOutput | None",
125
):
126
# NOTE(woosuk): We must retain references to the GPU tensors,
127
# as the copy operations are performed on a different CUDA stream than
@@ -129,7 +129,7 @@ class AsyncOutput(AsyncModelRunnerOutput):
129
self.model_runner_output = model_runner_output
130
self.sampler_output = sampler_output
131
self.num_sampled_tokens = num_sampled_tokens
132
- self.routed_experts = routed_experts
132
+ self.pending_aux_output = pending_aux_output
133
# Blocking (sleep) event to avoid busy-polling the CUDA driver lock.
134
self.copy_event = torch.cuda.Event(blocking=True)
135
self._has_fault: torch.Tensor | None = None
@@ -152,13 +152,15 @@ class AsyncOutput(AsyncModelRunnerOutput):
152
self.sampling_mask_tensors = (
153
sampler_output.sampling_mask_tensors.to_cpu_nonblocking()
154
)
155
- self.routed_experts_cpu: RoutedExpertsTensors | None = None
156
- if routed_experts is not None:
157
- self.routed_experts_cpu = routed_experts.to_cpu_nonblocking()
155
self.prompt_logprobs_dict = {
156
k: v.to_cpu_nonblocking() if v is not None else None
157
for k, v in self.model_runner_output.prompt_logprobs_dict.items()
158
}
159
+ if self.pending_aux_output is not None:
160
+ self.pending_aux_output.enqueue_cpu_copy(
161
+ num_sampled=self.num_sampled_tokens_np,
162
+ num_rejected=async_copy_to_np(sampler_output.num_rejected),
163
+ )
164
if check_ep_fault:
165
has_fault = get_ep_all2all_manager().query_fault()
166
self._has_fault = has_fault.to("cpu", non_blocking=True)
@@ -192,8 +194,10 @@ class AsyncOutput(AsyncModelRunnerOutput):
194
if self.logprobs_tensors is not None:
195
self.model_runner_output.logprobs = self.logprobs_tensors.tolists()
196
self.model_runner_output.prompt_logprobs_dict = self.prompt_logprobs_dict
195
- if self.routed_experts_cpu is not None:
196
- self.model_runner_output.routed_experts = self.routed_experts_cpu.tolists()
197
+ if self.pending_aux_output is not None:
198
+ self.model_runner_output.aux_output_connector_output = (
199
+ self.pending_aux_output.process_output()
200
+ )
201
202
if self._has_fault is not None and self._has_fault.item():
203
mask = get_ep_all2all_manager().query_active_mask()
vllm/v1/worker/gpu/model_runner.py
+24
-24
@@ -32,6 +32,10 @@ from vllm.compilation.counter import compilation_counter
32
from vllm.compilation.cuda_graph import CUDAGraphStat
33
from vllm.config import VllmConfig
34
from vllm.config.compilation import CUDAGraphMode
35
+from vllm.distributed.aux_output_connector.worker import (
36
+ AuxOutputWorkerConnector,
37
+ get_aux_output_connector,
38
+)
39
from vllm.distributed.parallel_state import (
40
get_dcp_group,
41
get_pp_group,
@@ -39,10 +43,6 @@ from vllm.distributed.parallel_state import (
43
from vllm.forward_context import BatchDescriptor, set_forward_context
44
from vllm.logger import init_logger
45
from vllm.model_executor.layers.fused_moe.all2all_utils import get_ep_all2all_manager
42
-from vllm.model_executor.layers.fused_moe.routed_experts_capturer import (
43
- RoutedExpertsCapturer,
44
- bind_routed_experts_capturer,
45
-)
46
from vllm.model_executor.layers.mamba.ops.ssu_dispatch import (
47
initialize_mamba_ssu_backend,
48
)
@@ -73,7 +73,6 @@ from vllm.v1.outputs import (
73
DraftTokenIds,
74
ECConnectorOutput,
75
ModelRunnerOutput,
76
- RoutedExpertsTensors,
76
)
77
from vllm.v1.watermarking import create_watermarker
78
from vllm.v1.watermarking.gpu_sampler import GPUWatermarkSampler
@@ -348,7 +347,8 @@ class GPUModelRunner(LoRAModelRunnerMixin):
347
348
# Expert parallelism load balancer.
349
self.eplb = EPLBController(self.parallel_config, self.device)
351
- self.routed_experts_capturer: RoutedExpertsCapturer | None = None
350
+ # The AuxOutput Connector owns R3 capture, copying, and storage.
351
+ self.aux_output_connector: AuxOutputWorkerConnector | None = None
352
353
set_offloader(create_offloader(self.vllm_config.offload_config))
354
@@ -356,15 +356,6 @@ class GPUModelRunner(LoRAModelRunnerMixin):
356
self.max_model_len = max_model_len
357
self.req_states.max_model_len = max_model_len
358
359
- def init_routed_experts_capturer(self) -> None:
360
- """Initialize target-model capture on every participating worker."""
361
- self.routed_experts_capturer = RoutedExpertsCapturer(
362
- max_num_batched_tokens=self.max_num_tokens,
363
- vllm_config=self.vllm_config,
364
- kv_cache_config=self.kv_cache_config,
365
- )
366
- bind_routed_experts_capturer(self.model, self.routed_experts_capturer)
367
-
359
def get_supported_tasks(self) -> tuple[SupportedTask, ...]:
360
tasks: list[SupportedTask] = []
361
if self.model_config.runner_type == "generate":
@@ -756,6 +747,12 @@ class GPUModelRunner(LoRAModelRunnerMixin):
747
else:
748
self.kv_connector = get_kv_connector(self.vllm_config, kv_caches_dict)
749
750
+ # AuxOutput connector requires resolved kv_cache_config.
751
+ if self.vllm_config.aux_output_config.enabled:
752
+ self.aux_output_connector = get_aux_output_connector(
753
+ self.model, self.vllm_config, kv_cache_config
754
+ )
755
+
756
def _init_kv_zero_meta(self) -> None:
757
"""Build KV-block zeroing metadata; invoked from gpu_worker."""
758
self.kv_block_zeroer = KVBlockZeroer(
@@ -1648,6 +1645,11 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1645
self.add_requests(scheduler_output)
1646
self.update_requests(scheduler_output)
1647
self.block_tables.apply_staged_writes()
1648
+ if self.aux_output_connector is not None:
1649
+ # Register this step before the GPU forward.
1650
+ self.aux_output_connector.begin_step(
1651
+ scheduler_output.aux_output_connector_metadata
1652
+ )
1653
if scheduler_output.total_num_scheduled_tokens == 0:
1654
# No need to run the model.
1655
empty_output = self.kv_connector.no_forward(scheduler_output)
@@ -1969,11 +1971,6 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1971
aux_hidden_states = None
1972
output_intermediate_tensors = model_output
1973
1972
- routed_experts = None
1973
- if not dummy_run and (capturer := self.routed_experts_capturer) is not None:
1974
- assert slot_mappings is not None
1975
- routed_experts = capturer.get_routed_experts(slot_mappings, num_toks)
1976
-
1974
finished_req_ids = scheduler_output.finished_req_ids
1975
self.execute_model_state = ExecuteModelState(
1976
input_batch=input_batch,
@@ -1984,7 +1981,6 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1981
dp_sync=dp_sync,
1982
finished_req_ids=finished_req_ids,
1983
ec_connector_output=ec_connector_output,
1987
- routed_experts=routed_experts,
1984
cudagraph_stats=cudagraph_stats,
1985
)
1986
@@ -2014,7 +2010,6 @@ class GPUModelRunner(LoRAModelRunnerMixin):
2010
dp_sync = self.execute_model_state.dp_sync
2011
finished_req_ids = self.execute_model_state.finished_req_ids
2012
ec_connector_output = self.execute_model_state.ec_connector_output
2017
- routed_experts = self.execute_model_state.routed_experts
2013
cudagraph_stats = self.execute_model_state.cudagraph_stats
2014
self.execute_model_state = None
2015
@@ -2083,6 +2078,10 @@ class GPUModelRunner(LoRAModelRunnerMixin):
2078
prompt_logprobs_dict=prompt_logprobs_dict, # type: ignore[arg-type]
2079
cudagraph_stats=cudagraph_stats,
2080
)
2081
+ pending_aux_output = None
2082
+ if self.aux_output_connector is not None:
2083
+ pending_aux_output = self.aux_output_connector.prepare_output(input_batch)
2084
+
2085
# Start async output copy here so that it can overlap with speculator proposal.
2086
async_output = AsyncOutput(
2087
model_runner_output=model_runner_output,
@@ -2091,7 +2090,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
2090
main_stream=self.main_stream,
2091
copy_stream=self.output_copy_stream,
2092
check_ep_fault=self.check_ep_fault,
2094
- routed_experts=routed_experts,
2093
+ pending_aux_output=pending_aux_output,
2094
)
2095
2096
mm_inputs: tuple[list[torch.Tensor], torch.Tensor] | None = None
@@ -2244,6 +2243,8 @@ class GPUModelRunner(LoRAModelRunnerMixin):
2243
"""Release GPU tensors (model weights, KV caches, workspace) so that
2244
memory is reclaimable when running in the same process."""
2245
torch.accelerator.synchronize()
2246
+ if self.aux_output_connector is not None:
2247
+ self.aux_output_connector.close()
2248
self.cudagraph_manager = None
2249
self.fast_prefill = None
2250
if hasattr(self, "kv_caches"):
@@ -2316,7 +2317,6 @@ class ExecuteModelState(NamedTuple):
2317
dp_sync: DPSyncState | None
2318
finished_req_ids: set[str]
2319
ec_connector_output: ECConnectorOutput | None
2319
- routed_experts: RoutedExpertsTensors | None
2320
cudagraph_stats: CUDAGraphStat | None
2321
2322
vllm/v1/worker/gpu/sample/batch_shard.py
-2
@@ -617,8 +617,6 @@ def gather_sampler_output(
617
device=device,
618
)
619
if local_output is not None:
620
- assert local_output.num_sampled is not None
621
- assert local_output.num_rejected is not None
620
assert not gather_num_nans or local_output.num_nans is not None
621
num_src_cols = min(
622
local_output.sampled_token_ids.shape[1], max_num_logits_per_req
vllm/v1/worker/gpu/sample/output.py
+2
-2
@@ -17,8 +17,8 @@ class SamplerOutput:
17
sampled_token_ids: torch.Tensor
18
logprobs_tensors: LogprobsTensors | None
19
num_nans: torch.Tensor | None
20
- num_sampled: torch.Tensor | None
21
- num_rejected: torch.Tensor | None = None
20
+ num_sampled: torch.Tensor
21
+ num_rejected: torch.Tensor
22
sampling_mask_tensors: SamplingMaskTensors | None = None
23
24
vllm/v1/worker/gpu_model_runner.py
-127
@@ -61,10 +61,6 @@ from vllm.lora.layers import BaseLayerWithLoRA, LoRAMapping, LoRAMappingType
61
from vllm.model_executor.layers.attention import Attention
62
from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
63
from vllm.model_executor.layers.fused_moe.all2all_utils import get_ep_all2all_manager
64
-from vllm.model_executor.layers.fused_moe.routed_experts_capturer import (
65
- RoutedExpertsCapturer,
66
- bind_routed_experts_capturer,
67
-)
64
from vllm.model_executor.layers.mamba.mamba_utils import MambaStateCopyFuncsByType
65
from vllm.model_executor.layers.mamba.ops.ssu_dispatch import (
66
initialize_mamba_ssu_backend,
@@ -176,8 +172,6 @@ from vllm.v1.outputs import (
172
LogprobsTensors,
173
ModelRunnerOutput,
174
PoolerOutput,
179
- RoutedExpertsLists,
180
- RoutedExpertsTensors,
175
SamplerOutput,
176
make_empty_encoder_model_runner_output,
177
)
@@ -293,7 +287,6 @@ class AsyncGPUModelRunnerOutput(AsyncModelRunnerOutput):
287
invalid_req_indices: list[int],
288
async_output_copy_stream: torch.cuda.Stream,
289
vocab_size: int,
296
- routed_experts: RoutedExpertsTensors | None = None,
290
check_ep_fault: bool = False,
291
num_nans: torch.Tensor | None = None,
292
):
@@ -309,7 +302,6 @@ class AsyncGPUModelRunnerOutput(AsyncModelRunnerOutput):
302
self._sampled_token_ids = sampled_token_ids
303
self.vocab_size = vocab_size
304
self._logprobs_tensors = logprobs_tensors
312
- self._routed_experts = routed_experts
305
self._num_nans = num_nans
306
self._has_fault: torch.Tensor | None = None
307
@@ -325,11 +317,6 @@ class AsyncGPUModelRunnerOutput(AsyncModelRunnerOutput):
317
if self._logprobs_tensors
318
else None
319
)
328
- self._routed_experts_cpu = (
329
- self._routed_experts.to_cpu_nonblocking()
330
- if self._routed_experts is not None
331
- else None
332
- )
320
self._num_nans_cpu = (
321
self._num_nans.to("cpu", non_blocking=True)
322
if self._num_nans is not None
@@ -370,10 +357,6 @@ class AsyncGPUModelRunnerOutput(AsyncModelRunnerOutput):
357
output.sampled_token_ids = valid_sampled_token_ids
358
output.logprobs = logprobs_lists
359
373
- if self._routed_experts_cpu is not None:
374
- output.routed_experts = self._routed_experts_cpu.tolists()
375
- del self._routed_experts
376
-
360
if self._num_nans_cpu is not None:
361
output.num_nans_in_logits = nans_to_dict(
362
self._num_nans_cpu.tolist(), output.req_id_to_index
@@ -537,9 +520,6 @@ class GPUModelRunner(
520
# These will be overridden in load_model()
521
self.is_multimodal_pruning_enabled = False
522
self.requires_sequential_video_encoding = False
540
- # Set to True after init_routed_experts_capturer() completes.
541
- # Prevents routed experts code from running during profiling/dummy run.
542
- self.routed_experts_initialized = False
523
self.max_model_len = model_config.max_model_len
524
525
# Always set to false after the first forward pass
@@ -2360,18 +2340,6 @@ class GPUModelRunner(
2340
block_table_gid_0 = _get_block_table(0)
2341
slot_mapping_gid_0 = slot_mappings[0]
2342
2363
- if self.routed_experts_initialized:
2364
- # Copy this step's attention slot_mapping into our private
2365
- # device buffer. The shared ``slot_mappings[attn_gid]`` is
2366
- # owned by the attention block table and will be overwritten
2367
- # by the next ``_prepare_inputs``; we need a stable snapshot
2368
- # because the async D2H may still be in flight on the copy
2369
- # stream when the next step runs.
2370
- slot_mapping_attn = slot_mappings[self.routed_experts_capturer.attn_gid]
2371
- self.routed_experts_slot_mapping_device[:num_tokens].copy_(
2372
- slot_mapping_attn[:num_tokens]
2373
- )
2374
-
2343
num_computed_tokens_cpu = self.input_batch.num_computed_tokens_cpu_tensor[
2344
:num_reqs_padded
2345
]
@@ -3745,20 +3713,6 @@ class GPUModelRunner(
3713
invalid_req_indices = []
3714
logprobs_lists = None
3715
if not self.use_async_scheduling:
3748
- # Sync scheduling: issue routed experts D2H into the pinned
3749
- # CPU buffer BEFORE ``_to_list`` below. ``_to_list`` does
3750
- # ``event.synchronize()`` on the async copy stream which
3751
- # waits for every D2H queued on the default stream since
3752
- # the last sync, so this enqueue is naturally covered
3753
- # without requiring its own synchronize.
3754
- if self.routed_experts_initialized:
3755
- buf = self.routed_experts_capturer.get_device_buffer()
3756
- total = scheduler_output.total_num_scheduled_tokens
3757
- self.routed_experts_cpu[:total].copy_(buf[:total], non_blocking=True)
3758
- self.routed_experts_slot_mapping_cpu[:total].copy_(
3759
- self.routed_experts_slot_mapping_device[:total],
3760
- non_blocking=True,
3761
- )
3716
with gpu_sync_allowed():
3717
# Get the valid generated tokens.
3718
max_gen_len = sampled_token_ids.shape[-1]
@@ -4802,39 +4756,14 @@ class GPUModelRunner(
4756
else None,
4757
num_nans_in_logits=num_nans_in_logits,
4758
cudagraph_stats=cudagraph_stats,
4805
- routed_experts=None,
4759
)
4760
4761
if not self.use_async_scheduling:
4809
- if self.routed_experts_initialized:
4810
- # Sync path: D2H was issued in ``_bookkeeping_sync`` and
4811
- # synchronized by ``_to_list``'s event.synchronize(), so
4812
- # the pinned buffers are ready to be wrapped as numpy.
4813
- total = scheduler_output.total_num_scheduled_tokens
4814
- output.routed_experts = RoutedExpertsLists(
4815
- routing_data=self.routed_experts_cpu[:total].numpy(),
4816
- slot_mapping=self.routed_experts_slot_mapping_cpu[:total].numpy(),
4817
- )
4762
return output
4763
4764
with record_function_or_nullcontext(
4765
"gpu_model_runner: AsyncGPUModelRunnerOutput"
4766
):
4823
- # Async path: produce a device-side snapshot that the async
4824
- # copy stream can D2H later. Both tensors must be private
4825
- # clones because:
4826
- # - ``routing_data`` source is the shared capturer buffer,
4827
- # which the next forward overwrites on the default stream.
4828
- # - ``slot_mapping`` source is our own
4829
- # ``routed_experts_slot_mapping_device``, which the
4830
- # next ``_prepare_inputs`` overwrites on the default
4831
- # stream while the D2H is still pending on the copy
4832
- # stream.
4833
- # Without clones, the copy stream would read torn data.
4834
- routed_experts_snapshot = self.get_routed_experts(
4835
- scheduler_output.total_num_scheduled_tokens
4836
- )
4837
-
4767
async_output = AsyncGPUModelRunnerOutput(
4768
model_runner_output=output,
4769
sampled_token_ids=sampler_output.sampled_token_ids,
@@ -4842,7 +4771,6 @@ class GPUModelRunner(
4771
invalid_req_indices=invalid_req_indices,
4772
async_output_copy_stream=self._get_or_create_async_output_copy_stream(),
4773
vocab_size=self.input_batch.vocab_size,
4845
- routed_experts=routed_experts_snapshot,
4774
check_ep_fault=self.check_ep_fault,
4775
num_nans=num_nans_device,
4776
)
@@ -7468,61 +7396,6 @@ class GPUModelRunner(
7396
kv_transfer_group.register_kv_caches(kv_caches)
7397
kv_transfer_group.set_host_xfer_buffer_ops(copy_kv_blocks)
7398
7471
- def get_routed_experts(
7472
- self,
7473
- num_tokens: int,
7474
- ) -> RoutedExpertsTensors | None:
7475
- if not self.routed_experts_initialized:
7476
- return None
7477
-
7478
- device_buffer = self.routed_experts_capturer.get_device_buffer()
7479
- return RoutedExpertsTensors(
7480
- routing_data=device_buffer[:num_tokens].clone(),
7481
- slot_mapping=self.routed_experts_slot_mapping_device[:num_tokens].clone(),
7482
- )
7483
-
7484
- def init_routed_experts_capturer(self):
7485
- logger.info(
7486
- "Initializing routed experts capturer, enable_return_routed_experts: %s",
7487
- self.model_config.enable_return_routed_experts,
7488
- )
7489
- self.routed_experts_capturer = RoutedExpertsCapturer(
7490
- max_num_batched_tokens=self.scheduler_config.max_num_batched_tokens,
7491
- vllm_config=self.vllm_config,
7492
- kv_cache_config=self.kv_cache_config,
7493
- )
7494
- bind_routed_experts_capturer(self.model, self.routed_experts_capturer)
7495
-
7496
- # Pinned CPU buffer for non-blocking D2H of ``routing_data`` on
7497
- # the sync scheduling path. Shape / dtype mirror the device
7498
- # capturer exactly so ``copy_`` is a straight memcpy.
7499
- self.routed_experts_cpu = torch.empty(
7500
- self.routed_experts_capturer.device_buffer.shape,
7501
- dtype=self.routed_experts_capturer.device_buffer.dtype,
7502
- device="cpu",
7503
- pin_memory=PIN_MEMORY,
7504
- )
7505
- # ``slot_mapping`` dtype is fixed to int64 by
7506
- # ``block_table.slot_mapping``; we mirror that here.
7507
- max_tokens = self.scheduler_config.max_num_batched_tokens
7508
- self.routed_experts_slot_mapping_cpu = torch.empty(
7509
- (max_tokens,),
7510
- dtype=torch.int64,
7511
- device="cpu",
7512
- pin_memory=PIN_MEMORY,
7513
- )
7514
- # Private device buffer so the shared ``block_table.slot_mapping``
7515
- # can be overwritten by the next ``_prepare_inputs`` while the
7516
- # D2H is still pending on the copy stream. Written in
7517
- # ``_prepare_inputs``, read in ``_bookkeeping_sync`` (sync path)
7518
- # or cloned into a snapshot (async path).
7519
- self.routed_experts_slot_mapping_device = torch.empty(
7520
- (max_tokens,),
7521
- dtype=torch.int64,
7522
- device=self.device,
7523
- )
7524
- self.routed_experts_initialized = True
7525
-
7399
def may_add_encoder_only_layers_to_kv_cache_config(self) -> None:
7400
"""Add encoder-only layers to the KV cache config."""
7401
block_size = self.vllm_config.cache_config.block_size
vllm/v1/worker/gpu_worker.py
-3
@@ -761,9 +761,6 @@ class Worker(WorkerBase):
761
),
762
)
763
764
- if self.model_config.enable_return_routed_experts:
765
- self.model_runner.init_routed_experts_capturer()
766
-
764
# Build KV-zero metadata outside the CuMem pool so the bookkeeping
765
# GPU tensors (seg_addrs, block-id buffers) use the standard PyTorch
766
# allocator and are not discarded during sleep/wake cycles.