Skip to content

perf: keep record streams as numpy arrays - #2980

Merged
mikasenghaas merged 7 commits into
mainfrom
perf/record-arrays
Oct 27, 2026
Merged

mikasenghaas merged 7 commits into
mainfrom
perf/record-arrays

Conversation

@mikasenghaas

@mikasenghaas mikasenghaas commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Episode.to_record() and Trace.to_record() return the per-token node streams as C-contiguous numpy arrays. That covers token_ids, mask, is_content, the rounded float streams and loss_weights.
    • Floats are float64, and the rounded streams use np.round.
    • orjson with OPT_SERIALIZE_NUMPY writes each stream in one pass.
    • Before, each stream became a Python list, each float went through round(), and pydantic serialized every element.
    • to_record() dumps the model without the streams, then adds them as arrays. Pydantic's JSON mode cannot emit numpy arrays, and its own encoder (model_dump_json) is 3.5x slower on real episodes (see Verification).
    • The streams now come last in each node record.
  • Add Episode.to_json() and Trace.to_json(): to_record() encoded with orjson, as compact UTF-8 bytes. The pair mirrors pydantic's model_dump/model_dump_json, which stay unchanged.
  • to_record() and to_json() take exclude, a pydantic exclude spec merged with the record's own excludes. The per-token streams are always included.
  • JSON-mode dumps (model_dump(mode="json"), model_dump_json) round with np.round instead of a per-value round(). The output is the same.
  • The agentic and best-of-n judge envs write the trace file with to_json(exclude=...), which drops ok and the hidden reasoning fields. Before, they edited the record dict. Add orjson as a dependency.
  • vf-eval --push keeps passing the episodes to the prime SDK. The SDK encodes them with orjson from perf: encode json with orjson in prime-runs and prime-traces prime#1109 on.

prime-rl counterpart: PrimeIntellect-ai/prime-rl#4257. In a replayed batch with PrimeIntellect-ai/prime#1109, the ship under monitor contention drops from 1016 s to 114 s.

Pinned until release: PrimeIntellect-ai/prime#1109 is merged but not released yet, so prime-runs (and prime-traces through override-dependencies) are pinned to its merge commit on prime main (adf8d25), with a TODO to replace them with the PyPI release asap and raise the prime-runs minimum. The pins only apply to this repo's lock; consumers need the release.

Breaking

  • to_record() node streams are numpy arrays, not lists. json.dumps(record) with the stdlib json raises TypeError.
    • Migration: encode with orjson and OPT_SERIALIZE_NUMPY.
  • Prime SDK releases before perf: encode json with orjson in prime-runs and prime-traces prime#1109 cannot encode to_record(), so their uploads fail with TypeError.
    • Migration: upgrade prime-runs.

Verification

Real data: one 2,048-episode training batch with 193M tokens. Each step below ran single-threaded over the whole batch.

Per batch Before After
to_record() + orjson (trace-file write) 103.3 s (50.4 ms/episode) 18.1 s (8.8 ms/episode)

A second check ran on 256 episodes of the same batch:

Per episode Time
to_record() + orjson (this branch) 13.1 ms
model_dump_json with the same excludes 47.9 ms
model_dump(mode="json") + orjson 46.3 ms
  • All three paths produce the same parsed content for each episode.
  • New tests:
    • the record arrays and to_json() have the content of the plain JSON dump, for traces and episodes, with and without rounding;
    • exclude merges with the record's own excludes and keeps the streams;
    • round_stream matches round() on 100k float32 values.
  • The judges' trace files parse to the same content as the old edit-then-encode path, for 32 real episodes.
  • tests/unit: all passed (Docker, PRIME_API_KEY and harbor tests skipped).

🤖 Generated with Claude Code


Note

Medium Risk
Changes the trace/episode serialization contract (NumPy in records, bytes from to_json) and pins Prime packages from git, which affects eval uploads and any code that assumed list-shaped streams or stdlib JSON.

