Skip to content

feat(inference): router replay on the V2 runner everywhere - #3830

Merged
samsja merged 6 commits into
mainfrom
feat/router-replay-v2-pd
Oct 14, 2026
Merged

samsja merged 6 commits into
mainfrom
feat/router-replay-v2-pd

Conversation

@faresobeid

@faresobeid faresobeid commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

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 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 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.

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.

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

@faresobeid
faresobeid marked this pull request as ready for review October 4, 2026 01:42
@samsja samsja closed this Oct 4, 2026
@faresobeid faresobeid reopened this Oct 5, 2026
@faresobeid
faresobeid force-pushed the feat/router-replay-v2-pd branch 2 times, most recently from 2b415a3 to 4a4ce60 Compare October 6, 2026 12:43
@faresobeid
faresobeid changed the base branch from main to chore/bump-vllm-0.31 October 6, 2026 12:43
Base automatically changed from chore/bump-vllm-0.31 to main October 6, 2026 14:46
@faresobeid
faresobeid force-pushed the feat/router-replay-v2-pd branch from 4a4ce60 to 801f8af Compare October 6, 2026 14:53
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.
…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).

@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.

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 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
mikasenghaas previously approved these changes Oct 14, 2026

@mikasenghaas mikasenghaas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm, lets wait for @samsja given that he closed before

Comment thread docs/inference.md Resolved
Signed-off-by: faresobeid <111092724+faresobeid@users.noreply.github.com>
@samsja
samsja merged commit 5f56770 into main Oct 14, 2026
19 of 20 checks passed
@samsja
samsja deleted the feat/router-replay-v2-pd branch October 14, 2026 19:29
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.

3 participants