Skip to content

perf: group EachKey/EachKeyErr array paths by index (4x dense extraction) - #291

Open
buger wants to merge 6 commits into
masterfrom
perf/eachkey-array-grouping
Open

buger wants to merge 6 commits into
masterfrom
perf/eachkey-array-grouping

Conversation

@buger

@buger buger commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

Motivation

Bulk extraction of array elements by index is the core use case of EachKey/EachKeyErr, and it is their slowest path: for every matching element, the callback loop rescans all pending paths and re-runs strconv.Atoi on each path's index component, in both functions.

What changes

Performance. The array branch now parses each pending path's index component once and groups path indexes by wanted element index: map[int]int32 list heads plus an int32 link buffer, both allocated lazily on first registration at a given array. Grouping activates only when some path contains an array component, computed once per call, so documents whose paths never target an array skip the per-[ scan and map creation entirely. The per-element callback walks only the paths waiting on the current element; prepending in reverse path order preserves ascending path order per element. The old pIdxFlags bookkeeping is removed from both functions.

Structure. EachKey and EachKeyErr were 215 near-identical lines each. They now live in a generated eachkey.go, produced by go generate from a single template (internal/eachkey/eachkey.go.tmpl) plus a dependency-free generator (internal/eachkey/main.go); the shared scanner exists once and the two callback variants are stamped out from it. Helper extraction, a shared core, and generics were all tried first and rejected on measured timing grounds; code generation is the only design that was byte-for-byte performance-neutral. go.mod stays at go 1.13, the generator adds no exported API and no dependencies, and regeneration is exactly reproducible.

Benchmarks. Permanent regime benchmarks live in issues_56_107_229_test.go (dense 128/1,024, sparse scalar and object, idle 62/128, shared terminal 128/1,024, flat objects, EachKeyErr dense), so every number below is reproducible with go test -bench.

Semantics note (please read)

EachKey/EachKeyErr treat paths as immutable for the duration of the call: array-index membership is captured when an array is entered, and the routing decision is captured at call entry. A callback that mutates the paths slice mid-iteration is not observed; previously it could redirect later matches as an artifact of re-reading components during iteration. That was never a documented contract, and TestEachKeyPathsSnapshotSemantics pins both cases. If the old observable behavior matters to anyone, speak up here.

Benchmarks

go1.26.3, darwin/arm64, paired alternating runs, -cpu=1 -benchmem, five or more pairs per side, plus independent verification.

workload master this PR change
EachKey dense 128 51.1 us 12.3 us 4.2x faster
EachKey dense 1,024 2.66 ms 103 us 25.8x faster
EachKeyErr dense 128 52.0 us 11.7 us 4.4x faster
EachKeyErr dense 1,024 2.58 ms 105 us 24.5x faster
Idle arrays, 62 object-only paths 10.1-11.2 us 9.2 us 8-18% faster
Idle arrays, 128 object-only paths 33.2 us 28.3 us 15-20% faster
Sparse, 2 of 16,384 scalar elements 196-266 us 182-190 us ~3-12% faster
Sparse, 2 of 16,384 object elements 531-546 us 518-551 us parity to ~4% faster
Flat objects, 62 paths 6.8 us 6.8 us parity
Shared terminal 128 (degenerate) 2.24 us 2.60 us 16-27% slower (0.4 us absolute)
Shared terminal 1,024 (degenerate) 18.7 us 21.6 us 15-32% slower (3 us absolute)

Allocation changes: dense 128 goes from 11 to 14 allocs (+704 B) and dense 1,024 from 22 to 24 (+3.3 KB), from the lazily created heads map and link buffer; idle, flat, and no-registration regimes allocate nothing. The refactoring commit was gated separately: identical allocation counts on all ten benchmarks and no timing regression surviving an overlap-range test across seven to twenty-two interleaved pairs.

Known regressions, stated plainly

  • Degenerate fixtures where many identical terminal paths share one index ({"array":["x"]} with N copies of {"array","[0]"}) measure 15-32% slower, 0.4 to 5 us absolute. This is grouping registration overhead, the trade buying the 4x to 26x dense win, and the in-repo BenchmarkEachKeySharedTerminal* benchmarks pin it.
  • Dense extraction pays 2-3 additional small allocations per call versus master, disclosed above.
  • Every other measured regime is faster than master or at parity.

Tests

Full suite passes except TestOracleSetPr286Regression, which fails identically on current master. New regression tests: duplicate paths deliver the same value to every copy; extreme index components never match and never suppress a sibling lookup; dense array-path sweep at 62/63/64/127/128/129/1024 with strict document-order assertions; EachKeyErr array semantics (shared indexes, terminal strings, missing trailing keys, error stop within one element, io.EOF graceful stop); snapshot semantics for both mutation cases.

Verification

Six independent verification passes were run against this branch: differential harnesses comparing 55,000 to 57,000 ordered execution records per revision (zero panics; identical to master everywhere except the documented and tested snapshot semantics), link-encoding soundness confirmed structurally, paired benchmarks of every claim with corrections the process itself caught and fixed (invalid sparse fixtures, an understated shared-terminal cost, an eagerly zeroed buffer, an expanded mutation surface, and two rewritten comments in the generated code, all remediated in-tree), and code-generation reproducibility confirmed byte-for-byte. No functional defects remain open.