Overview
Trace and episode JSON records now attach per-token fields (token_ids, mask, is_content, float training streams, loss_weights) as NumPy arrays instead of Python lists. to_record() dumps the model without those streams, then merges them via add_record_streams / MessageNode.record_streams; float streams use np.round (round_stream) at the configured precision.

Adds Trace.to_json() and Episode.to_json() — compact UTF-8 bytes from orjson with OPT_SERIALIZE_NUMPY. Both to_record and to_json accept an optional exclude merged with RECORD_DUMP_EXCLUDE via merge_exclude (judge envs use this to drop ok and hidden reasoning instead of mutating dicts).

Dependencies: runtime orjson, and temporary git pins for prime-runs / prime-traces until a PyPI release ships prime#1109-compatible encoding.

Breaking: to_record() dicts are no longer json.dumps-safe; callers must use to_json or orjson with NumPy support.

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

Note

Keep record streams as NumPy arrays and encode records with orjson

  • Trace.to_record and Episode.to_record now attach node streams (token IDs, mask, content flags, rounded float streams, loss weights) as NumPy arrays, with float streams rounded per float_decimals via round_stream in graph.py
  • Trace.to_json and Episode.to_json encode records with orjson's NumPy option; NaN and infinity become null
  • to_record accepts an exclude argument merged with built-in record exclusions via merge_exclude in trace.py; JudgeTask.from_trace and RankTask.record now use Trace.to_json with exclusions instead of mutating records and using stdlib JSON
  • Adds orjson>=3.10.0 as a dependency in pyproject.toml
  • Behavioral Change: JSON output format changes — NaN/inf encode as null, and records contain arrays instead of lists for stream fields; check consumers of to_json/to_record output

Macroscope summarized 4ae941f.

to_record built each per-token stream as a Python list and rounded the
float streams with a per-value round() call. Pydantic then walked every
element. On real 128k-token episodes this costs ~50 ms per episode, all
of it holding the GIL.

Records now carry the node streams as C-contiguous numpy arrays, with
floats in float64 and the rounded streams rounded with np.round. A
numpy-aware encoder (orjson with OPT_SERIALIZE_NUMPY) writes the same
bytes as before. JSON-mode dumps round with np.round too and give the
same output.

Callers that encode records with the stdlib json convert them first
with json_safe_record: the judge envs and the eval push, whose SDK
encodes with the stdlib json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Oct 25, 2026
@macroscopeapp

macroscopeapp Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs Human Approval

Macroscope's review found this PR requires human approval — This changes the default Trace/Episode record representation from Python lists to NumPy arrays and routes production judge snapshots through a new orjson-based serializer. It also pins runtime dependencies to an external Git revision, so the public serialization contract and installation impact need human review.

You can add or adjust custom eligibility rules. Learn more.

orjson writes the numpy streams of to_record() directly, so the judge
envs skip the list copy that json_safe_record makes for the stdlib
encoder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mikasenghaas and others added 2 commits October 26, 2026 12:28
The prime SDK now encodes records with orjson and OPT_SERIALIZE_NUMPY,
so callers no longer convert the stream arrays to lists first. The
node streams join the record after the pydantic dump, without the field
reorder: pydantic's JSON mode cannot emit numpy arrays, and its own
encoder is about 3.5x slower on real episodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mirrors pydantic's model_dump and model_dump_json pair. Pydantic's own
methods stay untouched: JSON mode must return plain JSON types, the
msgpack wire relies on python mode, and nested models never call an
override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread tests/unit/test_trace.py Resolved
Comment thread verifiers/v1/envs/agentic_judge/env.py Resolved
to_record and to_json take a pydantic exclude spec, merged with the
record's own excludes, so a judge drops ok and the hidden reasoning
fields without editing the record or calling orjson itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
samsja
samsja previously approved these changes Oct 26, 2026
…prime#1109

