Repository navigation
feat: let the host keep finished episodes' training arrays - #2934
Conversation
ApprovabilityVerdict: Needs Human Approval Macroscope's review found this PR requires human approval — This PR introduces host-side retention and cleanup of finished episodes while removing training arrays from the existing streamed episode contract. The worker, broker, client, and cancellation lifecycle all change, creating a cross-component runtime impact that merits human review. Notes:
You can add or adjust custom eligibility rules. Learn more. |
df25613 to
354627f
Compare
8323f9b to
c381181
Compare
`set_episode_hook` registers a hook the env server runs, off the event loop, on each finished episode before its final flush. A trainer's env servers use it to keep per-token training arrays (routing rows, sampling masks, top-k heads) themselves and leave references in `trace.info`. While a hook is set, streamed nodes carry no training arrays and no routing repairs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The episode hook becomes a `keep` / `discard` protocol with a strict contract: every episode the server passed to `keep` is either delivered in its run's successful reply or handed back to `discard`, including when the run is cancelled while or after `keep` runs, or the reply fails to send. The host can then free what it kept without any sweep. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dc964d5 to
71154c1
Compare
# Conflicts: # verifiers/v1/serve/delta.py # verifiers/v1/serve/server.py
A reply that fails to encode goes out as an error, so the client never sees the episode. Count it as undelivered so the hook frees what it kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nodes' routing rows, sampling masks and top-k heads now stay in the worker; an episode hook is the only way they leave it. Drop the keep_arrays switch and the routing-row repairs, which only patched arrays that were already streamed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A keep that raises now hands the episode to discard, and a shutdown that cancels the reply send still discards. A run abandoned while keep runs discards only once keep has returned, from whichever of the two finishes last, so discard never races the arrays keep is storing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A client whose caller cancels a run after the reply left drops the reply, so the host's kept arrays had no consumer. The client already sends a cancel; the server now keeps delivered episodes for LATE_CANCEL_SECONDS and discards one when a cancel for it arrives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A run cancelled while the client decodes its reply now sends a cancel too: the server already counted that episode delivered. - A cancel that lands after the run finished but before its reply was sent discards the kept episode. - Delivered episodes expire on a timer instead of on the next delivery, and a cancel never discards an expired one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 098cbdd. Configure here.
The pool broker forgot a run once its reply passed through and answered any later cancel itself, so a worker never discarded the arrays of a run its client abandoned after the reply. The broker now remembers which worker replied for LATE_CANCEL_SECONDS and forwards a late cancel there. The client also cancels when assembling or validating a delivered reply fails, not only when it is cancelled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
## Summary Router-replay rows, the score-centering top-k head and sampling masks travel today from the env servers to the orchestrator inline, and the orchestrator packs them into the trainer batch. At high concurrency with long contexts, the orchestrator node then holds terabytes of per-token arrays that only the trainer reads. Now they never leave the env servers inline: the train env servers keep them, and references are the only way they reach the trainer. There is no config and no inline path: - **Env servers keep the arrays** (`transports/payload.py`). vLLM still returns them inline, so the env server keeps full control over the response. When a train episode finishes, each env-server worker (verifiers' `set_episode_hook`, `PayloadHook.keep`) encodes every trainable branch's arrays (`encode_branch_arrays`), keeps the blob, serves it over HTTP (`GET <url>/<id>`, a stdlib threaded server per worker on the env server's bind host, loopback by default, `DELETE` to free). It leaves the URL and, per branch, the blob id, the kept fields and the routing shape in `trace.info["prime_rl_payload"]`. Env-server workers import no torch through this path. Episode streams never carry the arrays (verifiers#2934). - **The orchestrator packs references, never arrays.** A sample carries a `PayloadSlice` (url, id, kept token count, fields); truncation shortens the count. A micro batch lists one reference per sequence, so samples with and without arrays pack together; samples with and without routing do not (`orchestrator/trajectories.py`, `orchestrator/batch.py`). Padding dummies reference only routing rows. - **Trainer ranks fetch and assemble.** Before each step, every rank fetches its micro batches' blobs in parallel and assembles them (`pack_token_arrays`, `pad_token_arrays`: cut each to its sample, concatenate in packing order, pad; a sample without arrays gets empty rows), so the tensor conversion is unchanged (`trainer/rl/data.py`, `transports/payload.py`). - **Strict accounting, no sweep.** Every kept array is deleted exactly once (`DELETE <url>/<id>`), by whoever holds it last: the **trainer**, once every rank fetched a step's arrays (after the world all-reduce that agrees on failed fetches, one rank per data rank deletes); the **orchestrator**, for whatever it receives but never ships: the train sink for filtered and dropped traces and groups, untrained branches, pruned samples, samples that staleness masking voids, and batches and queues discarded at shutdown; the eval sink for every eval episode; the dispatcher for episodes that returned after their group was dropped; the **env server**, for episodes it kept that never reached the orchestrator, including runs the client abandoned after the reply (`PayloadHook.discard`, called by verifiers' delivery contract). - **Missing arrays fail loudly.** Each fetch retries before giving up, and bypasses HTTP proxies. A step where no micro batch's arrays can be fetched anywhere stops the run. A micro batch whose arrays still cannot be fetched is dropped from the loss on every rank that trains it (one all-reduce), never trained on zeros, and counted in `payload/dropped_micro_batches` (summed over data ranks). Its routing keeps the model's shape and repeats real rows, so the forward pass still routes like real data. - **Every env server keeps its arrays; eval deletes them.** prime-rl's `env-server` entrypoint always installs the hook, with no config. Eval never trains, so `EvalSink.add` deletes the arrays of every eval episode it receives, in the orchestrator and in the standalone `eval` entrypoint (`orchestrator/eval_sink.py`). - **Env-server replicas on every inference node.** On multi-node SLURM runs with inference nodes, every inference node runs a replica of every launcher-managed env server, bound to the node address. Each replica publishes its address to `envs/<split>/<name>.<node>.address`. The orchestrator connects to all replicas and sends each run to the replica with the fewest runs in flight (`orchestrator/envs.py`). So env-server memory and CPU spread over all inference nodes, even for a single env. The launcher sets `orchestrator.env_server_replicas` to the number of inference nodes. Builds on PrimeIntellect-ai/verifiers#2934 (merged): episode streams without training arrays, and `set_episode_hook` with its keep/discard contract. `deps/verifiers` points at its merge commit on verifiers `main` (`60290d436`). ## Breaking - **Env server placement.** On multi-node SLURM runs with inference nodes, launcher-managed env servers now run as one replica per inference node instead of one server next to the orchestrator. Env-server logs move to `logs/envs/<split>/<name>.<node>.log`. No option restores the old placement. To pin a source's env server elsewhere, run it yourself and point the source at it with `serve.address`. - **Train env servers must run prime-rl's `env-server` entrypoint.** Episode streams no longer carry training arrays, so a train source served any other way (`serve.address` pointing at a plain verifiers server) delivers none. Runs that need sampling masks or top-k heads stop with an error saying so. - **Per-server limits apply per replica.** `serve.max_concurrent`, the worker pool and sandbox limits of a launcher-managed env server now apply to each replica, so a source's total rises with the number of inference nodes. Migration: divide a limit that must hold run-wide by the number of inference nodes. - **Persisted batches can't be replayed.** With the filesystem rollout transport, a step file now holds references, and the trainer deletes the arrays once it has fetched them. A trainer that re-reads an old step file after a restart finds the arrays gone and drops every micro batch (`payload/dropped_micro_batches`). - **The `sampling_support/size` metric is removed.** Traces reach the orchestrator without sampling masks, so nothing feeds it. ## Verification Qwen3-30B-A3B-Thinking on `i3_math` with router replay (48 layers × 8 experts, uint8) and IcePop score centering (top-128 head), batch 128 × group 8, up to 1,024 rollouts in flight, 16k-token completions, H200. **Inline (`main`) vs by reference**, 1 trainer + 1 inference node each, same config otherwise, 10 steps, measured on an earlier revision of this branch: | | inline | by reference | |---|---|---| | Reward (mean of steps 1–10) | 0.427 | 0.388 | | Mismatch KL | 4.0e-4 | 4.0e-4 | | Step time (orchestrator / trainer) | 381 s / 380 s | 378 s / 375 s | | Orchestrator peak RSS | 14.5 GiB | **2.7 GiB** | | Dropped micro batches | – | 0 | Per-step reward varies by ±0.07 at this batch size, so the reward gap is within about two standard errors; the trainer sees byte-identical arrays (unit test below) and the mismatch KL is unchanged. **Env-server replicas**, 2 trainer + 4 inference nodes, 27 steps each. one env server vs one replica per inference node: | | one env server | replica per inference node | |---|---|---| | Env-server peak RSS | 90.6 GiB on one node | 25.6–25.9 GiB on each of 4 nodes | | Peak memory used, inference nodes | 220 GiB on the env-server node, 122–164 GiB elsewhere | 144–158 GiB on every node | | Orchestrator peak RSS | 3.2 GiB | 3.3 GiB | | Payload fetch per trainer step (mean / max) | 1.26 s / 2.30 s | 0.74 s / 2.20 s | | Reward (mean of steps 1–27) | 0.398 | 0.415 | | Mismatch KL (mean) | 3.9e-4 | 3.9e-4 | | Step time (orchestrator / trainer) | 118 s / 117 s | 124 s / 122 s | | Dropped micro batches | 0 | 0 | The replica run continued to its 60-step limit and exited cleanly. Each replica's memory peaks early (≤ 25.9 GiB) and stays flat at ~22–23 GiB, so eager deletes keep up. Over 60 steps: no dropped micro batches, payload fetch 0.67 s mean, orchestrator peak RSS 3.3 GiB, reward mean 0.417. In both runs, most env-server memory is the math runtime's per-episode subprocesses and the pool workers; the kept arrays are about 10 GiB in total. **Transport**, measured on an earlier revision of this branch: deltas into the orchestrator 8.8 GiB → 120 MiB per step, trainer batch 2.6 GiB → 78 MiB per step. **Tests:** `tests/unit/transports/test_payload.py`: real verifiers traces go through the env-server hook, `trace_to_samples`, packing (truncation, padding, multi-sample bins) and the HTTP fetch, and each fetched micro batch holds exactly its branches' encoded arrays in packing order. Samples with and without routing never share a micro batch. Explicit heads, masks with sampler logprobs, plain masks and a sample without arrays survive truncation, mixed packing, padding and the trainer's tensor conversion. Also covered: the routing of a micro batch whose fetch failed (truncated sample included), a `keep` that fails midway, a branch whose arrays don't encode, the bind host (wildcard included), and the store and `discard`. `tests/unit/orchestrator/test_train_sink_payloads.py`: the train sink deletes exactly the arrays of filtered, dropped, stale, pruned and discarded samples and leaves only the shipped ones; the eval sink deletes those of eval episodes; the dispatcher deletes those of episodes that returned after their group was dropped. `tests/unit/orchestrator/test_env_replicas.py`: least-loaded routing over replicas. `pytest -m "not gpu" tests/unit` after merging `main`: 1003 passed; after the last fixes, the affected suites (orchestrator, transports, trainer, configs, eval) pass 498. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > Changes the training data path (HTTP payload fetch, strict delete accounting, micro-batch drop on fetch failure) and multi-node env-server placement; misconfiguration or network issues can silently drop training signal or fail steps. > > **Overview** > **Training arrays (router replay, sampling masks, top-k heads) are kept on env servers and passed by reference** instead of riding inline through the orchestrator and rollout transport. The `env-server` entrypoint installs `PayloadHook`, which encodes branch arrays over HTTP and stamps `trace.info["prime_rl_payload"]`; samples carry `PayloadSlice`, the packer threads `payloads` through micro batches, and trainer ranks **fetch** blobs before each step then **release** them after a DP-wide sync. Dropped or never-shipped arrays are deleted from train/eval sinks, the dispatcher on cancel, and when fetches fail micro batches are zeroed out of the loss (with `payload/dropped_micro_batches`). > > **Multi-node runs spread env-server load**: `orchestrator.env_server_replicas` (auto-set to inference node count) launches one env server per inference node; the orchestrator connects to all replicas and routes episodes **least-loaded first**. SLURM templates move env-server startup off the orchestrator node; address files use `<name>.<replica>.address`. > > Orchestrator batching no longer inlines slice/pack logic for routed experts and masks—that moves to `transports/payload.py`. Live monitor deltas keep full delta dicts; the `sampling_support/size` metric is removed. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit d65bfc0. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Summary
TRAINING_ARRAY_FIELDS: routing rows, sampling masks, top-k heads) are left out of every delta, so they never cross the env server → client hop. The routing-row repairs, which only patched rows already streamed, are gone on both sides.set_episode_hook(hook)(verifiers.v1.serve) is the only way the arrays leave a worker. It installs anEpisodeHookwith two methods:keep(episode, sampling)runs off the event loop on each finished episode, before its final flush. A host keeps the arrays itself and leaves references intrace.info; what it writes to the traces streams with the final flush.discard(episode)frees whatkeepkept for an episode that was not delivered.keepis either delivered in its run's successful reply or handed todiscard, exactly once and never whilekeepstill runs. This covers akeepthat raises, a cancel that lands during or afterkeep(shutdown included), a reply that fails to encode or send, and a client that cancels the run after the reply left, including while it assembles or validates the reply: the client always sends a cancel, the pool broker forwards a cancel to the worker that replied withinLATE_CANCEL_SECONDS, and that worker discards the kept episode. The host can then free what it keeps without any sweep.Used by PrimeIntellect-ai/prime-rl#4196: its train env-server workers keep each trainable branch's arrays and serve them to the trainer, so the orchestrator never holds them.
Breaking
routed_experts,sampling_maskortop_logprobsfrom streamed nodes now getsNone. Migration: keep the arrays on the server withset_episode_hookand fetch them from there.routing_repairsdeltas are gone.EpisodeAssemblyno longer applies them, and no server sends them.Tests
tests/unit/serve/test_delta.py(an episode streams and reassembles without its training arrays) andtests/unit/serve/test_episode_hook.py(a failed, abandoned or finishedkeep, a cancel before or after the reply leaves, and a cancel during the client's final decode each discard exactly once; an expired delivery is never discarded) andtests/unit/serve/test_pool_cancel.py(the broker routes a late cancel to the worker that replied); fulltests/unit: 239 passed. prime-rl#4196 coverskeep/discardend to end.🤖 Generated with Claude Code
Note
High Risk
Breaking wire semantics for training arrays and cancel/discard lifecycle; incorrect hook or cancel handling could leak GPU memory or drop data trainers depend on.
Overview
Per-token training arrays no longer cross the env-server wire. Streamed node deltas now omit
routed_experts,sampling_mask, andtop_logprobs(TRAINING_ARRAY_FIELDS), and therouting_repairsmechanism is removed from bothDeltaStreamerandEpisodeAssembly. Clients that assembleWireEpisodefrom deltas should expect those fields to beNone.set_episode_hookis the supported way for a host to retain arrays on the worker:keep(episode, sampling)runs in a background thread after rollout finishes (before the final delta flush);discard(episode)runs when delivery fails or the client abandons the run. The server tracks kept episodes through reply send and aLATE_CANCEL_SECONDSwindow so a late cancel can still triggerdiscard.Client and pool behavior is tightened so abandonment is signaled reliably:
EnvClient.runreuses a stablerequest_idand fires_cancel_runif decode/validation fails after the reply, or on request cancellation; the pool broker forwards cancels to the worker that still holds an in-flight run or recently replied within the late-cancel window.Tests now assert reassembly without training arrays instead of routing-row repair/parametrized dtypes.
Reviewed by Cursor Bugbot for commit 1cee4be. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Let host keep finished episodes' training arrays via episode hooks
EpisodeHookwithkeep(episode, sampling)anddiscard(episode), plusset_episode_hookto configure it. When a run finishes, the server runskeepin a worker thread before the final delta flush and tracks the kept episode for cleanup.routed_experts,sampling_mask, andtop_logprobstraining arrays (seeTRAINING_ARRAY_FIELDS), androuting_repairsdelta logic is removed from both streaming and assembly (delta.py).EnvClientcan cancel a run after its reply arrived, andEnvServerPoolforwards such cancels to the worker that handled the request withinLATE_CANCEL_SECONDS(client.py, pool.py).routed_experts(the updated test asserts it isNone); consumers relying on the old streaming repair path in test_delta.py behavior will need the hook to obtain training arrays.Macroscope summarized 1cee4be.