Skip to content

perf: packed arrays write through the leaf cache - #68

Draft
alexvictoor wants to merge 2 commits into
masterfrom
perf/packed-write-leaf-cache
Draft

alexvictoor wants to merge 2 commits into
masterfrom
perf/packed-write-leaf-cache

Conversation

@alexvictoor

@alexvictoor alexvictoor commented Nov 8, 2026 •

Copy link
Copy Markdown
Member

What

PackedArray.add(), increment() and set() now go through the leaf cache introduced in #63, in both the TypeScript (src/packedarray/PackedArray.ts) and the AssemblyScript (assembly/packedarray/PackedArray.ts) implementations.

Until now only get() used the cache: every write walked the set tree for each byte it touched (getPackedIndex(byteNum, index, true), 3-5 dependent memory reads) and merely refreshed the cache afterwards (noteLeaf). A write now takes the byte address straight from the cached leaf (leafIndex * 8 + (index & 7)) and descends only for the sets the cache has no leaf for.

Why

The 8 virtual indexes of a group (index >> 3) share one leaf per byte set, so sequential writes (Histogram.add, decoding, runs of recordValue on nearby values) repeated the same tree walks up to 8 times per set. The write path is where packed histograms spend most of their time.

How it works

The cache gets a third entry state next to a leaf index (>= 0) and NO_LEAF (-1): UNKNOWN_LEAF (-2), "not looked up yet for the cached group".

  • A read or write on a new group makes it the cached one and marks all 8 entries UNKNOWN_LEAF. A miss costs no extra tree walk: a write looks up (with insert) only the sets it touches and records them; get() resolves UNKNOWN_LEAF entries on demand, without insert, and records a leaf or NO_LEAF. Random-write workloads therefore keep exactly one descent per touched byte, as before.
  • fillLeafCache / noteLeaf / safeGetPackedIndexgetPackedIndex are replaced by a single cacheGroupOf(index).
  • set() keeps its zero special case: a zero byte for a set with a NO_LEAF entry returns without creating anything (identical to the former non-inserting probe), an UNKNOWN_LEAF entry is probed without insert as before, a cached leaf gets the zero byte written.

