Skip to content

perf: faster packed histograms (leaf cache, raw accessors, add path) - #63

Merged
alexvictoor merged 2 commits into
masterfrom
perf/packed
Nov 7, 2026
Merged

alexvictoor merged 2 commits into
masterfrom
perf/packed

Conversation

@alexvictoor

@alexvictoor alexvictoor commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Summary

Speeds up PackedHistogram (the bitBucketSize: "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 in Histogram.ts, and is based on master.

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 addToCountAtIndex did a full get plus a full set. Three changes:

  • Leaf cache in PackedArray.get(). The 8 virtual indexes of a group (index >> 3) share one leaf per byte set, so get() 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() and add() record the leaves they insert for the cached group, so interleaved reads and writes stay coherent; clear() and setVirtualLength() invalidate.
  • Raw loads/stores on the tree walk. PackedArrayContext keeps a dataStart pointer and the hot accessors (getAtShortIndex, getPackedSlotIndicators, getAtByteIndex, ...) read through it instead of bounds-checked Uint16Array / Uint8Array indexing. Indexes come from the tree itself, which stays within the array.
  • Histogram.incrementCountAtIndex / addToCountAtIndex on packed counts call PackedArray.add(): one walk per byte actually touched, instead of get + set. Subtract keeps get + set, because adding a wrapped negative value would create entries in all 8 byte sets.

Bug fixed on the way

PackedArray.add() (and increment()) was dead code on master: it had two implicit narrowing casts that only fail to compile when something instantiates it, and addAtByteIndex returned the byte sum as u8, so the carry the caller reads with >> 8 was 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 master with an index-out-of-range inside set(); 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/:

  • Leaf cache in PackedArray.get(): same design as the AssemblyScript version (one tree walk per group of 8 indexes, set()/add() keep it coherent, clear()/setVirtualLength() invalidate).
  • Cheaper arithmetic on the hot paths: Math.pow and float divisions replaced in the slot computation (>>>), the carry handling and the byte weights (precomputed table). Dead bytesAlreadySet logic removed from set().
  • Not ported: raw loads/stores (no JS equivalent). PackedHistogram already calls add() / increment() directly, and the JS add() carry was already correct.

Quick benchmark, same process, 1024 random values with counts 1..100, range 0..1e7, 3 digits (single run, rough):

op before after speedup
p99 1617 us 182 us 8.9x
1000 x recordValueWithCount 207 us 188 us 1.1x

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 master binary vs this branch loaded in the same process, identical seeded workload (src/bench/wasm-compare.mjs from #62 plus a record op), Node 24.16 on an Apple Silicon laptop, median of 15 rounds. Histograms hold 1024 random values with counts 1..100.

scenario op before after speedup
wide (0..2^53, 3 digits, ~45k counts) p99 347.6 µs 134.1 µs 2.6x
wide p50 311.7 µs 127.8 µs 2.4x
wide add + subtract 645.9 µs 356.0 µs 1.8x
wide recordValueWithCount 103 ns 42 ns 2.5x
latency (0..1e7, 3 digits) p99 139.3 µs 49.4 µs 2.8x
latency add + subtract 327.1 µs 155.8 µs 2.1x
latency recordValueWithCount 77 ns 35 ns 2.2x
dense (0..1e5, 2 digits, 100k values) p99 24.8 µs 5.7 µs 4.3x
dense add + subtract 142.3 µs 58.1 µs 2.4x
dense recordValueWithCount 74 ns 30 ns 2.4x
any reset unchanged 1.0x

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)
scenario                      type      op         baseline     simd         speedup
wide (0..2^53, 3 digits)      8         p99        21.63 µs     24.28 µs     0.89x
wide (0..2^53, 3 digits)      8         p50        20.84 µs     34.97 µs     0.60x
wide (0..2^53, 3 digits)      8         p99.99     21.82 µs     20.72 µs     1.05x
wide (0..2^53, 3 digits)      8         add+sub    45.19 µs     43.67 µs     1.03x
wide (0..2^53, 3 digits)      8         reset      15.12 µs     14.54 µs     1.04x
wide (0..2^53, 3 digits)      8         record     8 ns         14 ns        0.54x
wide (0..2^53, 3 digits)      16        p99        21.63 µs     21.50 µs     1.01x
wide (0..2^53, 3 digits)      16        p50        21.03 µs     21.02 µs     1.00x
wide (0..2^53, 3 digits)      16        p99.99     21.72 µs     21.67 µs     1.00x
wide (0..2^53, 3 digits)      16        add+sub    43.18 µs     43.30 µs     1.00x
wide (0..2^53, 3 digits)      16        reset      16.85 µs     17.30 µs     0.97x
wide (0..2^53, 3 digits)      16        record     16 ns        14 ns        1.09x
wide (0..2^53, 3 digits)      32        p99        21.88 µs     21.72 µs     1.01x
wide (0..2^53, 3 digits)      32        p50        21.45 µs     21.18 µs     1.01x
wide (0..2^53, 3 digits)      32        p99.99     21.23 µs     21.73 µs     0.98x
wide (0..2^53, 3 digits)      32        add+sub    43.21 µs     44.48 µs     0.97x
wide (0..2^53, 3 digits)      32        reset      17.10 µs     17.16 µs     1.00x
wide (0..2^53, 3 digits)      32        record     15 ns        16 ns        0.97x
wide (0..2^53, 3 digits)      64        p99        21.85 µs     21.72 µs     1.01x
wide (0..2^53, 3 digits)      64        p50        21.66 µs     21.73 µs     1.00x
wide (0..2^53, 3 digits)      64        p99.99     21.26 µs     22.06 µs     0.96x
wide (0..2^53, 3 digits)      64        add+sub    45.18 µs     44.69 µs     1.01x
wide (0..2^53, 3 digits)      64        reset      20.29 µs     18.98 µs     1.07x
wide (0..2^53, 3 digits)      64        record     15 ns        15 ns        1.03x
wide (0..2^53, 3 digits)      Packed    p99        347.62 µs    134.10 µs    2.59x
wide (0..2^53, 3 digits)      Packed    p50        311.65 µs    127.81 µs    2.44x
wide (0..2^53, 3 digits)      Packed    p99.99     344.73 µs    134.27 µs    2.57x
wide (0..2^53, 3 digits)      Packed    add+sub    645.91 µs    355.96 µs    1.81x
wide (0..2^53, 3 digits)      Packed    reset      274 ns       275 ns       1.00x
wide (0..2^53, 3 digits)      Packed    record     103 ns       42 ns        2.47x
latency (0..1e7, 3 digits)    8         p99        7.01 µs      6.85 µs      1.02x
latency (0..1e7, 3 digits)    8         p50        6.37 µs      6.49 µs      0.98x
latency (0..1e7, 3 digits)    8         p99.99     6.84 µs      6.82 µs      1.00x
latency (0..1e7, 3 digits)    8         add+sub    15.52 µs     15.59 µs     1.00x
latency (0..1e7, 3 digits)    8         reset      4.91 µs      4.73 µs      1.04x
latency (0..1e7, 3 digits)    8         record     13 ns        14 ns        0.95x
latency (0..1e7, 3 digits)    16        p99        6.93 µs      7.03 µs      0.98x
latency (0..1e7, 3 digits)    16        p50        6.53 µs      6.55 µs      1.00x
latency (0..1e7, 3 digits)    16        p99.99     7.12 µs      7.12 µs      1.00x
latency (0..1e7, 3 digits)    16        add+sub    15.79 µs     15.77 µs     1.00x
latency (0..1e7, 3 digits)    16        reset      5.86 µs      5.75 µs      1.02x
latency (0..1e7, 3 digits)    16        record     14 ns        14 ns        1.03x
latency (0..1e7, 3 digits)    32        p99        7.00 µs      7.17 µs      0.98x
latency (0..1e7, 3 digits)    32        p50        6.55 µs      6.69 µs      0.98x
latency (0..1e7, 3 digits)    32        p99.99     7.01 µs      7.02 µs      1.00x
latency (0..1e7, 3 digits)    32        add+sub    15.75 µs     15.50 µs     1.02x
latency (0..1e7, 3 digits)    32        reset      5.72 µs      5.90 µs      0.97x
latency (0..1e7, 3 digits)    32        record     16 ns        14 ns        1.10x
latency (0..1e7, 3 digits)    64        p99        7.17 µs      7.05 µs      1.02x
latency (0..1e7, 3 digits)    64        p50        6.55 µs      6.64 µs      0.99x
latency (0..1e7, 3 digits)    64        p99.99     6.90 µs      7.16 µs      0.96x
latency (0..1e7, 3 digits)    64        add+sub    15.78 µs     16.85 µs     0.94x
latency (0..1e7, 3 digits)    64        reset      5.70 µs      5.88 µs      0.97x
latency (0..1e7, 3 digits)    64        record     15 ns        14 ns        1.07x
latency (0..1e7, 3 digits)    Packed    p99        139.32 µs    49.43 µs     2.82x
latency (0..1e7, 3 digits)    Packed    p50        120.77 µs    45.45 µs     2.66x
latency (0..1e7, 3 digits)    Packed    p99.99     138.35 µs    49.24 µs     2.81x
latency (0..1e7, 3 digits)    Packed    add+sub    327.12 µs    155.81 µs    2.10x
latency (0..1e7, 3 digits)    Packed    reset      245 ns       305 ns       0.80x
latency (0..1e7, 3 digits)    Packed    record     77 ns        35 ns        2.21x
dense (0..1e5, 2 digits)      8         p99        736 ns       734 ns       1.00x
dense (0..1e5, 2 digits)      8         p50        733 ns       739 ns       0.99x
dense (0..1e5, 2 digits)      8         p99.99     736 ns       742 ns       0.99x
dense (0..1e5, 2 digits)      8         add+sub    2.09 µs      2.09 µs      1.00x
dense (0..1e5, 2 digits)      8         reset      495 ns       472 ns       1.05x
dense (0..1e5, 2 digits)      8         record     14 ns        14 ns        1.02x
dense (0..1e5, 2 digits)      16        p99        697 ns       723 ns       0.96x
dense (0..1e5, 2 digits)      16        p50        644 ns       650 ns       0.99x
dense (0..1e5, 2 digits)      16        p99.99     721 ns       727 ns       0.99x
dense (0..1e5, 2 digits)      16        add+sub    2.35 µs      2.24 µs      1.05x
dense (0..1e5, 2 digits)      16        reset      595 ns       568 ns       1.05x
dense (0..1e5, 2 digits)      16        record     14 ns        13 ns        1.06x
dense (0..1e5, 2 digits)      32        p99        697 ns       754 ns       0.92x
dense (0..1e5, 2 digits)      32        p50        648 ns       678 ns       0.96x
dense (0..1e5, 2 digits)      32        p99.99     704 ns       751 ns       0.94x
dense (0..1e5, 2 digits)      32        add+sub    2.23 µs      2.29 µs      0.97x
dense (0..1e5, 2 digits)      32        reset      626 ns       586 ns       1.07x
dense (0..1e5, 2 digits)      32        record     16 ns        14 ns        1.15x
dense (0..1e5, 2 digits)      64        p99        704 ns       704 ns       1.00x
dense (0..1e5, 2 digits)      64        p50        655 ns       644 ns       1.02x
dense (0..1e5, 2 digits)      64        p99.99     707 ns       711 ns       0.99x
dense (0..1e5, 2 digits)      64        add+sub    2.28 µs      2.24 µs      1.02x
dense (0..1e5, 2 digits)      64        reset      579 ns       568 ns       1.02x
dense (0..1e5, 2 digits)      64        record     14 ns        14 ns        1.01x
dense (0..1e5, 2 digits)      Packed    p99        24.78 µs     5.70 µs      4.34x
dense (0..1e5, 2 digits)      Packed    p50        22.33 µs     5.13 µs      4.35x
dense (0..1e5, 2 digits)      Packed    p99.99     24.65 µs     5.67 µs      4.35x
dense (0..1e5, 2 digits)      Packed    add+sub    142.26 µs    58.14 µs     2.45x
dense (0..1e5, 2 digits)      Packed    reset      181 ns       173 ns       1.04x
dense (0..1e5, 2 digits)      Packed    record     74 ns        30 ns        2.44x

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

dt-alexandre-victoor and others added 2 commits November 7, 2026 20:07
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
alexvictoor merged commit 2763c8e into master Nov 7, 2026
4 checks passed
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.

2 participants