The SDK encodes the numpy record streams with orjson from #1109 on. TODO: replace the git pin
with the PyPI release asap and raise the prime-runs minimum to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mikasenghaas
mikasenghaas merged commit f166cd5 into main Oct 27, 2026
36 checks passed
@mikasenghaas
mikasenghaas deleted the perf/record-arrays branch October 27, 2026 15:58
mikasenghaas added a commit to PrimeIntellect-ai/prime-rl that referenced this pull request Oct 27, 2026
## Summary

- Bump verifiers to PrimeIntellect-ai/verifiers#2980. With it,
`Episode.to_record()` returns the per-token node streams as numpy
arrays.
- The file monitor's orjson (`OPT_SERIALIZE_NUMPY`) writes them in one
pass, so the file monitor needs no code change.
- The bump adds `orjson` to the verifiers dependencies. `uv.lock` is
relocked for it.
- The prime monitors keep passing the episodes to the SDK. From
PrimeIntellect-ai/prime#1109 on, the SDK encodes the numpy streams with
orjson.
- `test_episode_records.py` checks the upload path with the SDK's own
encoders: the trace line, the samples batch and the training viewer
table.

**Pinned until release:** PrimeIntellect-ai/prime#1109 is merged but not
released yet, so `prime-runs` and `prime-traces` are pinned to its merge
commit on prime main (`adf8d25`), with a TODO to replace the pins with
the PyPI release and raise the `prime-runs` minimum. `deps/verifiers` is
on verifiers main with PrimeIntellect-ai/verifiers#2980 (f166cd5). With
the released SDK, the prime monitors' uploads fail with `TypeError`.

## Verification

Real data: one 2,048-episode training batch with 193M tokens, replayed
through the real `TrainSink` and `Orchestrator.finalize_train_batch`
with a no-op sender.

| | Before | After |
|---|---|---|
| File monitor serialization, single-threaded, per batch | 103.3 s |
18.1 s |
| `FileMonitor.log_episodes` during arrival, per batch | 113.4 s | 19.9
s |
| Ship with the file and prime serializers running in background threads
| 1015.7 s | 114.2 s |

- Ship time runs from taking the batch to shipping it. The last row is a
worst case: each monitor's real per-episode serializer loops in its own
thread, as with a backlog. These threads hold the GIL in long C calls
and starve the ship's pure-Python steps. Without them, the ship on this
base takes ~42 s.
- In the last row, before is `main` with the released SDK, and after is
this stack with PrimeIntellect-ai/prime#1109. GC time in the ship drops
from 103.7 s to 0.6 s, and the longest pause from 4.52 s to 0.01 s.
- The prime serializer thread still costs 147 ms per episode after. Of
that, 12 ms is the encode and the rest is the SDK's level-9 gzip, which
releases the GIL.
- The prime SDK trace line drops from 114.7 ms per episode (`main` and
the released SDK) to 12.2 ms (this stack with
PrimeIntellect-ai/prime#1109) on 512 episodes, with the same content.
- `pytest tests/unit/orchestrator tests/unit/utils
tests/unit/train/sft/test_prime_monitor.py
tests/unit/test_dashboard_artifacts.py` with the
PrimeIntellect-ai/prime#1109 SDK: 181 passed. With the released SDK,
`test_received_episode_uploads_plain_json` fails with `TypeError: Object
of type ndarray is not JSON serializable`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Temporary git pins on core upload/training SDK packages change
resolution and reproducibility until PyPI catches up; behavior change is
limited to serialization of episode traces but affects all prime monitor
uploads.
> 
> **Overview**
> **Pins `prime-runs` and `prime-traces` to the Prime main commit that
ships PrimeIntellect-ai/prime#1109** (orjson-based trace/record encoding
for numpy per-token streams), via `tool.uv.sources`, an override on
`prime-traces`, and a refreshed `uv.lock`. TODOs call out swapping these
git pins for the next PyPI releases.
> 
> **Aligns upload-path tests** in `test_episode_records.py` with the
SDK: trace lines use `prime_traces._encode_record`, and sample JSON uses
`prime_runs._http.encode_json`, with an explicit check that encoded
`token_ids` remain plain JSON lists—not the old `json.dumps` /
`to_mapping` stand-ins.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
d5b8db3. 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>
kcoopermiller added a commit that referenced this pull request Oct 29, 2026
## Summary

