Skip to content

fix(inference): cut router replay's engine-side cost on vLLM 0.32 - #3920

Merged
mikasenghaas merged 14 commits into
mainfrom
fix/routed-experts-engine-cost
Oct 21, 2026
Merged

mikasenghaas merged 14 commits into
mainfrom
fix/routed-experts-engine-cost

Conversation

@faresobeid

@faresobeid faresobeid commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Router replay (enable_return_routed_experts) costs a lot of generation throughput on vLLM 0.32. vLLM's AuxOutput store and worker code are unchanged since 0.31, where the numbers below were measured. In the v031 defaults experiment (GLM-4.5-Air TP8, about 400 running requests), arm C vs arm B was −25% generation. Most of the cost is CPU work in vLLM's AuxOutput path on the output worker (TP0) and in EngineCore, not GPU work. This PR adds three vLLM patches in patches.py. Routing rows are unchanged.

Where the time goes

GLM-4.5-Air TP8 on H200, one node, /inference/v1/generate, closed loop with 400 concurrent requests, max 16k tokens, sampling masks on. Diagnostics: per-function timers, per-thread CPU, GC callbacks, and stack samples in worker TP0 and EngineCore.

  1. Store eviction on the output worker (largest cost). vLLM sizes BlockObjectStore to the KV cache, so under load it is full and every put evicts. _evict_to_fit scans the LRU from the front and skips keys referenced by running requests. Those keys pile up at the front, so each put rescans ~300k keys: 9 ms per put, 46 puts/s, on the writer thread. It holds the GIL against the thread that launches the next step, and execute_model goes from 2.7 to 13.8 ms per call.
  2. One array per request per step. The output worker returns dict[request_id, AuxRequestOutput]. Each step pickles one small array per running request to EngineCore, each as its own out-of-band buffer. EngineCore rebuilds them one object at a time. The cost grows with requests per step, and the churn drives GC.
  3. Engine GC. vLLM freezes the startup heap of EngineCore and of the workers but keeps the default thresholds.

Fix

  • monkey_patch_aux_output_store
    • Before upstream _evict_to_fit runs on a full store, it moves the referenced keys at the front of the LRU to the end. A referenced key cannot be evicted, and release moves it to the end anyway, so the victims are identical to upstream.
    • The output worker builds the store at 2× vLLM's size. At 100% KV the referenced rows alone can exceed a store of exactly KV size. put then raises BlockObjectStoreError in the writer thread and the engine dies. The condition does not depend on LRU order, so upstream raises on the same sequence. Before this PR the engine was too slow to fill the KV cache in this harness. With only the eviction fix, it reached 100% KV and died.
  • monkey_patch_packed_aux_outputs + PackedAuxOutputs: the worker sends one object per step (id→index dict, token starts, offsets, one rows array). It pickles as three byte strings and answers the scheduler's in / [] lookups.
  • monkey_patch_engine_gc: EngineCore keeps vLLM's freeze and gets set_threshold(50_000, 20, 100). The workers get gc.collect() + gc.freeze() + the same thresholds after compile_or_warm_up_model.

Results (gen tok/s, steady window 240–900 s)

Earlier runs (node A, 400 concurrent, 16k):

arm gen tok/s tok/s per request KV usage
router replay off, main 9,356 23.5 0.72
router replay on, main 5,724 (−39%) 14.4 0.85
on, GC + packing (no store fix) 8,250 20.9 0.76
on, store eviction only (1× size) crashed at 100% KV
on, all three (1× size) 9,412 23.8 0.72
off, all three 9,596 24.1 0.72

Ablation of the branch as pushed (node B, same node for every row):

