perf(codex): stop unmarshalling the whole rollout to compute token usage - #1879
Open
ecgang wants to merge 2 commits into
Open
perf(codex): stop unmarshalling the whole rollout to compute token usage#1879ecgang wants to merge 2 commits into
ecgang wants to merge 2 commits into
Conversation
parseRolloutTokenCount was the only place that unmarshalled a rollout token_count line, and it reported just input/output totals. The turn-end token calculation needs cached_input_tokens too, so it carried its own copy of the same three-step unmarshal. Extract parseRolloutTokenUsage, returning the whole tokenUsageData, and make parseRolloutTokenCount a wrapper over it. No behaviour change: the tailer keeps its signature and its results. This lands on its own because it touches the live `entire review` stream tailer, which is unrelated to the performance work that needs it. Refs: STAQPRO-802 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01KYSZCJHHK5GMD8XQYZXYEHSR
The turn-end hook hands CalculateTokenUsage the entire cumulative rollout on every turn, and it unmarshalled every line to check the line's envelope type. It needs three things out of all that: the last cumulative total_token_usage at or before the checkpoint boundary, the last one after it, and how many token_count events followed the boundary. Everything else parsed was waste, and expensive waste: rollouts embed large encrypted_content reasoning blobs, and json.RawMessage.UnmarshalJSON copies every payload it decodes, so each hook scanned, validated and heap-copied every blob in the session to throw it away unread. Cost per hook grew with session length, which is why late-turn hooks felt like a hang. Walk the rollout with a byte cursor and unmarshal only lines containing the literal token_count. That filter cannot miss an event, because the string is the value of the event's own type field, and every line it admits still goes through the full envelope check — so per-line decisions, and the numbers, are unchanged. Lines that merely mention token_count in their text are rejected there exactly as before. Measured on a synthetic rollout (4 reasoning blobs per turn at 8KB each, boundary one turn back from EOF): 50 turns 6.03ms -> 0.216ms 200 turns 23.9ms -> 0.843ms 500 turns 59.4ms -> 2.11ms Allocation at 500 turns: 20.7MB/33.5k allocs -> 0.64MB/13k allocs. This is a constant-factor fix, not a complexity-class one. The byte scan is still linear in the transcript, so a session still costs O(N^2) bytes scanned; what goes away is unmarshalling and copying them. Parses per hook are now one per token_count event rather than one per line. Tests were written against the old implementation and pass unchanged against both, including the case that makes the cheap shortcuts unsafe: an event_msg of another type whose text merely mentions token_count must not displace the baseline. Refs: STAQPRO-802 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01KYSZJNNBQPHENZ6BB00ZX90B
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.
Fixes #1836.
Per @gtrrz-victor's direction on the issue, this reevaluates the Codex token-usage path rather than porting Claude's slice-first pattern. Scope held to Codex-only, token-usage calculation only, same numbers out.
Where the cost was
Not the whole-file split —
splitJSONL'sbytes.Split/TrimSpacereturn subslices and copy nothing. It'sjson.RawMessage.UnmarshalJSON, which is*m = append((*m)[0:0], data...): it copies. So everyresponse_itemline'sencrypted_contentreasoning blob was scanned, validated and heap-copied on every hook, then thrown away unread. The old path measured ~280 MB/s — unmarshal-bound.CalculateTokenUsageneeds three things from all that work: the last cumulativetotal_token_usageat or before the boundary, the last one after it, and how manytoken_countevents followed. Everything else parsed was waste.What changed
Walk the rollout with a
bytes.IndexBytecursor and unmarshal only lines containing the literaltoken_count.The filter cannot miss an event: that string is the value of the event's own
typefield, so any line the envelope check would accept contains it. Every admitted line still goes through the full three-step envelope check, so per-line decisions — and therefore the numbers — are unchanged. Lines that merely mentiontoken_countin their text are rejected there, exactly as before; nothing is trusted on the strength of the byte match alone.Two tempting shortcuts were tried and rejected because they silently corrupt token counts:
token_count, unmarshal once after the walk). ~3× faster again, and wrong: a plainevent_msgof another type whose text mentionstoken_count— an agent discussing this issue emits one — overwrites the real baseline candidate, the deferred parse fails, the baseline goes nil, and nothing is subtracted. On a fixture, truth{fresh 1000, cache 3000, out 200}reported as{2000, 7000, 300}. Both decoy shapes are now regression tests.SessionState+ reverse-scan from EOF.fromOffsetis a line number, so locating its byte boundary needs the forward walk anyway, andapiCallsstill needs the delta span. What's left is invalidation surface across resume/adopt/import/compaction that fails silently when stale.Measured
BenchmarkCalculateTokenUsage, synthetic rollout with 4 reasoning blobs per turn at 8 KB each, boundary one turn back from EOF:Allocation at 500 turns: 20.7 MB / 33.5k allocs → 0.64 MB / 13k allocs.
This is a constant-factor fix, not a complexity-class one, and it does not satisfy the issue's acceptance criterion as literally worded. Being explicit since the issue asked for parses "bounded by the lines added that turn (plus O(1) baseline lookup)":
token_countevent, i.e. O(turns since session start), not O(delta). Total parse work over a session drops from O(N²) to O(N).Verification
Tests were written against the pre-change implementation and pass unchanged against both, which is what makes "same numbers out" a measurement rather than a claim:
nil, nilcontract)splitJSONLcoordinates, which count only non-empty linesevent_msgof another type mentioningtoken_count, and the substring inside a base64encrypted_contentblob (URL-safe base64 includes_)baseline > last(session reset/compaction), pinning thatCacheReadTokens/OutputTokensstay unclamped while onlyInputTokensis floored at zero — deliberate existing asymmetry this change must not altertoken_countevents rather than transcript size, counting unmarshals rather than wall-clock so it's deterministicAdditionally, a throwaway differential harness ran the old and new implementations over every real Codex rollout on my machine — 481 files, 609 MB, 80,410 lines, 19,742 real
token_countevents, 7,696 comparisons at both ends, past EOF and 11 interior offsets per file: 0 mismatches and 0 false negatives. Every real event contained the byte marker, which is the empirical form of the safety argument above. That harness read local session data and is not part of this PR.mise run checkgreen (fmt, lint 0 issues, unit + integration), e2e canary 59/59 and 4/4.Out of scope
Left alone deliberately, per your comment narrowing scope to token-usage only:
SubagentAwareExtractorfor Codex so turn-end extraction stops re-reading the rollout from disk. It's a real cost worth fixing, but it's a different interface with a different risk surface. Say the word and I'll file it as a follow-up.GetTranscriptPosition— which produces the stored offset — counts every line including blanks, while the token calculation counts only non-empty ones. Blank-free rollouts are unaffected, so nothing is broken today. Happy to fix separately. It's also why porting the Claude slice-first pattern verbatim would have misfired:transcript.SliceFromLinecounts physical newlines, so it would shift the baseline.Also unchanged: the shared pipeline's whole-file transcript copy,
encrypted_contentin stored checkpoints, Claude and other agents, the sharedtranscriptpackage, and accounting semantics or reported fields.Commits
refactor(codex): one reader for the token_count envelope—review_tokens.goalready had this three-step unmarshal for theentire reviewstream tailer, but droppedcached_input_tokens. ExtractedparseRolloutTokenUsage; the tailer'sparseRolloutTokenCountis now a wrapper over it. Separate commit because it touches a working, unrelated feature.perf(codex): stop unmarshalling the whole rollout to read two numbers— the walk, the prefilter, and the tests.🤖 Generated with Claude Code