Skip to content

execution/commitment: arena-allocate prefixNode extensions - #23188

Open
awskii wants to merge 4 commits into
mainfrom
awskii/cmt-prefix-ext-arena
Open

execution/commitment: arena-allocate prefixNode extensions#23188
awskii wants to merge 4 commits into
mainfrom
awskii/cmt-prefix-ext-arena

Conversation

@awskii

@awskii awskii commented Aug 11, 2026

Copy link
Copy Markdown
Member

prefixTrie.Insert allocated a fresh []byte for every new leaf's extension. prefixNodes themselves already come from a slab arena that survives across batches; their extension bytes did not, so a block's trie build paid one heap allocation per new key.

Changes

Add a byte-chunk arena alongside the node slabs — 64KB chunks, a new chunk swapped in on overflow rather than grown, so outstanding extensions keep their backing and every returned slice has cap == len. An extension larger than a chunk takes its own exact-capacity allocation instead of forcing a chunk to grow under live sub-slices. resetArena truncates every chunk in place, matching Updates.Reset's arena-ring policy, so a steady-state batch refills them without allocating.

The split path now caps node.ext = oldExt[:m:m]. With per-extension allocations the spare capacity was harmless; once extensions share arena memory, an append there would reach into the bytes oldChild.ext owns.

Benchmark_PrefixTrieBuildAcrossResets (20k keys, insert + Reset per iteration), Apple M5 Max, -benchtime=200x -count=8:

before after
sec/op 1.677m 1.448m −13.69%
B/op 4.365Mi 3.149Mi −27.85%
allocs/op 39.99k 19.99k −50.01%

All p=0.000, n=8.

Note on the benchmark choice: Benchmark_Commitment_DirectVsParallel cannot see this change at all — runParallelBench calls WrapKeyUpdates, which drives every Insert, before b.StartTimer(), so the whole trie build sits outside the timer. An earlier revision of this PR quoted figures from that build phase measured through a temporary benchmark; those predated the resetArena fix and are superseded by the table above, which exercises the reset cycle directly.

prefixTrie.Insert allocated a fresh []byte for every new leaf's extension.
prefixNodes themselves already come from a slab arena that survives across
batches; their extension bytes did not, so a block's trie build paid one
heap allocation per new key.

Add a byte-chunk arena alongside the node slabs: 64KB chunks, a new chunk
swapped in on overflow rather than grown, so outstanding extensions keep
their backing. resetArena now truncates and reuses the first chunk, matching
the slab policy, so a steady-state batch allocates no extension bytes.

Benchmark_Commitment_DirectVsParallel cannot see this: runParallelBench
calls WrapKeyUpdates, which drives every Insert, before b.StartTimer(), so
the whole trie build is outside the timer. Measured on the build phase
itself (-benchtime=3x -count=8):

  sec/op     208.2m -> 198.3m   -4.73%  (p=0.000)
  allocs/op  1.825M -> 1.326M  -27.36%  (p=0.000)

A -memprofilerate=1 profile over the same corpus confirms it: Insert's flat
allocation objects fall 2,462,526 -> 962,526 across 3 iterations, the 1.5M
per-leaf allocations replaced by 1,431 chunk allocations.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR reduces per-key heap allocations during commitment prefix-trie construction by moving leaf-extension storage into an arena, matching the existing slab allocator used for prefixNode objects. This targets the hot path in prefixTrie.Insert where each new leaf previously allocated a fresh []byte for its extension.

Changes:

  • Added an extension-byte chunk arena (prefixExtChunkSize chunks) to prefixArena, with allocExt returning slices with cap == len to prevent accidental aliasing via append.
  • Updated prefixTrie.Insert to allocate leaf extensions via the new arena instead of append([]byte(nil), ...).
  • Added targeted tests to validate extension allocation semantics, chunk-boundary correctness, and reset reuse behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
execution/commitment/prefix_trie.go Introduces extension-byte chunk arena in prefixArena and routes leaf extension allocation through it to reduce heap allocs.
execution/commitment/prefix_trie_test.go Adds tests validating allocExt behavior (capacity, nil-on-empty, non-aliasing), chunk-boundary correctness, and reset reuse of chunk backing.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@AskAlexSharov AskAlexSharov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the extension arena. No correctness bug that fires today: allocExt copies, the three-index return keeps handed-out slices append-safe, every in-package reader of node.ext copies before the arena is reset, and the background folds read the trie under trieMu with Stop() draining them before Reset(). The notes below are latent hazards, reuse, and one measurement gap.

One item that cannot be anchored to a line in this diff:

The optimized path is not covered by any committed benchmark. runParallelBench (execution/commitment/parallel_streaming_bench_test.go:56) calls WrapKeyUpdates between b.StopTimer() and b.StartTimer(), so the whole prefix-trie build sits outside the timer in every Benchmark_Commitment_*. The PR description names this, but the consequence is that the 27% allocation win is defended by nothing in CI's bench / benchmarks (parallel) job -- a later regression here would be invisible. A build-phase benchmark over the same corpus (or a dedicated variant that keeps WrapKeyUpdates inside the timed region) would close it.

Comment thread execution/commitment/prefix_trie.go
slabIdx int
nextIdx int
extChunks [][]byte
extChunkIdx int

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extChunkIdx is derivable state: it is always len(a.extChunks)-1. The index only ever advances together with an append to extChunks, and resetArena puts both back to 0 / len 1. So the if a.extChunkIdx >= len(a.extChunks) branch in allocExt is always taken, and the re-fetch after it always yields the freshly appended chunk.

Dropping the field and reading a.extChunks[len(a.extChunks)-1] removes one invariant a reader has to verify by hand. It also matters for the oversize case: the "growing in place is harmless" reasoning holds only while the newly selected chunk is guaranteed empty, which is precisely what this lockstep gives you.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applied, and I think this one stops holding once the chunk-retention comment is addressed — the two interact.

extChunkIdx == len(extChunks)-1 was true only because resetArena dropped every chunk but the first, so the arena could never be sitting on a non-last chunk. Now that reset retains the grown chunks truncated, the next batch starts at chunk 0 with several empty chunks above it, and the index is precisely what lets it refill them in order instead of appending past them. Deriving it would make every reset re-grow the tail.

Your underlying point still lands though: the "growing in place is harmless" reasoning did depend on the lockstep. That is no longer what guarantees it — the explicit oversize guard is, so no chunk is ever grown under live sub-slices regardless of which one is current.

Comment thread execution/commitment/prefix_trie.go Outdated

// allocExt copies b into the current chunk, swapping in a fresh chunk instead of growing this one
// so sub-slices already handed out keep their backing array.
func (a *prefixArena) allocExt(b []byte) []byte {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the fourth chunked byte arena in this package, all with the same shape -- 64KB chunks, "the returned sub-slice must stay valid until reset":

  • plainKeyArena.intern -- parallel_update.go
  • keyArena.copy -- streaming_commitment.go
  • Updates.arenaAlloc / arenaEnsureCap -- commitment.go
  • prefixArena.allocExt -- here

They differ only in the overflow policy and in whether the returned slice is capped (arenaAlloc returns arena[off:needed], without the three-index form, so its callers do not get the append-safety this PR adds here). One shared byteArena type with a single overflow policy would remove three near-copies and give every caller the capped return. The new constant is also spelled 65536 while the other three use 64 * 1024.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on all four counts, and the constant is now 64 * 1024 to match its siblings.

Not unifying them in this PR: keyArena and plainKeyArena are both moving under the streaming-commitment removal (#23191), which relocates keyArena into streaming_deep_fold.go, and Updates.arenaAlloc has the different contract you point out — uncapped return, ring of arenas indexed by generation. Folding all four together across those in-flight moves would make both diffs hard to review. Worth a follow-up once #23191 and #23183 land, and the capped return is the part actually worth propagating — arenaAlloc's callers not getting append-safety is a live sharp edge, not just duplication.

Comment thread execution/commitment/prefix_trie.go
Comment thread execution/commitment/prefix_trie.go
Comment thread execution/commitment/prefix_trie.go Outdated
if !ok {
newLeaf := t.arena.allocNode()
newLeaf.ext = append([]byte(nil), hashedKey[keyOffset+1:]...)
newLeaf.ext = t.arena.allocExt(hashedKey[keyOffset+1:])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting for the follow-up: the per-leaf extension was one of two heap allocations per new node. The other is the children slice -- []*prefixNode{oldChild} and []*prefixNode{oldChild, newLeaf} in the split branch, plus the append(node.children, nil) growth just below -- and merged := &Update{} on the duplicate-key path. That is consistent with the residual 1.326M allocs/op after this change. A children region in the same arena (or a fixed [16]*prefixNode block per node) would compound with this PR rather than overlap it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted, and it matches what the profile shows — the residual is dominated by the children slices, with merged := &Update{} behind them.

Deliberately out of scope here: the children region changes node layout rather than just where bytes come from, so it wants its own before/after. A fixed [16]*prefixNode per node trades ~128 bytes per node against the current dense slice, which is the opposite trade for a trie whose nodes are mostly sparse — a children arena with the dense layout kept is the version I would measure first.

Comment thread execution/commitment/prefix_trie_test.go
awskii added 3 commits August 11, 2026 20:28
- resetArena kept only the first chunk, so any batch needing more than 64KB of
  extensions reallocated every chunk above the first on every reset. Truncate
  all chunks in place instead, matching Updates.Reset's arena-ring policy. This
  is what makes "a steady-state batch allocates no extension bytes" actually
  true rather than true only for sub-chunk batches.
- allocExt had no guard for an extension larger than a chunk: it moved to a
  fresh chunk and let append reallocate it, contradicting the swap-don't-grow
  contract in its own doc. Oversize extensions now get their own exact-capacity
  allocation, matching plainKeyArena.intern's fallback.
- The split path handed out node.ext = oldExt[:m], keeping capacity over bytes
  oldChild.ext owns. Harmless when every extension was its own allocation;
  with arena backing an append there would reach into a sibling's nibbles.
  Capped to oldExt[:m:m], which caps oldChild.ext too.
- Documented that ext is arena-backed and only valid until Reset.
- resetArena's doc no longer omits the extension chunks.
- The chunk-boundary test derived its key count from the chunk size instead of
  a hand-tuned 6000.

extChunkIdx stays: it is derivable from len(extChunks) only under the old
keep-one-chunk reset. Now that reset retains the grown chunks, the index is
what lets the next batch refill them from the start instead of appending past
them.

Benchmark_PrefixTrieBuildAcrossResets (20k keys, insert+Reset per iteration),
Apple M5 Max, -benchtime=200x -count=8:

  sec/op     1.677m -> 1.448m  -13.69%
  B/op      4.365Mi -> 3.149Mi  -27.85%
  allocs/op  39.99k ->  19.99k  -50.01%

All p=0.000, n=8.
…ion arena

No committed benchmark covered the change: runParallelBench calls
WrapKeyUpdates, which drives every Insert, outside its timed region, so the
whole prefix-trie build sits outside the timer in every Benchmark_Commitment_*.
The allocation win was measured through a throwaway benchmark and defended by
nothing in CI, so a later regression here would be invisible.

Benchmark_PrefixTrieBuildAcrossResets times the build itself across Reset
cycles, which is also what exercises the arena's per-batch chunk reuse. It is
the benchmark the PR description's numbers come from.

@awskii awskii left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the unanchored item — the measurement gap is real and now closed.

Benchmark_PrefixTrieBuildAcrossResets is committed (prefix_trie_bench_test.go). You are right that the win was defended by nothing: I had measured through a throwaway benchmark and deleted it, so CI's bench / benchmarks (parallel) job would not have caught a regression here. It times the build across Reset cycles rather than a single build, which is also what exercises the chunk reuse — and it is the benchmark the numbers in the description now come from, so they are reproducible.

That choice turned out to matter beyond coverage: your resetArena note was right that "a steady-state batch allocates no extension bytes" only held below one chunk, and it was the reset-cycle benchmark that showed it. With all chunks truncated in place instead of dropped:

sec/op     1.677m -> 1.448m  -13.69%
B/op      4.365Mi -> 3.149Mi  -27.85%
allocs/op  39.99k ->  19.99k  -50.01%

p=0.000, n=8. The earlier 27%/4.7% figures were from the pre-fix policy measured through the throwaway benchmark; they are gone from the description.

Everything else in the review is applied except the extChunkIdx removal, which stops holding once chunks are retained — replied inline. The four-arena unification I would rather do after #23191 lands, since it moves keyArena out of streaming_commitment.go entirely.

@AskAlexSharov
AskAlexSharov added this pull request to the merge queue Aug 12, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 12, 2026
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.

3 participants