Skip to main content

cortiq_engine/
qtensor.rs

1//! QTensor — weight tensor with pluggable storage.
2//!
3//! Two backings, one interface:
4//! - `F32`   — owned dense floats (small models, tests). Every operation
5//!   is bit-identical to the historical `&[f32]` code paths.
6//! - `Mapped` — quantized bytes zero-copy from the CMF mmap (`q8_row` /
7//!   `q8_2f`). The matvec is fused: int8 rows × f32 activations, the
8//!   q8_2f column field folds into a pre-scale of the input
9//!   (`x'[i] = col[i]·x[i]`), so the inner loop is the same i8 dot as
10//!   q8_row. This is what lets a 15B file run in a few GB of RSS.
11//!
12//! Extension point: new dtypes = new match arm here, nothing else moves.
13
14use crate::pool::{matvec_rows, matvec_rows2, Pool};
15use cortiq_core::quant::{f16_to_f32, GROUP_SIZE};
16use cortiq_core::{CmfModel, TensorDtype};
17use std::sync::Arc;
18
19pub enum QTensor {
20    F32 {
21        data: Vec<f32>,
22        rows: usize,
23        cols: usize,
24    },
25    Mapped {
26        model: Arc<CmfModel>,
27        /// Index into the model's tensor directory.
28        idx: usize,
29        dtype: TensorDtype,
30        rows: usize,
31        cols: usize,
32        /// Per-row scales, dequantized to f32 up front (tiny).
33        row_scale: Vec<f32>,
34        /// q8_2f column field (θ), dequantized up front; empty for q8_row.
35        col_field: Vec<f32>,
36    },
37}
38
39impl QTensor {
40    pub fn from_f32(data: Vec<f32>, rows: usize, cols: usize) -> Self {
41        debug_assert_eq!(data.len(), rows * cols);
42        Self::F32 { data, rows, cols }
43    }
44
45    /// Wrap a directory tensor without dequantizing the payload.
46    /// Falls back to dequantized f32 for dtypes without a fused kernel.
47    pub fn from_model(model: &Arc<CmfModel>, name: &str) -> Result<Self, String> {
48        let idx = model
49            .tensors
50            .iter()
51            .position(|t| t.name == name)
52            .ok_or_else(|| format!("tensor '{name}' not found in CMF directory"))?;
53        let entry = &model.tensors[idx];
54        if entry.shape.len() != 2 {
55            return Err(format!("QTensor::from_model needs 2-D, got '{name}'"));
56        }
57        let (rows, cols) = (entry.shape[0], entry.shape[1]);
58        let bytes = model.entry_bytes(entry);
59
60        match entry.dtype {
61            TensorDtype::Q8Row | TensorDtype::Q8_2f => {
62                let n = rows * cols;
63                let scales_off = n;
64                let row_scale: Vec<f32> = (0..rows)
65                    .map(|o| {
66                        f16_to_f32(u16::from_le_bytes([
67                            bytes[scales_off + o * 2],
68                            bytes[scales_off + o * 2 + 1],
69                        ]))
70                    })
71                    .collect();
72                let col_field: Vec<f32> = if entry.dtype == TensorDtype::Q8_2f {
73                    let col_off = n + rows * 2;
74                    (0..cols)
75                        .map(|i| {
76                            f16_to_f32(u16::from_le_bytes([
77                                bytes[col_off + i * 2],
78                                bytes[col_off + i * 2 + 1],
79                            ]))
80                        })
81                        .collect()
82                } else {
83                    Vec::new()
84                };
85                Ok(Self::Mapped {
86                    model: model.clone(),
87                    idx,
88                    dtype: entry.dtype,
89                    rows,
90                    cols,
91                    row_scale,
92                    col_field,
93                })
94            }
95            // vbit: fused kernel unpacks variable-bit rows from mmap.
96            TensorDtype::Vbit if cols % GROUP_SIZE == 0 => Ok(Self::Mapped {
97                model: model.clone(),
98                idx,
99                dtype: entry.dtype,
100                rows,
101                cols,
102                row_scale: Vec::new(),
103                col_field: Vec::new(),
104            }),
105            // q4_block: fused kernel reads nibbles straight from mmap —
106            // a 14B q4 file no longer explodes into ×8 f32 RAM.
107            TensorDtype::Q4Block if cols % GROUP_SIZE == 0 => Ok(Self::Mapped {
108                model: model.clone(),
109                idx,
110                dtype: entry.dtype,
111                rows,
112                cols,
113                row_scale: Vec::new(),
114                col_field: Vec::new(),
115            }),
116            // No fused kernel yet → dequantize once (correct, more RAM).
117            _ => {
118                let mut data = vec![0.0f32; rows * cols];
119                cortiq_core::quant::dequant_tensor(entry, bytes, &mut data)?;
120                Ok(Self::from_f32(data, rows, cols))
121            }
122        }
123    }
124
125    pub fn rows(&self) -> usize {
126        match self {
127            Self::F32 { rows, .. } | Self::Mapped { rows, .. } => *rows,
128        }
129    }
130
131    pub fn cols(&self) -> usize {
132        match self {
133            Self::F32 { cols, .. } | Self::Mapped { cols, .. } => *cols,
134        }
135    }
136
137    /// Dense f32 view — only for owned tensors. Masked/sparse execution
138    /// paths require it; quantized weights don't support masks yet.
139    pub fn as_f32(&self) -> Option<&[f32]> {
140        match self {
141            Self::F32 { data, .. } => Some(data),
142            Self::Mapped { .. } => None,
143        }
144    }
145
146    fn quant_bytes(&self) -> &[u8] {
147        match self {
148            Self::Mapped { model, idx, .. } => model.entry_bytes(&model.tensors[*idx]),
149            Self::F32 { .. } => unreachable!("quant_bytes on F32"),
150        }
151    }
152
153    /// Dequantize one row into `dst` (embedding lookup).
154    pub fn row_f32(&self, r: usize, dst: &mut [f32]) {
155        let cols = self.cols();
156        debug_assert_eq!(dst.len(), cols);
157        match self {
158            Self::F32 { data, .. } => dst.copy_from_slice(&data[r * cols..(r + 1) * cols]),
159            Self::Mapped {
160                dtype,
161                row_scale,
162                col_field,
163                ..
164            } => {
165                if *dtype == TensorDtype::Q4Block {
166                    let (packed, scales) = q4_split(self.quant_bytes(), self.rows(), cols);
167                    let gpr = cols / GROUP_SIZE;
168                    for gi in 0..gpr {
169                        let g = r * gpr + gi;
170                        let s = f16_to_f32(u16::from_le_bytes([scales[g * 2], scales[g * 2 + 1]]));
171                        for (k, &b) in packed[g * 16..(g + 1) * 16].iter().enumerate() {
172                            dst[gi * GROUP_SIZE + k * 2] = ((b & 0x0F) as f32 - 8.0) * s;
173                            dst[gi * GROUP_SIZE + k * 2 + 1] = (((b >> 4) & 0x0F) as f32 - 8.0) * s;
174                        }
175                    }
176                    return;
177                }
178                if *dtype == TensorDtype::Vbit {
179                    let bytes = self.quant_bytes();
180                    let rows = self.rows();
181                    let ng = cols / GROUP_SIZE;
182                    let bits = &bytes[..rows];
183                    let sc_off = rows;
184                    let mut off = sc_off + rows * ng * 2;
185                    for rr in 0..r {
186                        off += (cols * bits[rr] as usize + 7) / 8;
187                    }
188                    let b = bits[r] as usize;
189                    let l = ((1usize << (b - 1)) - 1) as f32;
190                    let data = &bytes[off..];
191                    let (mut acc, mut nbits, mut idx) = (0u64, 0usize, 0usize);
192                    for (i, d) in dst.iter_mut().enumerate() {
193                        while nbits < b {
194                            acc = (acc << 8) | data[idx] as u64;
195                            idx += 1;
196                            nbits += 8;
197                        }
198                        let u = ((acc >> (nbits - b)) & ((1u64 << b) - 1)) as f32;
199                        nbits -= b;
200                        let so = (r * ng + i / GROUP_SIZE) * 2;
201                        let sv = f16_to_f32(u16::from_le_bytes([
202                            bytes[sc_off + so],
203                            bytes[sc_off + so + 1],
204                        ]));
205                        *d = (u - l) * sv;
206                    }
207                    return;
208                }
209                let q = &self.quant_bytes()[r * cols..(r + 1) * cols];
210                let s = row_scale[r];
211                match dtype {
212                    TensorDtype::Q8Row => {
213                        for (d, &b) in dst.iter_mut().zip(q) {
214                            *d = (b as i8) as f32 * s;
215                        }
216                    }
217                    TensorDtype::Q8_2f => {
218                        for (i, (d, &b)) in dst.iter_mut().zip(q).enumerate() {
219                            *d = (b as i8) as f32 * s * col_field[i];
220                        }
221                    }
222                    _ => unreachable!(),
223                }
224            }
225        }
226    }
227
228    /// Can this tensor's columns be read cheaply (for sparse down_proj)?
229    /// True for F32/Q8Row/Q8_2f (per-row scale, direct strided access);
230    /// false for group-packed q4/vbit (column access would unpack whole
231    /// groups — sparse execution falls back to f32 for those).
232    pub fn sparse_col_ok(&self) -> bool {
233        match self {
234            Self::F32 { .. } => true,
235            Self::Mapped { dtype, .. } => {
236                matches!(dtype, TensorDtype::Q8Row | TensorDtype::Q8_2f)
237            }
238        }
239    }
240
241    /// down_proj [hidden, inter]: accumulate `w · col(c)` into `out`
242    /// [hidden] — reads ONLY column `c` (one neuron) from the mmap,
243    /// no full-matrix dequant. `out[k] += w · down[k, c]`.
244    pub fn add_col_scaled(&self, c: usize, w: f32, out: &mut [f32]) {
245        let inter = self.cols();
246        let hidden = self.rows();
247        debug_assert_eq!(out.len(), hidden);
248        match self {
249            Self::F32 { data, .. } => {
250                for (k, o) in out.iter_mut().enumerate() {
251                    *o += w * data[k * inter + c];
252                }
253            }
254            Self::Mapped {
255                dtype, row_scale, col_field, ..
256            } => {
257                let q = self.quant_bytes();
258                let colf = if *dtype == TensorDtype::Q8_2f {
259                    col_field[c]
260                } else {
261                    1.0
262                };
263                let wc = w * colf;
264                for (k, o) in out.iter_mut().enumerate() {
265                    let b = q[k * inter + c] as i8 as f32;
266                    *o += wc * b * row_scale[k];
267                }
268            }
269        }
270    }
271
272    /// Dot of row `r` with `x` (gate/up active-neuron path). Reads only
273    /// row `r` from the mmap — no full dequant. q4/vbit dequant the row
274    /// into `scratch` first (rare for active-FFN weights).
275    pub fn row_dot(&self, r: usize, x: &[f32], scratch: &mut [f32]) -> f32 {
276        let cols = self.cols();
277        match self {
278            Self::F32 { data, .. } => {
279                let row = &data[r * cols..(r + 1) * cols];
280                row.iter().zip(x).map(|(w, v)| w * v).sum()
281            }
282            Self::Mapped { dtype, row_scale, col_field, .. } => match dtype {
283                TensorDtype::Q8Row => {
284                    let q = &self.quant_bytes()[r * cols..(r + 1) * cols];
285                    dot_i8_f32(q, x) * row_scale[r]
286                }
287                TensorDtype::Q8_2f => {
288                    let q = &self.quant_bytes()[r * cols..(r + 1) * cols];
289                    dot_i8_col_f32(q, x, col_field) * row_scale[r]
290                }
291                _ => {
292                    self.row_f32(r, scratch);
293                    scratch.iter().zip(x).map(|(w, v)| w * v).sum()
294                }
295            },
296        }
297    }
298
299    /// `out = W · x` (row-major). F32 delegates to the historical
300    /// bit-exact path; Mapped runs the fused int8 kernel.
301    pub fn matvec(&self, x: &[f32], out: &mut [f32], pool: Option<&Pool>) {
302        match self {
303            Self::F32 { data, .. } => matvec_rows(pool, data, x, out),
304            Self::Mapped {
305                model,
306                idx,
307                dtype,
308                rows,
309                cols,
310                row_scale,
311                col_field,
312            } => {
313                let _ = (model, idx);
314                if *dtype == TensorDtype::Q4Block {
315                    q4matvec(self.quant_bytes(), x, *rows, *cols, out, pool);
316                    return;
317                }
318                if *dtype == TensorDtype::Vbit {
319                    vbitmatvec(self.quant_bytes(), x, *rows, *cols, out, pool);
320                    return;
321                }
322                let xs = prescale(x, col_field, *dtype);
323                // D5: large q8 matrices (lm_head-class) — hybrid
324                // CPU∥GPU: split the rows, both sides compute
325                // SIMULTANEOUSLY (same math, shared prescale).
326                // GPU share: CMF_GPU_SPLIT (0..1, default 0.5).
327                if *rows >= crate::gpu::min_rows()
328                    && matches!(dtype, TensorDtype::Q8Row | TensorDtype::Q8_2f)
329                    && std::env::var("CMF_GPU_LMHEAD").map(|v| v != "0").unwrap_or(true)
330                    && crate::gpu::enabled_here()
331                {
332                    let frac = std::env::var("CMF_GPU_SPLIT")
333                        .ok()
334                        .and_then(|v| v.parse::<f32>().ok())
335                        .unwrap_or(0.5)
336                        .clamp(0.0, 1.0);
337                    let cpu_rows = ((*rows as f32) * (1.0 - frac)) as usize;
338                    let (out_cpu, out_gpu) = out.split_at_mut(cpu_rows);
339                    let bytes = self.quant_bytes();
340                    let ok = std::thread::scope(|sc| {
341                        let g = sc.spawn(|| {
342                            crate::gpu::q8_matvec_range(
343                                model,
344                                *idx,
345                                cpu_rows,
346                                &row_scale[cpu_rows..],
347                                &xs,
348                                *rows - cpu_rows,
349                                *cols,
350                                out_gpu,
351                            )
352                        });
353                        if cpu_rows > 0 {
354                            qmatvec(
355                                &bytes[..cpu_rows * *cols],
356                                &row_scale[..cpu_rows],
357                                &xs,
358                                cpu_rows,
359                                *cols,
360                                out_cpu,
361                                pool,
362                            );
363                        }
364                        g.join().unwrap_or(false)
365                    });
366                    if ok {
367                        return;
368                    }
369                    // GPU failed — CPU finishes its half.
370                    qmatvec(
371                        &bytes[cpu_rows * *cols..(*rows) * *cols],
372                        &row_scale[cpu_rows..],
373                        &xs,
374                        *rows - cpu_rows,
375                        *cols,
376                        out_gpu,
377                        pool,
378                    );
379                    return;
380                }
381                qmatvec(self.quant_bytes(), row_scale, &xs, *rows, *cols, out, pool);
382            }
383        }
384    }
385
386    /// Fused two-input matvec (MTP verify pair): weights streamed once.
387    pub fn matvec2(&self, x1: &[f32], x2: &[f32], o1: &mut [f32], o2: &mut [f32], pool: Option<&Pool>) {
388        match self {
389            Self::F32 { data, .. } => matvec_rows2(pool, data, x1, x2, o1, o2),
390            Self::Mapped {
391                dtype,
392                rows,
393                cols,
394                row_scale,
395                col_field,
396                ..
397            } => {
398                if *dtype == TensorDtype::Q4Block {
399                    q4matvec(self.quant_bytes(), x1, *rows, *cols, o1, pool);
400                    q4matvec(self.quant_bytes(), x2, *rows, *cols, o2, pool);
401                    return;
402                }
403                if *dtype == TensorDtype::Vbit {
404                    vbitmatvec(self.quant_bytes(), x1, *rows, *cols, o1, pool);
405                    vbitmatvec(self.quant_bytes(), x2, *rows, *cols, o2, pool);
406                    return;
407                }
408                let x1s = prescale(x1, col_field, *dtype);
409                let x2s = prescale(x2, col_field, *dtype);
410                qmatvec2(self.quant_bytes(), row_scale, &x1s, &x2s, *rows, *cols, o1, o2, pool);
411            }
412        }
413    }
414}
415
416impl QTensor {
417    /// Batched matvec (prefill-GEMM): xs — row-major [b, cols],
418    /// out — row-major [b, rows]. Element-wise semantics are IDENTICAL
419    /// to b matvec calls (same dot kernels in the same order); the win —
420    /// the weight row streams from DRAM once per batch, not b times.
421    pub fn matmat(&self, xs_all: &[f32], b: usize, out: &mut [f32], pool: Option<&Pool>) {
422        let cols = self.cols();
423        let rows = self.rows();
424        debug_assert_eq!(xs_all.len(), b * cols);
425        debug_assert_eq!(out.len(), b * rows);
426        match self {
427            Self::F32 { data, .. } => {
428                let out_addr = SendMut(out.as_mut_ptr());
429                let run = |start: usize, end: usize| {
430                    for o in start..end {
431                        let row = &data[o * cols..(o + 1) * cols];
432                        for bi in 0..b {
433                            let x = &xs_all[bi * cols..(bi + 1) * cols];
434                            let mut acc = 0f32;
435                            for j in 0..cols {
436                                acc += row[j] * x[j];
437                            }
438                            unsafe { *out_addr.at(bi * rows + o) = acc };
439                        }
440                    }
441                };
442                dispatch_rows(pool, rows, &run);
443            }
444            Self::Mapped {
445                dtype,
446                row_scale,
447                col_field,
448                ..
449            } => {
450                if matches!(dtype, TensorDtype::Q4Block | TensorDtype::Vbit) {
451                    // No batch kernel — honest element-wise fallback.
452                    for bi in 0..b {
453                        let x = &xs_all[bi * cols..(bi + 1) * cols];
454                        let (head, tail) = out[bi * rows..].split_at_mut(rows);
455                        let _ = tail;
456                        self.matvec(x, head, pool);
457                    }
458                    return;
459                }
460                let pre: Vec<std::borrow::Cow<'_, [f32]>> = (0..b)
461                    .map(|bi| {
462                        prescale(&xs_all[bi * cols..(bi + 1) * cols], col_field, *dtype)
463                    })
464                    .collect();
465                // D5: large prefill-batch GEMMs — on the GPU (threshold by
466                // work volume: submission carries b×rows×cols MACs).
467                if b >= 8
468                    && b * rows * cols >= 128_000_000
469                    && crate::gpu::enabled_here()
470                {
471                    if let Self::Mapped { model, idx, .. } = self {
472                        let flat: Vec<f32> =
473                            pre.iter().flat_map(|v| v.iter().copied()).collect();
474                        if crate::gpu::q8_matmat(
475                            model, *idx, row_scale, &flat, b, rows, cols, out)
476                        {
477                            return;
478                        }
479                    }
480                }
481                let q = self.quant_bytes();
482                qmatmat(q, row_scale, &pre, rows, cols, out, pool);
483            }
484        }
485    }
486}
487
488/// Batched q8 kernel: same math as qmatvec, the row makes a single
489/// pass from memory for the whole batch.
490fn qmatmat(
491    q: &[u8],
492    row_scale: &[f32],
493    pre: &[std::borrow::Cow<'_, [f32]>],
494    rows: usize,
495    cols: usize,
496    out: &mut [f32],
497    pool: Option<&Pool>,
498) {
499    let b = pre.len();
500    debug_assert_eq!(out.len(), b * rows);
501    #[cfg(target_arch = "aarch64")]
502    if sdot_enabled() {
503        let acts: Vec<SplitAct> = pre.iter().map(|x| split_act(x)).collect();
504        let out_addr = SendMut(out.as_mut_ptr());
505        let run = |start: usize, end: usize| {
506            for o in start..end {
507                let row = &q[o * cols..(o + 1) * cols];
508                for (bi, act) in acts.iter().enumerate() {
509                    let v = row_dot_sdot(row, act) * row_scale[o];
510                    unsafe { *out_addr.at(bi * rows + o) = v };
511                }
512            }
513        };
514        dispatch_rows(pool, rows, &run);
515        return;
516    }
517    let out_addr = SendMut(out.as_mut_ptr());
518    let run = |start: usize, end: usize| {
519        for o in start..end {
520            let row = &q[o * cols..(o + 1) * cols];
521            for (bi, x) in pre.iter().enumerate() {
522                let mut acc = 0f32;
523                for j in 0..cols {
524                    acc += (row[j] as i8) as f32 * x[j];
525                }
526                unsafe { *out_addr.at(bi * rows + o) = acc * row_scale[o] };
527            }
528        }
529    };
530    dispatch_rows(pool, rows, &run);
531}
532
533/// Split rows across pool workers (shared qmatvec pattern).
534fn dispatch_rows(pool: Option<&Pool>, rows: usize, run: &(dyn Fn(usize, usize) + Sync)) {
535    match pool {
536        Some(pool) if rows >= 256 => {
537            pool.run(&|widx, n| {
538                let chunk = rows.div_ceil(n);
539                let start = widx * chunk;
540                let end = (start + chunk).min(rows);
541                if start < end {
542                    run(start, end);
543                }
544            });
545        }
546        _ => run(0, rows),
547    }
548}
549
550/// Split a q4_block blob into (packed nibbles, f16 group scales).
551fn q4_split(bytes: &[u8], rows: usize, cols: usize) -> (&[u8], &[u8]) {
552    let groups = rows * cols / GROUP_SIZE;
553    bytes.split_at(groups * 16)
554}
555
556/// Unpack 8 MSB-first B-bit values from exactly B bytes (fixed shifts —
557/// no serial bit-buffer, auto-vectorizable). Every 32-value group starts
558/// byte-aligned (32·B/8 is integral for B∈3..8), so groups decompose
559/// into 4 such blocks.
560#[inline(always)]
561fn unpack8<const B: usize>(data: &[u8]) -> [i32; 8] {
562    let mut acc = 0u64;
563    for i in 0..B {
564        acc = (acc << 8) | data[i] as u64;
565    }
566    let mask = (1u64 << B) - 1;
567    let mut out = [0i32; 8];
568    for (k, o) in out.iter_mut().enumerate() {
569        *o = ((acc >> ((7 - k) * B)) & mask) as i32;
570    }
571    out
572}
573
574/// Fused vbit matvec straight from the mapped bytes (spec §3, P13
575/// FIG.3): [u8 bits: rows][f16 scales: rows·cols/32][bit-packed rows,
576/// MSB-first, byte-padded]. Row data offsets are a prefix sum over
577/// bits — computed once per call, then rows split across the pool.
578fn vbitmatvec(bytes: &[u8], x: &[f32], rows: usize, cols: usize, out: &mut [f32], pool: Option<&Pool>) {
579    debug_assert_eq!(out.len(), rows);
580    let ng = cols / GROUP_SIZE;
581    let bits = &bytes[..rows];
582    let sc_off = rows;
583    let data_off = sc_off + rows * ng * 2;
584    let mut offsets = Vec::with_capacity(rows + 1);
585    let mut off = data_off;
586    for r in 0..rows {
587        offsets.push(off);
588        off += (cols * bits[r] as usize + 7) / 8;
589    }
590    offsets.push(off);
591
592    let offsets = &offsets;
593
594    // SDOT path: unpack the row to centered i8 once, then per-group
595    // int8 dot against the quantized activations — same A8W8 contract
596    // as q8 (bounded noise; CMF_SDOT=0 keeps the exact scalar path).
597    #[cfg(target_arch = "aarch64")]
598    if sdot_enabled() {
599        let act = split_act(x);
600        let act = &act;
601        let row_dot = move |r: usize| -> f32 {
602            let b = bits[r] as usize;
603            let l = ((1i32 << (b - 1)) - 1) as i32;
604            let mask = (1u64 << b) - 1;
605            let data = &bytes[offsets[r]..offsets[r + 1]];
606            if b == 8 {
607                // u−L reaches 128 → does not fit i8; exact f32 path.
608                let (mut acc, mut nbits, mut idx) = (0u64, 0usize, 0usize);
609                let mut dot = 0f32;
610                for g in 0..ng {
611                    let so = (r * ng + g) * 2;
612                    let sgf = f16_to_f32(u16::from_le_bytes([
613                        bytes[sc_off + so],
614                        bytes[sc_off + so + 1],
615                    ]));
616                    let xg = &x[g * GROUP_SIZE..(g + 1) * GROUP_SIZE];
617                    let mut gd = 0f32;
618                    for &xv in xg.iter() {
619                        if nbits < 8 {
620                            acc = (acc << 8) | data[idx] as u64;
621                            idx += 1;
622                            nbits += 8;
623                        }
624                        let u = ((acc >> (nbits - 8)) & 0xFF) as i32;
625                        nbits -= 8;
626                        gd += (u - l) as f32 * xv;
627                    }
628                    dot += gd * sgf;
629                }
630                return dot;
631            }
632            // Per-worker scratch: this closure runs for every row of the
633            // tensor (lm_head ≈ 150k rows/token) — a heap allocation per
634            // row was measurable pure overhead.
635            thread_local! {
636                static VBIT_SCRATCH: std::cell::RefCell<Vec<u8>> =
637                    const { std::cell::RefCell::new(Vec::new()) };
638            }
639            #[inline(always)]
640            fn fill<const B: usize>(data: &[u8], l: i32, buf: &mut [u8]) {
641                for (blk, chunk) in buf.chunks_exact_mut(8).enumerate() {
642                    let u = unpack8::<B>(&data[blk * B..]);
643                    for k in 0..8 {
644                        chunk[k] = (u[k] - l) as i8 as u8;
645                    }
646                }
647            }
648            let _ = mask;
649            VBIT_SCRATCH.with(|scratch| {
650                let mut buf = scratch.borrow_mut();
651                buf.resize(cols, 0);
652                match b {
653                    3 => fill::<3>(data, l, &mut buf),
654                    4 => fill::<4>(data, l, &mut buf),
655                    5 => fill::<5>(data, l, &mut buf),
656                    6 => fill::<6>(data, l, &mut buf),
657                    _ => unreachable!(),
658                }
659                let mut dot = 0f32;
660                for g in 0..ng {
661                    let so = (r * ng + g) * 2;
662                    let s = f16_to_f32(u16::from_le_bytes([
663                        bytes[sc_off + so],
664                        bytes[sc_off + so + 1],
665                    ]));
666                    let d = unsafe {
667                        dot_i8_sdot(
668                            &buf[g * GROUP_SIZE..(g + 1) * GROUP_SIZE],
669                            &act.xq[g * GROUP_SIZE..(g + 1) * GROUP_SIZE],
670                        )
671                    } as f32
672                        * act.sx;
673                    dot += d * s;
674                }
675                for &(j, xv) in &act.outliers {
676                    let so = (r * ng + j / GROUP_SIZE) * 2;
677                    let s = f16_to_f32(u16::from_le_bytes([
678                        bytes[sc_off + so],
679                        bytes[sc_off + so + 1],
680                    ]));
681                    // xq is zeroed at outlier slots — add the exact term.
682                    dot += (buf[j] as i8) as f32 * s * xv;
683                }
684                dot
685            })
686        };
687        match pool {
688            Some(pool) if rows >= 256 => {
689                let out_addr = SendMut(out.as_mut_ptr());
690                pool.run(&move |widx, n| {
691                    let chunk = rows.div_ceil(n);
692                    let start = widx * chunk;
693                    let end = (start + chunk).min(rows);
694                    for r in start..end {
695                        // SAFETY: disjoint row ranges per worker.
696                        unsafe { *out_addr.at(r) = row_dot(r) };
697                    }
698                });
699            }
700            _ => {
701                for (r, dst) in out.iter_mut().enumerate() {
702                    *dst = row_dot(r);
703                }
704            }
705        }
706        return;
707    }
708
709    // Per-bit-width specialized inner loops: the compiler unrolls the
710    // constant shifts (the generic bit-buffer loop was branch-bound —
711    // 5.6 vs 13.2 tok/s q4 on the 0.8B).
712    #[inline(always)]
713    fn dot_row<const B: usize>(
714        data: &[u8],
715        bytes: &[u8],
716        sc_off: usize,
717        r: usize,
718        ng: usize,
719        x: &[f32],
720    ) -> f32 {
721        let l = ((1i32 << (B - 1)) - 1) as f32;
722        let gbytes = GROUP_SIZE * B / 8;
723        let mut dot = 0f32;
724        for g in 0..ng {
725            let so = (r * ng + g) * 2;
726            let s = f16_to_f32(u16::from_le_bytes([bytes[sc_off + so], bytes[sc_off + so + 1]]));
727            let xg = &x[g * GROUP_SIZE..(g + 1) * GROUP_SIZE];
728            let gd0 = &data[g * gbytes..(g + 1) * gbytes];
729            let mut gd = 0f32;
730            for blk in 0..GROUP_SIZE / 8 {
731                let u = unpack8::<B>(&gd0[blk * B..]);
732                let xb = &xg[blk * 8..blk * 8 + 8];
733                for k in 0..8 {
734                    gd += (u[k] as f32 - l) * xb[k];
735                }
736            }
737            dot += gd * s;
738        }
739        dot
740    }
741    let row_dot = move |r: usize| -> f32 {
742        let data = &bytes[offsets[r]..offsets[r + 1]];
743        match bits[r] {
744            3 => dot_row::<3>(data, bytes, sc_off, r, ng, x),
745            4 => dot_row::<4>(data, bytes, sc_off, r, ng, x),
746            5 => dot_row::<5>(data, bytes, sc_off, r, ng, x),
747            6 => dot_row::<6>(data, bytes, sc_off, r, ng, x),
748            8 => dot_row::<8>(data, bytes, sc_off, r, ng, x),
749            b => unreachable!("vbit bit-width {b} (validated at load)"),
750        }
751    };
752    match pool {
753        Some(pool) if rows >= 256 => {
754            let out_addr = SendMut(out.as_mut_ptr());
755            pool.run(&move |widx, n| {
756                let chunk = rows.div_ceil(n);
757                let start = widx * chunk;
758                let end = (start + chunk).min(rows);
759                for r in start..end {
760                    // SAFETY: disjoint row ranges per worker.
761                    unsafe { *out_addr.at(r) = row_dot(r) };
762                }
763            });
764        }
765        _ => {
766            for (r, dst) in out.iter_mut().enumerate() {
767                *dst = row_dot(r);
768            }
769        }
770    }
771}
772
773/// Fused q4_block matvec straight from the mapped bytes. SDOT path when
774/// dotprod is available (port of vmfcore `dot_q4_block_sdot`, measured
775/// +23% on q4 decode): nibbles → centered i8, int8×int8 `sdot` per
776/// 32-group, exact outlier correction — the same A8W8 contract as q8.
777/// `CMF_SDOT=0` keeps the exact scalar path.
778fn q4matvec(bytes: &[u8], x: &[f32], rows: usize, cols: usize, out: &mut [f32], pool: Option<&Pool>) {
779    debug_assert_eq!(out.len(), rows);
780    let (packed, scales) = q4_split(bytes, rows, cols);
781    let gpr = cols / GROUP_SIZE;
782
783    #[cfg(target_arch = "aarch64")]
784    if sdot_enabled() {
785        let act = split_act(x);
786        let act = &act;
787        let row_dot = move |r: usize| -> f32 {
788            let mut acc =
789                unsafe { dot_q4_row_sdot(packed, scales, r * gpr, gpr, &act.xq) } * act.sx;
790            // xq is zeroed at outlier slots — add the exact terms.
791            for &(j, xv) in &act.outliers {
792                let flat = r * cols + j;
793                let byte = packed[flat / 2];
794                let nib = if flat & 1 == 0 { byte & 0x0F } else { byte >> 4 };
795                let s = f16_to_f32(u16::from_le_bytes([
796                    scales[(flat / GROUP_SIZE) * 2],
797                    scales[(flat / GROUP_SIZE) * 2 + 1],
798                ]));
799                acc += ((nib as i32 - 8) as f32) * s * xv;
800            }
801            acc
802        };
803        match pool {
804            Some(pool) if rows >= 256 => {
805                let out_addr = SendMut(out.as_mut_ptr());
806                pool.run(&move |widx, n| {
807                    let chunk = rows.div_ceil(n);
808                    let start = widx * chunk;
809                    let end = (start + chunk).min(rows);
810                    for r in start..end {
811                        // SAFETY: disjoint row ranges per worker.
812                        unsafe { *out_addr.at(r) = row_dot(r) };
813                    }
814                });
815            }
816            _ => {
817                for (r, dst) in out.iter_mut().enumerate() {
818                    *dst = row_dot(r);
819                }
820            }
821        }
822        return;
823    }
824
825    let row_dot = |r: usize| -> f32 {
826        let mut acc = 0f32;
827        for gi in 0..gpr {
828            let g = r * gpr + gi;
829            let s = f16_to_f32(u16::from_le_bytes([scales[g * 2], scales[g * 2 + 1]]));
830            let pk = &packed[g * 16..(g + 1) * 16];
831            let xg = &x[gi * GROUP_SIZE..(gi + 1) * GROUP_SIZE];
832            let mut ga = 0f32;
833            for (k, &b) in pk.iter().enumerate() {
834                ga += ((b & 0x0F) as f32 - 8.0) * xg[k * 2]
835                    + (((b >> 4) & 0x0F) as f32 - 8.0) * xg[k * 2 + 1];
836            }
837            acc += ga * s;
838        }
839        acc
840    };
841    match pool {
842        Some(pool) if rows >= 256 => {
843            let out_addr = SendMut(out.as_mut_ptr());
844            pool.run(&move |widx, n| {
845                let chunk = rows.div_ceil(n);
846                let start = widx * chunk;
847                let end = (start + chunk).min(rows);
848                for r in start..end {
849                    // SAFETY: disjoint row ranges per worker.
850                    unsafe { *out_addr.at(r) = row_dot(r) };
851                }
852            });
853        }
854        _ => {
855            for (r, dst) in out.iter_mut().enumerate() {
856                *dst = row_dot(r);
857            }
858        }
859    }
860}
861
862/// θ col-field fold for q8_2f activations. Borrowed pass-through for
863/// every other dtype — the old unconditional `x.to_vec()` was a pure
864/// per-matvec allocation on the q8_row hot path.
865pub(crate) fn prescale<'a>(
866    x: &'a [f32],
867    col_field: &[f32],
868    dtype: TensorDtype,
869) -> std::borrow::Cow<'a, [f32]> {
870    if dtype == TensorDtype::Q8_2f {
871        x.iter().zip(col_field).map(|(a, c)| a * c).collect()
872    } else {
873        std::borrow::Cow::Borrowed(x)
874    }
875}
876
877// ───────────────────── A8W8 SDOT path (port of vmfcore, ×1.78 decode) ─────────────────────
878
879/// SDOT enabled? Default ON when the CPU has ARMv8.2 dotprod;
880/// `CMF_SDOT=0` disables (falls back to i8×f32 NEON).
881fn sdot_enabled() -> bool {
882    use std::sync::OnceLock;
883    static ON: OnceLock<bool> = OnceLock::new();
884    *ON.get_or_init(|| {
885        let want = std::env::var("CMF_SDOT").map(|v| v != "0").unwrap_or(true);
886        #[cfg(target_arch = "aarch64")]
887        {
888            want && std::arch::is_aarch64_feature_detected!("dotprod")
889        }
890        #[cfg(not(target_arch = "aarch64"))]
891        {
892            let _ = want;
893            false
894        }
895    })
896}
897
898/// Two-field activation split (≡ vmfcore `q8_split_prep`): outlier
899/// channels (>8·rms) are computed exactly in f32; the bulk (outliers
900/// zeroed → clean absmax) goes through int8 SDOT. Computed ONCE per
901/// matvec, shared by all rows/workers.
902struct SplitAct {
903    xq: Vec<i8>,
904    sx: f32,
905    outliers: Vec<(usize, f32)>,
906}
907
908fn split_act(x: &[f32]) -> SplitAct {
909    let n = x.len();
910    let rms = (x.iter().map(|&v| (v * v) as f64).sum::<f64>() / n.max(1) as f64).sqrt() as f32;
911    let thr = 8.0 * rms;
912    let outliers: Vec<(usize, f32)> = (0..n)
913        .filter(|&j| x[j].abs() > thr)
914        .map(|j| (j, x[j]))
915        .collect();
916    let mut xb = x.to_vec();
917    for &(j, _) in &outliers {
918        xb[j] = 0.0;
919    }
920    let amax = xb.iter().fold(0f32, |m, &v| m.max(v.abs()));
921    let sx = if amax > 0.0 { amax / 127.0 } else { 1.0 };
922    let inv = 1.0 / sx;
923    let xq = xb
924        .iter()
925        .map(|&v| (v * inv).round().clamp(-127.0, 127.0) as i8)
926        .collect();
927    SplitAct { xq, sx, outliers }
928}
929
930/// int8(weight)·int8(activation) → i32 via `sdot` (inline asm — the
931/// vdotq intrinsic is unstable; port of vmfcore `dot_i8_sdot`).
932#[cfg(target_arch = "aarch64")]
933#[target_feature(enable = "neon,dotprod")]
934unsafe fn dot_i8_sdot(w: &[u8], xq: &[i8]) -> i32 {
935    // SAFETY: callers uphold slice-length contracts (see call sites).
936    unsafe {
937        use core::arch::aarch64::*;
938        use core::arch::asm;
939        let wp = w.as_ptr() as *const i8;
940        let n = w.len();
941        let (mut a0, mut a1, mut a2, mut a3) =
942            (vdupq_n_s32(0), vdupq_n_s32(0), vdupq_n_s32(0), vdupq_n_s32(0));
943        let mut i = 0;
944        while i + 64 <= n {
945            let (w0, x0) = (vld1q_s8(wp.add(i)), vld1q_s8(xq.as_ptr().add(i)));
946            let (w1, x1) = (vld1q_s8(wp.add(i + 16)), vld1q_s8(xq.as_ptr().add(i + 16)));
947            let (w2, x2) = (vld1q_s8(wp.add(i + 32)), vld1q_s8(xq.as_ptr().add(i + 32)));
948            let (w3, x3) = (vld1q_s8(wp.add(i + 48)), vld1q_s8(xq.as_ptr().add(i + 48)));
949            asm!(
950                "sdot {a0:v}.4s, {w0:v}.16b, {x0:v}.16b",
951                "sdot {a1:v}.4s, {w1:v}.16b, {x1:v}.16b",
952                "sdot {a2:v}.4s, {w2:v}.16b, {x2:v}.16b",
953                "sdot {a3:v}.4s, {w3:v}.16b, {x3:v}.16b",
954                a0 = inout(vreg) a0, a1 = inout(vreg) a1, a2 = inout(vreg) a2, a3 = inout(vreg) a3,
955                w0 = in(vreg) w0, x0 = in(vreg) x0, w1 = in(vreg) w1, x1 = in(vreg) x1,
956                w2 = in(vreg) w2, x2 = in(vreg) x2, w3 = in(vreg) w3, x3 = in(vreg) x3,
957                options(pure, nomem, nostack),
958            );
959            i += 64;
960        }
961        while i + 16 <= n {
962            let (wv, xv) = (vld1q_s8(wp.add(i)), vld1q_s8(xq.as_ptr().add(i)));
963            asm!("sdot {a:v}.4s, {w:v}.16b, {x:v}.16b",
964                 a = inout(vreg) a0, w = in(vreg) wv, x = in(vreg) xv, options(pure, nomem, nostack));
965            i += 16;
966        }
967        let mut s = vaddvq_s32(vaddq_s32(vaddq_s32(a0, a1), vaddq_s32(a2, a3)));
968        while i < n {
969            s += (*wp.add(i)) as i32 * xq[i] as i32;
970            i += 1;
971        }
972        s
973}
974}
975
976/// Row-blocked SDOT: 4 output rows per pass — the activation chunk is
977/// loaded once and reused, 4 independent accumulators hide sdot latency
978/// (port of vmfcore `dot_i8_sdot_4rows`).
979#[cfg(target_arch = "aarch64")]
980#[target_feature(enable = "neon,dotprod")]
981unsafe fn dot_i8_sdot_4rows(w0: &[u8], w1: &[u8], w2: &[u8], w3: &[u8], xq: &[i8]) -> [i32; 4] {
982    // SAFETY: callers uphold slice-length contracts (see call sites).
983    unsafe {
984        use core::arch::aarch64::*;
985        use core::arch::asm;
986        let n = xq.len();
987        let px = xq.as_ptr();
988        let (p0, p1, p2, p3) = (
989            w0.as_ptr() as *const i8,
990            w1.as_ptr() as *const i8,
991            w2.as_ptr() as *const i8,
992            w3.as_ptr() as *const i8,
993        );
994        let (mut a0, mut a1, mut a2, mut a3) =
995            (vdupq_n_s32(0), vdupq_n_s32(0), vdupq_n_s32(0), vdupq_n_s32(0));
996        let mut i = 0;
997        while i + 16 <= n {
998            let x = vld1q_s8(px.add(i));
999            let v0 = vld1q_s8(p0.add(i));
1000            let v1 = vld1q_s8(p1.add(i));
1001            let v2 = vld1q_s8(p2.add(i));
1002            let v3 = vld1q_s8(p3.add(i));
1003            asm!(
1004                "sdot {a0:v}.4s, {v0:v}.16b, {x:v}.16b",
1005                "sdot {a1:v}.4s, {v1:v}.16b, {x:v}.16b",
1006                "sdot {a2:v}.4s, {v2:v}.16b, {x:v}.16b",
1007                "sdot {a3:v}.4s, {v3:v}.16b, {x:v}.16b",
1008                a0 = inout(vreg) a0, a1 = inout(vreg) a1, a2 = inout(vreg) a2, a3 = inout(vreg) a3,
1009                v0 = in(vreg) v0, v1 = in(vreg) v1, v2 = in(vreg) v2, v3 = in(vreg) v3, x = in(vreg) x,
1010                options(pure, nomem, nostack),
1011            );
1012            i += 16;
1013        }
1014        let mut r = [vaddvq_s32(a0), vaddvq_s32(a1), vaddvq_s32(a2), vaddvq_s32(a3)];
1015        while i < n {
1016            let xi = *px.add(i) as i32;
1017            r[0] += (*p0.add(i)) as i32 * xi;
1018            r[1] += (*p1.add(i)) as i32 * xi;
1019            r[2] += (*p2.add(i)) as i32 * xi;
1020            r[3] += (*p3.add(i)) as i32 * xi;
1021            i += 1;
1022        }
1023        r
1024}
1025}
1026
1027/// SDOT row dot with exact outlier correction:
1028/// `dot = sdot(w, xq)·sx + Σ_outl w[j]·x[j]` (then × row_scale by caller).
1029#[cfg(target_arch = "aarch64")]
1030#[inline]
1031fn row_dot_sdot(row: &[u8], act: &SplitAct) -> f32 {
1032    let mut acc = unsafe { dot_i8_sdot(row, &act.xq) } as f32 * act.sx;
1033    for &(j, xv) in &act.outliers {
1034        acc += (row[j] as i8) as f32 * xv;
1035    }
1036    acc
1037}
1038
1039/// One q4 row via SDOT: each 32-group's nibbles unpack to centered i8
1040/// (nib−8 ∈ [−8,7]), int8×int8 `sdot` against the pre-quantized
1041/// activation group, × the group's f16 scale. Returns Σ_g dot_g·s_g;
1042/// the caller multiplies by the activation scale and adds the exact
1043/// outlier terms (port of vmfcore `dot_q4_block_sdot`, +23% measured).
1044/// Nibble order matches the writer: element 2k = low nibble, 2k+1 = high
1045/// → zip(lo,hi) restores flat order.
1046#[cfg(target_arch = "aarch64")]
1047#[target_feature(enable = "neon,dotprod")]
1048unsafe fn dot_q4_row_sdot(packed: &[u8], scales: &[u8], g0: usize, gpr: usize, xq: &[i8]) -> f32 {
1049    // SAFETY: callers uphold slice-length contracts (16 packed bytes and
1050    // 2 scale bytes per group; xq.len() == gpr·GROUP_SIZE).
1051    unsafe {
1052        use core::arch::aarch64::*;
1053        use core::arch::asm;
1054        let lomask = vdupq_n_u8(0x0F);
1055        let eight = vdupq_n_s8(8);
1056        let mut acc = 0f32;
1057        for gi in 0..gpr {
1058            let g = g0 + gi;
1059            let s = f16_to_f32(u16::from_le_bytes([scales[g * 2], scales[g * 2 + 1]]));
1060            let b = vld1q_u8(packed.as_ptr().add(g * 16));
1061            let lo = vandq_u8(b, lomask);
1062            let hi = vshrq_n_u8::<4>(b);
1063            let e0 = vsubq_s8(vreinterpretq_s8_u8(vzip1q_u8(lo, hi)), eight);
1064            let e1 = vsubq_s8(vreinterpretq_s8_u8(vzip2q_u8(lo, hi)), eight);
1065            let x0 = vld1q_s8(xq.as_ptr().add(gi * GROUP_SIZE));
1066            let x1 = vld1q_s8(xq.as_ptr().add(gi * GROUP_SIZE + 16));
1067            let (mut a0, mut a1) = (vdupq_n_s32(0), vdupq_n_s32(0));
1068            asm!(
1069                "sdot {a0:v}.4s, {e0:v}.16b, {x0:v}.16b",
1070                "sdot {a1:v}.4s, {e1:v}.16b, {x1:v}.16b",
1071                a0 = inout(vreg) a0, a1 = inout(vreg) a1,
1072                e0 = in(vreg) e0, x0 = in(vreg) x0, e1 = in(vreg) e1, x1 = in(vreg) x1,
1073                options(pure, nomem, nostack),
1074            );
1075            acc += vaddvq_s32(vaddq_s32(a0, a1)) as f32 * s;
1076        }
1077        acc
1078    }
1079}
1080
1081// ───────────────────── fused int8 kernels ─────────────────────
1082
1083/// `acc += w · row` where the row is centered i8 — NEON widen+fma on
1084/// aarch64, scalar elsewhere. The KV-cache q8 value path rides on this.
1085#[inline]
1086pub(crate) fn axpy_i8_f32(acc: &mut [f32], row: &[i8], w: f32) {
1087    #[cfg(target_arch = "aarch64")]
1088    unsafe {
1089        return axpy_i8_f32_neon(acc, row, w);
1090    }
1091    #[allow(unreachable_code)]
1092    {
1093        for (a, &b) in acc.iter_mut().zip(row) {
1094            *a += w * b as f32;
1095        }
1096    }
1097}
1098
1099#[cfg(target_arch = "aarch64")]
1100#[target_feature(enable = "neon")]
1101unsafe fn axpy_i8_f32_neon(acc: &mut [f32], row: &[i8], w: f32) {
1102    // SAFETY: callers uphold slice-length contracts (see call sites).
1103    unsafe {
1104        use core::arch::aarch64::*;
1105        let n = acc.len().min(row.len());
1106        let ap = acc.as_mut_ptr();
1107        let rp = row.as_ptr();
1108        let wv = vdupq_n_f32(w);
1109        let mut j = 0usize;
1110        while j + 16 <= n {
1111            let rb = vld1q_s8(rp.add(j));
1112            let lo = vmovl_s8(vget_low_s8(rb));
1113            let hi = vmovl_s8(vget_high_s8(rb));
1114            for (off, half) in [(0, lo), (8, hi)] {
1115                let f0 = vcvtq_f32_s32(vmovl_s16(vget_low_s16(half)));
1116                let f1 = vcvtq_f32_s32(vmovl_s16(vget_high_s16(half)));
1117                let o = j + off;
1118                vst1q_f32(ap.add(o), vfmaq_f32(vld1q_f32(ap.add(o)), wv, f0));
1119                vst1q_f32(ap.add(o + 4), vfmaq_f32(vld1q_f32(ap.add(o + 4)), wv, f1));
1120            }
1121            j += 16;
1122        }
1123        while j < n {
1124            *ap.add(j) += w * (*rp.add(j)) as f32;
1125            j += 1;
1126        }
1127}
1128}
1129
1130/// i8 row · f32 x. NEON on aarch64 (ported from vmfcore `dot_i8_f32_neon`,
1131/// ≈9× scalar), scalar elsewhere.
1132#[inline]
1133pub(crate) fn dot_i8_f32(w: &[u8], x: &[f32]) -> f32 {
1134    #[cfg(target_arch = "aarch64")]
1135    unsafe {
1136        return dot_i8_f32_neon(w, x);
1137    }
1138    #[allow(unreachable_code)]
1139    {
1140        let mut sum = 0.0f32;
1141        for (j, &b) in w.iter().enumerate() {
1142            sum += (b as i8) as f32 * x[j];
1143        }
1144        sum
1145    }
1146}
1147
1148/// i8 row · (x ⊙ col_field) — the q8_2f row dot with the θ col-field
1149/// folded into the product (no prescaled copy of x). NEON on aarch64,
1150/// scalar elsewhere. Used by the active-neuron path `row_dot`.
1151#[inline]
1152fn dot_i8_col_f32(w: &[u8], x: &[f32], col: &[f32]) -> f32 {
1153    #[cfg(target_arch = "aarch64")]
1154    unsafe {
1155        return dot_i8_col_f32_neon(w, x, col);
1156    }
1157    #[allow(unreachable_code)]
1158    {
1159        let mut sum = 0.0f32;
1160        for (j, &b) in w.iter().enumerate() {
1161            sum += (b as i8) as f32 * x[j] * col[j];
1162        }
1163        sum
1164    }
1165}
1166
1167#[cfg(target_arch = "aarch64")]
1168#[target_feature(enable = "neon")]
1169unsafe fn dot_i8_col_f32_neon(w: &[u8], x: &[f32], col: &[f32]) -> f32 {
1170    // SAFETY: callers uphold slice-length contracts (see call sites).
1171    unsafe {
1172        use core::arch::aarch64::*;
1173        let n = x.len();
1174        let wp = w.as_ptr() as *const i8;
1175        let xp = x.as_ptr();
1176        let cp = col.as_ptr();
1177        let (mut a0, mut a1, mut a2, mut a3) =
1178            (vdupq_n_f32(0.0), vdupq_n_f32(0.0), vdupq_n_f32(0.0), vdupq_n_f32(0.0));
1179        let mut j = 0usize;
1180        while j + 16 <= n {
1181            let wb = vld1q_s8(wp.add(j));
1182            let lo = vmovl_s8(vget_low_s8(wb));
1183            let hi = vmovl_s8(vget_high_s8(wb));
1184            let w0 = vcvtq_f32_s32(vmovl_s16(vget_low_s16(lo)));
1185            let w1 = vcvtq_f32_s32(vmovl_s16(vget_high_s16(lo)));
1186            let w2 = vcvtq_f32_s32(vmovl_s16(vget_low_s16(hi)));
1187            let w3 = vcvtq_f32_s32(vmovl_s16(vget_high_s16(hi)));
1188            a0 = vfmaq_f32(a0, w0, vmulq_f32(vld1q_f32(xp.add(j)), vld1q_f32(cp.add(j))));
1189            a1 = vfmaq_f32(a1, w1, vmulq_f32(vld1q_f32(xp.add(j + 4)), vld1q_f32(cp.add(j + 4))));
1190            a2 = vfmaq_f32(a2, w2, vmulq_f32(vld1q_f32(xp.add(j + 8)), vld1q_f32(cp.add(j + 8))));
1191            a3 = vfmaq_f32(a3, w3, vmulq_f32(vld1q_f32(xp.add(j + 12)), vld1q_f32(cp.add(j + 12))));
1192            j += 16;
1193        }
1194        let mut sum = vaddvq_f32(vaddq_f32(vaddq_f32(a0, a1), vaddq_f32(a2, a3)));
1195        while j < n {
1196            sum += (*wp.add(j)) as f32 * *xp.add(j) * *cp.add(j);
1197            j += 1;
1198        }
1199        sum
1200}
1201}
1202
1203#[cfg(target_arch = "aarch64")]
1204#[target_feature(enable = "neon")]
1205unsafe fn dot_i8_f32_neon(w: &[u8], x: &[f32]) -> f32 {
1206    // SAFETY: callers uphold slice-length contracts (see call sites).
1207    unsafe {
1208        use core::arch::aarch64::*;
1209        let n = x.len();
1210        let wp = w.as_ptr() as *const i8;
1211        let xp = x.as_ptr();
1212        let (mut a0, mut a1, mut a2, mut a3) =
1213            (vdupq_n_f32(0.0), vdupq_n_f32(0.0), vdupq_n_f32(0.0), vdupq_n_f32(0.0));
1214        let mut j = 0usize;
1215        while j + 16 <= n {
1216            let wb = vld1q_s8(wp.add(j));
1217            let lo = vmovl_s8(vget_low_s8(wb));
1218            let hi = vmovl_s8(vget_high_s8(wb));
1219            let w0 = vcvtq_f32_s32(vmovl_s16(vget_low_s16(lo)));
1220            let w1 = vcvtq_f32_s32(vmovl_s16(vget_high_s16(lo)));
1221            let w2 = vcvtq_f32_s32(vmovl_s16(vget_low_s16(hi)));
1222            let w3 = vcvtq_f32_s32(vmovl_s16(vget_high_s16(hi)));
1223            a0 = vfmaq_f32(a0, w0, vld1q_f32(xp.add(j)));
1224            a1 = vfmaq_f32(a1, w1, vld1q_f32(xp.add(j + 4)));
1225            a2 = vfmaq_f32(a2, w2, vld1q_f32(xp.add(j + 8)));
1226            a3 = vfmaq_f32(a3, w3, vld1q_f32(xp.add(j + 12)));
1227            j += 16;
1228        }
1229        let mut sum = vaddvq_f32(vaddq_f32(vaddq_f32(a0, a1), vaddq_f32(a2, a3)));
1230        while j < n {
1231            sum += (*wp.add(j)) as f32 * *xp.add(j);
1232            j += 1;
1233        }
1234        sum
1235}
1236}
1237
1238#[allow(clippy::too_many_arguments)]
1239fn qmatvec(
1240    q: &[u8],
1241    row_scale: &[f32],
1242    xs: &[f32],
1243    rows: usize,
1244    cols: usize,
1245    out: &mut [f32],
1246    pool: Option<&Pool>,
1247) {
1248    debug_assert_eq!(out.len(), rows);
1249
1250    #[cfg(target_arch = "aarch64")]
1251    if sdot_enabled() {
1252        let act = split_act(xs);
1253        let out_addr = SendMut(out.as_mut_ptr());
1254        let run_range = |start: usize, end: usize| {
1255            let mut o = start;
1256            while o + 4 <= end {
1257                let r = unsafe {
1258                    dot_i8_sdot_4rows(
1259                        &q[o * cols..(o + 1) * cols],
1260                        &q[(o + 1) * cols..(o + 2) * cols],
1261                        &q[(o + 2) * cols..(o + 3) * cols],
1262                        &q[(o + 3) * cols..(o + 4) * cols],
1263                        &act.xq,
1264                    )
1265                };
1266                for k in 0..4 {
1267                    let mut acc = r[k] as f32 * act.sx;
1268                    for &(j, xv) in &act.outliers {
1269                        acc += (q[(o + k) * cols + j] as i8) as f32 * xv;
1270                    }
1271                    // SAFETY: disjoint row ranges per worker.
1272                    unsafe { *out_addr.at(o + k) = acc * row_scale[o + k] };
1273                }
1274                o += 4;
1275            }
1276            while o < end {
1277                let v = row_dot_sdot(&q[o * cols..(o + 1) * cols], &act) * row_scale[o];
1278                unsafe { *out_addr.at(o) = v };
1279                o += 1;
1280            }
1281        };
1282        match pool {
1283            Some(pool) if rows >= 256 => {
1284                pool.run(&|widx, n| {
1285                    let chunk = rows.div_ceil(n);
1286                    let start = widx * chunk;
1287                    run_range(start, (start + chunk).min(rows));
1288                });
1289            }
1290            _ => run_range(0, rows),
1291        }
1292        return;
1293    }
1294
1295    let row_dot = |o: usize| -> f32 { dot_i8_f32(&q[o * cols..(o + 1) * cols], xs) * row_scale[o] };
1296    match pool {
1297        Some(pool) if rows >= 256 => {
1298            let out_addr = SendMut(out.as_mut_ptr());
1299            pool.run(&move |widx, n| {
1300                let chunk = rows.div_ceil(n);
1301                let start = widx * chunk;
1302                let end = (start + chunk).min(rows);
1303                for o in start..end {
1304                    // SAFETY: disjoint row ranges per worker.
1305                    unsafe { *out_addr.at(o) = row_dot(o) };
1306                }
1307            });
1308        }
1309        _ => {
1310            for (o, dst) in out.iter_mut().enumerate() {
1311                *dst = row_dot(o);
1312            }
1313        }
1314    }
1315}
1316
1317#[allow(clippy::too_many_arguments)]
1318fn qmatvec2(
1319    q: &[u8],
1320    row_scale: &[f32],
1321    x1: &[f32],
1322    x2: &[f32],
1323    rows: usize,
1324    cols: usize,
1325    o1: &mut [f32],
1326    o2: &mut [f32],
1327    pool: Option<&Pool>,
1328) {
1329    #[cfg(target_arch = "aarch64")]
1330    if sdot_enabled() {
1331        let a1s = split_act(x1);
1332        let a2s = split_act(x2);
1333        let p1 = SendMut(o1.as_mut_ptr());
1334        let p2 = SendMut(o2.as_mut_ptr());
1335        let run_range = |start: usize, end: usize| {
1336            for o in start..end {
1337                let row = &q[o * cols..(o + 1) * cols];
1338                // SAFETY: disjoint row ranges per worker.
1339                unsafe {
1340                    *p1.at(o) = row_dot_sdot(row, &a1s) * row_scale[o];
1341                    *p2.at(o) = row_dot_sdot(row, &a2s) * row_scale[o];
1342                }
1343            }
1344        };
1345        match pool {
1346            Some(pool) if rows >= 256 => {
1347                pool.run(&|widx, n| {
1348                    let chunk = rows.div_ceil(n);
1349                    let start = widx * chunk;
1350                    run_range(start, (start + chunk).min(rows));
1351                });
1352            }
1353            _ => run_range(0, rows),
1354        }
1355        return;
1356    }
1357
1358    let row_dots = |o: usize| -> (f32, f32) {
1359        let row = &q[o * cols..(o + 1) * cols];
1360        (dot_i8_f32(row, x1) * row_scale[o], dot_i8_f32(row, x2) * row_scale[o])
1361    };
1362    match pool {
1363        Some(pool) if rows >= 256 => {
1364            let p1 = SendMut(o1.as_mut_ptr());
1365            let p2 = SendMut(o2.as_mut_ptr());
1366            pool.run(&move |widx, n| {
1367                let chunk = rows.div_ceil(n);
1368                let start = widx * chunk;
1369                let end = (start + chunk).min(rows);
1370                for o in start..end {
1371                    let (s1, s2) = row_dots(o);
1372                    // SAFETY: disjoint row ranges per worker.
1373                    unsafe {
1374                        *p1.at(o) = s1;
1375                        *p2.at(o) = s2;
1376                    }
1377                }
1378            });
1379        }
1380        _ => {
1381            for o in 0..rows {
1382                let (s1, s2) = row_dots(o);
1383                o1[o] = s1;
1384                o2[o] = s2;
1385            }
1386        }
1387    }
1388}
1389
1390#[derive(Clone, Copy)]
1391struct SendMut(*mut f32);
1392unsafe impl Send for SendMut {}
1393unsafe impl Sync for SendMut {}
1394
1395impl SendMut {
1396    #[inline]
1397    fn at(self, i: usize) -> *mut f32 {
1398        unsafe { self.0.add(i) }
1399    }
1400}
1401
1402#[cfg(test)]
1403mod tests {
1404    use super::*;
1405
1406    #[test]
1407    fn f32_matvec_matches_matvec_rows_bitexact() {
1408        let (rows, cols) = (300, 40);
1409        let w: Vec<f32> = (0..rows * cols).map(|i| (i as f32 * 0.017).sin()).collect();
1410        let x: Vec<f32> = (0..cols).map(|i| (i as f32 * 0.05).cos()).collect();
1411        let qt = QTensor::from_f32(w.clone(), rows, cols);
1412
1413        let mut a = vec![0.0f32; rows];
1414        matvec_rows(None, &w, &x, &mut a);
1415        let mut b = vec![0.0f32; rows];
1416        qt.matvec(&x, &mut b, None);
1417        assert_eq!(a, b);
1418    }
1419
1420    #[test]
1421    fn sdot_kernel_exact_on_grid() {
1422        // Activations already on the i8 grid (±1 with amax=1 → sx=1/127,
1423        // xq=±127 dequantizes EXACTLY) → the SDOT path must match the
1424        // exact f32 dot to float rounding. This isolates kernel
1425        // correctness from quantization noise.
1426        eprintln!("sdot_enabled = {}", sdot_enabled());
1427        let (rows, cols) = (9, 80); // odd rows → exercises 4-row + tail
1428        let w: Vec<u8> = (0..rows * cols)
1429            .map(|i| (((i * 37) % 251) as i32 - 125) as i8 as u8)
1430            .collect();
1431        let scales: Vec<f32> = (0..rows).map(|o| 0.005 + o as f32 * 0.001).collect();
1432        let x: Vec<f32> = (0..cols)
1433            .map(|i| match i % 3 {
1434                0 => 1.0,
1435                1 => -1.0,
1436                _ => 0.0,
1437            })
1438            .collect();
1439        let mut a = vec![0.0f32; rows];
1440        qmatvec(&w, &scales, &x, rows, cols, &mut a, None);
1441        for o in 0..rows {
1442            let mut acc = 0.0f32;
1443            for j in 0..cols {
1444                acc += (w[o * cols + j] as i8) as f32 * x[j];
1445            }
1446            let expect = acc * scales[o];
1447            assert!(
1448                (a[o] - expect).abs() < 1e-3 * expect.abs().max(1e-3),
1449                "row {o}: {} vs {expect}",
1450                a[o]
1451            );
1452        }
1453    }
1454
1455    #[test]
1456    fn sdot_a8w8_noise_is_bounded() {
1457        // Off-grid activations: A8 quantization noise must stay small in
1458        // relative L2 over the whole output (realistic accuracy contract;
1459        // vmfcore measured argmax-identical decode on real models).
1460        let (rows, cols) = (16, 512);
1461        let w: Vec<u8> = (0..rows * cols)
1462            .map(|i| (((i * 37) % 251) as i32 - 125) as i8 as u8)
1463            .collect();
1464        let scales = vec![0.01f32; rows];
1465        let x: Vec<f32> = (0..cols).map(|i| (i as f32 * 0.21).sin()).collect();
1466        let mut a = vec![0.0f32; rows];
1467        qmatvec(&w, &scales, &x, rows, cols, &mut a, None);
1468        let (mut num, mut den) = (0f64, 0f64);
1469        for o in 0..rows {
1470            let mut acc = 0.0f32;
1471            for j in 0..cols {
1472                acc += (w[o * cols + j] as i8) as f32 * x[j];
1473            }
1474            let expect = acc * scales[o];
1475            num += ((a[o] - expect) as f64).powi(2);
1476            den += (expect as f64).powi(2);
1477        }
1478        let rel = (num / den.max(1e-12)).sqrt();
1479        assert!(rel < 0.05, "A8W8 relative L2 error too high: {rel}");
1480    }
1481
1482    #[test]
1483    fn i8_dot_neon_matches_scalar() {
1484        let n = 100;
1485        let w: Vec<u8> = (0..n).map(|i| ((i * 37 + 11) % 251) as u8).collect();
1486        let x: Vec<f32> = (0..n).map(|i| (i as f32 * 0.13).sin()).collect();
1487        let mut scalar = 0.0f32;
1488        for j in 0..n {
1489            scalar += (w[j] as i8) as f32 * x[j];
1490        }
1491        let fast = dot_i8_f32(&w, &x);
1492        assert!((scalar - fast).abs() < 1e-3 * scalar.abs().max(1.0));
1493    }
1494
1495    /// Fused vbit matvec must match full dequant_vbit + dense matvec.
1496    #[test]
1497    fn vbitmatvec_matches_full_dequant() {
1498        let (rows, cols) = (6, 64);
1499        let ng = cols / GROUP_SIZE;
1500        // Hand-craft: bits per row, f16 scales, packed rows.
1501        let bits: Vec<u8> = vec![3, 4, 5, 6, 8, 4];
1502        let mut bytes = bits.clone();
1503        for g in 0..rows * ng {
1504            let s = 0.02 + 0.001 * g as f32;
1505            bytes.extend_from_slice(&cortiq_core::quant::f32_to_f16(s).to_le_bytes());
1506        }
1507        for r in 0..rows {
1508            let b = bits[r] as usize;
1509            let (mut acc, mut nb) = (0u64, 0usize);
1510            let mut rowbytes = Vec::new();
1511            for i in 0..cols {
1512                let v = ((i * 7 + r * 13) % (1 << b)) as u64;
1513                acc = (acc << b) | v;
1514                nb += b;
1515                while nb >= 8 {
1516                    nb -= 8;
1517                    rowbytes.push(((acc >> nb) & 0xFF) as u8);
1518                }
1519            }
1520            if nb > 0 {
1521                rowbytes.push(((acc << (8 - nb)) & 0xFF) as u8);
1522            }
1523            bytes.extend_from_slice(&rowbytes);
1524        }
1525        let x: Vec<f32> = (0..cols).map(|i| (i as f32 * 0.19).sin()).collect();
1526
1527        let mut reference = vec![0f32; rows * cols];
1528        cortiq_core::quant::dequant_vbit(&bytes, rows, cols, &mut reference).unwrap();
1529        let mut expect = vec![0f32; rows];
1530        for r in 0..rows {
1531            expect[r] = reference[r * cols..(r + 1) * cols]
1532                .iter()
1533                .zip(&x)
1534                .map(|(w, xv)| w * xv)
1535                .sum();
1536        }
1537        let mut got = vec![0f32; rows];
1538        vbitmatvec(&bytes, &x, rows, cols, &mut got, None);
1539        // SDOT path quantizes activations to i8 (A8W8): bounded noise,
1540        // same contract as q8 (exact path is pinned by CMF_SDOT=0 in
1541        // the golden-parity gate).
1542        let tol = if sdot_enabled() { 6e-2 } else { 1e-4 };
1543        let scale = expect.iter().fold(0f32, |m, v| m.max(v.abs())).max(1e-6);
1544        for r in 0..rows {
1545            assert!(
1546                (got[r] - expect[r]).abs() < tol * scale,
1547                "row {r}: {} vs {}",
1548                got[r],
1549                expect[r]
1550            );
1551        }
1552    }
1553
1554    /// Fused q4 matvec must match the reference full-dequant + dense
1555    /// matvec bit-for-bit in structure (same f32 math, group order).
1556    #[test]
1557    fn q4matvec_matches_full_dequant() {
1558        let (rows, cols) = (8, 64);
1559        let groups = rows * cols / GROUP_SIZE;
1560        // Hand-craft a q4_block blob: nibbles then f16 scales.
1561        let mut bytes = Vec::with_capacity(groups * 16 + groups * 2);
1562        for i in 0..groups * 16 {
1563            bytes.push((((i * 7 + 3) % 256) & 0xFF) as u8);
1564        }
1565        for g in 0..groups {
1566            let s = 0.01 + 0.003 * g as f32;
1567            bytes.extend_from_slice(&cortiq_core::quant::f32_to_f16(s).to_le_bytes());
1568        }
1569        let x: Vec<f32> = (0..cols).map(|i| (i as f32 * 0.17).sin()).collect();
1570
1571        let mut reference = vec![0.0f32; rows * cols];
1572        cortiq_core::quant::dequant_q4_block(&bytes, &mut reference);
1573        let mut expect = vec![0.0f32; rows];
1574        for r in 0..rows {
1575            expect[r] = reference[r * cols..(r + 1) * cols]
1576                .iter()
1577                .zip(&x)
1578                .map(|(w, xv)| w * xv)
1579                .sum();
1580        }
1581
1582        let mut got = vec![0.0f32; rows];
1583        q4matvec(&bytes, &x, rows, cols, &mut got, None);
1584        // SDOT path quantizes activations to i8 (A8W8): bounded noise,
1585        // same contract as q8/vbit (exact path is pinned by CMF_SDOT=0
1586        // in the golden-parity gate).
1587        let tol = if sdot_enabled() { 6e-2 } else { 1e-4 };
1588        let scale = expect.iter().fold(0f32, |m, v| m.max(v.abs())).max(1.0);
1589        for r in 0..rows {
1590            assert!(
1591                (got[r] - expect[r]).abs() < tol * scale,
1592                "row {r}: {} vs {}",
1593                got[r],
1594                expect[r]
1595            );
1596        }
1597    }
1598
1599    /// q4 SDOT outlier correction: a single huge activation channel
1600    /// (>8·rms → outlier, zeroed in xq) must still contribute its EXACT
1601    /// term. On-grid bulk (±1/0 → xq dequantizes exactly) isolates the
1602    /// correction from A8W8 noise. cols must exceed 64: at n=64 the
1603    /// 8·rms threshold equals sqrt(v²+rest) ≥ v, so a single outlier
1604    /// can never qualify (8² = n).
1605    #[test]
1606    fn q4matvec_sdot_outlier_exact() {
1607        let (rows, cols) = (4, 128);
1608        let groups = rows * cols / GROUP_SIZE;
1609        let mut bytes = Vec::with_capacity(groups * 16 + groups * 2);
1610        for i in 0..groups * 16 {
1611            bytes.push(((i * 11 + 5) % 256) as u8);
1612        }
1613        for g in 0..groups {
1614            let s = 0.02 + 0.002 * g as f32;
1615            bytes.extend_from_slice(&cortiq_core::quant::f32_to_f16(s).to_le_bytes());
1616        }
1617        let mut x: Vec<f32> = (0..cols)
1618            .map(|i| match i % 3 {
1619                0 => 1.0,
1620                1 => -1.0,
1621                _ => 0.0,
1622            })
1623            .collect();
1624        x[17] = 300.0; // ≫ 8·rms → outlier channel
1625
1626        let mut reference = vec![0.0f32; rows * cols];
1627        cortiq_core::quant::dequant_q4_block(&bytes, &mut reference);
1628        let mut expect = vec![0.0f32; rows];
1629        for r in 0..rows {
1630            expect[r] = reference[r * cols..(r + 1) * cols]
1631                .iter()
1632                .zip(&x)
1633                .map(|(w, xv)| w * xv)
1634                .sum();
1635        }
1636        let mut got = vec![0.0f32; rows];
1637        q4matvec(&bytes, &x, rows, cols, &mut got, None);
1638        let scale = expect.iter().fold(0f32, |m, v| m.max(v.abs())).max(1.0);
1639        for r in 0..rows {
1640            assert!(
1641                (got[r] - expect[r]).abs() < 2e-3 * scale,
1642                "row {r}: {} vs {} (outlier term must be exact)",
1643                got[r],
1644                expect[r]
1645            );
1646        }
1647    }
1648}