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