Repository navigation
perf: faster packed histograms (leaf cache, raw accessors, add path) - #63
Merged
Merged
Conversation
Three changes to the AssemblyScript PackedArray used by PackedHistogram: - Leaf cache for reads. The 8 virtual indexes of a group (index >> 3) share one leaf per byte set, so PackedArray.get() now walks the set trees once per group and reuses the leaf indexes for the next 7 reads. set() and add() keep the cache coherent for the cached group, context rebuilds (clear, resize) invalidate it. - Raw loads/stores in PackedArrayContext. The accessors on the set-tree walk go through a dataStart pointer instead of bounds-checked typed array indexing; indexes come from the tree itself. - Histogram routes increments and positive adds on packed counts to PackedArray.add(), one walk per touched byte instead of a get plus a set. Subtract keeps get + set so wrapped negative values do not inflate every byte set. PackedArray.add() did not compile before (two implicit narrowing casts, never instantiated) and lost the carry between bytes because addAtByteIndex returned the sum as u8. Both fixed, with tests. Measured against the previous binary in the same process (node 24, median of 15 rounds): packed p99 347µs -> 134µs (~45k counts), 139µs -> 49µs (latency range), add+subtract 1.8-2.5x, recordValue 103ns -> 42ns. Non-packed histograms unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Jg4gNfTZpd8eVPAvqx3js
…implementation - PackedArray.get() walks the set trees once per group of 8 indexes and serves the other reads from cached leaf indexes; set()/add() keep the cache coherent, clear()/setVirtualLength() invalidate it. - Replace Math.pow/float divisions on the hot paths (slot computation, carry handling, byte weights); drop dead bytesAlreadySet logic in set(). - Add tests for carries, interleaved reads/writes, random workload, resize. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
alexvictoor
force-pushed
the
perf/packed
branch
from
November 7, 2026 19:08
5439842 to
9b6d714
Compare
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.
Summary
Speeds up
PackedHistogram(thebitBucketSize: "packed"flavour), in both the AssemblyScript/WebAssembly implementation and the pure JS one. Independent from #62: it only touches the packed array and two dispatch lines inHistogram.ts, and is based onmaster.Every access to a packed count walks a per-byte "set tree" (up to 4 levels of bounds-checked typed-array reads), once per populated byte. A percentile lookup did that for every index, and
addToCountAtIndexdid a fullgetplus a fullset. Three changes:PackedArray.get(). The 8 virtual indexes of a group (index >> 3) share one leaf per byte set, soget()now walks the trees once per group and serves the next 7 reads from the cached leaf indexes (a compare plus one byte load per populated set).set()andadd()record the leaves they insert for the cached group, so interleaved reads and writes stay coherent;clear()andsetVirtualLength()invalidate.PackedArrayContextkeeps adataStartpointer and the hot accessors (getAtShortIndex,getPackedSlotIndicators,getAtByteIndex, ...) read through it instead of bounds-checkedUint16Array/Uint8Arrayindexing. Indexes come from the tree itself, which stays within the array.Histogram.incrementCountAtIndex/addToCountAtIndexon packed counts callPackedArray.add(): one walk per byte actually touched, instead ofget+set. Subtract keepsget+set, because adding a wrapped negative value would create entries in all 8 byte sets.Bug fixed on the way
PackedArray.add()(andincrement()) was dead code onmaster: it had two implicit narrowing casts that only fail to compile when something instantiates it, andaddAtByteIndexreturned the byte sum asu8, so the carry the caller reads with>> 8was always zero. Both are fixed and covered by tests (add(42, 200); add(42, 100)now gives 300).Pre-existing limit, not addressed
The packed layout addresses entries with 16-bit short indexes and never switches back to an unpacked context when it outgrows that (the Java version does). Filling 5000 indexes with full 64-bit values crashes
masterwith an index-out-of-range insideset(); the same happens here. Packed histograms are meant for sparse, small counts, so this PR leaves that as is, but it's worth a follow-up.Pure JS implementation
The same ideas, minus the ones that do not apply to JS, are ported to
src/packedarray/:PackedArray.get(): same design as the AssemblyScript version (one tree walk per group of 8 indexes,set()/add()keep it coherent,clear()/setVirtualLength()invalidate).Math.powand float divisions replaced in the slot computation (>>>), the carry handling and the byte weights (precomputed table). DeadbytesAlreadySetlogic removed fromset().PackedHistogramalready callsadd()/increment()directly, and the JSadd()carry was already correct.Quick benchmark, same process, 1024 random values with counts 1..100, range 0..1e7, 3 digits (single run, rough):
Tests: 4 new cases in
src/packedarray/PackedArray.spec.ts(carry across bytes, interleaved reads/writes in a group, random workload vs a plain array, clear/resize invalidation).npm test: 184/184.Benchmarks (AssemblyScript)
Previous
masterbinary vs this branch loaded in the same process, identical seeded workload (src/bench/wasm-compare.mjsfrom #62 plus arecordop), Node 24.16 on an Apple Silicon laptop, median of 15 rounds. Histograms hold 1024 random values with counts 1..100.The 8/16/32/64 bit histograms are unchanged within noise (0.92x to 1.15x across all ops), as expected: their code paths are untouched.
Full run (all scenarios, all storage types)
Tests
assembly/__tests__/PackedArrayAccess.test.ts(new): carries across bytes, interleaved reads and writes inside one leaf group, set to a smaller value clearing higher bytes, a random set/add/get workload against a plain array model (sequential and random reads), clear, resize, and physical array growth while a group is cached.npm run astest: 133/133.npm test: 180/180 against the regenerated binary.🤖 Generated with Claude Code
https://claude.ai/code/session_013Jg4gNfTZpd8eVPAvqx3js