Skip to main content

trueno/backends/q4k/gemv/
mod.rs

1//! Row-major Q4_K matrix-vector multiplication.
2//!
3//! This module implements row-major GEMV where weights are stored row-first.
4//! Includes scalar, AVX2-optimized, and parallel dispatch implementations.
5
6mod scalar;
7
8#[cfg(target_arch = "x86_64")]
9mod avx2;
10
11#[cfg(target_arch = "x86_64")]
12mod avx512;
13
14use super::{SUPER_BLOCK_BYTES, SUPER_BLOCK_SIZE};
15
16// Re-export public API (preserves exact public surface)
17pub use scalar::{matmul_q4k_f32, matmul_q4k_f32_scalar};
18
19// Re-export crate-internal API (used by sibling test modules)
20#[allow(unused_imports)]
21pub(crate) use scalar::compute_chunk_q4k_scalar;
22
23/// Runtime dispatch for Q4K matmul - uses AVX2 if available, otherwise scalar
24///
25/// # Contract (GH-279)
26///
27/// Preconditions validated via `debug_assert!` (zero-cost in release):
28/// - `q4k_data.len() >= contracts::Q4_K.expected_bytes(out_dim, in_dim)`
29/// - `input.len() == in_dim`
30///
31/// These guarantee that inner-loop `expect()` calls on super-block sub-slices
32/// are unreachable: each super-block is sliced to exactly `SUPER_BLOCK_BYTES`
33/// (144), and all sub-accesses (`get(4..16)`, `get(16..144)`) fit within that.
34#[inline]
35pub fn matmul_q4k_f32_dispatch(
36    q4k_data: &[u8],
37    input: &[f32],
38    out_dim: usize,
39    in_dim: usize,
40) -> Vec<f32> {
41    // GH-279: Contract validation at dispatch boundary.
42    // Inner expect() calls are defense-in-depth — provably unreachable when
43    // this precondition holds, because every sb_data slice is SUPER_BLOCK_BYTES.
44    debug_assert_eq!(input.len(), in_dim, "Q4K dispatch: input length mismatch");
45    debug_assert!(
46        q4k_data.len() >= crate::contracts::Q4_K.expected_bytes(out_dim, in_dim),
47        "Q4K dispatch: buffer too small: {} bytes for [{}, {}] (need {})",
48        q4k_data.len(),
49        out_dim,
50        in_dim,
51        crate::contracts::Q4_K.expected_bytes(out_dim, in_dim),
52    );
53
54    #[cfg(target_arch = "x86_64")]
55    {
56        // For large Q4K matmuls (total work >= ~8M elements), use parallel execution.
57        // This catches FFN layers (8960×1536 = 13.7M) and lm_head (151936×1536).
58        // Threshold tested at 2M (2026-04-05) but REGRESSED: 1536×1536 went from
59        // 17→14 GFLOPS because parallel overhead (~40µs) dominates at 277µs total.
60        // Contract: cgp-q4k-parallel-threshold-v1.yaml documents negative result.
61        let total_work = out_dim * in_dim;
62        if total_work >= 8_000_000 {
63            return matmul_q4k_f32_parallel(q4k_data, input, out_dim, in_dim);
64        }
65
66        // AVX-512: 16-wide dequant+FMA (2× throughput vs AVX2)
67        // Contract: avx512-q4k-v1.yaml (C-AVX512-Q4K-001, C-AVX512-Q4K-002)
68        if is_x86_feature_detected!("avx512f")
69            && is_x86_feature_detected!("avx512bw")
70            && is_x86_feature_detected!("fma")
71        {
72            return unsafe { avx512::matmul_q4k_f32_avx512(q4k_data, input, out_dim, in_dim) };
73        }
74
75        if is_x86_feature_detected!("avx2") && is_x86_feature_detected!("fma") {
76            // SAFETY: We just verified AVX2 + FMA are available
77            return unsafe { avx2::matmul_q4k_f32_avx2(q4k_data, input, out_dim, in_dim) };
78        }
79    }
80
81    // Fallback to scalar with 4-way unroll
82    // #2567 made the non-x86 `matmul_q4k_f32_parallel` really parallel, but its only
83    // caller sat inside the x86_64 block above, so aarch64 kept running the serial
84    // kernel below. The dead function surfaced only when gx10 began running lint.
85    #[cfg(not(target_arch = "x86_64"))]
86    {
87        if out_dim * in_dim >= 8_000_000 {
88            return matmul_q4k_f32_parallel(q4k_data, input, out_dim, in_dim);
89        }
90    }
91
92    scalar::matmul_q4k_f32(q4k_data, input, out_dim, in_dim)
93}
94
95/// Fused Q4_K matrix-vector multiply for GGML column-major layout
96///
97/// Computes: output = input @ Q4K_weight (GGML convention: y = x @ W)
98/// where weight is stored in Q4_K format with GGML column-major super-block organization.
99///
100/// # GGML Column-Major Layout (PMAT-103)
101///
102/// For a weight tensor with shape [ne0, ne1] in GGML notation:
103/// - ne0 is the output dimension (rows)
104/// - ne1 is the input/reduction dimension (columns)
105/// - Elements are stored column-major: W[i,j] at offset i + j*ne0
106/// - Each column j (length ne0) contains weights from input[j] to all outputs
107/// - Super-blocks are organized by columns: column j uses super-blocks [j*blocks_per_col, (j+1)*blocks_per_col)
108///
109/// This matches GGUF tensor storage and enables fused kernel execution without transposition.
110///
111/// # Arguments
112/// * `q4k_data` - Raw Q4K bytes in GGML column-major layout [ne0, ne1]
113/// * `input` - F32 input vector [ne1] (input/reduction dimension)
114/// * `ne0` - Size of output dimension (rows in GGML, output size)
115/// * `ne1` - Size of input/reduction dimension (columns in GGML, input size)
116///
117/// # Returns
118/// F32 output vector [ne0]
119///
120/// # Example
121/// ```rust,ignore
122/// // GGUF ffn_gate: shape [intermediate_dim, hidden_dim] = [8960, 1536]
123/// // Computes: intermediate = hidden @ ffn_gate
124/// let output = matmul_q4k_f32_colmajor(&q4k_bytes, &hidden, 8960, 1536);
125/// // output has 8960 elements
126/// ```
127
128// ============================================================================
129// Parallel Execution Helpers
130// ============================================================================
131
132#[cfg(target_arch = "x86_64")]
133fn matmul_q4k_f32_parallel(
134    q4k_data: &[u8],
135    input: &[f32],
136    out_dim: usize,
137    in_dim: usize,
138) -> Vec<f32> {
139    use std::thread;
140
141    // Use fewer threads with larger chunks for better cache efficiency
142    let num_threads = thread::available_parallelism().map(|p| p.get()).unwrap_or(4).min(12);
143
144    let chunk_size = (out_dim + num_threads - 1) / num_threads;
145    let num_blocks_per_row = (in_dim + SUPER_BLOCK_SIZE - 1) / SUPER_BLOCK_SIZE;
146    let row_bytes = num_blocks_per_row * SUPER_BLOCK_BYTES;
147
148    // Uninit: compute_chunk_* writes *out_val = hsum(acc) for every element.
149    let mut output: Vec<f32> = Vec::with_capacity(out_dim);
150    // SAFETY: Each thread's compute_chunk writes every element in its chunk (SET).
151    unsafe {
152        output.set_len(out_dim);
153    }
154    let has_avx512 = is_x86_feature_detected!("avx512f")
155        && is_x86_feature_detected!("avx512bw")
156        && is_x86_feature_detected!("fma");
157    let has_avx2 = is_x86_feature_detected!("avx2") && is_x86_feature_detected!("fma");
158
159    thread::scope(|s| {
160        let input_ref = input;
161        let q4k_ref = q4k_data;
162        // CGP-DBUF: iterate directly instead of collecting into Vec.
163        for (chunk_idx, chunk) in output.chunks_mut(chunk_size).enumerate() {
164            let start_row = chunk_idx * chunk_size;
165
166            s.spawn(move || {
167                if has_avx512 {
168                    // Contract: avx512-q4k-v1.yaml (C-AVX512-Q4K-001)
169                    unsafe {
170                        avx512::compute_chunk_q4k_avx512(
171                            q4k_ref,
172                            input_ref,
173                            chunk,
174                            start_row,
175                            out_dim,
176                            in_dim,
177                            num_blocks_per_row,
178                            row_bytes,
179                        );
180                    }
181                } else if has_avx2 {
182                    unsafe {
183                        avx2::compute_chunk_q4k_avx2(
184                            q4k_ref,
185                            input_ref,
186                            chunk,
187                            start_row,
188                            out_dim,
189                            in_dim,
190                            num_blocks_per_row,
191                            row_bytes,
192                        );
193                    }
194                } else {
195                    scalar::compute_chunk_q4k_scalar(
196                        q4k_ref,
197                        input_ref,
198                        chunk,
199                        start_row,
200                        out_dim,
201                        in_dim,
202                        num_blocks_per_row,
203                        row_bytes,
204                    );
205                }
206            });
207        }
208    });
209
210    output
211}
212
213/// Q4_K GEMV on non-x86_64: SCALAR PER ROW, BUT ACTUALLY PARALLEL (#2567).
214///
215/// This function was named `..._parallel` and called `scalar::matmul_q4k_f32`
216/// — the serial routine — directly. On every ARM machine the hottest kernel in
217/// quantized inference therefore ran single-threaded, silently, and no gate
218/// could see it: the numbers are correct and only the speed is wrong.
219///
220/// The fix is not new SIMD. The parallel structure above has NOTHING
221/// x86-specific in it — chunking rows across threads is architecture-neutral,
222/// and the x86 path already falls back to `scalar::compute_chunk_q4k_scalar`
223/// per chunk when neither AVX-512 nor AVX2 is present. That is exactly the
224/// shape needed here, so aarch64 gets N-core parallelism over the same scalar
225/// inner kernel it was already using, with no new unsafe code and no change to
226/// the arithmetic.
227///
228/// A NEON inner kernel remains open (#2567 also covers the SIMD half). This
229/// closes the half that is a plain structural omission rather than missing
230/// intrinsics — and it is the half the function's own NAME already promised.
231///
232/// MEASURED ON gx10 (GB10, aarch64, 20 cores), 8960x1536, release, 10 warmups:
233///
234///   serial   median 2.17 ms   (2.166 - 2.181, 0.7% spread)
235///   parallel median 1.79 ms   (1.757 - 1.811, 3% spread)
236///
237/// The raw bench output of that run was not preserved, so no ratio is
238/// stated here (PERF-010: a number a reader could quote must cite the
239/// evidence/ receipt that produced it; re-measure before citing one).
240/// 2.17 ms to 1.79 ms from up to 12 threads is modest, and the reason is in this file
241/// already: thread::scope spawns threads on EVERY CALL, and the x86 threshold
242/// comment above puts that overhead at ~40us. Twelve spawns is ~0.48 ms, about
243/// 27% of the 1.79 ms parallel time. It is not DRAM bandwidth — 7.4 MiB in
244/// 1.79 ms is 4.3 GB/s, far below what GB10 unified memory sustains.
245///
246/// So a thread POOL would recover most of the remaining headroom, and the NEON
247/// kernel would cut the per-byte work the threads are dividing. Both are
248/// larger changes; this one is the structural defect, fixed, with the number
249/// stated rather than rounded up.
250#[cfg(not(target_arch = "x86_64"))]
251fn matmul_q4k_f32_parallel(
252    q4k_data: &[u8],
253    input: &[f32],
254    out_dim: usize,
255    in_dim: usize,
256) -> Vec<f32> {
257    use std::thread;
258
259    // Same policy as the x86 path: fewer threads with larger chunks, for cache
260    // efficiency rather than raw thread count.
261    let num_threads = thread::available_parallelism().map(|p| p.get()).unwrap_or(4).min(12);
262
263    // One thread is not parallel; fall through rather than pay scope overhead.
264    if num_threads <= 1 || out_dim == 0 {
265        return scalar::matmul_q4k_f32(q4k_data, input, out_dim, in_dim);
266    }
267
268    let chunk_size = out_dim.div_ceil(num_threads);
269    let num_blocks_per_row = in_dim.div_ceil(SUPER_BLOCK_SIZE);
270    let row_bytes = num_blocks_per_row * SUPER_BLOCK_BYTES;
271
272    let mut output: Vec<f32> = Vec::with_capacity(out_dim);
273    // SAFETY: every chunk's compute_chunk_q4k_scalar writes every element in
274    // its chunk before any read, exactly as the x86_64 path above relies on.
275    unsafe {
276        output.set_len(out_dim);
277    }
278
279    thread::scope(|s| {
280        let input_ref = input;
281        let q4k_ref = q4k_data;
282        for (chunk_idx, chunk) in output.chunks_mut(chunk_size).enumerate() {
283            let start_row = chunk_idx * chunk_size;
284            s.spawn(move || {
285                scalar::compute_chunk_q4k_scalar(
286                    q4k_ref,
287                    input_ref,
288                    chunk,
289                    start_row,
290                    out_dim,
291                    in_dim,
292                    num_blocks_per_row,
293                    row_bytes,
294                );
295            });
296        }
297    });
298
299    output
300}
301
302// ── #2567: the aarch64 "parallel" path is parallel, and still exact ────────
303#[cfg(test)]
304mod issue_2567_aarch64_parallel_tests {
305    use super::*;
306
307    /// Build a Q4_K buffer of the right size. The CONTENTS do not need to be
308    /// meaningful for this test: both paths dequantise the same bytes with the
309    /// same routine, so any deterministic filling exercises the property under
310    /// test — that CHUNKING ACROSS THREADS changes nothing.
311    fn q4k_buffer(out_dim: usize, in_dim: usize) -> Vec<u8> {
312        let blocks = in_dim.div_ceil(SUPER_BLOCK_SIZE);
313        let n = out_dim * blocks * SUPER_BLOCK_BYTES;
314        // Bytes are kept <= 0x3F so that any f16 scale field built from
315        // them has a small exponent and decodes FINITE. Unconstrained bytes
316        // produce NaN scales, which trips the AVX-512 path's own dequant
317        // postcondition (`result.iter().all(|v| v.is_finite())`) — a fixture
318        // defect that reads as a kernel failure.
319        (0..n).map(|i| ((i * 31 + 7) % 0x40) as u8).collect()
320    }
321
322    fn input_vec(in_dim: usize) -> Vec<f32> {
323        (0..in_dim).map(|i| ((i % 17) as f32 - 8.0) * 0.125).collect()
324    }
325
326    /// THE CORRECTNESS PROPERTY, tested on EVERY architecture: the CHUNK
327    /// BOUNDARY is invisible.
328    ///
329    /// Each output row is an independent dot product, so splitting rows across
330    /// threads must give bit-identical results however the split falls. This
331    /// is the only thing the aarch64 change actually claims, and it is about
332    /// chunking rather than about NEON — so it runs here on x86 too.
333    ///
334    /// TWO WRONG ORACLES CAME FIRST, and both are worth recording because each
335    /// looked like a bug in the new code:
336    ///
337    ///   1. Comparing `matmul_q4k_f32_parallel` against `scalar::matmul_q4k_f32`
338    ///      failed on x86 with `-15746.977 != -15746.998`. On x86 that function
339    ///      IS the AVX path; ~1e-6 is FMA reassociation, not a chunking fault.
340    ///
341    ///   2. Comparing chunked `compute_chunk_q4k_scalar` against
342    ///      `scalar::matmul_q4k_f32` failed EVEN AT ONE CHUNK, with
343    ///      `-15746.977 != -15746.947`. The two scalar routines are not each
344    ///      other's oracle: `matmul_q4k_f32` accumulates into a 4-wide array
345    ///      (`acc = [0.0f32; 4]`) while `compute_chunk_q4k_scalar` uses a
346    ///      single running `sum`. Different summation order, ~2e-6, and it
347    ///      predates this change — the x86 parallel path has always had it in
348    ///      its own scalar fallback.
349    ///
350    /// So the assertion is chunk-invariance of ONE kernel against ITSELF.
351    #[test]
352    fn the_chunk_boundary_is_invisible() {
353        for (out_dim, in_dim) in [(1, 256), (7, 256), (16, 512), (33, 256), (64, 256)] {
354            let data = q4k_buffer(out_dim, in_dim);
355            let input = input_vec(in_dim);
356            let num_blocks_per_row = in_dim.div_ceil(SUPER_BLOCK_SIZE);
357            let row_bytes = num_blocks_per_row * SUPER_BLOCK_BYTES;
358
359            let run = |threads: usize| -> Vec<f32> {
360                let chunk_size = out_dim.div_ceil(threads);
361                let mut out = vec![0.0f32; out_dim];
362                for (idx, chunk) in out.chunks_mut(chunk_size).enumerate() {
363                    scalar::compute_chunk_q4k_scalar(
364                        &data,
365                        &input,
366                        chunk,
367                        idx * chunk_size,
368                        out_dim,
369                        in_dim,
370                        num_blocks_per_row,
371                        row_bytes,
372                    );
373                }
374                out
375            };
376
377            let one = run(1);
378            for threads in [2usize, 3, 5, 12, 64] {
379                let many = run(threads);
380                for (i, (a, b)) in one.iter().zip(many.iter()).enumerate() {
381                    assert_eq!(
382                        a.to_bits(),
383                        b.to_bits(),
384                        "[{out_dim}, {in_dim}] threads={threads} row {i}: \
385                         {a} != {b}. A chunk boundary changed the arithmetic."
386                    );
387                }
388            }
389        }
390    }
391
392    /// The aarch64 path now uses `compute_chunk_q4k_scalar`, so its numbers
393    /// shift from the old `matmul_q4k_f32` by that summation-order difference.
394    /// It must be that difference and nothing larger — a real defect would not
395    /// sit at 1e-5 relative.
396    #[test]
397    fn the_kernel_switch_is_only_summation_order() {
398        for (out_dim, in_dim) in [(7, 256), (33, 256), (64, 512)] {
399            let data = q4k_buffer(out_dim, in_dim);
400            let input = input_vec(in_dim);
401            let old = scalar::matmul_q4k_f32(&data, &input, out_dim, in_dim);
402
403            let num_blocks_per_row = in_dim.div_ceil(SUPER_BLOCK_SIZE);
404            let row_bytes = num_blocks_per_row * SUPER_BLOCK_BYTES;
405            let mut new = vec![0.0f32; out_dim];
406            scalar::compute_chunk_q4k_scalar(
407                &data,
408                &input,
409                &mut new,
410                0,
411                out_dim,
412                in_dim,
413                num_blocks_per_row,
414                row_bytes,
415            );
416
417            for (i, (a, b)) in old.iter().zip(new.iter()).enumerate() {
418                // The synthetic buffer is arbitrary bytes, so some rows decode
419                // f16 scales that are NaN or infinite. Both kernels must AGREE
420                // on that — a row finite in one and not the other would be a
421                // real defect — but a NaN pair carries no magnitude to compare.
422                assert_eq!(
423                    a.is_finite(),
424                    b.is_finite(),
425                    "[{out_dim}, {in_dim}] row {i}: finiteness disagrees ({a} vs {b})"
426                );
427                if !a.is_finite() {
428                    continue;
429                }
430                let denom = a.abs().max(b.abs()).max(1.0);
431                let rel = (a - b).abs() / denom;
432                assert!(
433                    rel < 1e-4,
434                    "[{out_dim}, {in_dim}] row {i}: {a} vs {b} (rel {rel:.3e}) — \
435                     larger than summation-order reassociation explains"
436                );
437            }
438        }
439    }
440
441    /// Degenerate shapes must not panic or read out of bounds — the short
442    /// final chunk and the single-row case are where an off-by-one in the
443    /// chunk arithmetic would show.
444    #[test]
445    fn degenerate_shapes_are_safe() {
446        for (out_dim, in_dim) in [(1, 256), (2, 256), (3, 256)] {
447            let data = q4k_buffer(out_dim, in_dim);
448            let input = input_vec(in_dim);
449            let out = matmul_q4k_f32_parallel(&data, &input, out_dim, in_dim);
450            assert_eq!(out.len(), out_dim);
451            // Not asserting finiteness: the buffer is arbitrary bytes and
452            // some decode to NaN f16 scales. What matters here is that the
453            // chunk arithmetic does not panic or read out of bounds.
454        }
455    }
456}
457
458// ── #2567: the measurement, run explicitly on the host that has the defect ─
459//
460// `--ignored` on purpose: this is a TIMING observation, and a wall-clock
461// assertion in a normally-running test is the class that has failed eleven
462// times in this repo. It prints numbers for a human to read and asserts only
463// that the parallel path is not SLOWER, which is a correctness-of-dispatch
464// claim rather than a performance threshold.
465//
466//   cargo test -p aprender-compute --lib issue_2567_measure -- --ignored --nocapture
467#[cfg(test)]
468mod issue_2567_measure {
469    use super::*;
470    use std::time::Instant;
471
472    #[test]
473    #[ignore = "timing observation; run explicitly on the host under test"]
474    fn parallel_vs_serial_on_an_ffn_shaped_matmul() {
475        // 8960x1536 is the FFN layer the x86 threshold comment names as the
476        // case the parallel dispatch exists to catch.
477        let (out_dim, in_dim) = (8960usize, 1536usize);
478        let blocks = in_dim.div_ceil(SUPER_BLOCK_SIZE);
479        // See q4k_buffer: bytes <= 0x3F keep every f16 scale finite.
480        let data: Vec<u8> = (0..out_dim * blocks * SUPER_BLOCK_BYTES)
481            .map(|i| ((i * 31 + 7) % 0x40) as u8)
482            .collect();
483        let input: Vec<f32> = (0..in_dim).map(|i| ((i % 17) as f32 - 8.0) * 0.125).collect();
484
485        let time = |f: &dyn Fn() -> Vec<f32>| -> Vec<f64> {
486            let mut out = Vec::new();
487            // TEN warmups, not two. A first measurement on gx10 discarded two
488            // and produced a BIMODAL serial series — 6.59, 6.60, 6.60, 4.55,
489            // 2.13, 2.14, 2.15 ms — whose median (4.55) sat in the empty space
490            // between the two modes and inflated the reported speedup. The
491            // machine was still settling (cache/DVFS). A median is only
492            // meaningful over a settled distribution, so the warmup runs until
493            // it is one.
494            for i in 0..17 {
495                let t = Instant::now();
496                let r = f();
497                std::hint::black_box(&r);
498                let ms = t.elapsed().as_secs_f64() * 1000.0;
499                if i >= 10 {
500                    out.push(ms);
501                }
502            }
503            out
504        };
505
506        let serial = time(&|| scalar::matmul_q4k_f32(&data, &input, out_dim, in_dim));
507        let parallel = time(&|| matmul_q4k_f32_parallel(&data, &input, out_dim, in_dim));
508
509        let median = |v: &[f64]| {
510            let mut s = v.to_vec();
511            s.sort_by(|a, b| a.partial_cmp(b).expect("finite timings"));
512            s[s.len() / 2]
513        };
514        let (ms_s, ms_p) = (median(&serial), median(&parallel));
515        println!("arch            {}", std::env::consts::ARCH);
516        println!("threads         {:?}", std::thread::available_parallelism());
517        println!("shape           {out_dim}x{in_dim}");
518        println!("serial   median {ms_s:.2} ms   samples {serial:?}");
519        println!("parallel median {ms_p:.2} ms   samples {parallel:?}");
520        println!("speedup         {:.2}x", ms_s / ms_p);
521        // ON X86 THIS RATIO CONFLATES TWO EFFECTS. `matmul_q4k_f32_parallel`
522        // is the AVX path there, so the number is SIMD *and* threads against a
523        // pure-scalar serial baseline — 77x in release, which says nothing
524        // about the change this test exists for. On aarch64 both sides are the
525        // same scalar kernel and the ratio isolates parallelism alone, which
526        // is why the figure quoted for #2567 is the aarch64 one.
527        if cfg!(target_arch = "x86_64") {
528            println!("note            x86: ratio is SIMD+threads vs scalar, not parallelism alone");
529        }
530
531        // NOT a threshold. Only: dispatching to the parallel path must not be
532        // slower than the serial one it replaced. On x86 both are the same
533        // family so this is near 1.0; on aarch64 before this fix it was
534        // exactly 1.0 by construction, because "parallel" called serial.
535        assert!(
536            ms_p <= ms_s * 1.5,
537            "parallel ({ms_p:.2} ms) is materially slower than serial ({ms_s:.2} ms)"
538        );
539    }
540}
541
542#[cfg(test)]
543mod parallel_matches_serial {
544    use super::*;
545
546    /// The threaded Q4_K path computes what the serial kernel computes, on every arch. The
547    /// x86_64-only coverage module never ran the non-x86 variant, which aarch64 now uses.
548    #[test]
549    fn test_q4k_parallel_matches_serial_on_every_arch() {
550        let (out_dim, in_dim) = (96, 512);
551        let mut state = 0x2545_F491_u32;
552        let mut next = move || {
553            state ^= state << 13;
554            state ^= state >> 17;
555            state ^= state << 5;
556            state
557        };
558        let mut q4k = vec![0u8; out_dim * (in_dim / SUPER_BLOCK_SIZE) * SUPER_BLOCK_BYTES];
559        for b in &mut q4k {
560            *b = (next() >> 24) as u8;
561        }
562        for sb in q4k.chunks_exact_mut(SUPER_BLOCK_BYTES) {
563            sb[..4].copy_from_slice(&[0x66, 0x2E, 0x66, 0x22]); // d ~ 0.1, dmin ~ 0.012 (f16)
564        }
565        let input: Vec<f32> =
566            (0..in_dim).map(|_| (next() >> 8) as f32 / 16_777_216.0 - 0.5).collect();
567        let want = scalar::matmul_q4k_f32(&q4k, &input, out_dim, in_dim);
568        let got = matmul_q4k_f32_parallel(&q4k, &input, out_dim, in_dim);
569        assert_eq!(got.len(), want.len());
570        for (row, (g, w)) in got.iter().zip(&want).enumerate() {
571            assert!(
572                (g - w).abs() <= 1e-3 * w.abs().max(1.0),
573                "row {row}: parallel {g}, serial {w}"
574            );
575        }
576    }
577}