Replaces the git pins added in #2980 with the PyPI release of
PrimeIntellect-ai/prime#1109 (orjson-encoded records and numpy streams).
That release is PrimeIntellect-ai/prime#1126, which ships `prime-runs`
0.1.5 and `prime-traces` 0.0.11.

Both are now on PyPI. `uv.lock` moves `prime-runs` and `prime-traces`
from the `adf8d25c` git sources to PyPI 0.1.5 / 0.0.11. No other package
moves. The lockfile also changes from `revision = 4` to `5`, because uv
0.13 writes `default-groups` into it.

## Changes

- `prime-runs>=0.1.4` → `prime-runs>=0.1.5`.
- Removes the `prime-runs` git source in `[tool.uv.sources]`, pinned to
`adf8d25c`.
- Removes the `prime-traces` git override in `override-dependencies`,
pinned to the same commit. `prime-runs` 0.1.5 requires
`prime-traces>=0.0.11`, so verifiers no longer needs to pin it.
- Adds `prime-traces = false` to `[tool.uv.exclude-newer-package]`.
Without it, the 7-day `exclude-newer` cooldown hides `prime-traces`
0.0.11 and the lock fails to resolve. `prime-runs` is already exempt.

## Checks

- In a fresh venv, `pip install prime-runs==0.1.5` resolves
`prime-traces` 0.0.11.
- `uv sync` installs `prime-runs` 0.1.5 and `prime-traces` 0.0.11.
- `ruff check .` passes.
- `tests/unit`: 243 passed, 10 skipped. The skips need `harbor` or
`PRIME_API_KEY`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- 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]
> ### Require prime-runs 0.1.5 from PyPI
> Bumps the minimum `prime-runs` version to 0.1.5 in
[pyproject.toml](https://github.com/PrimeIntellect-ai/verifiers/pull/3001/files#diff-50c86b7ed8ac2cf95bd48334961bf0530cdc77b5a56f852c5c61b89d735fd711)
and removes the uv sources and override-dependencies entries that pulled
`prime-runs` and `prime-traces` from specific Git revisions. Adds
`prime-traces = false` to `tool.uv.exclude-newer-package` so the cutoff
does not apply to it. Behavioral Change: both packages now resolve from
PyPI instead of pinned Git commits.
>
> <!-- Macroscope's changelog starts here -->
> #### Changes since #3001 opened
>
> - Updated the dependency lock to require `prime-runs` version 0.1.5
from PyPI. [2d3f59a]
> <!-- Macroscope's changelog ends here -->
>
> <!-- Macroscope's review summary starts here -->
>
> <sup><a href="https://app.macroscope.com">Macroscope</a> summarized
1b749f4.</sup>
> <!-- Macroscope's review summary ends here -->
>
<!-- Macroscope's pull request summary ends here -->

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Dependency and lockfile-only change with no application code edits;
behavior shifts from pinned git commits to released PyPI versions
already exercised via the prior pins.
> 
> **Overview**
> This PR **drops temporary git pins** for `prime-runs` and
`prime-traces` and **consumes the published PyPI releases** instead
(`prime-runs` **0.1.5**, `prime-traces` **0.0.11** via `prime-runs`'
dependency).
> 
> **`pyproject.toml`** raises the minimum to `prime-runs>=0.1.5`,
removes the `prime-runs` git entry under `[tool.uv.sources]`, removes
the `prime-traces` git `override-dependencies` block (and related TODO
comments), and adds **`prime-traces = false`** to
`[tool.uv.exclude-newer-package]` so the 7-day `exclude-newer` window
does not block resolving the new trace package.
> 
> **`uv.lock`** reflects PyPI sources for both packages, drops the
manifest override for `prime-traces`, and bumps lock **revision** to 5
(uv writing `default-groups` on the root package).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
2d3f59a. 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