Repository navigation
perf(v1): per-token node streams as numpy arrays - #2961
Merged
Merged
Conversation
A node's per-token streams (token_ids, mask, is_content, logprobs and the consumer-stamped advantages / reference_logprobs / trainer_logprobs / entropies / loss_weights) were Python lists: one object per token that the cyclic garbage collector walks and msgpack packs one by one. A training orchestrator holding thousands of long agentic episodes spends a large share of each full collection walking them. - Validation turns every stream into a numpy array of its dtype (int32 ids, bool masks, float32 values) from a list, an array or a raw-bytes `__nd__` dict. The fields are typed as plain annotated arrays: a `list | ndarray` union makes pydantic try the list branch element by element. - The env server still builds nodes unvalidated from lists; its token-span comparisons go through `same_tokens`, so either form compares correctly. - `RunRequest.token_arrays` asks the server to stream these fields as `__nd__` raw bytes. Servers that predate it ignore it and send lists, which validate the same. JSON records and other python dumps still give lists. - `Branch` returns numpy arrays (token_ids int32, sampled_mask bool, spread streams and loss_weights float32, mm_token_type_ids int32); `spread` is vectorised and matches the list version exactly (randomised test). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
ApprovabilityVerdict: Needs Human Approval Macroscope's review found this PR requires human approval — This is a broad production refactor that changes token-stream types, public Branch/MessageNode APIs, graph processing, and the env-server wire format. It requires coordinated client/server compatibility and review of downstream consumers despite strong targeted test coverage. You can add or adjust custom eligibility rules. Learn more. |
The env server held every node stream as a Python list until the wire dump converted it. Now the renderer client converts the turn's tokens to typed arrays once, and the turn's commit builds each node from them: - Each node owns a copy of its slice, so no node keeps the turn's full-context prompt alive. - renderer_token_ids is set only after multimodal expansion; it was a second copy of token_ids on every node. - The multimodal prompt projection is vectorised. The stream field types now carry their dtype (IntStream is an int32 array), so the per-field dtype map and validators are gone. Field defaults are typed empty arrays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mikasenghaas
commented
Oct 23, 2026
The env server streamed raw-bytes arrays only when the client asked with RunRequest.token_arrays. The only client always asked, so drop the flag: node streams always ride the wire as raw bytes. An env server and its client must both include this change. An older client cannot validate the array form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
prime-rl decodes raw-bytes node streams in its live trace files, so encode_ndarray and decode_ndarray are now public names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
samsja
previously approved these changes
Oct 24, 2026
hallerite
previously approved these changes
Oct 24, 2026
renderers 0.1.13.dev17 returns completion logprobs as a float32 array (PrimeIntellect-ai/renderers#201). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TurnTokens held its streams as Any, because the config tree imports types.py and must load without numpy. The config tree needs only ID, DialectName and SamplingConfig from it, so those move to configs/types.py (types.py re-exports them) and types.py now imports numpy. The stream types (IntStream, BoolStream, FloatStream) and the ndarray codec move from graph.py to types.py. TurnTokens' prompt_ids, completion_ids, completion_logprobs, renderer_prompt_ids and is_content use them, so validation makes typed arrays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ID and DialectName move to configs/harness.py (DialectName types the harness dialect setting), and SamplingConfig and Sampling move to the new configs/sampling.py. configs/types.py goes, and verifiers.v1.types no longer re-exports them: every importer, and the vf.* lazy table, use the new modules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hallerite
previously approved these changes
Oct 24, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mikasenghaas
added a commit
to PrimeIntellect-ai/prime-rl
that referenced
this pull request
Oct 24, 2026
…#4226) ## 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. - **vLLM to the env server:** `deps/renderers` (PrimeIntellect-ai/renderers#201) decodes sampled logprobs to a float32 array, without a float64 list round trip. `deps/verifiers` (PrimeIntellect-ai/verifiers#2961) builds env-server nodes as owned arrays. - **Env server to the orchestrator:** env servers always stream node streams as raw-bytes arrays. In-flight assemblies hold the bytes, and validated episodes hold arrays that view them. - **Algorithms** work on arrays, vectorised: - `assign_advantages` and `assign_reference_logprobs`; - `scalar_advantage` and `is_trainable`; - `stamp_loss_routing`; - grpo `length_weighted_baseline` and `prompt` loss aggregation; - echo observation weights; - the advantage-range curriculum gate; - `_prune_zero_advantages`, which also ran per token on the event loop. - **Samples and micro batches:** `TrainingSample` and `MicroBatch` streams are numpy arrays. - Packing keeps `main`'s shape: each sample's arrays are appended and concatenated once per bin, and padding uses `np.pad`. - On the wire, each `MicroBatch` stream is a raw buffer of a fixed dtype (`MICRO_BATCH_DTYPES`, shipped by the batch encoder's `enc_hook`). The per-micro-batch alignment assert also checks each stream's dtype. - **Trainer:** each stream becomes a tensor through one numpy copy that also widens the dtype (`np.frombuffer` + `astype`), not `torch.tensor(list)`. - **`MicroBatch.env_names`** holds one name per sequence, not one per token. A new `padding` field holds the padding count. The per-env entropy and mismatch-KL metrics expand the names over `sequence_lengths` and select tokens with vectorised masks. The annotation export strips padding by count. - **Live trace files** decode the raw-bytes streams with verifiers' public `decode_ndarray` before writing JSON. - **opd/opsd** send token ids to the scorer as lists. Depends on PrimeIntellect-ai/verifiers#2961. `deps/verifiers` points at that PR's commit, so this stays a draft until it merges and the pin moves to `main`. PrimeIntellect-ai/renderers#201 has merged; `deps/renderers` points at `main`. Companion to #4225, which stops automatic full GCs. This PR makes each full GC cheaper. ## Breaking - **The `MicroBatch` wire format changes.** Per-token streams are raw buffers, not msgpack lists, and `env_names` is per sequence. The orchestrator and the trainer must run the same prime-rl commit. - **Env servers and the orchestrator must both include verifiers#2961.** Node streams always ride the env-server wire as raw bytes, which an older client cannot validate. ## Verification - **Replay at scale.** A recorded batch of 2048 agentic episodes (147 groups, 393k nodes, 194M tokens, branches up to 262k tokens) went through the production env-server stream, decode, GRPO scoring, sample building, `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. - **Disk and upload formats on the same 2048 episodes,** this branch against `main`: - Byte-identical for every episode: the file monitor's trace records (default `float_decimals`), live trace files, the Prime Traces payload, the samples projection and the training-viewer parquet rows. - Full-precision records (`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. - **Orchestrator equivalence.** On an earlier recorded batch (115 samples, 7.8M tokens), every packed `MicroBatch` field and every scalar advantage matched `main` (float32 compare). That held for mean and `prompt` loss aggregation, with and without truncation. Every trainer tensor also matched `main` on 104 micro batches with padding and dummy batches. - **Unit tests:** - New `tests/unit/orchestrator/test_episode_records.py` sends 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`, `utils` and the dashboard tests pass locally. One exception: `test_prefill_logprobs_uses_inference_generate` fails at import, because the local vLLM lacks `GenerateTokensResponse`. - **Not yet run:** a GPU training run. ## 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. | | `main` | This PR | |---|---|---| | Decode (unpack + assemble + validate) | 19.4 s | 6.9 s | | Memory for the held episodes | 21.2 GB | 6.0 GB | | Score + build samples | 23.0 s | 7.8 s | | Memory for the pending samples | 6.8 GB | 3.1 GB | | Full GC, episodes + samples held | 14.8 s | 7.6 s | | Pack (`prepare_batch`) | 9.5 s | 3.6 s | | Batch wire size | 6.79 GB | 3.51 GB | | Batch encode | 5.3 s | 1.1 s | | Trainer decode | 20.4 s | 0.003 s | | Trainer tensor conversion | 25.4 s | 0.20 s | | Peak orchestrator RSS | 85.3 GB | 17.5 GB | 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](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!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`/`padding` semantics. > > **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):** `MicroBatch` per-token fields are **raw byte buffers** (`MICRO_BATCH_DTYPES`) via `encode_array`, not msgpack lists. **`env_names`** is **one entry per sequence** (not per token); new **`padding`** tracks trailing pad tokens for annotation export and metrics. Trainer builds tensors with **`np.frombuffer` + `astype`** instead of `torch.tensor(list)`. > > **Algorithms & sink** use vectorized mask/weight ops; OPD/OPSD pass **`token_ids.tolist()`** to scorers. **Live trace files** decode `__nd__` payloads with **`decode_ndarray`** before JSON. Disk/upload paths still emit plain JSON lists (new `test_episode_records.py`). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9652f05. 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
to PrimeIntellect-ai/prime-rl
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
to PrimeIntellect-ai/prime-rl
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>
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
A
MessageNode's per-token streams were Python lists, with one object per token. These streams aretoken_ids,mask,is_contentandlogprobs, plus the consumer-stampedadvantages,reference_logprobs,trainer_logprobs,entropiesandloss_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.IntStreamis an int32 array,BoolStreama bool array andFloatStreama float32 array. Validation accepts a list, an array or a raw-bytes__nd__dict, the encodingrouted_expertsalready uses. Field defaults are typed empty arrays.np.ndarraywith a JSON schema attached. Alist[int] | np.ndarrayunion makes pydantic try the list branch element by element: about 200 ms per 200k-token node.@field_serializer, becauseserialize_as_any(the wire dump) skips serializers inAnnotatedtypes.TurnTokensstreams are typed arrays too:prompt_ids,completion_ids,completion_logprobs,renderer_prompt_idsandis_contentuse the stream types.verifiers.v1.typesfor onlyID,DialectNameandSamplingConfig, and must load without numpy.IDandDialectNamenow live inverifiers.v1.configs.harness, andSamplingConfig(with itsSamplingalias) in the newverifiers.v1.configs.sampling.verifiers.v1.typesno longer has them; every importer and thevf.*lazy table use the new modules.verifiers.v1.typesnow imports numpy and holds the stream types and the ndarray codec, which moved fromgraph.py. The config tree still loads without numpy and withoutverifiers.v1.types.TurnTokens).renderer_token_idsis set only after multimodal expansion. Before, every node held a second copy oftoken_idsthere.np.array_equal.previous_renderer_token_idsconverts the context once per turn.__nd__dicts), not msgpack arrays. A server that predates this PR still sends lists, which validate the same way.encode_ndarray/decode_ndarrayare public, so consumers of raw deltas (prime-rl's live trace files) can decode them.float_decimals. Loss weights stay exact. Python dumps without the wire context also still give lists.Branchreturns numpy arrays:token_idsint32,sampled_maskbool, the spread streams andloss_weightsfloat32,mm_token_type_idsint32.spreadis 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
Branch.token_ids,sampled_mask,logprobs,advantages,reference_logprobs,trainer_logprobs,entropies,loss_weights(name)andmm_token_type_idsreturn 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()ornp.concatenate.MessageNode.renderer_token_idsisNoneunless multimodal expansion changed the prompt. Readlogical_idsto get the renderer tokens in every case.TurnTokens.prompt_ids,completion_ids,completion_logprobs,renderer_prompt_idsandis_contentare typed numpy arrays. Lists still validate, into arrays.Tests
tests/unit/test_token_arrays.py:__nd__wire round trip, includingloss_weights, and lists without the context;spreadmatches the previous list implementation exactly.list(...)or.tolist().tests/unitpasses locally, except three tests that fail the same way on this PR's base: tworuntimes/test_private_network.pyVM tests andtest_api.py::test_config_tree_imports_without_the_runtime. The test host's venv has a.pthhook that loadsnvidia_cutlass_dslinto every process. Run by hand, the config tree loads neither numpy norverifiers.v1.types.ty check verifierspasses.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
mainand on this PR (with PrimeIntellect-ai/prime-rl#4226 as the client):main(lists)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
Note
Medium Risk
Breaking public API (
Branch/MessageNodestreams 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, andBranchaggregations (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.typesso the config tree can load without NumPy:SamplingConfig/Sampling→verifiers.v1.configs.sampling,IDandDialectName→verifiers.v1.configs.harness, with imports and lazyvf.*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_idswhen the prompt is multimodal-expanded. Env-server deltas stream new nodes withtoken_arraysso token streams use raw-bytes encoding via publicencode_ndarray/decode_ndarray.Bumps
renderers[multimodal]to>=0.1.13.dev17and addstests/unit/test_token_arrays.py(wire round-trip, spread parity, committed-node ownership). Existing graph/semantic tests compare streams via.tolist()/list(...).Reviewed by Cursor Bugbot for commit 058db32. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Store per-token node streams as typed NumPy arrays in
MessageNodeIntStream,BoolStream,FloatStream) and a_token_streamvalidator in graph.py so token IDs, masks, logprobs, advantages, entropies, and loss weights onMessageNodebecome int32/bool/float32 arrays.same_tokensequality helper instead of list comparisons.Branchproperties andspreadin trace.py now return concatenated typed arrays instead of Python lists; JSON dumps stay list-based, and an opt-in token-array wire format uses publicencode_ndarray/decode_ndarray(also used by delta streaming in delta.py).TurnTokens, and types.py loosens those annotations toAnyto avoid a NumPy import.Branch.token_ids,sampled_mask,logprobs,advantages,mm_token_type_idsand related properties return NumPy arrays instead of lists;Branch.spreadreturns a float32 array. Out-of-tree code comparing these with==or expecting lists will behave differently.Changes since #2961 opened
renderers[multimodal]dependency version constraint [0d44cb4]verifiers.v1.typesmodule, includingencode_ndarray,decode_ndarray,as_listutilities,_token_streamfactory with pydanticBeforeValidatorandWithJsonSchema, typed stream aliasesIntStream,BoolStream,FloatStream, andempty_streamdefault factory [6a17126]TurnTokensmodel fields to use typed numpy array streams with automatic serialization inverifiers.v1.typesmodule [6a17126]MessageNodeclass inverifiers.v1.graphmodule to use centralized token stream utilities fromverifiers.v1.types[6a17126]verifiers.v1.configs.typesmodule definingDialectName,SamplingConfig,Samplingalias, andIDtype with validation logic [6a17126]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, andverifiers.v1.utils.genericmodules to source types fromverifiers.v1.configs.types[6a17126]verifiers.v1.configs.typesmodule toverifiers.v1.configs.samplingand redistributed type definitions by movingIDandDialectNametype aliases toverifiers.v1.configs.harness[61c8062]SamplingandSamplingConfigfromverifiers.v1.typestoverifiers.v1.configs.samplingacross all consuming modules [61c8062]IDandDialectNamefromverifiers.v1.typestoverifiers.v1.configs.harnessacross all consuming modules [61c8062]ID,DialectName,Sampling, andSamplingConfigfromverifiers.v1.typesmodule and updated lazy-loading configuration inverifiers.v1.__init__[61c8062]verifiers.v1modules, test files, and related configuration modules [83e11cd]_token_streamfunction in theverifiers/v1module [058db32]📊 Macroscope summarized 49ae611. 3 files reviewed, 1 issue evaluated, 0 issues filtered, 1 comment posted
🗂️ Filtered Issues