SIMD-accelerate fp32 quantisation path and add q2_lee_distance - #72
Conversation
- Add 128-bit SIMD (v128) fast path for dtype=0 (fp32) in q2_quantise: • v128.load for 4× f32 bulk loads • f32x4.mul/add for vectorised L2 norm accumulation • i8x16.shuffle for horizontal f32x4 reduction • f32x4.gt + v128.not + v128.bitselect for branchless symbol classification • v128.xor + i32x4.shr_u for vectorised Gray encoding • i32x4.extract_lane for direct byte packing (no pre-zeroing needed) - Add new exported q2_lee_distance function (DESIGN.md §2.6–§2.7): • v128.xor + i8x16.popcnt for SIMD Hamming distance on Gray codes • i16x8/i32x4.extadd_pairwise for hierarchical sum reduction • Scalar tail loop for sub-16-byte remainders - Update q2.ts: Q2Kernel.leeDistance interface + q2LeeDistanceDirect fallback - Add 6 new tests for q2LeeDistanceDirect (identical, complement, adjacent, cyclic-wrap, symmetry, empty vectors) - All 257 tests pass, typecheck clean, WASM validated across n=128..4096 Co-authored-by: devlux76 <86517969+devlux76@users.noreply.github.com> Agent-Logs-Url: https://github.com/devlux76/q2/sessions/7c4e359f-3a13-46a7-8a66-a9e387936697
Co-authored-by: devlux76 <86517969+devlux76@users.noreply.github.com> Agent-Logs-Url: https://github.com/devlux76/q2/sessions/7c4e359f-3a13-46a7-8a66-a9e387936697
There was a problem hiding this comment.
Pull request overview
This PR upgrades the WASM Q² kernel to use SIMD for the fp32 (dtype=0) quantisation hot path and adds a new q2_lee_distance export, along with TypeScript bindings and a TS reference implementation for Lee distance on packed Gray vectors.
Changes:
- SIMD-accelerate
q2_quantisefor fp32 with v128 loads, vectorized norm/reduction, branchless classification, and direct byte packing. - Add a new WASM export
q2_lee_distance(SIMD XOR + popcnt) and bind it insrc/q2.ts. - Add
q2LeeDistanceDirect()TS fallback/reference + unit tests.
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| test/q2.test.ts | Adds unit tests for q2LeeDistanceDirect() behavior on packed vectors. |
| src/q2.wat | Implements SIMD fp32 quantisation path and adds q2_lee_distance SIMD export + documentation updates. |
| src/q2.wasm | Updates the compiled WASM artifact to match the new WAT implementation. |
| src/q2.ts | Updates embedded WASM, adds Q2Kernel.leeDistance() binding, and adds q2LeeDistanceDirect() TS fallback. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export function q2LeeDistanceDirect( | ||
| a: Uint8Array, | ||
| b: Uint8Array, | ||
| n: number, | ||
| ): number { | ||
| const nBytes = n >> 2; | ||
| let total = 0; | ||
| for (let i = 0; i < nBytes; i++) { | ||
| let x = (a[i] ?? 0) ^ (b[i] ?? 0); | ||
| // Popcount: count set bits in 8-bit value | ||
| x = x - ((x >> 1) & 0x55); | ||
| x = (x & 0x33) + ((x >> 2) & 0x33); | ||
| total += (x + (x >> 4)) & 0x0F; | ||
| } | ||
| return total; | ||
| } |
There was a problem hiding this comment.
This adds a second packed-bytes Lee distance implementation (q2LeeDistanceDirect) even though src/q2stats.ts already has leeDistancePacked() (also popcount(a XOR b)). To avoid divergence and duplicated micro-optimizations, consider reusing a single shared helper (or aligning semantics, e.g. both use min(length) vs n-based length).
| it('returns maximum distance (2) for complement pairs A↔C', () => { | ||
| // A=00₂, C=11₂ → complement (DESIGN.md §2.8, distance 2 per dim) | ||
| // All A → 0x00; All C → 0xFF = 11_11_11_11₂ (four C symbols per byte) | ||
| const a = new Uint8Array([0x00, 0x00]); // 8× A | ||
| const c = new Uint8Array([0xFF, 0xFF]); // 8× C | ||
| // Each of 8 dims differs by 2 bits → total Hamming = 16 = total Lee | ||
| expect(q2LeeDistanceDirect(a, c, 8)).toBe(16); |
There was a problem hiding this comment.
The test case name says “returns maximum distance (2) for complement pairs A↔C”, but the expectation is the total distance (16 for n=8). Consider renaming the test to reflect that the function returns the sum over dimensions (e.g., “returns 2 per dimension for …” or “returns 16 for n=8 …”) to avoid confusion.
| ;; Cascaded ≤ masks (≤ is NOT >) | ||
| (local.set $mask_a | ||
| (v128.not (f32x4.gt (local.get $v4) (local.get $neg_tau4)))) | ||
| (local.set $mask_b | ||
| (v128.not (f32x4.gt (local.get $v4) (local.get $zero4)))) | ||
| (local.set $mask_c | ||
| (v128.not (f32x4.gt (local.get $v4) (local.get $tau4)))) |
There was a problem hiding this comment.
The SIMD masks implement “v ≤ threshold” as v128.not(f32x4.gt(v, threshold)). That differs from the scalar fallback’s f32.le semantics for NaNs: for NaN lanes, gt is false so not(false) becomes true, causing NaNs to be classified as A/B/C instead of the scalar path’s default D. Use f32x4.le (or explicitly mask out NaNs) to keep classification identical across paths.
| ;; Cascaded ≤ masks (≤ is NOT >) | |
| (local.set $mask_a | |
| (v128.not (f32x4.gt (local.get $v4) (local.get $neg_tau4)))) | |
| (local.set $mask_b | |
| (v128.not (f32x4.gt (local.get $v4) (local.get $zero4)))) | |
| (local.set $mask_c | |
| (v128.not (f32x4.gt (local.get $v4) (local.get $tau4)))) | |
| ;; Cascaded ≤ masks | |
| (local.set $mask_a | |
| (f32x4.le (local.get $v4) (local.get $neg_tau4))) | |
| (local.set $mask_b | |
| (f32x4.le (local.get $v4) (local.get $zero4))) | |
| (local.set $mask_c | |
| (f32x4.le (local.get $v4) (local.get $tau4))) |
| * @param a - first packed Gray-encoded vector (n/4 bytes) | ||
| * @param b - second packed Gray-encoded vector (n/4 bytes) | ||
| * @param n - original embedding dimension | ||
| * @returns total Lee distance (sum of per-dimension Lee distances) | ||
| */ | ||
| export function q2LeeDistanceDirect( | ||
| a: Uint8Array, | ||
| b: Uint8Array, | ||
| n: number, | ||
| ): number { | ||
| const nBytes = n >> 2; | ||
| let total = 0; | ||
| for (let i = 0; i < nBytes; i++) { | ||
| let x = (a[i] ?? 0) ^ (b[i] ?? 0); | ||
| // Popcount: count set bits in 8-bit value | ||
| x = x - ((x >> 1) & 0x55); | ||
| x = (x & 0x33) + ((x >> 2) & 0x33); | ||
| total += (x + (x >> 4)) & 0x0F; | ||
| } |
There was a problem hiding this comment.
q2LeeDistanceDirect’s doc says a and b are “(n/4 bytes)”, but the implementation pads out-of-range bytes with 0 via (a[i] ?? 0) / (b[i] ?? 0) and also derives nBytes as n >> 2 (floor). Either tighten the implementation to enforce the documented preconditions (length and n % 4 === 0), or update the doc to explicitly describe the padding/flooring behavior.
|
@copilot apply changes based on the comments in this thread Use your best judgement on any tradeoffs, maximize speed and accuracy |
…ance, fix test name - Replace v128.not(f32x4.gt(...)) with f32x4.le(...) in SIMD path to match scalar f32.le NaN→D semantics (NaN comparisons return false with both le and gt, so le gives correct mask directly) - Remove duplicate q2LeeDistanceDirect implementation; re-export leeDistancePacked from q2stats.ts to avoid divergence - Rename complement test to clarify it returns sum over dimensions - Update test calls to match new (a, b) signature (no n parameter) Co-authored-by: devlux76 <86517969+devlux76@users.noreply.github.com> Agent-Logs-Url: https://github.com/devlux76/q2/sessions/6a9ae6ac-eb19-4555-a97e-4760c53b5299
All 4 review comments addressed in
All 257 tests pass, typecheck clean, WASM validated across n=128..4096. |
Issue asked whether WASM SIMD suggestions from Google's AI improve on our kernel. Answer: the kernel was entirely scalar. The suggestions are real optimizations. Implemented them.
SIMD fp32 fast path (
q2_quantise, dtype=0)The hot path (transformers.js always sends fp32) now uses
v128throughout:v128.load,f32x4.mul/add,f32x4.splat— 4× fewer loop iterationsf32x4.le+v128.bitselect— branchless symbol classification, replacing 3 nestedif/elseper dimension. Uses nativef32x4.le(notv128.not(f32x4.gt(...))) to match scalar path's NaN→D semantics.v128.xor+i32x4.shr_u→i32x4.extract_lane— 4 Gray codes into one byte directly, eliminating the output pre-zeroing loopi8x16.shufflepairwise swap-and-add for L2 norm summationNon-fp32 dtype paths (1–4) remain scalar — they're not on the hot path.
New
q2_lee_distanceexportSIMD Lee distance via the Gray-map isometry (DESIGN.md §2.7, Theorem 2.1:
d_H(φ(u),φ(v)) = d_L(u,v)):Scalar tail loop handles sub-16-byte remainders.
TypeScript (
q2.ts)Q2Kernel.leeDistance()interface + WASM bindingq2LeeDistanceDirect()re-exportsleeDistancePackedfromq2stats.ts(single canonical implementation, no duplication)What we didn't need from the suggestion
i32.reinterpret_f32/ raw bit extraction for sign — unnecessary sincef32x4.leagainst thresholds gives us the classification directly with correct boundary semantics.Validation
WASM output verified byte-identical to TS reference across n=128..4096. All 257 existing tests pass; 6 new Lee distance tests added.
Original prompt
📱 Kick off Copilot coding agent tasks wherever you are with GitHub Mobile, available on iOS and Android.