Repository navigation
feat: non-blocking, concurrent prime uploads - #4363
Merged
Merged
Conversation
PrimeIntellect-ai/prime#1136 gzips at level 1, keeps up to eight upload requests in flight, and bounds the upload queue by records with a submit that never blocks. prime-runs moves into override-dependencies next to prime-traces, because verifiers pins it to an older commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SDK's submit never blocks now, so the prime monitor calls run.log_metrics and run.log_episodes on the event loop. Each asyncio.to_thread hop had to win the GIL from the uploader thread several times, so under load a log() call took up to 87 ms. The load test drops its expected failure for 1,000 episodes. Its fake Traces endpoint gzips at the SDK's level and counts lines under a lock, since the client now sends several requests at once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The load test used one 2.2 MB episode. A production RL run's episodes are larger and vary a lot: median 6.4 MB, p90 17 MB, p99 35 MB, uncompressed. Each episode now draws its size from a lognormal through that median and p90, which also matches the run's mean and p99. Sizes are capped at 60 MB, under Prime Traces' 64 MiB line limit. The episodes share 24 traces at log-spaced sizes, so the test's memory stays small. A thousand episodes is now about 8 GB of JSON, close to one production step of 1,024 samples. The file monitor's check counted only the first trace chunk, read whole. It now streams every chunk, since 8 GB spans two. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Nov 2, 2026
A log() call may await the monitor's own work: the file monitor awaits its write on a worker thread, which encodes the episode. At production sizes that takes up to ~400 ms on CI for a 60 MB episode, while the event loop keeps serving other tasks. The lag limits cover the loop, so log() calls get a looser limit of 1 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At production sizes the file monitor encodes each episode in one call, which stalls the event loop with a p99 of ~60 ms on CI. The limit covers that until the encoder works in short steps, which lowers it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Verifiers main requires prime-runs from PyPI instead of pinning a git commit, so with the submodule on main the SDK overrides go away. This also moves prime-sandboxes to 0.5.4, which verifiers main requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # deps/verifiers # pyproject.toml # uv.lock
kcoopermiller
previously approved these changes
Nov 3, 2026
# Conflicts: # pyproject.toml
kcoopermiller
approved these changes
Nov 3, 2026
mikasenghaas
added a commit
to PrimeIntellect-ai/verifiers
that referenced
this pull request
Nov 3, 2026
…races (#3042) ## Summary - **`Episode.to_json` and `Trace.to_json` encode one node at a time.** - Pydantic's dump and orjson each hold the GIL for a whole call. Before, each method made one call over the whole record, so encoding a large episode stalled every other thread of the process, such as an asyncio event loop, for as long as it took. - Each call now covers one node, and the encoder releases the GIL after each millisecond of encoding (`time.sleep(0)`). Without that release, a busy thread keeps the GIL until CPython's 5 ms switch interval forces a handoff, and an event loop waits that long several times per wakeup. Releasing after every node instead made encoding ~3x slower. - The output is byte for byte `orjson.dumps(to_record(), option=OPT_SERIALIZE_NUMPY)`. Each trace and node is dumped as its declared field type, as the parent's dump does. The parts are joined once at the end, so the record is copied once. - **`Trace.run`.** `Trace` gets a `run` field, and `Episode.record_run(run)` sets it on the episode and on each of its traces. - Prime Traces reads the run from each trace (`trace.run.id`) and derives an episode's run from its members. So `to_record()` and `to_json()` now carry what the Prime SDK used to stamp, and the SDK can upload `to_json()` as it is (PrimeIntellect-ai/prime#1140). - The run types (`PolicySpan`, `TrainWorkInfo`, `EvalWorkInfo`, `WorkInfo`, `TrainRunInfo`, `EvalRunInfo`, `RunInfo`) move from `episode.py` to `trace.py`, which `Trace` needs. `verifiers.v1` exports them as before. - The delta stream sends `run` with the trace header. Env servers send `None`; the consumer stamps the run when the episode arrives. ## Verification - `pytest tests/unit`: 245 passed, 10 skipped (no harbor, no `PRIME_API_KEY`). - `to_json` equals `orjson.dumps(to_record())` byte for byte: for a trace and an episode, with and without float rounding, with nested `exclude` specs (`__all__` plus a per-trace index), and with a `TaskData` subclass. - `record_run` puts the run on the episode and every trace, `to_json` carries it, and the record validates back to the same run. - Worst event-loop stall while another thread encodes, and CPU per episode. The episodes are synthetic agentic episodes at the sizes of a production RL run: | Episode | Before: CPU / worst stall | After: CPU / worst stall | |---|---|---| | 8.4 MB (production mean) | 20 ms / 15 ms | 21 ms / 2.3 ms | | 35 MB (production p99) | 81 ms / 55 ms | 91 ms / 5.2 ms | | 133 MB (production max) | 319 ms / 191 ms | 359 ms / 12 ms | - End-to-end numbers are in PrimeIntellect-ai/prime-rl#4366, which writes and uploads episodes with this encoder under prime-rl's monitor load test. ## Merge order 1. ~~PrimeIntellect-ai/prime#1136~~ and ~~PrimeIntellect-ai/prime#1140~~: merged and released as `prime-runs` 0.1.6 and `prime-traces` 0.0.12. 2. **#3042 (this PR)** and PrimeIntellect-ai/prime-rl#4363, in either order. 3. PrimeIntellect-ai/prime-rl#4366 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Serialization logic is substantially rewritten (though contract-tested); wire JSON gains optional `trace.run`, and code that imported run types from `verifiers.v1.episode` must import from `trace` or `vf` instead. > > **Overview** > **`Trace.to_json` and `Episode.to_json`** no longer dump the full record in one pydantic/orjson call. They build JSON in pieces (`splice_json`, `trace_json_parts`), encoding each message node separately and yielding the GIL about every millisecond so large rollouts don’t block asyncio and other threads. Output stays **byte-identical** to `orjson.dumps(to_record(), OPT_SERIALIZE_NUMPY)`, including nested `exclude` specs and custom task types. > > **Run metadata moves onto each trace.** `Trace` gains optional `run`, and **`Episode.record_run`** now stamps the same `RunInfo` on the episode and every trace in `traces`. The run-related models (`PolicySpan`, `TrainRunInfo`, `EvalRunInfo`, etc.) live in **`trace.py`** instead of `episode.py`; **`import verifiers.v1 as vf`** still exposes them. The live trace delta stream sends **`run` in the trace header** (often `None` until the consumer stamps it). > > Tests assert `to_json` ↔ `to_record` parity and that stamped runs round-trip through JSON validation. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit dabe65d. 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>
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.
Summary
Prime uploads keep up with the orchestrator, and logging to Prime never blocks it. Companion to PrimeIntellect-ai/prime#1136.
prime-runs[train]>=0.1.6, which pullsprime-traces0.0.12 from PyPI. These releases contain feat: non-blocking, concurrent run uploads in prime-runs and prime-traces prime#1136: gzip level 1, up to 8 upload requests in flight, an upload queue bounded by records whosesubmitnever blocks, and lazily built upload records.uv.lockis written with uv 0.12.0 (lockfile revision 3); only the two SDK packages change.PrimeMonitorcallsrun.log_metricsandrun.log_episodesdirectly on the event loop, not throughasyncio.to_thread. A queue put that never blocks needs no thread, and each hop had to win the GIL back from the uploader thread.tests/unit/monitors/test_monitor_load.py:log()limit of 1 s. Alog()call may await the monitor's own work: the file monitor awaits its write, which encodes the episode on a worker thread, while the event loop keeps serving other tasks. The lag limits still cover the loop.Verification
Headline: Prime monitor, 1,000 production-sized episodes (~8 GB of JSON), main against this branch. Main ran this branch's test, with the fake endpoint gzipping at main's level 9.
log()Scale: 10,000 episodes of 2.2 MB (~22 GB), from a local copy of the test with the count raised:
log()max 1.4 ms; event-loop lag p99 14 ms, max 21 ms; 570 MB peak RSSThe upload rate is ~180 episodes/s (~400 MB/s of JSON). Encoding the records is the limit.
Full load-test grid at production sizes, this branch (dev box): 9 passed.
log()maxCI: on the CI runner, the file monitor at 1,000 episodes measured event-loop lag p99 60 ms and max 157 ms, within this PR's limits (100 / 250 ms), and
log()up to 299 ms (limit 1 s). With #4366 the same case measures p99 6 ms and max 12 ms.Other checks:
pytest tests/unit/train/sft/test_prime_monitor.py tests/unit/utils/test_links.py tests/unit/orchestrator/test_episode_records.py tests/unit/train/rl -m "not gpu": 29 passed.uvx uv@0.12.0 lock --checkpasses.Merge order
feat: non-blocking, concurrent run uploads in prime-runs and prime-traces prime#1136andfeat: upload an episode's own to_json when it names the run prime#1140: merged and released asprime-runs0.1.6 andprime-traces0.0.12.🤖 Generated with Claude Code
Note
Medium Risk
Changes the hot path for RL training telemetry (episode/metrics enqueue) and bumps external SDK versions; misbehavior could drop or delay uploads under load, though load tests now enforce delivery and loop responsiveness.
Overview
Prime logging no longer blocks the orchestrator event loop by pinning
prime-runs[train]>=0.1.6(andprime-traces0.0.12 in the lockfile), which uses a non-blocking upload queue, concurrent uploads, and lighter gzip.PrimeMonitornow callsrun.log_metricsandrun.log_episodesdirectly instead ofasyncio.to_thread, matching SDK behavior where enqueue never blocks.The monitor load test is reworked to use production-like episode sizes (lognormal ~6.4 MB median), shared trace templates for memory, a thread-safe fake Traces API, and multi-chunk file delivery checks. The Prime monitor at 1,000 episodes is no longer expected to fail; responsiveness asserts split event-loop lag (p99 100 ms) from per-
log()duration (max 1 s).Reviewed by Cursor Bugbot for commit 11ae629. Bugbot is set up for automated code reviews on this repo. Configure here.