Skip to content

perf: per-token streams as numpy arrays, micro batches as raw buffers - #4226

Merged
mikasenghaas merged 15 commits into
mainfrom
perf/orchestrator-token-arrays
Oct 24, 2026
Merged

mikasenghaas merged 15 commits into
mainfrom
perf/orchestrator-token-arrays

Conversation

@mikasenghaas

@mikasenghaas mikasenghaas commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

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 (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.
  • 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


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).

Reviewed by Cursor Bugbot for commit 9652f05. Bugbot is set up for automated code reviews on this repo. Configure here.

mikasenghaas and others added 2 commits October 22, 2026 20:40
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>
@mikasenghaas mikasenghaas changed the title perf(orchestrator): per-token streams as numpy arrays perf: per-token streams as numpy arrays, micro batches as raw buffers Oct 22, 2026
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>
mikasenghaas and others added 2 commits October 23, 2026 15:03
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>
Comment thread src/prime_rl/monitors/file/monitor.py Outdated
Comment thread src/prime_rl/orchestrator/batch.py Outdated
mikasenghaas and others added 3 commits October 23, 2026 15:13
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
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>
mikasenghaas and others added 5 commits October 24, 2026 13:08
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
mikasenghaas marked this pull request as ready for review October 24, 2026 13:36
@mikasenghaas
mikasenghaas merged commit 00f3e39 into main Oct 24, 2026
25 of 26 checks passed
@mikasenghaas
mikasenghaas deleted the perf/orchestrator-token-arrays branch October 24, 2026 13:40
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants