Repository navigation
perf: packed arrays write through the leaf cache - #68
Draft
alexvictoor wants to merge 2 commits into
Draft
alexvictoor wants to merge 2 commits into
alexvictoor wants to merge 2 commits into
Conversation
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
force-pushed
the
perf/packed-write-leaf-cache
branch
from
November 8, 2026 17:38
cad8998 to
39199e6
Compare
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>
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.
What
PackedArray.add(),increment()andset()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 ofrecordValueon 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".UNKNOWN_LEAF. A miss costs no extra tree walk: a write looks up (with insert) only the sets it touches and records them;get()resolvesUNKNOWN_LEAFentries on demand, without insert, and records a leaf orNO_LEAF. Random-write workloads therefore keep exactly one descent per touched byte, as before.fillLeafCache/noteLeaf/safeGetPackedIndexgetPackedIndexare replaced by a singlecacheGroupOf(index).set()keeps its zero special case: a zero byte for a set with aNO_LEAFentry returns without creating anything (identical to the former non-inserting probe), anUNKNOWN_LEAFentry is probed without insert as before, a cached leaf gets the zero byte written.Why it is correct
resizeArraycopies them in place andexpandEntrycreates new non-leaf entries but leaves the leaves where they are. A cached leaf index is valid for the lifetime of the context.NO_LEAF/UNKNOWN_LEAFentries 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 firstNO_LEAF.cachedGroup = -1insetVirtualLength/clear), unchanged.arrayContext.isPacked; the "storage full"-1can only come out of an inserting descent, which is still whereunpack()is reached, so the carry-over semantics ofadd(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,addof a 1024-value histogram, sequential scan + writes):npx vitest bench src/bench/histogram-data-access.bench.ts(recordValueof random values over the whole range, i.e. ~0% cache hit, a pure miss-path check):Relative to the untouched Int32 rows the miss path is neutral, as intended.
Ad-hoc sequential write bench (not committed;
PackedArrayof 4096 indexes and packed histograms recording values 1..2047, which map 1:1 to counts indexes):add(JS)increment(JS)set(JS)recordValueWithCount(JS)recordValueWithCountrecordValueTests
src/packedarray/PackedArray.fc.spec.ts: two new fast-check properties against a plain array model: interleavedget/add/increment/setconcentrated 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,setto a smaller value / zero through the cache, cached writes across the unpacked fallback.UNKNOWN_LEAFlikeNO_LEAFinget) 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 ofsrc/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
getPackedIndex(), the onlyvirtualLengthcheck of the packed path, so after a cached accessadd(31, 5)on a 30-long array silently wrote into the leaf.cacheGroupOf()now checks the bound once forget,addandset(JS and AS), with the same error message as before. Test added inPackedArray.spec.ts.UNKNOWN_LEAFstate, howget()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