Repository navigation
perf: per-token streams as numpy arrays, micro batches as raw buffers - #4226
Merged
Merged
Conversation
The orchestrator held every in-flight and buffered episode, and every pending training sample, as Python lists with one entry per token: roughly 1.4x the memory, and billions of references for each full garbage collection to walk once thousands of long agentic episodes are in flight. - Env servers stream per-token node streams as raw-bytes arrays (verifiers `RunRequest.token_arrays`), so in-flight assemblies hold bytes and validated episodes hold typed numpy arrays (int32 ids, bool masks, float32 values). - Advantage, reference-logprob and loss-weight assignment (grpo prompt aggregation, echo, loss routing), the advantage-range gate and zero-advantage pruning work on arrays, vectorised. - `TrainingSample` streams are numpy arrays; `prepare_sample` turns them into the unchanged `MicroBatch` wire format with one `tolist()` per stream. - Live trace files decode the raw-bytes streams before writing JSON. On recorded agentic SWE rollouts (8 groups of 16, 115 samples, 7.8M tokens) the packed MicroBatches are identical to before (float32 compare; mean and prompt loss aggregation; with and without truncation). Per episode held: full GC 7.1 -> 3.1 ms, memory -30%, wire decode 14 -> 9 ms, scoring and sample building 5 -> 2 ms. Bumps verifiers for `token_arrays` and numpy node streams. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MicroBatch per-token streams were msgpack lists: one Python object per token to build in the orchestrator, ~2x the bytes on the wire, and an element-wise `torch.tensor(list)` per stream on the trainer. - Streams are numpy arrays in the orchestrator (packing, padding and dummy batches concatenate arrays) and raw buffers of a fixed dtype on the wire (`MICRO_BATCH_DTYPES`, shipped by the batch encoder's `enc_hook`). - The trainer reads them with one numpy copy that also widens the dtype (`micro_batch_stream` + `astype`), instead of building tensors from lists. - `env_names` is one name per sequence (was one per token), and the padding count is explicit (`padding`); per-env entropy and mismatch-KL metrics select tokens with vectorised masks, and annotations strip padding by count. On recorded agentic rollouts (104 micro batches, 6.5M tokens, padding and dummy batches included) the trainer's tensors are identical to before. Wire 260 -> 137 MB, encode 0.42 -> 0.10 s, decode 1.35 -> 0.00 s, list-to-tensor conversion 2.33 -> 0.11 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
verifiers now builds env-server node streams as arrays, and renderers returns completion logprobs as a float32 array. The per-token streams are arrays from the vLLM response to the trainer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
verifiers now always streams node streams as raw-bytes arrays, so the orchestrator no longer asks for them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…en-arrays # Conflicts: # deps/verifiers
mikasenghaas
commented
Oct 23, 2026
Packing reads the sample streams directly, appends each sample's arrays and concatenates them once per bin, and pads with np.pad. This replaces the per-stream helpers (_stream, micro_batch_stream and the inner joined/pad functions). The wire dtype is now checked in one place: the per-micro-batch alignment assert fails when a stream is not its MICRO_BATCH_DTYPES dtype. The trainer reads each stream with np.frombuffer. The file monitor decodes live node streams with verifiers' public decode_ndarray. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
renderers returns completion_logprobs as a float32 array, so the compact and native evidence tuples no longer compare with ==. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Wire-decoded episodes hold read-only numpy streams. Check that the file monitor's trace stream, live files and annotations, and the Prime upload projections, still carry JSON lists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
samsja
previously approved these changes
Oct 24, 2026
renderers#201 merged, so deps/renderers moves to the merge commit on main. verifiers now requires renderers 0.1.13.dev17. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ndarray codec now lives in verifiers.v1.types, so the file monitor imports decode_ndarray from there. 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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mikasenghaas
added a commit
to PrimeIntellect-ai/verifiers
that referenced
this pull request
Oct 24, 2026
## Summary A `MessageNode`'s per-token streams were Python lists, with one object per token. These streams are `token_ids`, `mask`, `is_content` and `logprobs`, plus the consumer-stamped `advantages`, `reference_logprobs`, `trainer_logprobs`, `entropies` and `loss_weights`. The cyclic GC walks each object on every full collection, and msgpack packs them one by one. An RL orchestrator that holds thousands of long agentic episodes spent minutes per full collection, with the GIL held. This PR makes every stream a typed numpy array, from the renderer client to the wire. - **The field types carry their dtype.** `IntStream` is an int32 array, `BoolStream` a bool array and `FloatStream` a float32 array. Validation accepts a list, an array or a raw-bytes `__nd__` dict, the encoding `routed_experts` already uses. Field defaults are typed empty arrays. - The types are plain `np.ndarray` with a JSON schema attached. A `list[int] | np.ndarray` union makes pydantic try the list branch element by element: about 200 ms per 200k-token node. - Serialization stays a `@field_serializer`, because `serialize_as_any` (the wire dump) skips serializers in `Annotated` types. - **`TurnTokens` streams are typed arrays too:** `prompt_ids`, `completion_ids`, `completion_logprobs`, `renderer_prompt_ids` and `is_content` use the stream types. - The config tree imported `verifiers.v1.types` for only `ID`, `DialectName` and `SamplingConfig`, and must load without numpy. `ID` and `DialectName` now live in `verifiers.v1.configs.harness`, and `SamplingConfig` (with its `Sampling` alias) in the new `verifiers.v1.configs.sampling`. `verifiers.v1.types` no longer has them; every importer and the `vf.*` lazy table use the new modules. - `verifiers.v1.types` now imports numpy and holds the stream types and the ndarray codec, which moved from `graph.py`. The config tree still loads without numpy and without `verifiers.v1.types`. - **The env server builds nodes from arrays.** - The renderer client converts the turn's token lists to arrays once (`TurnTokens`). - The turn's commit copies each node's slice. A view would keep the turn's full-context prompt alive. - `renderer_token_ids` is set only after multimodal expansion. Before, every node held a second copy of `token_ids` there. - The multimodal prompt projection is vectorised. Token-span comparisons use `np.array_equal`. - Renderer bridging still takes lists, so `previous_renderer_token_ids` converts the context once per turn. - **The env server always streams node streams as raw bytes** (`__nd__` dicts), not msgpack arrays. A server that predates this PR still sends lists, which validate the same way. - **`encode_ndarray` / `decode_ndarray` are public**, so consumers of raw deltas (prime-rl's live trace files) can decode them. - **JSON records are unchanged.** They are still lists, with logprob, advantage and entropy streams rounded to `float_decimals`. Loss weights stay exact. Python dumps without the wire context also still give lists. - **`Branch` returns numpy arrays:** `token_ids` int32, `sampled_mask` bool, the spread streams and `loss_weights` float32, `mm_token_type_ids` int32. `spread` is vectorised. Part of a set with PrimeIntellect-ai/renderers#201 (merged: logprobs decode to a float32 array without a list round trip) and PrimeIntellect-ai/prime-rl#4226 (the consumer). This PR requires `renderers>=0.1.13.dev17`, the first dev release with #201. ## Breaking - **Env server and client must both include this PR.** An older client cannot validate the raw-bytes node streams, so its rollouts fail validation. A newer client with an older server still works. - `Branch.token_ids`, `sampled_mask`, `logprobs`, `advantages`, `reference_logprobs`, `trainer_logprobs`, `entropies`, `loss_weights(name)` and `mm_token_type_ids` return numpy arrays, not lists. `MessageNode`'s stream fields hold arrays too. Callers that compare with `==`, rely on truthiness or concatenate with `+` must use `.tolist()`, `len()`, `.any()` or `np.concatenate`. - `MessageNode.renderer_token_ids` is `None` unless multimodal expansion changed the prompt. Read `logical_ids` to get the renderer tokens in every case. - `TurnTokens.prompt_ids`, `completion_ids`, `completion_logprobs`, `renderer_prompt_ids` and `is_content` are typed numpy arrays. Lists still validate, into arrays. ## Tests - `tests/unit/test_token_arrays.py`: - typed arrays after validation, and typed empty defaults; - `__nd__` wire round trip, including `loss_weights`, and lists without the context; - JSON records stay lists and round correctly; - committed env-server nodes hold owned typed arrays and no duplicate renderer ids; - over 300 randomised branches mixing list and array nodes, vectorised `spread` matches the previous list implementation exactly. - Graph and semantic tests compare streams via `list(...)` or `.tolist()`. - `tests/unit` passes locally, except three tests that fail the same way on this PR's base: two `runtimes/test_private_network.py` VM tests and `test_api.py::test_config_tree_imports_without_the_runtime`. The test host's venv has a `.pth` hook that loads `nvidia_cutlass_dsl` into every process. Run by hand, the config tree loads neither numpy nor `verifiers.v1.types`. - `ty check verifiers` passes. ## Measured A recorded batch of 2048 agentic episodes (393k nodes, 194M tokens, branches up to 262k tokens) was replayed through the production env-server and wire code, on `main` and on this PR (with PrimeIntellect-ai/prime-rl#4226 as the client): | | `main` (lists) | This PR (arrays) | |---|---|---| | Env server: memory for the held traces | 22.6 GB | 5.7 GB | | Env server: stream encode (dump + pack) | 26.2 s | 9.3 s | | Wire size | 3.72 GB | 2.99 GB | | Env server: full GC with the traces held | 13.5 s | 6.8 s | | Client: decode (unpack + assemble + validate) | 19.4 s | 6.9 s | | Client: memory for the held episodes | 21.2 GB | 6.0 GB | The replay is single-process and CPU-only. GC is disabled during timed phases and timed separately. Memory is RSS growth. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Breaking public API (`Branch`/`MessageNode` streams are arrays) and env-server node wire format requires a matching client; core trace/graph/training paths are affected but covered by targeted tests. > > **Overview** > **Per-token trace data is stored as typed NumPy arrays end-to-end**, replacing Python lists on `MessageNode`, `TurnTokens`, and `Branch` aggregations (`token_ids`, masks, logprobs, advantages, loss weights, etc.). Validation accepts lists, arrays, or `__nd__` wire dicts; JSON records still serialize as lists. > > **Config types move out of `verifiers.v1.types`** so the config tree can load without NumPy: `SamplingConfig` / `Sampling` → `verifiers.v1.configs.sampling`, `ID` and `DialectName` → `verifiers.v1.configs.harness`, with imports and lazy `vf.*` exports updated accordingly. > > **Turn commit and training client paths** convert renderer output to arrays once, copy per-node slices (no views), vectorize multimodal attribution, and only set `renderer_token_ids` when the prompt is multimodal-expanded. **Env-server deltas** stream new nodes with `token_arrays` so token streams use raw-bytes encoding via public `encode_ndarray` / `decode_ndarray`. > > Bumps **`renderers[multimodal]`** to `>=0.1.13.dev17` and adds **`tests/unit/test_token_arrays.py`** (wire round-trip, spread parity, committed-node ownership). Existing graph/semantic tests compare streams via `.tolist()` / `list(...)`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 058db32. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- Macroscope's pull request summary starts here --> <!-- Macroscope will only edit the content between these invisible markers, and the markers themselves will not be visible in the GitHub rendered markdown. --> <!-- If you delete either of the start / end markers from your PR's description, Macroscope will append its summary at the bottom of the description. --> > [!NOTE] > ### Store per-token node streams as typed NumPy arrays in `MessageNode` > - Adds typed stream aliases (`IntStream`, `BoolStream`, `FloatStream`) and a `_token_stream` validator in [graph.py](https://github.com/PrimeIntellect-ai/verifiers/pull/2961/files#diff-64c9d635eb215902a512c9e8ab316fd9b9967c36db03d4049aacc812cdfbd740) so token IDs, masks, logprobs, advantages, entropies, and loss weights on `MessageNode` become int32/bool/float32 arrays. > - Rewrites graph commit, prefix reconciliation, and multimodal attribution helpers in [graph.py](https://github.com/PrimeIntellect-ai/verifiers/pull/2961/files#diff-64c9d635eb215902a512c9e8ab316fd9b9967c36db03d4049aacc812cdfbd740) to use array operations and a new `same_tokens` equality helper instead of list comparisons. > - `Branch` properties and `spread` in [trace.py](https://github.com/PrimeIntellect-ai/verifiers/pull/2961/files#diff-834820c3aa80d87ee1c1c57fb54c74f09c66ac727036d964e6005c8613c00b5e) now return concatenated typed arrays instead of Python lists; JSON dumps stay list-based, and an opt-in token-array wire format uses public `encode_ndarray`/`decode_ndarray` (also used by delta streaming in [delta.py](https://github.com/PrimeIntellect-ai/verifiers/pull/2961/files#diff-106a565d15f6f5c86f7357854d512768b3f8a4775026a0eae32c69b828f5da31)). > - [train.py](https://github.com/PrimeIntellect-ai/verifiers/pull/2961/files#diff-c9bcd6c691890b1de6942fe012d2e74678460b22bf335721a8bba5e939d139c3) converts generated streams to arrays before building `TurnTokens`, and [types.py](https://github.com/PrimeIntellect-ai/verifiers/pull/2961/files#diff-88a4e4ee0c58ac5dc7344161a011374082a3767eda2ad32af97c942dca982112) loosens those annotations to `Any` to avoid a NumPy import. > - Behavioral Change: `Branch.token_ids`, `sampled_mask`, `logprobs`, `advantages`, `mm_token_type_ids` and related properties return NumPy arrays instead of lists; `Branch.spread` returns a float32 array. Out-of-tree code comparing these with `==` or expecting lists will behave differently. > > <!-- Macroscope's changelog starts here --> > #### Changes since #2961 opened > > - Updated `renderers[multimodal]` dependency version constraint [0d44cb4] > - Introduced typed numpy array stream infrastructure for token data in `verifiers.v1.types` module, including `encode_ndarray`, `decode_ndarray`, `as_list` utilities, `_token_stream` factory with pydantic `BeforeValidator` and `WithJsonSchema`, typed stream aliases `IntStream`, `BoolStream`, `FloatStream`, and `empty_stream` default factory [6a17126] > - Converted `TurnTokens` model fields to use typed numpy array streams with automatic serialization in `verifiers.v1.types` module [6a17126] > - Refactored `MessageNode` class in `verifiers.v1.graph` module to use centralized token stream utilities from `verifiers.v1.types` [6a17126] > - Created `verifiers.v1.configs.types` module defining `DialectName`, `SamplingConfig`, `Sampling` alias, and `ID` type with validation logic [6a17126] > - Updated imports across `verifiers.v1.configs.agent`, `verifiers.v1.configs.cli.eval`, `verifiers.v1.configs.env`, `verifiers.v1.configs.harness`, `verifiers.v1.configs.judge`, `verifiers.v1.configs.taskset`, and `verifiers.v1.utils.generic` modules to source types from `verifiers.v1.configs.types` [6a17126] > - Renamed `verifiers.v1.configs.types` module to `verifiers.v1.configs.sampling` and redistributed type definitions by moving `ID` and `DialectName` type aliases to `verifiers.v1.configs.harness` [61c8062] > - Updated import paths for `Sampling` and `SamplingConfig` from `verifiers.v1.types` to `verifiers.v1.configs.sampling` across all consuming modules [61c8062] > - Updated import paths for `ID` and `DialectName` from `verifiers.v1.types` to `verifiers.v1.configs.harness` across all consuming modules [61c8062] > - Removed re-exports of `ID`, `DialectName`, `Sampling`, and `SamplingConfig` from `verifiers.v1.types` module and updated lazy-loading configuration in `verifiers.v1.__init__` [61c8062] > - Reordered and deduplicated imports across all `verifiers.v1` modules, test files, and related configuration modules [83e11cd] > - Reflowed and rewrapped the docstring text for the `_token_stream` function in the `verifiers/v1` module [058db32] > <!-- Macroscope's changelog ends here --> > > <!-- Macroscope's review summary starts here --> > > <details> > <summary>📊 <a href="https://app.macroscope.com">Macroscope</a> summarized 49ae611. 3 files reviewed, 1 issue evaluated, 0 issues filtered, 1 comment posted</summary> > > ### 🗂️ Filtered Issues > > </details><!-- Macroscope's review summary ends here --> > <!-- Macroscope's pull request summary ends here --> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
verifiers#2961 merged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mikasenghaas
marked this pull request as ready for review
October 24, 2026 13:36
samsja
approved these changes
Oct 24, 2026
faresobeid
pushed a commit
that referenced
this pull request
Oct 24, 2026
## Summary - `node_policy_versions` counts sampled tokens with `np.count_nonzero(node.mask)` instead of `sum(node.mask)`. `MessageNode.mask` is a numpy bool array since PrimeIntellect-ai/verifiers#2961, which #4226 pulled into main. Python's `sum()` walks that array one token at a time and returns an `np.int64`. `np.count_nonzero` is vectorized, accepts lists too, and the `int()` cast keeps the metric values as plain ints. - `test_token_policy_ages` builds its masks as numpy arrays, which is the type main now produces. Targets #4141. Checks: `ruff check` and `ruff format --check` pass. I also ran `token_policy_ages` on the test case with list masks and with numpy masks. Both returned `(20, 8, 3)` as plain ints. I did not run pytest, because the import chain needs `torch` and the test machine (macOS) does not have it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Small metric-calculation fix for numpy masks; behavior unchanged aside from correct int types and performance. > > **Overview** > **Fixes sampled-token counting** now that `MessageNode.mask` is a numpy bool array (from verifiers): `node_policy_versions` uses `int(np.count_nonzero(node.mask))` instead of `sum(node.mask)`, avoiding slow element-wise summation and keeping metrics as plain Python `int`s rather than `np.int64`. > > The unit test builds trace masks with `np.array(...)` to match production types. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 5a96878. 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>
faresobeid
pushed a commit
that referenced
this pull request
Oct 24, 2026
## Summary - `node_policy_versions` counts sampled tokens with `np.count_nonzero(node.mask)` instead of `sum(node.mask)`. `MessageNode.mask` is a numpy bool array since PrimeIntellect-ai/verifiers#2961, which #4226 pulled into main. Python's `sum()` walks that array one token at a time and returns an `np.int64`. `np.count_nonzero` is vectorized, accepts lists too, and the `int()` cast keeps the metric values as plain ints. - `test_token_policy_ages` builds its masks as numpy arrays, which is the type main now produces. Targets #4141. Checks: `ruff check` and `ruff format --check` pass. I also ran `token_policy_ages` on the test case with list masks and with numpy masks. Both returned `(20, 8, 3)` as plain ints. I did not run pytest, because the import chain needs `torch` and the test machine (macOS) does not have it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Small metric-calculation fix for numpy masks; behavior unchanged aside from correct int types and performance. > > **Overview** > **Fixes sampled-token counting** now that `MessageNode.mask` is a numpy bool array (from verifiers): `node_policy_versions` uses `int(np.count_nonzero(node.mask))` instead of `sum(node.mask)`, avoiding slow element-wise summation and keeping metrics as plain Python `int`s rather than `np.int64`. > > The unit test builds trace masks with `np.array(...)` to match production types. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 5a96878. 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>
mikasenghaas
added a commit
that referenced
this pull request
Oct 25, 2026
## Summary - **Problem:** with about 12k long agentic episodes in flight, the orchestrator's event loop froze periodically. The freezes grew to 42 s every ~100 s after 40 min, and to 190 s every ~400 s after 4 h. Nothing ran during a freeze: no dispatch, no completions, and the trainer waited for the weight-broadcast handshake. The cause is CPython 3.12's automatic full (generation-2) collection. It holds the GIL, walks every object the orchestrator holds, and runs each time the long-lived heap grows by 25%. - **Shared `GCConfig`:** the trainer's step-based `GCConfig` moves to `prime_rl.configs.shared`, and the orchestrator uses it as `[orchestrator.gc]` (`interval`, default 50 steps). The trainer config is unchanged. `gc = null` keeps Python's default behavior. - **`GarbageCollector`** (`orchestrator/gc.py`): - At startup, it collects and freezes the startup heap. - It turns off automatic full collections. Young collections keep running. - It runs one full collection every `interval` steps, right after a batch ships, and logs how many objects it freed. - Collections that hold the GIL for 1 s or longer are logged. `gc/collections`, `gc/pause_max` and `gc/pause_total` join the pipeline metrics. - **Dispatcher cycle:** the dispatcher reads a failed request's exception with `task.exception()` instead of re-raising it with `task.result()`. The re-raise added the handler frame (which holds `task`) to the traceback. That made a task ↔ exception cycle that kept the partial episode alive until a full collection. Companion: #4226 (merged) moved per-token streams to numpy arrays, which removed most of the per-token Python objects. ## Verification Replay on one compute node. The real `orchestrator` entrypoint ran against a stub env server that replays 2048 recorded agentic episodes from real data over the verifiers serve protocol. Each episode streams turn by turn over 15–45 min. A stub trainer consumes batches over the filesystem transport and runs the weight-broadcast handshake each step (30 s per step). Settings: 12,288 episodes in flight, `batch_size = 256`, `seq_len = 262144`, 8 data ranks. Both variants ran side by side for 88 min with the same external probe (GC callbacks, a GIL-stall ticker thread, RSS). | | before (`main`) | after (this PR) | |---|---|---| | Full collections | 66 automatic | 1 scheduled (step 50) | | Longest full collection | 42.4 s | 37.2 s | | Total time frozen in full collections | 1,248 s (24% of the run) | 37 s | | Freezes over 10 s | 38 | 1 | | Longest freeze outside full collections | — | 0.5 s | | Broadcast handshake p90 / max | 32.4 s / 40.8 s | 0.15 s / 9.3 s | | Steps shipped | 42 | 53 | | Throughput after the first step | 63 steps/h | 69 steps/h | | Tokens shipped | 1.21 B | 1.49 B | | Peak RSS | 74 GB | 71 GB | | Errors | 0 | 0 | - On `main`, the automatic full collections grew from 1.5 s to 42.4 s as the heap grew, about every 2 min. This matches the production pattern. Each one freed only 0–153 objects. - With this PR, no automatic full collection ran. The one scheduled full collection freed 1,476 objects. - A scheduled full collection still costs as much as an automatic one at the same heap size (37 s here). This PR makes full collections rare and predictable. It does not make them cheaper. Unit tests: `tests/unit/orchestrator` 132 passed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes orchestrator runtime behavior (GC timing and event-loop pauses) and error-handling in the hot dispatch path; config is opt-out via `gc = null` but defaults alter production GC behavior. > > **Overview** > Addresses orchestrator event-loop freezes from CPython’s automatic full garbage collections on large in-flight episode heaps by **scheduling GC instead of letting it run unpredictably**. > > **`GCConfig`** is moved to shared config and wired into the orchestrator as `[orchestrator.gc]` (default every 50 steps; `null` restores Python defaults). A new **`GarbageCollector`** freezes the startup heap, disables automatic generation-2 collections (young GC still runs), runs a full `gc.collect()` on a fixed step interval after batches ship, and reports pause metrics (`gc/collections`, `gc/pause_max`, `gc/pause_total`) in the pipeline view. > > The **dispatcher** stops re-raising failed task exceptions via `task.result()` and uses `task.exception()` instead, avoiding a task↔traceback reference cycle that kept partial episodes alive until rare full collections. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a4047fa. 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
The orchestrator held every in-flight and buffered episode, and every pending
TrainingSample, as Python lists with one entry per token. That made memory about 3.5x larger and put hundreds of millions of references in every full GC. It also made the per-token Python loops expensive: advantage assignment, echo weighting and zero-advantage pruning. This PR keeps per-token streams as typed arrays (int32 ids, bool masks, float32 values) from the vLLM response to the trainer.deps/renderers(perf: return completion logprobs as a float32 array renderers#201) decodes sampled logprobs to a float32 array, without a float64 list round trip.deps/verifiers(perf(v1): per-token node streams as numpy arrays verifiers#2961) builds env-server nodes as owned arrays.assign_advantagesandassign_reference_logprobs;scalar_advantageandis_trainable;stamp_loss_routing;length_weighted_baselineandpromptloss aggregation;_prune_zero_advantages, which also ran per token on the event loop.TrainingSampleandMicroBatchstreams are numpy arrays.main's shape: each sample's arrays are appended and concatenated once per bin, and padding usesnp.pad.MicroBatchstream is a raw buffer of a fixed dtype (MICRO_BATCH_DTYPES, shipped by the batch encoder'senc_hook). The per-micro-batch alignment assert also checks each stream's dtype.np.frombuffer+astype), nottorch.tensor(list).MicroBatch.env_namesholds one name per sequence, not one per token. A newpaddingfield holds the padding count. The per-env entropy and mismatch-KL metrics expand the names oversequence_lengthsand select tokens with vectorised masks. The annotation export strips padding by count.decode_ndarraybefore writing JSON.Depends on PrimeIntellect-ai/verifiers#2961.
deps/verifierspoints at that PR's commit, so this stays a draft until it merges and the pin moves tomain. PrimeIntellect-ai/renderers#201 has merged;deps/rendererspoints atmain. Companion to #4225, which stops automatic full GCs. This PR makes each full GC cheaper.Breaking
MicroBatchwire format changes. Per-token streams are raw buffers, not msgpack lists, andenv_namesis per sequence. The orchestrator and the trainer must run the same prime-rl commit.Verification
prepare_batch(seq_len=262144, 8 ranks), the batch encoder, the trainer decoder and_micro_batch_to_tensor. Both trees produced the same 1802 samples, 167M sample tokens and 648 micro batches.main:float_decimals), live trace files, the Prime Traces payload, the samples projection and the training-viewer parquet rows.float_decimals=None) and annotation advantages print some floats differently, as the float32 value's shortest form. Every differing number is the same float32 value.MicroBatchfield and every scalar advantage matchedmain(float32 compare). That held for mean andpromptloss aggregation, with and without truncation. Every trainer tensor also matchedmainon 104 micro batches with padding and dummy batches.tests/unit/orchestrator/test_episode_records.pysends a wire-decoded episode through the file monitor's episode, live and annotation writers and the Prime upload projections. It checks that each output holds JSON lists.tests/unit/orchestrator,transports,eval,utilsand the dashboard tests pass locally. One exception:test_prefill_logprobs_uses_inference_generatefails at import, because the local vLLM lacksGenerateTokensResponse.Measured
The 2048-episode replay. It is single-process and CPU-only; GC is disabled during timed phases and timed separately; memory is RSS growth. Trainer figures add up all 8 ranks.
mainprepare_batch)The remaining full-GC time is pydantic's per-model objects (about 8M for this batch), not token data. #4225's scheduled-only full collection keeps it off the hot path.
🤖 Generated with Claude Code
Note
High Risk
Breaking batch wire format and env-server ndarray streams require lockstep orchestrator/trainer/verifiers upgrades; training correctness depends on dtype alignment and per-sequence
env_names/paddingsemantics.Overview
Moves per-token training data from Python lists to typed numpy arrays end-to-end (orchestrator samples → packed
MicroBatch→ trainer tensors), cutting memory, GC cost, and Python-loop overhead on advantage assignment, echo/GRPO weighting, pruning, and packing.Wire format (breaking):
MicroBatchper-token fields are raw byte buffers (MICRO_BATCH_DTYPES) viaencode_array, not msgpack lists.env_namesis one entry per sequence (not per token); newpaddingtracks trailing pad tokens for annotation export and metrics. Trainer builds tensors withnp.frombuffer+astypeinstead oftorch.tensor(list).Algorithms & sink use vectorized mask/weight ops; OPD/OPSD pass
token_ids.tolist()to scorers. Live trace files decode__nd__payloads withdecode_ndarraybefore JSON. Disk/upload paths still emit plain JSON lists (newtest_episode_records.py).Reviewed by Cursor Bugbot for commit 9652f05. Bugbot is set up for automated code reviews on this repo. Configure here.