arm load gen tok/s tok/s per request running KV mean / peak
all three 400 conc, 16k 9,679 24.6 393 0.71 / 0.98
store + GC (no packing) 400 conc, 16k 9,493 (−1.9%) 24.3 390 0.72
store only 400 conc, 16k 8,309 (−14%) 21.2 392 0.73 / 1.00, no error
all three 1000 conc, 2k, max_num_seqs=1024 9,328 9.6 970 0.25
store + GC (no packing) 1000 conc, 2k, max_num_seqs=1024 9,078 (−2.7%) 9.4 971 0.25
  • Store only: EngineCore gen-2 GC takes 10.7% of wall time. With GC tuning, EngineCore and the workers run 0 collections in the window.

  • Packing: worker TP0 MessageQueue.enqueue takes 0.32 vs 0.94 ms per call at 400 running, and 0.46 vs 2.85 ms at 970 running (2 calls per step). EngineCore's unpickle is hidden inside dequeue, so the bench below gives its size.

  • Scaling of packing (CPU bench of the per-step path; pickle + unpickle per step, ms):

    requests per step 400 1000 2000
    per-request dict 6.1 18.7 31.7
    packed 0.13 0.37 0.8

    The cost is the same for 46 or 78 MoE layers × top-8: it is per request, not per byte. Both halves sit on the step's critical path: the worker main thread, then the EngineCore loop. So packing matters more as engines run more requests per step, which large sparse MoE engines with DP/EP do. At 1000 requests it is already ~2.7% end to end on this model.

  • Store headroom cost: host RAM per engine ≤ 2 × KV tokens × MoE layers × top-k × itemsize, on the output worker only. The arena is an anonymous mmap, so only used slots cost RSS.

    • Here: 4.37M tokens × 46 × 8 × 1 B = 1.6 GB, so up to 3.2 GB.
    • DeepSeek-V3-class model (58 × 8, uint8), 4M KV tokens per engine: 1.9 GB → 3.7 GB.
    • 78 × 8 at 8M tokens: 5 GB → 10 GB per engine.
    • With more than 256 experts the rows are uint16, which doubles these numbers.

Checks

  • Routing rows. 24 sequential top_k=1 requests (12 prompts × 300/1000 tokens) on node B: token ids and routed_experts bytes are bitwise identical between main and this branch (24/24). Earlier, router replay off gave identical tokens (24/24).
  • Store eviction. Compared with upstream on random op sequences (300 seeds × 300 puts): same stored keys, same order of unreferenced keys (the only order that affects eviction), same errors. Bench with 450k slots and 320k live keys: 27.7 → 0.40 ms per step.
  • Unit tests. tests/unit/orchestrator, tests/unit/test_configs.py, tests/unit/inference with -m "not gpu": 381 passed (vLLM 0.32 venv). ruff check and ruff format --check are clean.

Notes


Note

Medium Risk
Monkey-patches vLLM engine, worker, and API-server internals and changes generate response shapes for logprobs and sampling masks; mitigated by unit tests and documented parity checks for routing rows and eviction.

Overview
Cuts CPU/GC cost on the router-replay path and on /inference/v1/generate under heavy RL concurrency by avoiding per-token Python objects and tuning garbage collection.

Engine / aux-output path: Registers four vLLM monkey patches in patches.py. Routed experts per scheduler step are sent as a single PackedAuxOutputs blob (replacing per-request dict pickling). The aux-output BlockObjectStore gets a 2× capacity headroom and an eviction tweak so referenced LRU keys are moved to the end before upstream eviction (avoids multi-ms rescans at full KV). EngineCore and GPU workers freeze the post-warmup heap and use higher gc thresholds; the API server does the same after startup.

Generate handler: PrimeRlServingTokens strips heavy fields early via _PackedOutputs, then returns base64-packed numpy for routed_experts, completion_logprobs, and CSR sampling_mask ({ids, counts}). Non-streaming requests use flat logprobs, and detokenize is disabled when there are no stop strings. A separate patch keeps FINAL_ONLY sampling masks as PackedSamplingMask in the output processor.

