Repository navigation
feat(inference): router replay on the V2 runner everywhere - #3830
Merged
Merged
Conversation
This was referenced Oct 4, 2026
faresobeid
marked this pull request as ready for review
October 4, 2026 01:42
This was referenced Oct 4, 2026
faresobeid
force-pushed
the
feat/router-replay-v2-pd
branch
2 times, most recently
from
October 6, 2026 12:43
2b415a3 to
4a4ce60
Compare
This was referenced Oct 6, 2026
faresobeid
force-pushed
the
feat/router-replay-v2-pd
branch
from
October 6, 2026 14:53
4a4ce60 to
801f8af
Compare
vLLM 0.31 captures routed experts through the AuxOutput connector and rejects it with P/D connectors: a decode instance never computes a remotely prefilled prompt, so its worker would materialize missing rows (BlockObjectStoreError), and the first forward starts mid-block. Carry our upstream AuxOutput fix as a plugin patch: - the config check skips the P/D connector rejection for NixlConnector (alone or inside MultiConnector); every other check still runs; - requests that arrive with do_remote_prefill emit rows only from their first forward (num_computed_tokens then); the floor survives preemption; - the worker does not buffer or publish the partially known first block. The pinned router drops as many leading decode rows as prefill returned, so the decode server left-pads its rows to routed_experts_prompt_start (the same payload size as 0.30's full-length decode rows). Remove the config rejection added by the 0.31 bump.
faresobeid
force-pushed
the
feat/router-replay-v2-pd
branch
from
October 6, 2026 16:18
801f8af to
a827863
Compare
This was referenced Oct 6, 2026
…rward Anchor the routing rows at the end (start = prompt + completion - 1 - rows), so a P/D decode response no longer zero-pads its rows to the prompt start. The router cuts decode rows by position since v0.2.3 (router#61).
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b1b2742. Configure here.
…ked /generate path The start moves into _PackedOutputs (#3900), so routed_experts.py and serving_tokens.py stay as on main.
faresobeid
added a commit
that referenced
this pull request
Oct 14, 2026
…r GC (#3900) ## Problem Under RL load (GLM-4.5-Air, 1 trainer + 1 inference node, ~130-300 concurrent 16k-token requests), the vLLM API server spends 25% (top_p 1.0) to 42-43% (top_p 0.97 + sampling replay) of its wall time in gen-2 GC, with single pauses up to 64 s. During a pause the server answers nothing (/health, /metrics, completions, weight-update calls). With sampling replay this cost -30% effective generation throughput and +50% step time, and with default router health checks 25-83% of rollouts failed. Cause: tens of millions of GC-tracked per-token objects alive at once in the API-server process: `Logprob` objects and dicts per token plus decoded strings (output processor), one pydantic object per token in `_create_tokens_logprobs`, one Python list per token for the sampling mask, and the JSON encoding of all of it. Every gen-2 pass walks them all. ## Change `/inference/v1/generate` (prime-rl's `PrimeRlServingTokens`) now builds no per-token objects: - **Packed payloads**, encoded like `routed_experts` (one `encode_array` helper for all three; `routed_experts.py` is removed). `routed_experts` adds `start`, anchored at the last forwarded token: `len(prompt) + len(completion) - 1 - len(rows)`. Without P/D this equals `routed_experts_prompt_start`; on a P/D decode instance (#3830) the rows begin at its first forward. - `choice.completion_logprobs = {data, shape, dtype}`: base64 float32, the sampled token's logprob per completion token (clamped at -9999 like upstream). `choice.logprobs` is no longer set, so `/generate` no longer returns `top_logprobs` (whatever `sampling_params.logprobs` asks for). renderers asks for `logprobs = 1` and only reads the sampled logprob, so nothing in our stack uses them. - `choice.sampling_mask = {"ids": {...}, "counts": {...}}`: CSR base64 int32 arrays instead of `list[list[int]]`. - **Flat logprobs, no detokenization**: non-streaming `/generate` requests get `flat_logprobs = True` (vLLM's `FlatLogprobs`: a few lists per request instead of a dict + `Logprob` objects per token) unless they ask for prompt logprobs, and `detokenize = False` unless they set stop strings (`/generate` returns token ids only, so the text and the per-token decoded logprob strings were never used). - **Masks stay numpy in the output processor** (`monkey_patch_packed_sampling_masks`, non-streaming requests only): keep only the per-step id arrays (untracked by GC) instead of a tuple per token, and hand the serving layer a `PackedSamplingMask(ids, counts)` instead of `list[list[int]]`. - **API-server GC**: after app init, `gc.collect(); gc.freeze()` (startup objects leave GC) and `gc.set_threshold(50_000, 20, 100)` (default `(700, 10, 10)`). - Streaming `/generate` and the OpenAI endpoints are unchanged. Clients: PrimeIntellect-ai/renderers#174 reads `completion_logprobs` (same checks as the per-token path); PrimeIntellect-ai/verifiers#2778 decodes the packed mask. Both keep the old forms for stock vLLM. ## Pins - `deps/renderers` 41f9290 -> ca029b5 (renderers main, the merge commit of renderers#174). - `deps/verifiers` 2cb0a6d -> e888a71 (verifiers main, the merge commit of verifiers#2778). Landing order: renderers#174 -> verifiers#2778 -> this PR, with the prime-rl re-pins to renderers/verifiers main in the same commit as the server change. An old client against a new server gets no `logprobs.content`. ## Measurement One node, GLM-4.5-Air TP8, 130 concurrent closed-loop requests (Polaris prompts, top_p 0.97 / top_k 512, max 16k tokens, ~12.5k tokens/response), 15 min per arm, before (main) vs after (this branch): | arm | gen tok/s | done reqs | errors | gen2 GC % wall | all GC % wall | gen2 count | max GC pause s | health p50/p99/max s | resp MB mean | client parse ms mean | tokens/resp | |:------------------|------------:|------------:|---------:|-----------------:|----------------:|-------------:|-----------------:|:-----------------------|---------------:|-----------------------:|--------------:| | base-nomask | 5497 | 512 | 0 | 11.8 | 13 | 164 | 1.87 | 0.003/1.34/21.7 | 1.71 | 28.5 | 12490 | | fix-nomask | 5601 | 516 | 0 | 0 | 0 | 0 | 0 | 0.003/0.01/0.3 | 0.12 | 1 | 12619 | | base-mask | 5453 | 508 | 0 | 23.8 | 25.8 | 111 | 4.27 | 0.003/4.37/30.1 | 1.83 | 31.4 | 12479 | | fix-mask | 5446 | 504 | 0 | 0 | 0.9 | 0 | 0.99 | 0.003/0.07/1.8 | 0.31 | 1.4 | 12582 | | fix-mask-nogctune | 5480 | 512 | 0 | 5.1 | 5.7 | 31 | 2.63 | 0.003/1.94/2.8 | 0.31 | 1.3 | 12500 | The closed-loop tok/s does not move, because the engine core keeps generating during API-server pauses. The RL cost came from what the pauses block (health checks, dispatch, responses, weight updates), measured here by /health latency. Ablation: packing alone takes gen-2 GC from 23.8% to 5.1% of wall time; gc.freeze + thresholds take it to zero gen-2 passes. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes the `/generate` response contract and patches vLLM internals; clients must understand packed fields, though streaming and OpenAI routes are unchanged. > > **Overview** > Under heavy RL concurrency, `/inference/v1/generate` no longer builds millions of per-token Python objects in the API server. **Non-streaming** responses now return **packed, base64 numpy payloads** for `routed_experts`, new `completion_logprobs` (sampled-token logprob per position), and CSR `sampling_mask` (`ids` + `counts`), with serialization consolidated in `serving_tokens` and `routed_experts.py` removed. > > The handler enables **flat logprobs** and disables **detokenization** when safe (no prompt logprobs / no stop strings), so upstream stops materializing `Logprob` dicts and decoded strings. A vLLM **output-processor monkey patch** keeps sampling-mask chunks as numpy until finish for `FINAL_ONLY` requests. > > After app startup, the API server runs **`gc.collect()` / `gc.freeze()`** and raises GC thresholds to cut gen-2 pauses. Unit tests cover packing and `post_process`; **`renderers`** is pinned to a release that reads the new fields (paired client PRs). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a6f0da5. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
mikasenghaas
previously approved these changes
Oct 14, 2026
mikasenghaas
left a comment
Member
There was a problem hiding this comment.
lgtm, lets wait for @samsja given that he closed before
Signed-off-by: faresobeid <111092724+faresobeid@users.noreply.github.com>
samsja
approved these changes
Oct 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

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 (tokenprompt_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):NixlConnector(alone or insideMultiConnector). Every other AuxOutput check still runs.kv_transfer_paramswith aremote_engine_idand nodo_remote_decode; NIXL clearsdo_remote_prefillbut keeps that) emits rows only from its first forward on D (num_computed_tokensat that point: normallyprompt_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.Router v0.2.3 required (router#61); the decode
startcomes 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/generateanchors the rows at the end:start = len(prompt) + len(completion) - 1 - len(rows)(equal torouted_experts_prompt_startwithout P/D). This PR does not touchrouted_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 pinsvllm-routerto v0.2.3 inpyproject.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/inferenceon this head: 214 passed.ruff check/ruff format --check: clean.test_routed_experts_pd.py): the scheduler floor (first forward, preemption, cleanup) on vLLM's realAuxOutputSchedulerConnector, and the worker skip on the realRoutedExpertsBuffer.test_combined_replay_uses_v2_runneris parametrized over single-node and P/D.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)cache_salt. Identical means tokens, prompt and completion routing rows (100%) and sampling masks; rows = prompt + completion - 1.Using V2 Model Runner; noBlockObjectStoreError.Re-run (job 2900, 2026-10-06, same setup, launched from per-role configs written by feat(inference): P/D per-role KV offload #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 freshcache_salt; 0 errors.Not run: injected NIXL load failure (
kv_load_failure_policy=recompute), MTP on decode, anrlrun 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, mergedstart= 0), same sampling masks, same logprobs.cache_salton the colocated side: 20/20 identical.sitecustomizeforced vLLM's capturer touint16on P, D and the colocated server. The responses carrydtype: uint16. Concurrency 1: 10/10 identical; concurrency 10: 20/20 identical.num_gpu_blocks_override=1300, 1000-4000 token prompts, up to 1024 tokens, concurrency 10): 6 preemptions on D, 20/20 identical.Using V2 Model Runner, noBlockObjectStoreError.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.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_expertswithdeployment.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-routeris 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.
Reviewed by Cursor Bugbot for commit e586ef8. Bugbot is set up for automated code reviews on this repo. Configure here.