Skip to content

perf: SIMD kernels for counts array scans in the WASM histograms - #62

Open
alexvictoor wants to merge 1 commit into
masterfrom
perf/simd
Open

alexvictoor wants to merge 1 commit into
masterfrom
perf/simd

Conversation

@alexvictoor

Copy link
Copy Markdown
Member

Summary

Vectorizes the whole-counts-array loops of the AssemblyScript histograms backed by flat storage (8/16/32/64 bit counts). The packed histogram keeps its scalar paths.

  • getValueAtPercentile: sums 64 bytes of counts per step with widening pairwise adds (extadd_pairwise / extend_low|high), and only runs the scalar loop inside the block where the running total crosses the target count.
  • add / subtract (same bucket layout and same element width): lane-wise add/sub one vector at a time, with the other side's total accumulated in the same pass. Mixed widths and packed operands fall back to the existing scalar loop.
  • establishInternalTackingValues: skips all-zero vectors with any_true and only looks at individual lanes of the first and last non-zero vector (used by subtract and by decoding).
  • Storage.clear / Storage.resize: memory.fill / memory.copy (bulk memory, already enabled by default).

Dispatch on storage type and element width is resolved at compile time (idof / sizeof in folded ifs), so each Histogram<T, U> instantiation gets a straight-line kernel and no runtime branch.

Builds and the AssemblyScript test runner now pass --enable simd. WebAssembly SIMD is supported by every current browser (Chrome 91+, Firefox 89+, Safari 16.4+) and Node 16.4+; the README WebAssembly section now says so. On an older runtime initWebAssembly() rejects, as before for any unsupported module.

Benchmarks

src/bench/wasm-compare.mjs (new) loads two builds of the module in the same process and runs an identical seeded workload against both. Numbers below: previous master binary vs this branch, Node 24.16 on an Apple Silicon laptop, median of 15 rounds. Histograms hold 1024 random values with counts 1..100 (the "wide" scenario mirrors src/bench's default build() config: auto-resize, 3 digits, values up to 2^53, about 45k counts).

scenario counts op before after speedup
wide (0..2^53, 3 digits) 8 bit p99 19.86 µs 1.28 µs 15.6x
wide 8 bit add+subtract 41.79 µs 3.04 µs 13.8x
wide 8 bit reset 13.79 µs 0.24 µs 56.6x
wide 16 bit p99 20.57 µs 1.94 µs 10.6x
wide 16 bit add+subtract 41.22 µs 6.14 µs 6.7x
wide 32 bit p99 20.73 µs 3.20 µs 6.5x
wide 32 bit p50 20.37 µs 3.14 µs 6.5x
wide 32 bit add+subtract 42.53 µs 12.08 µs 3.5x
wide 32 bit reset 16.08 µs 0.78 µs 20.5x
wide 64 bit p99 20.82 µs 3.85 µs 5.4x
wide 64 bit add+subtract 42.86 µs 24.12 µs 1.8x
wide packed p99 332 µs 326 µs 1.0x
latency (0..1e7, 3 digits) 32 bit p99 6.90 µs 1.13 µs 6.1x
latency 32 bit add+subtract 15.52 µs 4.26 µs 3.6x
latency 32 bit reset 5.67 µs 0.52 µs 10.9x
dense (0..1e5, 2 digits, 100k values) 8 bit add+subtract 2.05 µs 0.11 µs 18.7x
dense 32 bit p99 693 ns 162 ns 4.3x
dense 64 bit p99 692 ns 172 ns 4.0x
Full run (all scenarios, all storage types)
scenario                      type      op         baseline     simd         speedup
wide (0..2^53, 3 digits)      8         p99        19.86 µs     1.28 µs      15.57x
wide (0..2^53, 3 digits)      8         p50        19.47 µs     1.29 µs      15.11x
wide (0..2^53, 3 digits)      8         p99.99     19.93 µs     1.24 µs      16.13x
wide (0..2^53, 3 digits)      8         add+sub    41.79 µs     3.04 µs      13.76x
wide (0..2^53, 3 digits)      8         reset      13.79 µs     244 ns       56.57x
wide (0..2^53, 3 digits)      16        p99        20.57 µs     1.94 µs      10.58x
wide (0..2^53, 3 digits)      16        p50        20.03 µs     1.90 µs      10.56x
wide (0..2^53, 3 digits)      16        p99.99     20.66 µs     1.95 µs      10.58x
wide (0..2^53, 3 digits)      16        add+sub    41.22 µs     6.14 µs      6.71x
wide (0..2^53, 3 digits)      16        reset      16.05 µs     421 ns       38.13x
wide (0..2^53, 3 digits)      32        p99        20.73 µs     3.20 µs      6.48x
wide (0..2^53, 3 digits)      32        p50        20.37 µs     3.14 µs      6.50x
wide (0..2^53, 3 digits)      32        p99.99     20.84 µs     3.21 µs      6.49x
wide (0..2^53, 3 digits)      32        add+sub    42.53 µs     12.08 µs     3.52x
wide (0..2^53, 3 digits)      32        reset      16.08 µs     784 ns       20.51x
wide (0..2^53, 3 digits)      64        p99        20.82 µs     3.85 µs      5.41x
wide (0..2^53, 3 digits)      64        p50        20.33 µs     3.77 µs      5.39x
wide (0..2^53, 3 digits)      64        p99.99     20.81 µs     3.83 µs      5.43x
wide (0..2^53, 3 digits)      64        add+sub    42.86 µs     24.12 µs     1.78x
wide (0..2^53, 3 digits)      64        reset      18.39 µs     1.47 µs      12.51x
wide (0..2^53, 3 digits)      Packed    p99        332.16 µs    325.68 µs    1.02x
wide (0..2^53, 3 digits)      Packed    p50        302.16 µs    294.21 µs    1.03x
wide (0..2^53, 3 digits)      Packed    p99.99     331.35 µs    320.28 µs    1.03x
wide (0..2^53, 3 digits)      Packed    add+sub    600.59 µs    622.26 µs    0.97x
wide (0..2^53, 3 digits)      Packed    reset      200 ns       218 ns       0.92x
latency (0..1e7, 3 digits)    8         p99        6.85 µs      476 ns       14.40x
latency (0..1e7, 3 digits)    8         p50        6.38 µs      448 ns       14.22x
latency (0..1e7, 3 digits)    8         p99.99     6.87 µs      456 ns       15.05x
latency (0..1e7, 3 digits)    8         add+sub    15.61 µs     1.07 µs      14.64x
latency (0..1e7, 3 digits)    8         reset      4.90 µs      223 ns       21.94x
latency (0..1e7, 3 digits)    16        p99        6.95 µs      759 ns       9.16x
latency (0..1e7, 3 digits)    16        p50        6.48 µs      702 ns       9.23x
latency (0..1e7, 3 digits)    16        p99.99     6.99 µs      741 ns       9.43x
latency (0..1e7, 3 digits)    16        add+sub    15.49 µs     2.13 µs      7.27x
latency (0..1e7, 3 digits)    16        reset      5.67 µs      330 ns       17.19x
latency (0..1e7, 3 digits)    32        p99        6.90 µs      1.13 µs      6.08x
latency (0..1e7, 3 digits)    32        p50        6.43 µs      1.07 µs      6.03x
latency (0..1e7, 3 digits)    32        p99.99     6.97 µs      1.14 µs      6.14x
latency (0..1e7, 3 digits)    32        add+sub    15.52 µs     4.26 µs      3.64x
latency (0..1e7, 3 digits)    32        reset      5.67 µs      522 ns       10.87x
latency (0..1e7, 3 digits)    64        p99        6.91 µs      1.28 µs      5.40x
latency (0..1e7, 3 digits)    64        p50        6.56 µs      1.18 µs      5.54x
latency (0..1e7, 3 digits)    64        p99.99     7.05 µs      1.29 µs      5.45x
latency (0..1e7, 3 digits)    64        add+sub    15.75 µs     8.49 µs      1.86x
latency (0..1e7, 3 digits)    64        reset      5.70 µs      777 ns       7.34x
latency (0..1e7, 3 digits)    Packed    p99        136.84 µs    124.44 µs    1.10x
latency (0..1e7, 3 digits)    Packed    p50        117.70 µs    109.65 µs    1.07x
latency (0..1e7, 3 digits)    Packed    p99.99     133.74 µs    127.71 µs    1.05x
latency (0..1e7, 3 digits)    Packed    add+sub    316.87 µs    314.00 µs    1.01x
latency (0..1e7, 3 digits)    Packed    reset      202 ns       248 ns       0.81x
dense (0..1e5, 2 digits)      8         p99        725 ns       102 ns       7.09x
dense (0..1e5, 2 digits)      8         p50        714 ns       101 ns       7.05x
dense (0..1e5, 2 digits)      8         p99.99     716 ns       101 ns       7.07x
dense (0..1e5, 2 digits)      8         add+sub    2.05 µs      110 ns       18.70x
dense (0..1e5, 2 digits)      8         reset      492 ns       57 ns        8.71x
dense (0..1e5, 2 digits)      16        p99        708 ns       145 ns       4.89x
dense (0..1e5, 2 digits)      16        p50        651 ns       136 ns       4.80x
dense (0..1e5, 2 digits)      16        p99.99     712 ns       146 ns       4.88x
dense (0..1e5, 2 digits)      16        add+sub    2.21 µs      198 ns       11.14x
dense (0..1e5, 2 digits)      16        reset      578 ns       74 ns        7.84x
dense (0..1e5, 2 digits)      32        p99        693 ns       162 ns       4.26x
dense (0..1e5, 2 digits)      32        p50        638 ns       156 ns       4.09x
dense (0..1e5, 2 digits)      32        p99.99     700 ns       167 ns       4.20x
dense (0..1e5, 2 digits)      32        add+sub    2.20 µs      390 ns       5.63x
dense (0..1e5, 2 digits)      32        reset      569 ns       102 ns       5.59x
dense (0..1e5, 2 digits)      64        p99        692 ns       172 ns       4.03x
dense (0..1e5, 2 digits)      64        p50        636 ns       163 ns       3.89x
dense (0..1e5, 2 digits)      64        p99.99     694 ns       171 ns       4.05x
dense (0..1e5, 2 digits)      64        add+sub    2.21 µs      754 ns       2.93x
dense (0..1e5, 2 digits)      64        reset      559 ns       191 ns       2.92x
dense (0..1e5, 2 digits)      Packed    p99        24.03 µs     22.91 µs     1.05x
dense (0..1e5, 2 digits)      Packed    p50        21.65 µs     20.70 µs     1.05x
dense (0..1e5, 2 digits)      Packed    p99.99     24.19 µs     23.16 µs     1.04x
dense (0..1e5, 2 digits)      Packed    add+sub    140.76 µs    140.61 µs    1.00x
dense (0..1e5, 2 digits)      Packed    reset      173 ns       146 ns       1.19x

Packed histograms are unchanged within noise (they never take the SIMD paths). Wasm binary grows from 119,670 to 122,509 bytes before compression.

Tests

  • assembly/__tests__/HistogramCountsKernels.test.ts (new) cross-checks each kernel against a scalar reference loop, for every storage width, with array lengths that leave partial tail blocks, with counts that exercise the top bit of each width, and with edge cases (empty histogram, count only at index 0, single count in the last lane, histogram added to itself). A mutation check (signed instead of unsigned widening, sub instead of add) fails these tests as expected.
  • npm run astest: 249/249. npm test: 180/180 against the regenerated binary.

🤖 Generated with Claude Code

https://claude.ai/code/session_013Jg4gNfTZpd8eVPAvqx3js

Vectorize the whole-array loops of the AssemblyScript histograms whose
counts live in a flat StaticArray (8/16/32/64 bit storage):

- getValueAtPercentile: sum 64 bytes of counts per step with widening
  pairwise adds, only falling back to the scalar loop inside the block
  where the running total crosses the target count.
- add / subtract (same bucket layout, same element width): lane-wise
  add/sub, one vector at a time, with the other side's total summed in
  the same pass.
- establishInternalTackingValues: skip all-zero vectors with any_true,
  only inspect lanes of the first and last non-zero vector.
- Storage.clear / Storage.resize: memory.fill / memory.copy.

Type and width dispatch is resolved at compile time (idof / sizeof), so
each Histogram instantiation gets a straight-line kernel and the
PackedArray-backed histogram keeps its scalar paths.

Builds and the AssemblyScript test runner now pass --enable simd.
Measured against the previous binary in the same process (node 24,
median of 15 rounds), 32 bit histogram with ~45k counts:
getValueAtPercentile 20.7µs -> 3.2µs, add+subtract 42.5µs -> 12.1µs,
reset 16.1µs -> 0.8µs. 8 bit storage gains 14-16x on percentiles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Jg4gNfTZpd8eVPAvqx3js
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