Tests / deps: Adds PackedAuxOutputs pickle and store-eviction parity tests; updates serving-token unit tests. Bumps renderers and drops pytest-cov from the lockfile.

Reviewed by Cursor Bugbot for commit e1afb9c. Bugbot is set up for automated code reviews on this repo. Configure here.

@faresobeid
faresobeid marked this pull request as ready for review October 7, 2026 18:32
@faresobeid
faresobeid force-pushed the fix/routed-experts-engine-cost branch 2 times, most recently from b6320ec to 6e4a413 Compare October 10, 2026 15:11
…r GC

Return completion logprobs and sampling masks as base64 numpy arrays (like
routed_experts), keep masks as numpy in the output processor, request flat
logprobs and skip detokenization on /generate, and gc.freeze() plus higher GC
thresholds in the API server. Bumps renderers/verifiers to the companion
branches that parse the packed fields.
Streaming (DELTA) and cumulative outputs keep vLLM's chunk list and SamplingMask
form; only FINAL_ONLY requests, which PrimeRlServingTokens reads, get the packed
CSR arrays. Document that each mask chunk is one position.
@faresobeid
faresobeid force-pushed the fix/routed-experts-engine-cost branch from 6e4a413 to 6697bc1 Compare October 10, 2026 15:21

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/prime_rl/inference/patches.py
Comment thread src/prime_rl/inference/patches.py
Comment thread src/prime_rl/inference/vllm/serving_tokens.py

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c245572. Configure here.

gc.collect()
gc.freeze()
gc.set_threshold(*ENGINE_GC_THRESHOLDS)
return result

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worker GC freeze on every warmup

Medium Severity

compile_or_warm_up_model always runs gc.freeze() after the original warmup. A second call permanently pins every object allocated since the last freeze, including request-scoped leftovers, so worker RSS can grow without bound across sleep/wake, CUDA-graph recapture, or a later recompile.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c245572. Configure here.

…orwarded token

start = prompt + completion - 1 - rows. Equals routed_experts_prompt_start on a
colocated instance; on a P/D decode instance the rows begin at its first forward.
samsja pushed a commit that referenced this pull request Oct 14, 2026
Router replay now works with disaggregated P/D on vLLM 0.31, together
with sampling replay. It replaces main's config rejection of router
replay + P/D.

