Skip to content

feat: pass router-replay ids and sampling masks by handle on multi-node runs - #3902

Open
faresobeid wants to merge 33 commits into
mainfrom
feat/payload-segments
Open

faresobeid wants to merge 33 commits into
mainfrom
feat/payload-segments

Conversation

@faresobeid

@faresobeid faresobeid commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Router-replay ids and sampling masks are per-token arrays, so they grow with batch × context × layers × top-k. Inline, they travel as base64 through the API server, env servers and orchestrator, then once per CP rank over ZMQ. On multi-node runs (including disaggregated P/D) this PR passes them by handle: the inference server writes them to a file on the shared filesystem and only small segment descriptors travel. Single-node, eval and frozen-source rollouts stay inline.

What

  • Switch: orchestrator.payload_root. rl sets it to <run dir>/payloads for multi_node deployments with router replay or sampling-mask capture, and defaults trainer.data.num_workers = 1.
  • Inference: a train rollout's request carries extra_args.payload_dir = <payload_root>/v<k> (k = dispatch policy version). serving_tokens.py writes routing rows (uint8/uint16, as vLLM picks) and the CSR sampling mask to one fsynced file per request and returns payload segments (PayloadSegment(field, file, offset, pos, rows, dtype, shape)) instead. Mask ids are bit-packed at ceil(log2(vocab_size)) bits (18 for 150k vocabularies), with per-row start indices so any row range can be read.
  • Routed layers only: routed_layers(hf_config) lists the layers with a learned router. Dense MLP layers (first_k_dense_replace, mlp_layer_types, num_dense_layers, mlp_only_layers), Nemotron-H Mamba/attention layers and DeepSeek-V4 hash layers are left out. The server applies it to routing rows (by handle and inline). The trainer scatters the rows back with zero rows for the other layers, which is what vLLM leaves there. The DeepSeek-V4 trainer model routes hash layers by token id instead of replaying them.
  • P/D: both engines get payload_dir and each writes its own file. The router merges the two segment lists (router#60): prefill's routing, then decode's clipped at prefill's coverage; prefill's 1-token mask segment is dropped.
  • Orchestrator: clips segments to each sample (verifiers#2765), shifts them through packing and truncation, and never mixes inline and by-handle samples in a micro batch.
  • Trainer: each rank reads only its CP chunk, in the data worker, grouped by file with 32 pread threads; stale or short reads retry, then raise.
  • Retention: a shipped batch's files are deleted once inference applies the policy that trained on it; dropped or discarded work deletes its files immediately; an orphan sweep removes v<k> older than every live rollout and older than step - max_off_policy_steps. A fresh run wipes the root; a resumed run keeps it (fix(orchestrator): drop restored work that points at deleted by-handle payload files #4044 drops restored work whose files are gone).

Cost

Rows per token (routed layers only): Qwen3-30B-A3B 384 B, GLM-4.5-Air 360 B (−2.2 %), GLM-5.3 600 B (−3.8 %), DeepSeek-V4-Flash 240 B (−7 %), Nemotron-3-Nano 138 B (−56 %). Masks about 4 B per completion token + 2.25 B per kept id, −35 to −39 % vs 4 B per kept id on GPU runs below.

Measurement Inline By handle
GLM-4.5-Air, batch 128, 1+1 nodes (real run): orchestrator RSS / ZMQ wire per step 6.9 GB / 720 MB 3.2 GB / 70 MB
512 samples, 2.3M tokens, DP 8, CP 4 (offline): held / peak / egress 1.2 GB / +3.3 GB / 3.9 GB 0.29 GB / +0.57 GB / 0.33 GB
Nemotron-3-Super rows, 16 × 30k tokens, CP 8 (CPU bench): pack+encode / egress 3.9 s / 15 GB 0.3 s / 0.13 GB

Step time on GLM-4.5-Air was unchanged: at that size the gain is memory. Large-scale step time and shared-filesystem throughput have not been measured yet.

Correctness (GPU, Qwen3-30B-A3B TP1, VLLM_BATCH_INVARIANT=1, router replay + masks)

  • Colocated (vLLM 0.31): tokens equal; every routing/mask segment equals inline; 140/140 trainer micro batches (CP 1/2/4) bitwise equal.
  • P/D (vLLM 0.32, 1P1D NIXL, router built from router#60): 160 turns run 4 ways (P/D and colocated × inline and by handle), including concurrency 10, 22 decode preemptions and uint16 rows. Tokens, routing and masks bitwise equal in all modes; P/D rows equal colocated rows; 126/126 trainer micro batches bitwise equal. P/D response size 12–48 MB → 0.06–0.44 MB.
  • Packed mask ids and routed-layer rows (vLLM 0.32, compared against full-row servers from before the change): Qwen3-30B-A3B (colocated and 1P1D) and GLM-4.5-Air TP2 (dense layer 0), 40 turns each. After unpacking and expansion, routing equals the full rows (200/200 and 120/120 checks), masks are bitwise equal (160/160 and 80/80), and trainer micro batches at CP 1/2/4 are bitwise equal (28/28 each). Mask bytes in the payload files: −35 % (Qwen), −39 % (GLM). Layer maps were also checked on CPU, with real HF configs and prime-rl trainer configs, for DeepSeek-V4-Flash (3 hash layers), Nemotron-3-Nano (23 of 52 layers routed) and GLM-5.3.

Tests

pytest -m "not gpu" tests/unit/orchestrator tests/unit/test_configs.py tests/unit/inference tests/unit/transports tests/unit/train/rl: 410 passed; router#60 cargo test --lib routed_experts: 6 passed; ruff clean. Docs: docs/inference.md § Payloads by handle.

Before merge

  • verifiers#2765 and renderers#170 merge; re-pin deps/verifiers (now 410e84e8) and deps/renderers (now 4163c89).
  • router#60 merges and ships in a vllm-router release; bump the vllm-router pin (v0.2.3). Without it, P/D turns carry only decode's routing and the orchestrator drops it with a warning.
  • Whichever of this PR and feat: exact score centering over the sampling mask (arXiv:2609.20807) #3794 (score centering) lands second writes sampling_mask_logprobs by handle too.

Upstream: vllm-project/vllm#57968 (open) — Mooncake keys-only R3, an alternative design for the routing half (not a dependency).


Note

Medium Risk
Touches the full train data path (inference → orchestrator → trainer) and shared-filesystem retention; mistakes could drop or misread routing/mask data and bias RL, though extensive unit tests cover parity with inline mode.

Overview
Multi-node router replay and sampling-mask capture now spill per-token payloads to a shared filesystem instead of shipping huge inline arrays through HTTP, env servers, orchestrator, and ZMQ.

The rl entrypoint sets orchestrator.payload_root (typically <run dir>/payloads) when deployment is multi_node and routed experts or sampling masks are enabled, and defaults trainer.data.num_workers = 1 so payload reads can overlap training. Train rollouts pass extra_args.payload_dir = <root>/v<policy version>; vLLM serving_tokens fsyncs one file per request and returns payload segment descriptors. A new transports/payload layer handles CSR bit-packed masks, routed_layers-only routing rows, clip/pack in the orchestrator, CP-sharded pread in the trainer, and lifecycle GC (per-batch delete after policy apply, immediate cleanup on drops, version sweeps).

Inline payloads remain for single-node, eval, and clients without payload_dir. Docs add Payloads by handle.

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

Base automatically changed from chore/bump-vllm-0.31 to main October 6, 2026 14:46
@faresobeid
faresobeid force-pushed the feat/payload-segments branch from 1bcca41 to d0aff47 Compare October 6, 2026 14:53
@faresobeid
faresobeid force-pushed the feat/payload-segments branch from d0aff47 to 28baf5a Compare October 6, 2026 17:29
@faresobeid
faresobeid changed the base branch from main to perf/packed-generate-gc October 6, 2026 17:30
@faresobeid faresobeid changed the title feat: by-handle payload segments: reader, packing, CP-window reads, GC feat: by-handle payload segments: reader, packing, CP-window reads Oct 6, 2026
@faresobeid
faresobeid force-pushed the perf/packed-generate-gc branch from 355d9b2 to df49fe2 Compare October 7, 2026 00:39
@faresobeid
faresobeid force-pushed the feat/payload-segments branch 2 times, most recently from cfc1d59 to 93ea816 Compare October 7, 2026 13:59
@faresobeid
faresobeid force-pushed the perf/packed-generate-gc branch from 8760073 to 601f185 Compare October 8, 2026 14:35
@faresobeid
faresobeid force-pushed the feat/payload-segments branch from 93ea816 to f97b76a Compare October 8, 2026 14:39
@faresobeid
faresobeid force-pushed the perf/packed-generate-gc branch from 601f185 to c5f8b5a Compare October 8, 2026 19:16
@faresobeid
faresobeid force-pushed the feat/payload-segments branch 2 times, most recently from 5ed3500 to c2f9290 Compare October 9, 2026 23:48
@faresobeid
faresobeid force-pushed the perf/packed-generate-gc branch from c5f8b5a to bb77d68 Compare October 9, 2026 23:48
@faresobeid
faresobeid force-pushed the feat/payload-segments branch from 5a862b4 to 123233f Compare October 10, 2026 15:11
@faresobeid faresobeid changed the title feat: by-handle payload segments: reader, packing, CP-window reads feat: pass router-replay ids and sampling masks by handle on multi-node runs Oct 10, 2026
…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.

@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/vllm/serving_tokens.py
Comment thread src/prime_rl/transports/payload.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.

Stale Bugbot comment from a previous run.

Comment thread src/prime_rl/trainer/rl/data.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.

Stale Bugbot comment from a previous run.

Comment thread src/prime_rl/orchestrator/orchestrator.py Outdated
Base automatically changed from perf/packed-generate-gc to main October 14, 2026 15:44
# Conflicts:
#	src/prime_rl/inference/vllm/serving_tokens.py
#	src/prime_rl/orchestrator/orchestrator.py
#	tests/unit/inference/test_serving_tokens.py
# Conflicts:
#	deps/renderers
#	packages/prime-rl-configs/src/prime_rl/configs/rl.py
#	src/prime_rl/orchestrator/orchestrator.py
#	src/prime_rl/orchestrator/train_sink.py
#	src/prime_rl/orchestrator/types.py
faresobeid added a commit that referenced this pull request Oct 17, 2026

@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 72319a0. Configure here.

Comment thread src/prime_rl/orchestrator/train_sink.py
# Conflicts:
#	src/prime_rl/inference/vllm/serving_tokens.py
…train work

The sweep removed v<k> for k below min_fresh_version(step + 1), which assumes no rollout
dispatched earlier can still ship. Rollouts that outlive max_off_policy_steps (no in-flight
stale cancel) would lose their earlier turns' files and crash the trainer on read. Bound it by
the oldest group start in flight, in the sink or in an unread batch, and run it in the main loop
between items so no finished rollout is between holders.
…gregated P/D

The prefill and decode engines each write their rows to a payload file and the PD router
concatenates their segments (router#60), so rl no longer keeps P/D runs inline.
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 -->
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)
# Conflicts:
#	deps/renderers
#	deps/verifiers
#	src/prime_rl/inference/vllm/serving_tokens.py
#	src/prime_rl/orchestrator/batch.py
#	src/prime_rl/orchestrator/trajectories.py
#	src/prime_rl/trainer/rl/data.py
#	src/prime_rl/trainer/rl/train.py
#	src/prime_rl/transports/batch/types.py
#	tests/unit/inference/test_serving_tokens.py
#	uv.lock

This branch has not been deployed

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

1 participant