Skip to content

perf(parser): parallelize the cold Codex rollout parse on the same worker pool - #1010

Merged
iamtoruk merged 4 commits into
mainfrom
perf/parallel-codex-parse
Aug 17, 2026
Merged

perf(parser): parallelize the cold Codex rollout parse on the same worker pool#1010
iamtoruk merged 4 commits into
mainfrom
perf/parallel-codex-parse

Conversation

@iamtoruk

Copy link
Copy Markdown
Member

What

Extends #1008's worker-thread cold parse to Codex rollouts (the larger half of many corpora), reusing the same pool, policy and install pattern.

  • parseCodexFileFull(source, seenKeys) is the existing Codex createParser with a capture argument: no cache-hit lookup (so never a resume — resume/exact-hit files stay in-process, decided by a pre-pass with the loop's exact eligibility predicate and order), and the codex-cache entry comes back through capture instead of being written. One decoder, no second code path.
  • Worker parses against an empty dedup set and returns {path, calls, write?, keys}. Parent, in the serial loop's order: keys ∩ parserDedup empty → install calls, merge keys, writeCachedCodexResults into the parent's memCache (workers never touch codex-cache globals); overlap (a forked rollout replaying parent history) → discard and re-parse in-process. Worker failure → in-process fallback. Both install sites (Claude and Codex) now assert the echoed path matches the expected file and fail the run on mismatch.
  • The only cross-file coupling in the Codex decode is the dedup key itself (codex:${forkedFromId||sessionId}:${cumulativeTotal}:…), which is fully expressible as claimed keys; every other counter is a per-file parser local and codex.ts has no module-level mutable state.

Policy changes (src/parse-workers.ts): gate on pending bytes ≥ 200 MB alone (the previous files-count arm made 250-tiny-file corpora ~5% slower than serial — measured; now they stay serial); worker count max(files/50, bytes/200 MB) capped by cores−1 and the memory budget; per-worker RSS budget derived per parse — clamp(256 MB, 2 × avgFileBytes + 128 MB, 1 GB) — because a Codex worker peaks ~430 MB on a 260 MB rollout (measured) while a flat raise would have halved the Claude pool. Per-provider decision; at most one pool alive.

Measured (real corpus: 15,892 Claude files / 1.8 GB + 940 Codex rollouts / 4.0 GB, APFS snapshot, 16 cores)

run wall peak RSS verbose
cold, CODEBURN_PARSE_WORKERS=0 16.4 s 2.57 GB claude 0 / codex 0
cold, main (Claude workers only) 13.0 s 2.63 GB claude 8 / codex 0
cold, auto 10.5 s 2.15–2.7 GB claude 8 / codex 8
warm, auto 1.28 s 0.76 GB 0 / 0
250 files / 117 KB thin corpus, auto serial workers=0 (below 210 MB pending)

Identity vs serial at 4/8/auto: payload, all 10 cache shard bodies, and the 58 MB codex-results.json byte-identical.

Review

Independent adversarial review: equivalence could not be broken (dedup key claim proven complete: the only seenKeys.add sites on the full-decode path are returned in full; forked-rollout fixture in both filename orders with replays past the 5 s cutoff so the discard path runs; mutation-checked — disabling the overlap branch or reversing install order fails the tests). Its two findings (files-only gate pessimization; per-worker budget too low for Codex) are fixed above.

Tests

tests/parse-workers.test.ts 13: mixed Claude+Codex determinism e2e (payload + shard bodies + codex-results.json byte-identical at 0 vs 3 workers, discard count > 0), bytes-only gate cases (250/917 KB → 0; 5000/10 MB → 0; 150/4 GB → 8; 250/65 GB → 3 via the derived budget), worker failure → serial fallback, resume-class files never off-thread, no leaked workers, echoed path assertions.

tsc clean · npm test 2713 · test:locks 26 · app 488.

…rker pool

Codex is the bigger half of a real cold parse (4 GB of rollouts against 1.8 GB
of Claude sessions) and was still decoding one file at a time.

A whole-file rollout decode now runs on the #1008 pool. parseCodexFileFull is
the serial decode with the codex cache switched off: no hit lookup, and the
entry it would have written comes back to the parent instead. The worker runs it
against an EMPTY dedup set and returns the calls, the keys it claimed, and that
entry; the parent installs all three in the serial loop's order, so an empty key
intersection is the proof that a serial parse would have dropped nothing either.
On overlap the whole file is discarded and re-parsed in-process -- which is what
makes a forked rollout safe, since it replays its parent's token_count history
under the parent's key namespace and collides outright.

Nothing cross-file moves off the main thread: the dedup set, canonical project
paths and the codex cache's per-directory state all stay in the parent, and a
file the cache can serve exactly or resume into from a byte offset never reaches
a worker. The decision is per provider -- the Claude scan and the provider loop
run one after the other, so at most one pool is alive -- and the pool is
terminated when its scan ends.

The workload gate is now files OR bytes rather than both, and the count takes
max(files / 50, bytes / 200 MB): a corpus of a few hundred multi-hundred-MB
rollouts is as parallelisable as a few thousand small transcripts, and would
otherwise have earned one thread or none.
A mixed Claude + Codex corpus with forked rollouts in both creation orders,
asserting an identical payload, byte-identical cache shards and a byte-identical
codex-results.json between CODEBURN_PARSE_WORKERS=0 and =3, with the codex
discard count pinned above zero so the overlap path is really exercised.

Plus: a resumable rollout never reaching a worker (the decision line reports no
full parses pending after an append), the off-thread decode matching
parseCodexFileFull exactly including the cache entry it hands back, no cache file
written by the decode itself, no leaked threads, and the files-OR-bytes gate.
…t per parse

Three fixes from review, all measured on this box.

The workload gate was files OR bytes. The files arm is wrong: 250 pending files
holding 117 KB between them spawned 5 threads and ran ~5% SLOWER than serial,
and a file count only starts paying for itself around 400. Gate on bytes alone;
the count still takes max(files / 50, bytes / 200 MB), so a few hundred huge
rollouts keep their threads.

The flat 256 MB per-worker memory budget was contradicted by the Codex workload:
a 260 MB rollout peaks near 430 MB in its worker, linearly across the pool. It is
now derived per parse as clamp(256 MB, 2 x average pending file + 128 MB, 1 GB),
which leaves a corpus of small Claude transcripts where it was and stops
over-subscribing on rollouts. The parent's buffer of up to pool.size finished
results is part of that peak and is named in the comment.

The worker/file pairing at both install sites was positional, guarded only by
position (Claude) or a path membership check (Codex). Each worker now echoes its
path and the parent asserts it, outside the per-file try: a misalignment would
install one session's turns under another's path -- a wrong number nobody would
ever notice -- so it fails the run rather than being swallowed as a parse
failure. On the Claude side that meant hoisting the whole worker-result block
above the try, which is safe because an append never consumes a result in either
its shortcut or its straddled-fallthrough case.
@iamtoruk
iamtoruk merged commit da3d903 into main Aug 17, 2026
6 checks passed
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.

1 participant