**Problem on 0.31.** vLLM 0.31 captures routed experts through the
AuxOutput connector (V2 runner only, vllm#45635) and rejects P/D
connectors (`_verify_aux_output_compatibility`). The rejection is real:
a decode (D) instance never computes a remotely prefilled prompt, so its
worker would try to materialize the prompt rows from its block store
(`BlockObjectStoreError`), and D's first forward (token `prompt_len-1`)
starts mid-block, which the routed-experts buffer rejects.

**Fix (plugin patch `monkey_patch_routed_experts_with_pd_connectors`,
our upstream AuxOutput patch 02):**
- Config: skip only the P/D connector check when the KV transfer config
has `NixlConnector` (alone or inside `MultiConnector`). Every other
AuxOutput check still runs.
- Scheduler: a request on D (`kv_transfer_params` with a
`remote_engine_id` and no `do_remote_decode`; NIXL clears
`do_remote_prefill` but keeps that) emits rows only from its first
forward on D (`num_computed_tokens` at that point: normally
`prompt_len-1`, lower after a failed KV load, where D recomputes those
rows itself). The floor survives preemption and is dropped when the
request finishes.
- Worker: the partially known first block is not buffered or published;
later blocks D computes in full are published as usual.

**Router v0.2.3 required (router#61); the decode `start` comes from
#3900 (merged).** On a decode instance the rows start at its first
forward, so the response carries only the rows D computed. #3900's
packed `/generate` anchors the rows at the end: `start = len(prompt) +
len(completion) - 1 - len(rows)` (equal to `routed_experts_prompt_start`
without P/D). This PR does not touch
`routed_experts.py`/`serving_tokens.py`, so it and #3900 (and
#3920/#3794 on it) land in either order; P/D router replay needs both.
Router v0.2.3 cuts decode rows by position (`(prefill.start +
prefill.seq_len) - decode.start`) and also handles uint16 routing
(models with more than 256 experts). This PR pins `vllm-router` to
v0.2.3 in `pyproject.toml`/`uv.lock`. Without #3900, the merge would
drop real completion rows.

Also: removes `validate_disaggregated_no_routed_experts` (both
validators on main), docs.

Stack: **this PR** → #3831 (router replay with KV offload) → #3832 (P/D
per-role KV offload) → #4023 (draft, HiSparse on decode).

## Checks run
- `pytest -m "not gpu" tests/unit/test_configs.py tests/unit/inference`
on this head: 214 passed. `ruff check` / `ruff format --check`: clean.
- New tests (`test_routed_experts_pd.py`): the scheduler floor (first
forward, preemption, cleanup) on vLLM's real
`AuxOutputSchedulerConnector`, and the worker skip on the real
`RoutedExpertsBuffer`. `test_combined_replay_uses_v2_runner` is
parametrized over single-node and P/D.
- vLLM's own `tests/distributed/aux_output_connector/test_store.py`
(v0.31.0) with this patch applied plus patch 02's two new tests: 66
passed. Without the patch the two new tests fail.

## GPU validation (2026-10-06, vLLM 0.31.0, 1x 8xH200, Qwen3-30B-A3B
TP1, `VLLM_BATCH_INVARIANT=1`, router v0.2.2 with zero-padded decode
rows)
- 1P1D (NIXL) vs colocated, router replay + sampling masks: 10/10
identical at concurrency 1 (prompts 64-6000 tokens, 128 completion
tokens) and 20/20 at concurrency 10 with a fresh `cache_salt`. Identical
means tokens, prompt and completion routing rows (100%) and sampling
masks; rows = prompt + completion - 1.
- Forced decode preemption (D with 1300 blocks, 20 requests, 1000-4000
prompts, up to 1024 tokens, concurrency 10): 6 preemptions on D, 20/20
identical to colocated.
- All engines log `Using V2 Model Runner`; no `BlockObjectStoreError`.
Re-run (job 2900, 2026-10-06, same setup, launched from per-role configs
written by #3832's `write_pd_configs`): 1P1D (NIXL, no offload) vs
colocated, router replay + masks: 10/10 identical at concurrency 1 and
20/20 at concurrency 10 with a fresh `cache_salt`; 0 errors.

Not run: injected NIXL load failure
(`kv_load_failure_policy=recompute`), MTP on decode, an `rl` run with a
P/D config.

## GPU validation with router v0.2.3 (2026-10-14)
1x 8xH200, vLLM 0.31.0, Qwen3-30B-A3B TP1, `VLLM_BATCH_INVARIANT=1`,
router replay + sampling masks. prime-rl = #3830 (978146c) merged with
#3900 (20a3225); router = `release/v0.2.3` (ff818da, this PR + version
bump), wheel built locally. 1P1D (NIXL) through the router vs a
colocated server. Identical means: same tokens, same routing rows for
prompt and completion (100%, rows = prompt + completion - 1, merged
`start` = 0), same sampling masks, same logprobs.
- Concurrency 1 (prompts 64-6000 tokens, 128 completion tokens): 10/10
identical.
- Concurrency 10, fresh `cache_salt` on the colocated side: 20/20
identical.
- uint16: no knob exists (vLLM picks uint8 for <=256 experts), so a
test-only `sitecustomize` forced vLLM's capturer to `uint16` on P, D and
the colocated server. The responses carry `dtype: uint16`. Concurrency
1: 10/10 identical; concurrency 10: 20/20 identical.
- Forced decode preemption (D with `num_gpu_blocks_override=1300`,
1000-4000 token prompts, up to 1024 tokens, concurrency 10): 6
preemptions on D, 20/20 identical.
- Router logs: no errors or warnings during the runs (only the shutdown
warning). Engines: all `Using V2 Model Runner`, no
`BlockObjectStoreError`.
- Negative control, router v0.2.2 with the same P/D pair: 8/10 requests
fail (HTTP 500 `decode routed_experts has 128 rows, expected at least
<prompt_len>`). The 2 prompts of 64 tokens return 128 rows instead of
191, and the completion rows are wrong.


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **High Risk**
> Changes expert-routing capture on NIXL P/D via vLLM monkey patches and
removes prior safety guards; training correctness depends on router
0.2.3 merge behavior and patch edge cases (preemption, failed KV load).
> 
> **Overview**
> **Router replay** and **sampling replay** are now allowed on
**disaggregated prefill/decode (P/D)** deployments, where they were
previously blocked by config validation and vLLM’s AuxOutput + NIXL
incompatibility.
> 
> Config validators that rejected `enable_return_routed_experts` with
`deployment.type = "disaggregated"` are removed from inference and RL
configs. Docs now describe P/D behavior: prompt routing from prefill,
completion routing from decode, and that sampling capture can run
alongside router replay under P/D.
> 
> A startup **vLLM monkey patch**
(`monkey_patch_routed_experts_with_pd_connectors`) bypasses only the P/D
connector check in `_verify_aux_output_compatibility`, raises an emit
floor on decode so routed-expert rows start at the instance’s first
forward, and skips buffering a partial first block after remote prefill.
**`vllm-router` is pinned to v0.2.3** so the router can merge
prefill/decode expert rows correctly.
> 
> Unit tests cover scheduler floors, worker buffering, and V2 runner
setup for both single-node and disaggregated configs.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
e586ef8. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Signed-off-by: faresobeid <111092724+faresobeid@users.noreply.github.com>
# Conflicts:
#	src/prime_rl/inference/patches.py
@faresobeid faresobeid changed the title fix(inference): cut router replay's engine-side cost on vLLM 0.31 fix(inference): cut router replay's engine-side cost on vLLM 0.32 Oct 19, 2026
@mikasenghaas
mikasenghaas merged commit 78efef1 into main Oct 21, 2026
12 of 18 checks passed
@mikasenghaas
mikasenghaas deleted the fix/routed-experts-engine-cost branch October 21, 2026 14:12
mikasenghaas pushed a commit that referenced this pull request Oct 21, 2026
)

