Repository navigation
fix(inference): cut router replay's engine-side cost on vLLM 0.32 - #3920
Conversation
b6320ec to
6e4a413
Compare
…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.
…eviction, packed step outputs, engine GC)
6e4a413 to
6697bc1
Compare
…-cost # Conflicts: # src/prime_rl/inference/vllm/routed_experts.py
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ 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 |
There was a problem hiding this comment.
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.
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.
…gine-cost # Conflicts: # src/prime_rl/inference/patches.py # src/prime_rl/inference/vllm/routed_experts.py
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
) 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)


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 inpatches.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.BlockObjectStoreto the KV cache, so under load it is full and everyputevicts._evict_to_fitscans the LRU from the front and skips keys referenced by running requests. Those keys pile up at the front, so eachputrescans ~300k keys: 9 ms perput, 46 puts/s, on the writer thread. It holds the GIL against the thread that launches the next step, andexecute_modelgoes from 2.7 to 13.8 ms per call.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.Fix
monkey_patch_aux_output_store_evict_to_fitruns 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.putthen raisesBlockObjectStoreErrorin 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'sin/[]lookups.monkey_patch_engine_gc: EngineCore keeps vLLM's freeze and getsset_threshold(50_000, 20, 100). The workers getgc.collect()+gc.freeze()+ the same thresholds aftercompile_or_warm_up_model.Results (gen tok/s, steady window 240–900 s)
Earlier runs (node A, 400 concurrent, 16k):
Ablation of the branch as pushed (node B, same node for every row):
max_num_seqs=1024max_num_seqs=1024Store 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.enqueuetakes 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 insidedequeue, so the bench below gives its size.Scaling of packing (CPU bench of the per-step path; pickle + unpickle per step, ms):
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.
Checks
top_k=1requests (12 prompts × 300/1000 tokens) on node B: token ids androuted_expertsbytes are bitwise identical between main and this branch (24/24). Earlier, router replay off gave identical tokens (24/24).tests/unit/orchestrator,tests/unit/test_configs.py,tests/unit/inferencewith-m "not gpu": 381 passed (vLLM 0.32 venv).ruff checkandruff format --checkare clean.Notes
ReadErrors (~110 per 400-concurrent arm). Each response carries about 8 MB of routed-experts JSON. By-handle payloads (feat: pass router-replay ids and sampling masks by handle on multi-node runs #3902) avoid this path.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/generateunder 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 singlePackedAuxOutputsblob (replacing per-request dict pickling). The aux-outputBlockObjectStoregets 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 highergcthresholds; the API server does the same after startup.Generate handler:
PrimeRlServingTokensstrips heavy fields early via_PackedOutputs, then returns base64-packed numpy forrouted_experts,completion_logprobs, and CSRsampling_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 asPackedSamplingMaskin the output processor.Tests / deps: Adds
PackedAuxOutputspickle and store-eviction parity tests; updates serving-token unit tests. Bumpsrenderersand dropspytest-covfrom the lockfile.Reviewed by Cursor Bugbot for commit e1afb9c. Bugbot is set up for automated code reviews on this repo. Configure here.