buger added 6 commits October 4, 2026 12:22
Parse each path's array index component once at the gate and group
path indexes by wanted element index, instead of rescanning every
pending path and re-running strconv.Atoi on each of them for every
matching element. Group storage is a map[int]int of list heads plus
a stack-backed link buffer with a lazy heap fallback above 128
pending paths, built in reverse path order so callbacks still fire
in ascending path order per element.

Dense extraction (compact fixture, go1.26.3 darwin/arm64, medians):
128 indexes 50.6us -> 11.5us (4.4x), allocations unchanged (11);
1024 indexes 2.62ms -> 98us (27x), allocs unchanged (22).

Array index membership is snapshotted when entering an array;
mutating the paths slice from a callback no longer redirects
pending matches (documented by TestEachKeyPathsSnapshotSemantics).

Adds regression tests: duplicate paths, extreme/negative/overflow
index components, dense boundary sweep 62..1024 with strict
callback-order assertions, and EachKeyErr array semantics (shared
indexes, terminal strings, missing trailing keys, error/io.EOF
stops).
An address-taken [128]int stack buffer is zeroed by the compiler at
every '[' token, costing ~1KB of memset per visited array even when
no path targets it; that measured 10-13% slower than master on
array-heavy documents with object-only paths. Allocate the link
buffer on first path registration instead: untouched arrays now cost
nothing (measured at parity with master, 0 allocations), and arrays
with pending paths allocate one []int of len(paths).

Dense numbers unchanged at scale: 1024 indexes 22 allocs (same as
master), 128 indexes 12 allocs (+1 x 1KB versus master), speedup
retained (~4.2x at 128, ~25x at 1024 on the compact fixture).
… benchmarks

Array bookkeeping now activates only when a path actually contains an
array component, computed once per call: documents whose paths never
target an array skip the per-'[' path scan and map creation entirely,
measuring faster than master on array-heavy documents with object-only
paths, where master builds a map and zeroes a flag buffer at every '['
token. The heads map and link buffer are allocated lazily on first
registration, so sparse two-index extraction over object elements runs
faster with zero allocations, and links are int32, halving the buffer.

Adds permanent regime benchmarks to issues_56_107_229_test.go covering
dense, sparse (scalar and object elements), idle arrays, shared
terminal paths, flat objects, and the EachKeyErr dense case.

Medians, go1.26.3 darwin/arm64, -cpu=1, paired against master: dense
128 is 4.2x faster (12.3us vs 51.1us), dense 1024 25.6x (103us vs
2.65ms), idle arrays 8-18% faster, realistic sparse extraction ~6%
faster, flat objects at parity. Known residual costs, disclosed in the
PR: arrays of tiny scalar elements with few wanted indexes measure up
to ~7% slower, and degenerate fixtures with many identical terminal
paths sharing one index measure 14-23% slower (0.5-3us absolute).
The sparse benchmarks used components "3" and "16380" without
brackets, which are object keys, not array indexes: both benchmarks
produced zero callbacks and measured array skipping instead of
extraction. Use bracketed selectors so they measure what their names
claim. With valid fixtures, sparse extraction measures faster than
master on both scalar elements (~12%, 186us vs 211us over 16384
elements) and object elements (~3%), replacing the earlier claim of a
small scalar regression that the broken fixture had produced.

The anyArrayPath precompute snapshots the routing decision at call
entry, which expands the observable difference for callbacks that
mutate the paths slice: converting a non-array component into an
index before the array is reached is no longer observed (master
re-evaluated per array). Document the contract explicitly, paths are
treated as immutable for the duration of the call, and extend
TestEachKeyPathsSnapshotSemantics with the pre-entry mutation case
as its witness.
Extract the inlined array-path precheck and maintain scanning, validation,
index grouping, skip logic, and callback walks in one template. Generate
specialized EachKey and EachKeyErr functions with no runtime dispatch.
Regenerate with go generate ./....

Use generation after helper extraction, a shared core with specialized
array walks, and a generic core failed the timing gate. Keep go.mod at
Go 1.13 and use only the standard library for generation.

Validation: all ten benchmark regimes retain identical bytes and allocation
counts. Alternating baseline/candidate samples show no timing change beyond
noise; longer checks confirm the largest positive median shifts. Escape
analysis preserves stack-backed path buffers and non-escaping array
callbacks. The differential harness matches 126 ordered traces and returns.

The full suite has only the pre-existing TestOracleSetPr286Regression
failures, identical on b8bb21d and master. Vet adds no warnings. Generation
is reproducible and the output is gofmt clean.
Verification of the generation refactor flagged two comments that were
rewritten instead of preserved: the six-line note explaining cursor
position after the match block, and the empty-component guard comment.
Restore both texts byte-for-byte from the pre-refactor source. Because
the template shares one body, both variants now carry them; the texts
are unchanged from the originals, EachKeyErr simply gains the same
accurate documentation. go generate reproduces eachkey.go exactly.
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