Router replay (`enable_return_routed_experts`) costs a lot of generation
throughput on vLLM 0.32. vLLM's AuxOutput store and worker code are
unchanged since 0.31, where the numbers below were measured. In the v031
defaults experiment (GLM-4.5-Air TP8, about 400 running requests), arm C
vs arm B was −25% generation. Most of the cost is CPU work in vLLM's
AuxOutput path on the output worker (TP0) and in EngineCore, not GPU
work. This PR adds three vLLM patches in `patches.py`. Routing rows are
unchanged.

## Where the time goes
GLM-4.5-Air TP8 on H200, one node, `/inference/v1/generate`, closed loop
with 400 concurrent requests, max 16k tokens, sampling masks on.
Diagnostics: per-function timers, per-thread CPU, GC callbacks, and
stack samples in worker TP0 and EngineCore.

1. **Store eviction on the output worker** (largest cost). vLLM sizes
`BlockObjectStore` to the KV cache, so under load it is full and every
`put` evicts. `_evict_to_fit` scans the LRU from the front and skips
keys referenced by running requests. Those keys pile up at the front, so
each `put` rescans ~300k keys: 9 ms per `put`, 46 puts/s, on the writer
thread. It holds the GIL against the thread that launches the next step,
and `execute_model` goes from 2.7 to 13.8 ms per call.
2. **One array per request per step.** The output worker returns
`dict[request_id, AuxRequestOutput]`. Each step pickles one small array
per running request to EngineCore, each as its own out-of-band buffer.
EngineCore rebuilds them one object at a time. The cost grows with
requests per step, and the churn drives GC.
3. **Engine GC.** vLLM freezes the startup heap of EngineCore and of the
workers but keeps the default thresholds.