Why it is correct

  • A leaf never moves. Entries are only ever appended to the physical array; resizeArray copies them in place and expandEntry creates new non-leaf entries but leaves the leaves where they are. A cached leaf index is valid for the lifetime of the context.
  • Only NO_LEAF/UNKNOWN_LEAF entries can become stale, when a write creates the leaf, and the write records it in the same call. get() still relies on the "sets are populated from byte 0 upwards" property to stop at the first NO_LEAF.
  • Everything that rebuilds the context invalidates the cache (cachedGroup = -1 in setVirtualLength/clear), unchanged.
  • Unpacked fallback (fix: packed arrays move to an unpacked context once their packed storage is full (WASM and JS) #66): the fast path runs only when arrayContext.isPacked; the "storage full" -1 can only come out of an inserting descent, which is still where unpack() is reached, so the carry-over semantics of add (lower bytes already added, remainder added to the unpacked context) are unchanged.

Benchmarks

Apple Silicon laptop, hz (higher is better). The machine was ~10% noisier/slower during the "after" runs (visible on the Int32 rows, which this PR does not touch), so the Int32 row of the same run is given as a reference for the random-access bench.

npx vitest bench src/bench/histogram-add.bench.ts (JS packed histogram, add of a 1024-value histogram, sequential scan + writes):

bench before after
Packed Histogram add 2,190 hz 2,447 hz (+12%)

npx vitest bench src/bench/histogram-data-access.bench.ts (recordValue of random values over the whole range, i.e. ~0% cache hit, a pure miss-path check):

bench before after
PackedHistogram (JS) 17.13M hz (0.92x Int32Histogram) 15.59M hz (0.94x Int32Histogram)
WASM PackedHistogram 26.06M hz (0.99x WASM Int32Histogram) 24.84M hz (0.99x WASM Int32Histogram)

Relative to the untouched Int32 rows the miss path is neutral, as intended.

Ad-hoc sequential write bench (not committed; PackedArray of 4096 indexes and packed histograms recording values 1..2047, which map 1:1 to counts indexes):

bench before after
PackedArray sequential add (JS) 8.05M hz 21.25M hz (2.6x)
PackedArray sequential increment (JS) 7.92M hz 21.02M hz (2.7x)
PackedArray sequential set (JS) 4.33M hz 19.80M hz (4.6x)
PackedHistogram sequential recordValueWithCount (JS) 7.80M hz 19.96M hz (2.6x)
WASM PackedHistogram sequential recordValueWithCount 21.53M hz 24.32M hz (+13%)
WASM PackedHistogram sequential recordValue 20.83M hz 26.78M hz (+29%)

Tests

  • src/packedarray/PackedArray.fc.spec.ts: two new fast-check properties against a plain array model: interleaved get/add/increment/set concentrated on 4 groups (cache hits, writes to sets not looked up yet, carries across sets, sets to smaller values and to 0), and cached writes while the packed storage fills up and the array moves to the unpacked context.
  • assembly/__tests__/PackedArrayAccess.test.ts: sequential writes with no read in between, carries through cached and uncached sets up to the top byte, set to a smaller value / zero through the cache, cached writes across the unpacked fallback.
  • Both mutations tried on the JS fast path (wrong byte offset, treating UNKNOWN_LEAF like NO_LEAF in get) are caught by the new and the existing specs.
  • npm test: 208 passed, 4 skipped. npm run typecheck: clean. npm run astest: 15290/15291; the one failure (HistogramCountsKernels.test.ts:113, Histogram64 with lowestDiscernibleValue > 1) is pre-existing on master and unrelated to packed arrays.

Note: npm run prettier:fix (prettier 3) reformats most of src/ with trailing commas that the repo does not use, so only the touched files were formatted and the unrelated churn was left out.

Review follow-up

  • Bounds check restored. The write fast path bypassed getPackedIndex(), the only virtualLength check of the packed path, so after a cached access add(31, 5) on a 30-long array silently wrote into the leaf. cacheGroupOf() now checks the bound once for get, add and set (JS and AS), with the same error message as before. Test added in PackedArray.spec.ts.
  • Class comment now describes the UNKNOWN_LEAF state, how get() resolves it, and the cost of the eviction policy: any access to another group, writes included, evicts the cached group, so a read scan interleaved with writes to distant indexes re-walks the trees. Kept as is because write-around on a miss would make sequential writes (the common recordValue pattern this PR targets) miss every time.

🤖 Generated with Claude Code

add(), increment() and set() used to walk the set trees for every byte
they touched (getPackedIndex with insertAsNeeded), and only refreshed the
leaf cache afterwards. They now take the leaf of the cached group
directly and descend only for sets the cache has no leaf for.

A write on another group makes that group the cached one, with its
leaves marked UNKNOWN_LEAF (a third cache state next to a leaf index and
NO_LEAF), so a miss costs no extra tree walk: the write looks up only the
sets it touches and records them, and get() resolves the remaining sets
on demand. Random writes keep one descent per touched byte, sequential
writes (histogram add, recordValue runs) mostly skip the descents.

Correctness rests on the same invariants as the read cache: a leaf never
moves once created (entries are only appended, resizeArray copies them in
place), only NO_LEAF entries can become stale and the writes creating
such leaves record them, and anything rebuilding the context invalidates
the cache (cachedGroup = -1). The unpacked fallback is unchanged: the
fast path only runs on a packed context, and unpack() is still reached
from the inserting descents.

Both the TypeScript and the AssemblyScript implementations change the
same way, with property-based (fast-check) and as-test cases covering
interleaved get/add/set/increment within a group, carries across cached
and uncached sets, sets to smaller values, and the unpacked fallback
after cached writes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@alexvictoor
alexvictoor force-pushed the perf/packed-write-leaf-cache branch from cad8998 to 39199e6 Compare November 8, 2026 17:38
The write fast path skipped getPackedIndex(), which held the only
virtualLength check of the packed path, so an out-of-range index inside the
cached group was written into the leaf instead of throwing. Check the bound
once in cacheGroupOf(), for get, add and set alike, and describe the
UNKNOWN_LEAF state and the eviction cost of writes in the class comment.

Co-Authored-By: Claude Fable 5.1 <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.

1 participant