Skip to content

feat(inference): router replay with KV cache offload - #3831

Merged
faresobeid merged 12 commits into
mainfrom
feat/router-replay-kv-offload
Oct 19, 2026
Merged

faresobeid merged 12 commits into
mainfrom
feat/router-replay-kv-offload

Conversation

@faresobeid

@faresobeid faresobeid commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Router replay now works with inference.kv_cache_offload on vLLM 0.31, both native (OffloadingConnector) and Mooncake (MooncakeStoreConnector), with or without P/D. Per-role P/D offload (decode-side Mooncake with save_decode_cache) is in #3832; decode writes are not clamped, and prefill loads of decode-saved blocks go through the same clamp.

Problem on 0.31. vLLM 0.31 keeps routing rows in a local store keyed by block hash. No connector moves rows with the KV, so a block filled by an offload load has no rows on this instance. A request that needs them fails the engine step with BlockObjectStoreError. Upstream allows offload connectors and fails closed.

1. Load clamp. External loads stop at block_floor(routed_experts_prompt_start). vLLM never returns rows before that position, so the request never reads the loaded blocks' rows.

  • Native: wrap OffloadingConnector._max_loadable_tokens.
  • Mooncake: wrap MooncakeStoreConnector.get_num_new_matched_tokens and shrink load_specs[rid].kvpool_cached_tokens (and clear tail_key_boundaries), so update_state_after_alloc's assert holds.
  • Requests resumed after preemption (num_output_tokens > 0) and decode-side P/D requests are not limited: they emit rows only from their first forward, which always recomputes that token, so they keep their offload hits.

Why clamp instead of restoring rows. Restoring rows with offloaded blocks would need each connector to carry an extra per-block object, and the row store lives in the worker (TP rank 0) while load decisions are made in the scheduler. Neither connector has a hook for that. The clamp is a scheduler-side bound and keeps the hits that matter (multi-turn reloads).

2. Loaded-block cut (still needed on 0.31). Loaded blocks enter the GPU prefix cache. A later request with a lower routed_experts_prompt_start that hits them locally (for example a first turn hitting a prefix reloaded by another rollout's later turn) needs their rows and would crash the engine. The scheduler marks blocks covered by num_external_computed_tokens (unmarked when reallocated), and _get_local_prefix_cache_hit cuts such hits at the first loaded block the request needs rows from, then recomputes. Requests with output and decode-side P/D requests are skipped. It also covers non-P/D requests on a decode instance that hit NIXL-loaded blocks.

Hits kept: multi-turn reloads (verifiers sets routed_experts_prompt_start = path_len - 1). Hits lost: offloaded prefixes past it (first turns, shared prompts), which are recomputed.

validate_router_replay_without_kv_offload is deleted; docs updated. vLLM 0.31 allows offload connectors with routed-experts capture, and the P/D routed-experts bypass on main covers NIXL inside MultiConnector.

Checks run

  • pytest -m "not gpu" tests/unit/orchestrator tests/unit/test_configs.py tests/unit/inference on this head: 316 passed. Ruff: clean.
  • Tests: the parametrized load-limit test, and the loaded-block tracking on vLLM's real KVCacheManager.

GPU validation (2026-10-06, vLLM 0.31.0, 1x 8xH200, Qwen3-30B-A3B TP1, BI, native offload 40 GB CPU, 2000 GPU blocks; compared with a no-offload reference)

check result
A = P+200 with start=len(P) after P was evicted (external load) 3/3 rows identical; external hits 2992/4096/2592 = block_floor(start)
B = P+50 with start=0 right after A (local hit on A's loaded blocks) 3/3 identical; hit cut (0 local, 0 external)
C = P+70 with start=0 after eviction 3/3 identical (0 external)
negative control: same head without the cut engine dies at the first B: BlockObjectStoreError ("auxiliary output object does not exist")
native offload, 6 two-turn conversations, turn 2 vs reference 6/6 identical (tokens, rows, start, masks)
Mooncake (RDMA) on prefill + NIXL 1P1D, same conversations 6/6 identical; prefill external hits 16512; no errors
decode-saved KV (#3832: decode Mooncake cpu.num_bytes = 0 + save_decode_cache), same conversations, re-run on this stack (job 2900) 6/6 identical (tokens, 264 rows, start, masks); prefill external hits 16880 = sum block_floor(start), vs 16512 (the prompt-only maximum, the row above) → 23 completion blocks loaded from decode-saved KV; no errors

Tokens were identical everywhere.


Note

Medium Risk
Changes vLLM scheduler and KV offload connector behavior for routed-expert capture; incorrect bounds could cause BlockObjectStoreError or silent prefix-cache misses, though unit tests cover the core logic.

Overview
Router replay can now run together with inference.kv_cache_offload (native and Mooncake). The RL config no longer rejects that combination, and the inference docs describe the new behavior instead of calling the mix unsupported.

Because offloaded KV blocks do not carry routed-expert rows in vLLM’s local store, prime-rl adds two vLLM scheduler patches: clamp external loads so they stop at the block before routed_experts_prompt_start (with exceptions for P/D decode and resumed requests), and cut local prefix-cache hits at externally loaded blocks when a request would need missing routing rows. Multi-turn reloads keep safe offload hits; shared or first-turn prefixes may be recomputed.

Unit tests cover _routed_experts_load_limit, loaded-block tracking, and prefix-hit cutting on vLLM’s KVCacheManager.

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

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

@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 Resolved
Comment thread src/prime_rl/inference/patches.py Resolved

@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 Resolved
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.
vLLM 0.31 keeps routing rows in a local store keyed by block hash. No
connector moves rows with the KV, so a block filled by an offload load
has no rows here, and a request that needs them fails the engine step
(BlockObjectStoreError).

- Load clamp: native (OffloadingConnector._max_loadable_tokens) and
  Mooncake (get_num_new_matched_tokens) loads stop at
  block_floor(routed_experts_prompt_start). vLLM never returns rows
  before that position, so multi-turn reloads keep their hits. Decode-
  side P/D requests are not limited (they emit from their first forward).
- Loaded-block cut: loaded blocks then serve local prefix hits. A later
  request with a lower prompt start would need their rows, so its local
  hit is cut at the first loaded block it needs rows from.

Clamping is simpler than restoring rows with offloaded blocks: the row
store lives in the worker, the clamp is a scheduler-side bound, and
neither connector has a hook to carry extra per-block objects.

Remove validate_router_replay_without_kv_offload.
@faresobeid
faresobeid force-pushed the feat/router-replay-kv-offload branch from 9da6745 to 28c9c7a Compare October 6, 2026 16:18
@faresobeid
faresobeid force-pushed the feat/router-replay-v2-pd branch from 801f8af to a827863 Compare October 6, 2026 16:18
…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).
A request resumed after preemption emits routing rows only from num_tokens-1,
which its first forward always recomputes, so it never reads stored rows.

@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 1dc6dc3. Configure here.

Comment thread src/prime_rl/inference/patches.py Resolved
Base automatically changed from feat/router-replay-v2-pd to main October 14, 2026 19:29
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:
#	docs/inference.md
#	packages/prime-rl-configs/src/prime_rl/configs/rl.py
#	src/prime_rl/inference/patches.py
@faresobeid
faresobeid merged commit 1ed4cf1 into main Oct 19, 2026
17 of 18 checks passed
@faresobeid
faresobeid deleted the feat/router-replay-kv-offload branch October 19, 2026 08:32
samsja pushed a commit that referenced this pull request Oct 19, 2026
Disaggregated P/D with per-role KV offload, so P/D + router replay +
sampling replay + KV offload can be configured from the stack alone.
**Stacked on #3831.** Supersedes the parts of #3616 needed here.

**Per-role KV offload**
- `inference.deployment.prefill_kv_cache_offload` /
`decode_kv_cache_offload`: default `"inherit"` (use
`inference.kv_cache_offload`), `None` (`"None"` in TOML) disables
offload for that role, or a full offload config.
- Mooncake `cpu.num_bytes = 0` connects a role to the shared store
without starting a `mooncake_client` on its nodes (no storage
contributed; the vLLM process still uses its staging buffer). Native
offload still needs a positive size.
- Under P/D the Mooncake connector gets `save_decode_cache = true`:
decode also writes the KV it generates, so the next turn's prefill can
load the previous completion. Existing keys are skipped by the store, so
decode re-saving the prompt costs one existence check. Writes are not
clamped; prefill loads are clamped at `routed_experts_prompt_start` by
#3831, which keeps multi-turn completion blocks loadable.
- `_mooncake_store.sh.j2` runs per role: one master on the head node;
storage clients only on roles with capacity. Roles must agree on a
Mooncake disk path (one master, one `-root_fs_dir`).
- Mooncake keys carry model + TP/PCP/DCP/PP rank + content hash only, so
both roles must keep the same KV layout (TP size, `kv_cache_dtype`,
block size) to share the store. Documented.

**Launcher.** One engine config per role
(`inference-{prefill,decode}.json`, `InferenceConfig.for_pd_role` /
`write_pd_configs`); each node picks its file by `$ROLE`. Inference
nodes always run `ulimit -l unlimited` (NIXL/RDMA registers pinned host
pools).

Example (all storage on prefill; decode connects without capacity):
```toml
[inference.deployment.prefill_kv_cache_offload]
type = "mooncake"
[inference.deployment.prefill_kv_cache_offload.cpu]
num_bytes = 1_000_000_000_000

[inference.deployment.decode_kv_cache_offload]
type = "mooncake"
[inference.deployment.decode_kv_cache_offload.cpu]
num_bytes = 0
```
Resulting connectors: prefill `MultiConnector[Nixl,
MooncakeStore(save_decode_cache)]`, decode `MultiConnector[Nixl,
MooncakeStore(save_decode_cache)]`.

Left out (not needed for this use case): GLM-5.3 example configs,
monitoring dashboard and tooling, trainer DRAM contribution to the
store, NUMA/UCX per-rank binding, router timeout/retry fields, lease TTL
and staging-buffer fields, explicit `kv_transfer_config` passthrough.

## Checks run
- `pytest -m "not gpu" tests/unit/test_configs.py tests/unit/inference`:
223 passed. Ruff: clean. Test: `test_pd_role_connectors`.
- Dry run of `examples/advanced/glm-5.3/swe.toml` + router replay +
`top_p = 0.95` (sampling masks) + prefill Mooncake (1 TB + disk) +
decode Mooncake (`cpu.num_bytes = 0`): per-role JSON configs build
`[Nixl, MooncakeStore]` for both roles; `bash -n` on the generated
`rl.sbatch` passes; only prefill nodes start a storage client.

## GPU validation (2026-10-06, vLLM 0.31.0, 1x 8xH200, configs written
by `write_pd_configs`, router v0.2.2)
- Qwen3-30B-A3B TP1, `VLLM_BATCH_INVARIANT=1`, 1P1D NIXL, prefill
Mooncake (RDMA, 40 GB) + decode Mooncake `cpu.num_bytes = 0` with
`save_decode_cache`, router replay + sampling masks, 6 two-turn
conversations (turn 2 start = `len(p1 + c1) - 1`) after evicting
prefill's GPU cache, vs a colocated no-offload reference: **6/6
identical** (tokens, 264 rows, start, masks). Prefill external hits
**16880** = sum of block_floor(start), vs 16512 when only prefill writes
(prompt-only maximum, earlier run with the same conversations): the
turn-1 completion blocks were loaded from decode-saved KV. No errors.
- 1P1D without offload, same configs path: 10/10 and 20/20 (concurrency
10, fresh salt) identical to colocated.

Not validated: multi-node Mooncake role launch on SLURM (template
rendered and `bash -n` only).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes disaggregated inference launch paths, KV connector
composition, and Mooncake SLURM wiring; misconfiguration could break
multi-turn prefix reuse, though config validation and unit tests cover
the main connector shapes.
> 
> **Overview**
> Adds **per-role KV cache offload** for disaggregated prefill/decode
via `inference.deployment.prefill_kv_cache_offload` and
`decode_kv_cache_offload` (`inherit`, disabled, or a full offload
block). Resolvers expose `kv_cache_offload_by_role`, `for_pd_role()`,
and emit **`inference-{prefill,decode}.json`** so each node loads the
right connector set.
> 
> **Mooncake** now allows `cpu.num_bytes = 0` to join the shared store
without starting a storage client (native offload still requires
positive CPU bytes). Under P/D, the Mooncake connector gets
**`save_decode_cache`** so decode-written KV is available on the next
prefill turn; validation requires a **single disk path** across Mooncake
roles sharing one master.
> 
> SLURM templates take a **`mooncake_offloads`** map (replacing global
offload flags), role-aware `_mooncake_store.sh.j2`, and
**`inference-$ROLE.json`** in P/D jobs; inference nodes always run
**`ulimit -l unlimited`** for NIXL/RDMA pinned memory. Docs cover the
typical prefill-storage / decode-connect pattern and KV layout
constraints across roles.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
ee5dcc6. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
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