## Fix
- `monkey_patch_aux_output_store`
- Before upstream `_evict_to_fit` runs on a full store, it moves the
referenced keys at the front of the LRU to the end. A referenced key
cannot be evicted, and release moves it to the end anyway, so the
victims are identical to upstream.
- The output worker builds the store at 2× vLLM's size. At 100% KV the
referenced rows alone can exceed a store of exactly KV size. `put` then
raises `BlockObjectStoreError` in the writer thread and the engine dies.
The condition does not depend on LRU order, so upstream raises on the
same sequence. Before this PR the engine was too slow to fill the KV
cache in this harness. With only the eviction fix, it reached 100% KV
and died.
- `monkey_patch_packed_aux_outputs` + `PackedAuxOutputs`: the worker
sends one object per step (id→index dict, token starts, offsets, one
rows array). It pickles as three byte strings and answers the
scheduler's `in` / `[]` lookups.
- `monkey_patch_engine_gc`: EngineCore keeps vLLM's freeze and gets
`set_threshold(50_000, 20, 100)`. The workers get `gc.collect()` +
`gc.freeze()` + the same thresholds after `compile_or_warm_up_model`.

## Results (gen tok/s, steady window 240–900 s)
Earlier runs (node A, 400 concurrent, 16k):
| arm | gen tok/s | tok/s per request | KV usage |
|---|---|---|---|
| router replay off, main | 9,356 | 23.5 | 0.72 |
| router replay on, main | 5,724 (−39%) | 14.4 | 0.85 |
| on, GC + packing (no store fix) | 8,250 | 20.9 | 0.76 |
| on, store eviction only (1× size) | crashed at 100% KV | | |
| on, all three (1× size) | 9,412 | 23.8 | 0.72 |
| off, all three | 9,596 | 24.1 | 0.72 |

Ablation of the branch as pushed (node B, same node for every row):
| arm | load | gen tok/s | tok/s per request | running | KV mean / peak
|
|---|---|---|---|---|---|
| all three | 400 conc, 16k | **9,679** | 24.6 | 393 | 0.71 / 0.98 |
| store + GC (no packing) | 400 conc, 16k | 9,493 (−1.9%) | 24.3 | 390 |
0.72 |
| store only | 400 conc, 16k | 8,309 (−14%) | 21.2 | 392 | 0.73 / 1.00,
no error |
| all three | 1000 conc, 2k, `max_num_seqs=1024` | **9,328** | 9.6 | 970
| 0.25 |
| store + GC (no packing) | 1000 conc, 2k, `max_num_seqs=1024` | 9,078
(−2.7%) | 9.4 | 971 | 0.25 |

- **Store only**: EngineCore gen-2 GC takes 10.7% of wall time. With GC
tuning, EngineCore and the workers run 0 collections in the window.
- **Packing**: worker TP0 `MessageQueue.enqueue` takes 0.32 vs 0.94 ms
per call at 400 running, and 0.46 vs 2.85 ms at 970 running (2 calls per
step). EngineCore's unpickle is hidden inside `dequeue`, so the bench
below gives its size.
- **Scaling of packing** (CPU bench of the per-step path; pickle +
unpickle per step, ms):

  | requests per step | 400 | 1000 | 2000 |
  |---|---|---|---|
  | per-request dict | 6.1 | 18.7 | 31.7 |
  | packed | 0.13 | 0.37 | 0.8 |

