Repository navigation
feat: non-blocking, concurrent run uploads in prime-runs and prime-traces - #1136
Conversation
The upload thread spent ~70 ms of its ~78 ms per 2 MB episode in gzip at the default level 9, so it fell behind a fast producer and the upload queue dropped episodes. Level 1 is ~9x faster for ~13% more bytes on the wire. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
upload_lines sent one batch at a time, so network time and gzip ran in series with encoding. It now keeps up to max_in_flight (default 8, the most the service contract allows per producer) batches in flight on a thread pool. gzip and hashing release the GIL, so the upload rate is bound by record encoding alone. on_batch still runs on the calling thread, and the receipts come back in batch order. A failed batch stops new requests, and its error is raised once the requests in flight have finished. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The upload queue held 256 submit calls, and a full queue blocked the producer for up to 5 s before it dropped the batch. An async caller such as the prime-rl orchestrator waited that long on its arrival path. The queue is now bounded by records (max_queued_records, default 10,000), and submit never blocks: a batch that does not fit is dropped and counted at once. A batch always fits an empty queue. The traces sink also prepared every merged record as a wire mapping before the upload started, so a backlog of large episodes was held twice in memory. It now prepares each record as the client encodes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d6ab9cefaa
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
I ran this at Real-service burst. 1000 episodes of about 2.3 MiB, each with its own numpy arrays, submitted as fast as possible through the real
No warnings or errors on this PR's run. Worth fixing
Memory bound The queue has no byte limit. At the production sizes in prime-rl#4363 (mean 8.5 MB), 10,000 records is about 85 GB of JSON. The 570 and 860 MB peaks in this PR's description come from a load test where episodes share traces. With distinct arrays, my run reached 1.8 GiB for 1000 episodes of about 2.3 MiB, roughly 1.7 MiB of memory per episode. I'd add a byte budget on top of the record bound. #1137 has one that needs no encoding on the producer: the uploader keeps a running average of encoded size, and Not tested yet
The #1137 branch stays up if the write cap or byte budget code is useful: |
Main has #1136 as a squash of this branch's base, so sinks/traces.py conflicted only on that duplicate change. Main's copy equals #1136's head, so this keeps the branch's version of the file. The tree differs from main by this PR's own change alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
## Summary Prime uploads keep up with the orchestrator, and logging to Prime never blocks it. Companion to PrimeIntellect-ai/prime#1136. - **SDK pin.** `prime-runs[train]>=0.1.6`, which pulls `prime-traces` 0.0.12 from PyPI. These releases contain PrimeIntellect-ai/prime#1136: gzip level 1, up to 8 upload requests in flight, an upload queue bounded by records whose `submit` never blocks, and lazily built upload records. - `uv.lock` is written with uv 0.12.0 (lockfile revision 3); only the two SDK packages change. - **No thread hop for Prime.** `PrimeMonitor` calls `run.log_metrics` and `run.log_episodes` directly on the event loop, not through `asyncio.to_thread`. A queue put that never blocks needs no thread, and each hop had to win the GIL back from the uploader thread. - **A production-sized load test.** `tests/unit/monitors/test_monitor_load.py`: - **Episode sizes:** episodes draw their size from a lognormal fitted to a production RL run (median 6.4 MB, p90 17 MB; it also matches the mean of 8.5 MB and the p99 of 35 MB), capped at 60 MB. 1,000 episodes is ~8 GB of JSON, close to one production step of 1,024 samples. - **Memory:** episodes share 24 traces at log-spaced sizes, so the test's own memory stays small. - **Fake endpoint:** the fake Traces endpoint gzips at the SDK's level and counts lines under a lock, because the client now sends several requests at once. - **File monitor check:** it streams every trace chunk, since 8 GB spans two 5 GiB chunks. - **No expected failure:** the prime monitor at 1,000 episodes is no longer marked as one. - **A separate `log()` limit of 1 s.** A `log()` 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. - **The p99 lag limit rises from 50 to 100 ms.** 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. Main's code shows the same stall with this test. #4366 makes the encoder work in short steps and tightens the limits to 25 ms p99 and 100 ms max. ## 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. | | main | This branch | |---|---|---| | Episodes delivered | 725 of 1,000 | **1,000 of 1,000** | | Lost to a full upload queue | 27 | 0 | | Lost when the 60 s drain ran out | 248 | 0 | | Slowest `log()` | up to 5 s (blocked on the full queue) | **0.6 ms** | | Event-loop lag p99 / max | not reached (failed on delivery) | 14 / 17 ms | | Test time | 217 s | 28 s | **Scale: 10,000 episodes of 2.2 MB (~22 GB)**, from a local copy of the test with the count raised: | Setup | Result | |---|---| | No network time | 0 dropped; `log()` max 1.4 ms; event-loop lag p99 14 ms, max 21 ms; 570 MB peak RSS | | 100 ms + 100 MB/s per request, 1 request in flight | 2,595 delivered within the 60 s drain | | Same, 4 in flight | 9,822 delivered | | Same, 8 in flight (the default) | **10,000 delivered**; event-loop lag p99 12 ms; 860 MB peak RSS | The 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. | 1,000 episodes | Event-loop lag p99 / max | `log()` max | |---|---|---| | prime | 14 / 17 ms | 0.6 ms | | file | 47 / 93 ms | 177 ms | | wandb | 0.9 / 0.9 ms | 0.6 ms | **CI:** 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 --check` passes. ## 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. **#4363 (this PR)**: ready to merge. It does not need PrimeIntellect-ai/verifiers#3042. 3. PrimeIntellect-ai/verifiers#3042 4. #4366 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!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` (and `prime-traces` 0.0.12 in the lockfile), which uses a non-blocking upload queue, concurrent uploads, and lighter gzip. > > **`PrimeMonitor`** now calls `run.log_metrics` and `run.log_episodes` directly instead of `asyncio.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). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 11ae629. 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>
…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>
Summary
Uploads from
prime-runskeep up with a fast producer, and logging never blocks it. Part of a stack: merge order below.prime_traces.core.client.GZIP_LEVEL = 1, in the sync and async clients. The default level 9 took ~70 ms of the ~85 ms the uploader spent per 2.2 MB episode; level 1 is ~9x faster and sends ~13% more bytes. This takes over fix: gzip trace uploads at level 1 #1129.TracesClient.upload_lines,upload_recordsandupload_filekeep up tomax_in_flightbatches in flight (default 8, the most the service contract allows per producer). gzip and hashing release the GIL, so encoding the records is the only limit.on_batchstill runs on the calling thread, and receipts come back in batch order.UploadWorker.submitnever blocks. The queue is bounded by records (max_queued_records, default 10,000), not bysubmitcalls. A batch that does not fit is dropped and counted at once; a batch always fits an empty queue. Before, a full queue blocked the producer for up to 5 s, then dropped.TracesSinkprepares records lazily. It builds each record's wire mapping as the client encodes it, so a backlog of large episodes is not held twice in memory.Breaking
UploadWorker(max_queue_size=..., put_timeout=...)becomesUploadWorker(max_queued_records=...).DEFAULT_QUEUE_SIZEandDEFAULT_PUT_TIMEOUTare removed.Runandinit()are unchanged, so only direct users ofUploadWorkerare affected.Verification
pytest packages/prime-runs/tests: 297 passed, 5 skipped.pytest packages/prime-traces/tests: 186 passed.on_batchruns on the calling thread.Merge order
🤖 Generated with Claude Code