The cost is the same for 46 or 78 MoE layers × top-8: it is per request,
not per byte. Both halves sit on the step's critical path: the worker
main thread, then the EngineCore loop. So packing matters more as
engines run more requests per step, which large sparse MoE engines with
DP/EP do. At 1000 requests it is already ~2.7% end to end on this model.
- Store headroom cost: host RAM per engine ≤ 2 × KV tokens × MoE layers
× top-k × itemsize, on the output worker only. The arena is an anonymous
mmap, so only used slots cost RSS.
  - Here: 4.37M tokens × 46 × 8 × 1 B = 1.6 GB, so up to 3.2 GB.
- DeepSeek-V3-class model (58 × 8, uint8), 4M KV tokens per engine: 1.9
GB → 3.7 GB.
  - 78 × 8 at 8M tokens: 5 GB → 10 GB per engine.
- With more than 256 experts the rows are uint16, which doubles these
numbers.

## Checks
- **Routing rows.** 24 sequential `top_k=1` requests (12 prompts ×
300/1000 tokens) on node B: token ids and `routed_experts` bytes are
bitwise identical between main and this branch (24/24). Earlier, router
replay off gave identical tokens (24/24).
- **Store eviction.** Compared with upstream on random op sequences (300
seeds × 300 puts): same stored keys, same order of unreferenced keys
(the only order that affects eviction), same errors. Bench with 450k
slots and 320k live keys: 27.7 → 0.40 ms per step.
- **Unit tests.** `tests/unit/orchestrator`,
`tests/unit/test_configs.py`, `tests/unit/inference` with `-m "not
gpu"`: 381 passed (vLLM 0.32 venv). `ruff check` and `ruff format
--check` are clean.

## Notes
- Upstream-worthy: the eviction scan, store capacity at full KV, and
packed per-step aux outputs.
- Upstream: vllm-project/vllm#62044 (store fix; closed without merge, so
the store patch stays), #58328 (open; the store grows past 2x KV instead
of raising), #61086 (open; per-step R3 packing), #62086 (open draft; GC
thresholds).
- **Open question (API server, not this PR).** When ~100 length-capped
16k requests finish together, the API server loop stalls for ~25 s and
clients see `ReadError`s (~110 per 400-concurrent arm). Each response
carries about 8 MB of routed-experts JSON. By-handle payloads (#3902)
avoid this path.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Monkey-patches vLLM engine, worker, and API-server internals and
changes generate response shapes for logprobs and sampling masks;
mitigated by unit tests and documented parity checks for routing rows
and eviction.
>
> **Overview**
> Cuts CPU/GC cost on the **router-replay** path and on
**`/inference/v1/generate`** under heavy RL concurrency by avoiding
per-token Python objects and tuning garbage collection.
>
> **Engine / aux-output path:** Registers four vLLM monkey patches in
`patches.py`. Routed experts per scheduler step are sent as a single
**`PackedAuxOutputs`** blob (replacing per-request dict pickling). The
aux-output **`BlockObjectStore`** gets a 2× capacity headroom and an
eviction tweak so referenced LRU keys are moved to the end before
upstream eviction (avoids multi-ms rescans at full KV). **EngineCore**
and GPU workers **freeze** the post-warmup heap and use higher **`gc`
thresholds**; the API server does the same after startup.
>
> **Generate handler:** **`PrimeRlServingTokens`** strips heavy fields
early via **`_PackedOutputs`**, then returns **base64-packed numpy** for
`routed_experts`, **`completion_logprobs`**, and CSR **`sampling_mask`**
(`{ids, counts}`). Non-streaming requests use **flat logprobs**, and
**detokenize** is disabled when there are no stop strings. A separate
patch keeps **FINAL_ONLY** sampling masks as **`PackedSamplingMask`** in
the output processor.
>
> **Tests / deps:** Adds **`PackedAuxOutputs`** pickle and
store-eviction parity tests; updates serving-token unit tests. Bumps
**`renderers`** and drops **`pytest-cov`** from the lockfile.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
e1afb9c. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

(cherry picked from commit 78efef1)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants