Skip to main content

frink_core/
weight_matrix.rs

1//! `WeightMatrix`: a weight matrix that may live either as plain f32
2//! (small dims, embeddings, synthetic test weights) or as raw
3//! Q8_0/Q4_0 block bytes loaded straight from a GGUF file, with no f32
4//! expansion at load time. This is what lets frink load a
5//! multi-billion-parameter checkpoint without first blowing it up 4x
6//! in RAM: the loader (frink-models) hands tensors over still
7//! quantized, and every matmul call here dispatches to the fused
8//! dequant+dot kernels in frink-quant.
9
10use rayon::prelude::*;
11use std::ops::Range;
12use std::sync::Arc;
13
14use frink_gguf::GgmlType;
15
16use crate::tensor::Tensor;
17
18pub mod gpu_backend;
19pub mod hadamard;
20pub mod lora;
21mod repack_cache;
22
23#[cfg(any(feature = "cuda", feature = "metal", feature = "vulkan"))]
24use gpu_backend::BackendDispatch;
25use gpu_backend::{with_gpu_backend_caps, with_gpu_backends, BackendCaps, Cuda, Metal};
26pub use lora::{LoraDelta, LoraScale, LoraShapeError, LoraStack};
27pub use repack_cache::MapId;
28use repack_cache::{
29    get_or_repack_q4_0x4, get_or_repack_q4k, get_or_repack_q5k, get_or_repack_q6k,
30    get_or_repack_q8x4,
31};
32
33/// Backing storage for a quantized weight matrix's raw bytes: either an
34/// owned buffer (synthetic/test weights, or any tensor that had to be
35/// copied for some other reason) or a zero-copy view into a shared
36/// memory-mapped GGUF file. This is the fix for the "loader read
37/// everything into a fresh Vec<u8>" inefficiency: a real checkpoint's
38/// resident memory should be the mmap itself, not a second copy of it,
39/// which is how llama.cpp's mmap-based loader both avoid
40/// doubling a multi-hundred-gigabyte checkpoint's memory footprint.
41pub enum WeightBytes {
42    Owned(Vec<u8>),
43    Mapped {
44        mmap: Arc<memmap2::Mmap>,
45        range: Range<usize>,
46    },
47    /// A sub-range of a shared, lease-style buffer (e.g. one matrix
48    /// inside an `frink_core::expert_store::ExpertLease`'s combined
49    /// gate/up/down bytes). Holding the `Arc` here is exactly what
50    /// makes the store's lease pinning structural: as long as any
51    /// `WeightMatrix` built over these bytes is alive, the cache entry's
52    /// strong count stays >1 and eviction cannot reuse it.
53    Shared {
54        buf: Arc<Vec<u8>>,
55        range: Range<usize>,
56    },
57}
58
59impl WeightBytes {
60    pub fn as_slice(&self) -> &[u8] {
61        match self {
62            WeightBytes::Owned(v) => v,
63            WeightBytes::Mapped { mmap, range } => &mmap[range.clone()],
64            WeightBytes::Shared { buf, range } => &buf[range.clone()],
65        }
66    }
67
68    pub fn len(&self) -> usize {
69        self.as_slice().len()
70    }
71
72    pub fn is_empty(&self) -> bool {
73        self.len() == 0
74    }
75
76    /// The identity a repack cache may key on, or `None` for bytes that
77    /// must never be cached by address.
78    ///
79    /// This *replaces* an `address_is_stable() -> bool`, and the boolean
80    /// was the bug: a yes/no answer cannot say whether the mapping that
81    /// made the address meaningful is still alive, so the cache went on
82    /// trusting an address after the mapping behind it was gone. See
83    /// [`MapId`] for the ABA that produces and how the `Weak` closes it.
84    ///
85    /// `Shared` stays `None`, and for a different reason that a `Weak`
86    /// would NOT fix: it is a lease over an expert store's *recycled*
87    /// buffer, so the allocation stays alive and keeps its address while
88    /// its CONTENTS are replaced by another expert's. Identity is stable
89    /// there and still means nothing. That produced fluent garbage on
90    /// OLMoE with expert streaming on, while the raw weight bytes
91    /// compared equal, because the corruption was in the CACHE and not
92    /// in the weights.
93    ///
94    /// `Owned` stays `None` too: a freed `Vec`'s address is reused, and
95    /// nothing holds a handle that could witness the free.
96    pub fn map_id(&self) -> Option<MapId> {
97        match self {
98            WeightBytes::Mapped { mmap, range } => Some(MapId::of(mmap, range.start)),
99            WeightBytes::Owned(_) | WeightBytes::Shared { .. } => None,
100        }
101    }
102
103    /// True if this is a zero-copy mmap view rather than an owned
104    /// heap allocation -- useful for tests/diagnostics asserting that
105    /// the loader actually took the zero-copy path.
106    pub fn is_mapped(&self) -> bool {
107        matches!(self, WeightBytes::Mapped { .. })
108    }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
112pub enum QuantKind {
113    Q8_0,
114    Q4_0,
115    /// The dominant real-world GGUF quantization formats (most
116    /// published checkpoints ship as Q4_K_M or similar K-quant mixes,
117    /// not the legacy Q4_0/Q8_0 formats above). See
118    /// `frink_quant`'s module docs for the block layout and
119    /// independent Python cross-validation.
120    Q4K,
121    Q5K,
122    Q6K,
123    /// The two more-aggressive K-quant tiers, used in Q2_K/Q3_K_M/
124    /// Q3_K_L-style quant mixes (the far more common Q4_K_M/Q5_K_M
125    /// mixes only combine with Q6_K, already covered above). See
126    /// `frink_quant`'s module docs and independent Python
127    /// cross-validation.
128    Q2K,
129    Q3K,
130    /// Legacy, largely-obsolete-for-new-releases formats, still
131    /// occasionally encountered. See `frink_quant`'s module docs;
132    /// byte layouts verified against real `ggml-common.h` source.
133    Q4_1,
134    Q5_0,
135    Q5_1,
136    Q8_1,
137    /// Non-linear ("codebook") quants: a 4-bit index maps through a
138    /// shared 16-entry signed lookup table instead of a linear
139    /// `nibble*scale+min` transform. See `frink_quant`'s module docs
140    /// and independent Python cross-validation.
141    IQ4NL,
142    IQ4XS,
143    /// The codebook-grid low-bit formats used throughout published
144    /// "Dynamic" low-bit GGUFs of large MoE models (grid-table
145    /// magnitudes + shared sign patterns; scalar kernels only so far).
146    /// See `frink_quant`'s module docs and the ggml-cross-validated
147    /// independent Python reference.
148    IQ1S,
149    IQ2XXS,
150    IQ3XXS,
151    /// The second codebook-grid tier (ggml tags 17/21/22/29), which the
152    /// published `UD-*` recipes reach for when the `_XXS` tier is too
153    /// lossy -- IQ3_S especially, since it is most of what an `IQ3_M`
154    /// mix contains. Scalar kernels only; goldens are the real compiled
155    /// ggml dequantizers' own output, asserted bit-exactly.
156    IQ2XS,
157    IQ2S,
158    IQ3S,
159    IQ1M,
160    /// GGUF *block*-MXFP4 (17-byte interleaved blocks, ggml tag 39) --
161    /// not the same layout as `WeightMatrix::Mxfp4`'s two-buffer
162    /// safetensors form, though the math is identical. Scalar kernel
163    /// only so far.
164    Mxfp4Gguf,
165    /// ggml `TQ1_0` and PrismML `PTQ1_0`: base-3 trits, 256 and 128 to
166    /// a block (`frink_quant::ternary`). Bonsai 2 27B is PTQ1_0.
167    Tq1_0,
168    Ptq1_0,
169}
170
171impl QuantKind {
172    /// The name a GPU kernel table indexes this storage by, or `None`
173    /// for a storage no matvec kernel is written for.
174    ///
175    /// Exhaustive on purpose, with no `_` arm: a kind added to the enum
176    /// has to answer here, and a caller asks the BACKEND's own table
177    /// (`frink_metal::gpu::matvec_launch_meta`) whether that name has
178    /// a kernel. A second hand-written match of kinds to kernels is how
179    /// `Q5_0` and `PTQ1_0` came to be refused by every fused Metal path
180    /// in `frink-models` while the Metal table served both.
181    pub fn metal_kind_name(self) -> Option<&'static str> {
182        match self {
183            QuantKind::Q8_0 => Some("Q8_0"),
184            QuantKind::Q4_0 => Some("Q4_0"),
185            QuantKind::Q5_0 => Some("Q5_0"),
186            QuantKind::Q4K => Some("Q4_K"),
187            QuantKind::Q5K => Some("Q5_K"),
188            QuantKind::Q6K => Some("Q6_K"),
189            QuantKind::IQ4XS => Some("IQ4_XS"),
190            QuantKind::Ptq1_0 => Some("PTQ1_0"),
191            // No Metal matvec kernel: scalar host paths only.
192            QuantKind::Q2K
193            | QuantKind::Q3K
194            | QuantKind::Q4_1
195            | QuantKind::Q5_1
196            | QuantKind::Q8_1
197            | QuantKind::IQ4NL
198            | QuantKind::IQ1S
199            | QuantKind::IQ2XXS
200            | QuantKind::IQ3XXS
201            | QuantKind::IQ2XS
202            | QuantKind::IQ2S
203            | QuantKind::IQ3S
204            | QuantKind::IQ1M
205            | QuantKind::Mxfp4Gguf
206            | QuantKind::Tq1_0 => None,
207        }
208    }
209}
210
211impl QuantKind {
212    /// Every variant, so exhaustiveness can be *tested* rather than
213    /// trusted. The kernel-coverage tests below iterate this; adding a
214    /// variant without adding it here fails to compile (the match in
215    /// [`Self::name`] is exhaustive and this list is checked against it).
216    pub const ALL: &'static [QuantKind] = &[
217        QuantKind::Q8_0,
218        QuantKind::Q4_0,
219        QuantKind::Q4K,
220        QuantKind::Q5K,
221        QuantKind::Q6K,
222        QuantKind::Q2K,
223        QuantKind::Q3K,
224        QuantKind::Q4_1,
225        QuantKind::Q5_0,
226        QuantKind::Q5_1,
227        QuantKind::Q8_1,
228        QuantKind::IQ4NL,
229        QuantKind::IQ4XS,
230        QuantKind::IQ1S,
231        QuantKind::IQ2XXS,
232        QuantKind::IQ3XXS,
233        QuantKind::IQ2XS,
234        QuantKind::IQ2S,
235        QuantKind::IQ3S,
236        QuantKind::IQ1M,
237        QuantKind::Mxfp4Gguf,
238        QuantKind::Tq1_0,
239        QuantKind::Ptq1_0,
240    ];
241
242    /// The GGUF-facing name. Also the key
243    /// [`frink_metal::gpu::matvec_launch_meta`] is looked up by, which
244    /// is why it is one function and not a `Debug` impl.
245    pub fn name(self) -> &'static str {
246        match self {
247            QuantKind::Q8_0 => "Q8_0",
248            QuantKind::Q4_0 => "Q4_0",
249            QuantKind::Q4K => "Q4_K",
250            QuantKind::Q5K => "Q5_K",
251            QuantKind::Q6K => "Q6_K",
252            QuantKind::Q2K => "Q2_K",
253            QuantKind::Q3K => "Q3_K",
254            QuantKind::Q4_1 => "Q4_1",
255            QuantKind::Q5_0 => "Q5_0",
256            QuantKind::Q5_1 => "Q5_1",
257            QuantKind::Q8_1 => "Q8_1",
258            QuantKind::IQ4NL => "IQ4_NL",
259            QuantKind::IQ4XS => "IQ4_XS",
260            QuantKind::IQ1S => "IQ1_S",
261            QuantKind::IQ2XXS => "IQ2_XXS",
262            QuantKind::IQ3XXS => "IQ3_XXS",
263            QuantKind::IQ2XS => "IQ2_XS",
264            QuantKind::IQ2S => "IQ2_S",
265            QuantKind::IQ3S => "IQ3_S",
266            QuantKind::IQ1M => "IQ1_M",
267            QuantKind::Mxfp4Gguf => "MXFP4",
268            QuantKind::Tq1_0 => "TQ1_0",
269            QuantKind::Ptq1_0 => "PTQ1_0",
270        }
271    }
272}
273
274/// Which quant kinds have a **Metal matvec** kernel, as the kernel name
275/// [`frink_metal::gpu::matvec_launch_meta`] resolves.
276///
277/// The table itself is [`Metal::matvec_kernel`]; this is the name the
278/// rest of the tree already imports, kept so the single source of truth
279/// moving did not become 30 edits in crates owned by someone else.
280pub fn metal_matvec_kind_name(kind: QuantKind) -> Option<&'static str> {
281    Metal::matvec_kernel(kind)
282}
283
284/// Which quant kinds have a **Metal batched simdgroup GEMM**
285/// (`*_mul_mm_sg`), the prefill path. Delegates to
286/// [`Metal::gemm_supported`].
287pub fn metal_mul_mm_kind_supported(kind: QuantKind) -> bool {
288    Metal::gemm_supported(kind)
289}
290
291/// Maps a GGUF tensor's on-disk dtype to the [`QuantKind`] a
292/// [`WeightMatrix`] uses to pick a fused dequant+dot kernel, or `None`
293/// for a dtype with no quantized kernel (F32, or one not implemented at
294/// all).
295///
296/// **The single source of truth for that question**, for the same
297/// reason [`metal_mul_mm_kind_supported`] is for its own: this table
298/// used to be copied into six GGUF loaders, and the copies drifted.
299/// Three of them (`loader`, `glm52_gguf_loader`, `kimi_gguf_loader`)
300/// listed 21 dtypes while the other three (`mla_gguf_loader`,
301/// `gemma4_gguf_loader`, `hybrid_gguf_loader`) listed 17 -- missing
302/// `IQ1_S`, `IQ2_XXS`, `IQ3_XXS` and `MXFP4`. A miss is not a slow
303/// path, it is `LoadError::UnsupportedDtype`, so a DeepSeek-MLA
304/// checkpoint quantized to `IQ2_XXS` -- an ordinary combination for a
305/// model that large -- was refused outright while the identical quant
306/// loaded fine on the generic path.
307pub fn quant_kind_for(dtype: GgmlType) -> Option<QuantKind> {
308    match dtype {
309        GgmlType::Q8_0 => Some(QuantKind::Q8_0),
310        GgmlType::Q4_0 => Some(QuantKind::Q4_0),
311        GgmlType::Q4K => Some(QuantKind::Q4K),
312        GgmlType::Q5K => Some(QuantKind::Q5K),
313        GgmlType::Q6K => Some(QuantKind::Q6K),
314        GgmlType::Q2K => Some(QuantKind::Q2K),
315        GgmlType::Q3K => Some(QuantKind::Q3K),
316        GgmlType::Q4_1 => Some(QuantKind::Q4_1),
317        GgmlType::Q5_0 => Some(QuantKind::Q5_0),
318        GgmlType::Q5_1 => Some(QuantKind::Q5_1),
319        GgmlType::Q8_1 => Some(QuantKind::Q8_1),
320        GgmlType::IQ4NL => Some(QuantKind::IQ4NL),
321        GgmlType::IQ4XS => Some(QuantKind::IQ4XS),
322        GgmlType::IQ2XS => Some(QuantKind::IQ2XS),
323        GgmlType::IQ2S => Some(QuantKind::IQ2S),
324        GgmlType::IQ3S => Some(QuantKind::IQ3S),
325        GgmlType::IQ1M => Some(QuantKind::IQ1M),
326        GgmlType::IQ1S => Some(QuantKind::IQ1S),
327        GgmlType::IQ2XXS => Some(QuantKind::IQ2XXS),
328        GgmlType::IQ3XXS => Some(QuantKind::IQ3XXS),
329        GgmlType::MXFP4 => Some(QuantKind::Mxfp4Gguf),
330        GgmlType::TQ1_0 => Some(QuantKind::Tq1_0),
331        GgmlType::PTQ1_0 => Some(QuantKind::Ptq1_0),
332        _ => None,
333    }
334}
335
336/// Which quant kinds have a **CUDA batched GEMM** (`mul_mm`), the
337/// prefill path. Delegates to [`Cuda::gemm_supported`], which is where
338/// the "UNRUN ON HARDWARE" caveat is written down.
339pub fn cuda_mul_mm_kind_supported(kind: QuantKind) -> bool {
340    Cuda::gemm_supported(kind)
341}
342
343/// Which quant kinds have a **CUDA matvec** kernel, the decode path.
344/// Delegates to [`Cuda::matvec_kernel`], whose `Option<&str>` is the
345/// shape Metal needs and CUDA does not — the `bool` is this wrapper.
346pub fn cuda_matvec_kind_supported(kind: QuantKind) -> bool {
347    Cuda::matvec_kernel(kind).is_some()
348}
349
350/// Which quant kinds take the CPU integer `vec_dot` path (activation
351/// quantized to Q8/Q8_K, int8xint8 dots) rather than the much slower f32
352/// dequant-dot. `cols` matters: the K-quant kernels need a whole number
353/// of 256-element super-blocks, the legacy ones 32-element blocks.
354pub fn cpu_int_dot_kind_supported(kind: QuantKind, cols: usize) -> bool {
355    match kind {
356        QuantKind::Q8_0 | QuantKind::Q4_0 => cols.is_multiple_of(32),
357        QuantKind::Q4K | QuantKind::Q5K | QuantKind::Q6K => cols.is_multiple_of(256),
358        _ => false,
359    }
360}
361
362/// The backend dense matmuls will actually use in this process, decided
363/// by the same cached env/probe reads dispatch uses. CUDA wins when both
364/// are compiled in — and it wins here because it is first in
365/// [`gpu_backend::with_gpu_backends`], the single ordered list
366/// [`WeightMatrix::apply_gpu`] also expands, rather than because that
367/// order is written out a second time.
368pub fn active_backend() -> crate::kernel_registry::Backend {
369    #[allow(unused_macros)]
370    macro_rules! first_enabled {
371        ($b:ty) => {
372            if <$b as BackendDispatch>::dense_enabled() {
373                return <$b as BackendCaps>::ID;
374            }
375        };
376    }
377    with_gpu_backends!(first_enabled);
378    crate::kernel_registry::Backend::Cpu
379}
380
381/// Minimum multiply-accumulates a rayon task should carry before it is
382/// worth its own scheduling. Chosen by measurement, not derivation.
383///
384/// **This is a rayon-only mitigation and it is unreachable on
385/// [`crate::par::Backend::Spin`].** It exists to stop rayon splitting a
386/// matvec into tasks too small to repay a fork-join; the persistent pool
387/// has no fork-join to repay, so it chunks by pool width alone (see the
388/// `MIN_TASK_MACS` section of [`crate::par`]). Issue #27 asks for this
389/// constant to be deleted rather than retuned, and on the pool's path it
390/// is: [`WeightMatrix::min_rows_per_task`] returns before reading it
391/// whenever [`crate::par::backend`] picked the pool for this operation.
392/// It survives on the fork-join path, which is still every operation
393/// below [`crate::par::policy::SPIN_MIN_OP_MACS`], because removing it
394/// there re-opens the 13-16x small-model regression recorded on
395/// [`WeightMatrix::min_rows_per_task`].
396const MIN_TASK_MACS: usize = 1 << 16;
397
398/// Whether dense [`WeightMatrix::apply`] / [`WeightMatrix::apply_batch`]
399/// should try Metal first (when built with `--features metal`).
400///
401/// - `FRINK_METAL=0|false|off|cpu` — force CPU
402/// - `FRINK_METAL=1|true|on|metal` — force Metal attempt
403/// - unset / `auto` — Metal when [`frink_metal::gpu::probe`] finds a device
404///
405/// Decision is cached for the process lifetime (env read once).
406#[cfg(feature = "metal")]
407pub fn metal_dense_enabled() -> bool {
408    Metal::dense_enabled()
409}
410
411/// Whether dense [`WeightMatrix::apply`] should try CUDA first (when
412/// built with `--features cuda`).
413///
414/// - `FRINK_CUDA=0|false|off|cpu` — force skip CUDA dense
415/// - `FRINK_CUDA=1|true|on|cuda` — force CUDA attempt
416/// - unset / `auto` — CUDA when a device probe succeeds
417#[cfg(feature = "cuda")]
418pub fn cuda_dense_enabled() -> bool {
419    Cuda::dense_enabled()
420}
421
422/// Whether CPU Q8_0 / Q4_0 / Q4_K / Q5_K / Q6_K matvec should quantize the
423/// activation to int8 and use the integer `vec_dot` path. Q4_K
424/// additionally lazy-repacks into interleaved `block_q4_Kx8` for 8-wide
425/// GEMV; Q8_0 into `block_q8_0x4` and Q4_0 into `block_q4_0x4` for
426/// 4-wide GEMV.
427///
428/// Off by default *as a library*, and turned on by both binaries (see
429/// `frink_core::threads`'s siblings in `frink-cli`/`frink-server`,
430/// which set `FRINK_CPU_INT_DOT=1` unless the caller already chose).
431/// The split is deliberate: this is what llama.cpp's CPU backend does
432/// unconditionally -- quantize the activation to Q8, run integer
433/// `vec_dot` -- and it is worth 28% of CPU decode on Host B
434/// (Qwen2.5-0.5B Q8_0, `-ngl 0 -t 6`: 58.0 -> 80.5 tok/s). But it also
435/// perturbs results below the f32 reference's precision, and this
436/// crate's golden cross-validation against the independent NumPy
437/// reference asserts exact agreement. So the *inference product*
438/// defaults to fast and the *library default* stays reference-exact.
439///
440/// **This is the master switch, not the dispatch rule.** It says whether
441/// the tier is on at all; whether a given piece of work should take it
442/// is [`cpu_int_dot_for`], which also asks whether this host has the
443/// kernel for that workload's shape. Production dispatch calls that one.
444pub fn cpu_int_dot_enabled() -> bool {
445    #[cfg(test)]
446    {
447        // The env var is read once into a `OnceLock`, so a test cannot
448        // flip it after any other test has already observed it. Without
449        // an override, `cargo test` runs with int-dot *off* and every
450        // interleaved/i8mm batch kernel below is dead code in CI --
451        // which is how the whole repack tier went untested end to end.
452        // See [`tests::ForceIntDot`].
453        match INT_DOT_TEST_OVERRIDE.load(std::sync::atomic::Ordering::Acquire) {
454            0 => return false,
455            1 => return true,
456            _ => {}
457        }
458    }
459    use std::sync::OnceLock;
460    static ENABLED: OnceLock<bool> = OnceLock::new();
461    *ENABLED.get_or_init(|| {
462        matches!(
463            std::env::var("FRINK_CPU_INT_DOT").ok().as_deref(),
464            Some("1") | Some("true") | Some("on")
465        )
466    })
467}
468
469/// Test-only forcing of [`cpu_int_dot_enabled`]: `-1` unset, `0` off,
470/// `1` on. A global atomic rather than a thread-local because the paths
471/// it gates run on Rayon workers, which do not inherit thread-locals
472/// from the test thread.
473#[cfg(test)]
474static INT_DOT_TEST_OVERRIDE: std::sync::atomic::AtomicI8 = std::sync::atomic::AtomicI8::new(-1);
475
476/// Sets `FRINK_CPU_INT_DOT=1` unless the caller already expressed a
477/// preference. Call from a binary's startup, before any worker threads
478/// exist. See [`cpu_int_dot_enabled`] for why the default lives here
479/// rather than in the getter.
480///
481/// # Safety
482/// Must be called while the process is still single-threaded, since it
483/// mutates the process environment.
484pub unsafe fn default_cpu_int_dot_on() {
485    if std::env::var_os("FRINK_CPU_INT_DOT").is_none() && int_dot_is_a_win_here() {
486        unsafe { std::env::set_var("FRINK_CPU_INT_DOT", "1") };
487    }
488}
489
490/// Whether the int-dot path is faster than the f32 one on THIS
491/// architecture.
492///
493/// It is not universally faster, and the default said it was. The
494/// interleaved int8 kernels this path selects were written for
495/// aarch64: `i8mm` SMMLA tiers, interleave-8 NEON GEMV, the Q8_K repack.
496/// x86_64 has none of that, so on x86 the switch selects a scalar
497/// integer loop AND bypasses the AVX2 f32 dot that does exist
498/// (`dot_q4_k_f32` and friends are gated on `avx2` + `fma`).
499///
500/// Measured 2026-09-04 on an idle 32-core Ryzen 9 7945HX (Zen 4, with
501/// `avx512_vnni` that nothing here uses), `tg64`, int-dot on against
502/// off:
503///
504/// | model | on (was the default) | off |
505/// |---|---|---|
506/// | Llama-3.2-1B Q4_K_M | 10.08 | **48.94** |
507/// | Llama-3.2-1B Q6_K | 4.11 | **36.23** |
508/// | Llama-3.2-3B Q4_K_M | 4.73 | **19.30** |
509///
510/// So the default cost x86 between 4x and 8.8x of decode, and it is
511/// most of why `benchmarks/RESULTS.md` had no x86 row worth showing
512/// (#127). Prefill is unaffected (95.3 against 89.4 on the 1B), which
513/// is consistent: prefill goes through the batched GEMM rather than
514/// this dot.
515///
516/// **That measurement is per WORKLOAD, and the flag was per process.**
517/// It says the matvec half of the tier loses on x86 and says nothing
518/// against the batch half; the batch half simply had no x86 kernel to
519/// try, which is #152. Now that it does, the rule is
520/// [`int_dot_tier_here`] and this function is only its "is any half
521/// worth turning on by default" summary.
522///
523/// This is a DEFAULT, not a gate: `FRINK_CPU_INT_DOT=1` still turns it
524/// on anywhere.
525fn int_dot_is_a_win_here() -> bool {
526    let tier = int_dot_tier_here();
527    tier.matvec || tier.batch_gemm
528}
529
530/// Which shape of work a call site is asking the repacked integer tier
531/// for. Not a hint: the two are different kernels and, on x86, different
532/// answers.
533#[derive(Clone, Copy, PartialEq, Eq, Debug)]
534pub enum IntDotShape {
535    /// One activation against the whole matrix — `apply`, `apply_cpu_q8`,
536    /// the MoE per-expert dots. Decode, and the `nrc == 1` GEMV kernels.
537    Matvec,
538    /// A batch of activations at once, through the interleaved `×4`
539    /// GEMMs. Prefill.
540    BatchGemm,
541}
542
543/// **The** predicate for "does this work take the repacked integer
544/// tier". Every call site asks this and none restates it.
545///
546/// Two things have to be true: `FRINK_CPU_INT_DOT` is on (the master
547/// switch, [`cpu_int_dot_enabled`]), and this host has kernels worth
548/// taking for `shape` ([`int_dot_tier_here`]).
549///
550/// Splitting by shape is the whole point. The tier used to be one
551/// process-wide flag over two unrelated kernel families, so x86 had to
552/// choose between a batched GEMM it wanted and a matvec that cost it 4x
553/// to 8.8x of decode — and chose neither.
554pub fn cpu_int_dot_for(shape: IntDotShape) -> bool {
555    cpu_int_dot_enabled() && int_dot_tier_here().covers(shape)
556}
557
558/// Which halves of the repacked integer tier are worth taking on this
559/// host.
560#[derive(Clone, Copy, PartialEq, Eq, Debug)]
561struct IntDotTier {
562    matvec: bool,
563    batch_gemm: bool,
564}
565
566impl IntDotTier {
567    /// Exhaustive on purpose, with no `_` arm: a third workload shape
568    /// must state its own answer rather than inherit one.
569    fn covers(self, shape: IntDotShape) -> bool {
570        match shape {
571            IntDotShape::Matvec => self.matvec,
572            IntDotShape::BatchGemm => self.batch_gemm,
573        }
574    }
575}
576
577/// The per-host, per-workload rule, in one place.
578///
579/// - **aarch64**: the matvec half always — the interleave-8 NEON GEMV
580///   and the i8mm SMMLA GEMMs are the kernels this tier was written for,
581///   worth ~28% of decode and 15x of prefill (`FRINK_CPU_INT_DOT=0`
582///   takes Llama-3.2-1B Q4_K_M pp512 from 420.34 to 27.81 tok/s, #152).
583///   The batch half asks the kernels, which on a host with `i8mm` is the
584///   quad GEMM and on one with only `dotprod` is the width-4 `sdot` GEMM.
585/// - **x86_64**: the batch half only, and only when the AVX2 `×4` GEMMs
586///   are actually present. The matvec half stays off because it was
587///   MEASURED to lose — see the table above — and nothing in this change
588///   touches the kernel it loses to.
589/// - anywhere else: neither, because neither has a kernel.
590///
591/// `batch_gemm` is not a written-down claim about any architecture; it
592/// asks `frink_quant` whether a SIMD batch GEMM exists at the width
593/// this host packs with. A host cannot be told the tier is a win while
594/// its kernel is missing, and an x86 host without AVX2 gets the same
595/// answer a RISC-V one does.
596///
597/// It asks [`frink_quant::batch_gemm_is_accelerated`] and NOT
598/// `interleaved_gemm_is_accelerated`: the latter is about the
599/// interleave-8 quad kernels, which is the right question for whether to
600/// PREPARE a quad and the wrong one for whether the tier buys anything.
601/// A pre-i8mm aarch64 host — every M1 Mac, every A14-and-earlier iPhone,
602/// and the Cortex-A55-class cores that are still most of the Android
603/// fleet — runs a width-4 `dotprod` GEMM for Q4_K, Q5_K, Q8_0 and Q4_0,
604/// so the narrower predicate reports "no SIMD GEMM" on a host that is
605/// running one. Q6_K has no width-4 GEMM on purpose (its scalar Kx8 GEMM
606/// measured slower than the per-row NEON dot), and the per-kind
607/// `q*_gemm_uses_acts_x4` entry points are what keep that distinction.
608///
609/// # On the `cfg!` in here
610///
611/// `par::policy` warns against exactly this shape — "an
612/// architecture-conditional default is what `FRINK_CPU_INT_DOT` was" —
613/// and it is right that an *unmeasured* one is how this went wrong.
614/// This one is the measurement: the x86 matvec row above is a real
615/// before/after on a quiet host, and the x86 batch row is gated on a
616/// runtime probe rather than a guess. The two predicates also answer
617/// different questions and must not be merged: `policy::backend` picks
618/// the SCHEDULER by work size; this picks the KERNEL by workload shape.
619/// Whether the Q5_K batched matmul takes the Kx8 path: every aarch64
620/// host (the i8mm quad GEMM, the width-4 `sdot` GEMM, or the scalar Kx8
621/// body, as the Q4_K arm takes it unconditionally), and any other host
622/// whose `x4` GEMM has a SIMD kernel at this width (AVX2 since #159).
623///
624/// Asked of the kernels rather than written beside them, the shape
625/// `int_dot_tier_here` has: this read `cfg!(target_arch = "aarch64")`
626/// alone until 2026-09-15, which sent every x86 Q5_K prefill through
627/// the per-row GEMM -- 44.2 against llama.cpp's 378.7 tok/s on
628/// Llama-3.2-1B Q5_K_M (8.56x) on a Ryzen 5950X, beside Q4_K and Q6_K
629/// at 1.19x on the same host, because those two arms asked the kernels
630/// and this one asked the architecture. With the predicate: 417.1
631/// tok/s, 0.91x. Phi-4-mini's `attn_qkv` is Q5_K too.
632#[inline]
633fn q5k_batch_takes_kx8(interleave: usize) -> bool {
634    cfg!(target_arch = "aarch64") || frink_quant::q5_kx8_gemm_uses_acts_x4(interleave)
635}
636
637fn int_dot_tier_here() -> IntDotTier {
638    #[cfg(target_arch = "aarch64")]
639    {
640        IntDotTier {
641            matvec: true,
642            batch_gemm: frink_quant::batch_gemm_is_accelerated(frink_quant::preferred_interleave()),
643        }
644    }
645    #[cfg(target_arch = "x86_64")]
646    {
647        IntDotTier {
648            matvec: false,
649            batch_gemm: frink_quant::batch_gemm_is_accelerated(frink_quant::preferred_interleave()),
650        }
651    }
652    #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))]
653    {
654        IntDotTier {
655            matvec: false,
656            batch_gemm: false,
657        }
658    }
659}
660
661/// A batch of activations quantized once for reuse across several
662/// [`WeightMatrix::apply_batch_with_acts`] calls that read the same input
663/// (q/k/v on one normed batch; gate/up on another). Build with
664/// [`WeightMatrix::quantize_batch_acts`]. Q8_0/Q4_0 matrices consume
665/// [`BatchActs::Q8`]; the K-quants consume [`BatchActs::Q8K`].
666///
667/// `tiles` carries the *interleaved* activation quads the i8mm GEMMs read
668/// (llama.cpp's `wdata` after `ggml_quantize_mat_q8_K_4x8`), not just the
669/// per-position quantization. Sharing stops at the same place the
670/// quantization does: q/k/v build one set between them instead of three,
671/// gate/up one instead of two. It is empty on hosts with no i8mm kernel,
672/// where preparing a quad buys nothing.
673///
674/// `cols` is recorded so a set built for one width can never be handed to
675/// a matrix of another. The tiles are chunked four positions wide for
676/// every kind (`Q8K_ACTS_X4_NC`, and `Q4_KX8_GEMM_NC` / `Q5_KX8_GEMM_NC`
677/// are the same 4), which is why one set serves Q4_K, Q5_K and Q6_K --
678/// and, in the [`BatchActs::Q8`] variant, both Q8_0 and Q4_0.
679pub enum BatchActs {
680    Q8 {
681        acts: Vec<frink_quant::Q8Activations>,
682        tiles: Vec<frink_quant::Q8ActsX4>,
683        cols: usize,
684    },
685    Q8K {
686        acts: Vec<frink_quant::Q8KActivations>,
687        tiles: Vec<frink_quant::Q8KActsX4>,
688        cols: usize,
689    },
690}
691
692// Sharing one quad set across kinds is only sound while every `x4`
693// consumer chunks the batch the same way. If one of these widths is ever
694// retuned on its own, the quads a Q4_K gate builds stop lining up with
695// what a Q5_K sibling indexes, and the failure is a wrong answer rather
696// than a panic -- so it fails the build instead.
697const _: () = {
698    assert!(frink_quant::Q4_KX8_GEMM_NC == frink_quant::Q8K_ACTS_X4_NC);
699    assert!(frink_quant::Q5_KX8_GEMM_NC == frink_quant::Q8K_ACTS_X4_NC);
700};
701
702pub enum WeightMatrix {
703    F32(Tensor),
704    Quantized {
705        data: WeightBytes,
706        rows: usize,
707        cols: usize,
708        kind: QuantKind,
709    },
710    /// MXFP4 (OCP Microscaling 4-bit float, Kimi K3's real routed-expert
711    /// format): unlike every `Quantized` kind above, which store one
712    /// interleaved block buffer per row, Kimi K3's real checkpoint
713    /// stores the packed 4-bit codes and per-group E8M0 scales as two
714    /// *separate* tensors (confirmed against a real shard header, see
715    /// `frink_quant`'s MXFP4 module docs) -- so this variant holds two
716    /// independently zero-copy-mappable buffers instead of `Quantized`'s
717    /// single `data` buffer. `apply`/`apply_batch` dispatch to
718    /// `frink_quant::dot_mxfp4_row_f32`, which reads directly from
719    /// these buffers without ever materializing a dequantized f32 copy
720    /// of the whole matrix -- the same zero-copy-mmap-plus-fused-dot
721    /// discipline as every `Quantized` kind, letting a real MXFP4
722    /// checkpoint's resident memory stay close to its on-disk size
723    /// instead of the ~8x larger eager-f32-dequant footprint.
724    Mxfp4 {
725        packed: WeightBytes,
726        scale: WeightBytes,
727        rows: usize,
728        cols: usize,
729    },
730    /// `base` with one or more LoRA adapters attached: every product
731    /// this matrix computes is `W x + Σ_i s_i · B_i (A_i x)`, the
732    /// low-rank term added inside the same method that computed `W x`
733    /// (see [`lora`]). `base` is never itself `Adapted`:
734    /// [`Self::attach_lora`] pushes onto the existing stack instead.
735    ///
736    /// A fourth variant rather than a field on the other three, so that
737    /// every place that reaches PAST the methods for raw bytes -- a
738    /// fused Metal stack, a simdgroup-GEMM descriptor, a Q8 row dot --
739    /// has to say what it does with an adapter, and the answer written
740    /// into each of them is `None`: those callers fall back to the
741    /// methods, which serve the delta, rather than run the base weights
742    /// and drop it.
743    Adapted {
744        base: Box<WeightMatrix>,
745        lora: LoraStack,
746    },
747    /// A weight PrismML folded a Hadamard rotation into
748    /// (`hadamard::HadamardFold`): the product is right only after the
749    /// activation has been transformed the same way, so every `apply`
750    /// here transforms and then runs `base`, and every fused launch
751    /// view answers `None` for it as it does for `Adapted`. The row
752    /// lookup (`FoldSite::RowLookup`) restores a stored row instead.
753    Folded {
754        base: Box<WeightMatrix>,
755        fold: std::sync::Arc<hadamard::HadamardFold>,
756    },
757}
758
759impl WeightMatrix {
760    /// Attaches one adapter's `(A, B)` pair. A second adapter on the
761    /// same weight joins the first's stack; the base is boxed exactly
762    /// once.
763    pub fn attach_lora(&mut self, delta: LoraDelta) {
764        assert_eq!(delta.rows(), self.rows(), "LoRA delta rows");
765        assert_eq!(delta.cols(), self.cols(), "LoRA delta cols");
766        if let WeightMatrix::Adapted { lora, .. } = self {
767            lora.push(delta);
768            return;
769        }
770        let placeholder = WeightMatrix::F32(Tensor::new(Vec::new(), vec![0, 0]));
771        let base = std::mem::replace(self, placeholder);
772        *self = WeightMatrix::Adapted {
773            base: Box::new(base),
774            lora: LoraStack::new(delta),
775        };
776    }
777
778    /// The adapters on this matrix, if any.
779    pub fn lora(&self) -> Option<&LoraStack> {
780        match self {
781            WeightMatrix::Adapted { lora, .. } => Some(lora),
782            _ => None,
783        }
784    }
785
786    /// Wraps `self` in a Hadamard fold (`hadamard::HadamardFold`).
787    /// The fold's width, when it has signs, must be this matrix's
788    /// column count.
789    pub fn fold_hadamard(&mut self, fold: std::sync::Arc<hadamard::HadamardFold>) {
790        if let Some(w) = fold.width() {
791            assert_eq!(
792                w,
793                self.cols(),
794                "Hadamard sign vector width vs matrix columns"
795            );
796        }
797        let placeholder = WeightMatrix::F32(Tensor::new(Vec::new(), vec![0, 0]));
798        let base = std::mem::replace(self, placeholder);
799        *self = WeightMatrix::Folded {
800            base: Box::new(base),
801            fold,
802        };
803    }
804
805    /// The fold on this matrix, if any.
806    pub fn hadamard_fold(&self) -> Option<&std::sync::Arc<hadamard::HadamardFold>> {
807        match self {
808            WeightMatrix::Folded { fold, .. } => Some(fold),
809            _ => None,
810        }
811    }
812
813    /// The matrix a GPU launch should read, and the rotation its INPUT
814    /// needs before it does.
815    ///
816    /// One accessor rather than two, because a caller that took the
817    /// base and forgot the fold would run transformed weights against
818    /// an untransformed activation: a wrong answer that nothing fails
819    /// on. `(self, None)` for every matrix that is not folded.
820    pub fn launch_parts(
821        &self,
822    ) -> (
823        &WeightMatrix,
824        Option<&std::sync::Arc<hadamard::HadamardFold>>,
825    ) {
826        match self {
827            WeightMatrix::Folded { base, fold } => (base, Some(fold)),
828            _ => (self, None),
829        }
830    }
831
832    /// The weights under any adapter: `self` when there is none. NOT
833    /// under a fold: a folded matrix's base answers a different
834    /// product, so `base()` of a `Folded` is the `Folded` itself and a
835    /// caller that wants the bytes goes through `hadamard_fold()`.
836    pub fn base(&self) -> &WeightMatrix {
837        match self {
838            WeightMatrix::Adapted { base, .. } => base,
839            _ => self,
840        }
841    }
842
843    /// Raw quantized byte length, or 0 for a float matrix. For
844    /// comparing two backings of the same weight.
845    pub fn bytes_len(&self) -> usize {
846        match self {
847            WeightMatrix::Quantized { data, .. } => data.len(),
848            WeightMatrix::Adapted { base, .. } | WeightMatrix::Folded { base, .. } => {
849                base.bytes_len()
850            }
851            _ => 0,
852        }
853    }
854
855    /// Do two matrices hold the same quantized bytes?
856    ///
857    /// Exists to answer one question: when a streamed expert and a
858    /// resident one disagree about a model's output, is the difference
859    /// in the WEIGHTS or downstream of them?
860    pub fn bytes_eq(&self, other: &WeightMatrix) -> bool {
861        match (self.base(), other.base()) {
862            (WeightMatrix::Quantized { data: a, .. }, WeightMatrix::Quantized { data: b, .. }) => {
863                a.as_slice() == b.as_slice()
864            }
865            _ => false,
866        }
867    }
868
869    pub fn rows(&self) -> usize {
870        match self {
871            WeightMatrix::F32(t) => t.rows(),
872            WeightMatrix::Quantized { rows, .. } => *rows,
873            WeightMatrix::Mxfp4 { rows, .. } => *rows,
874            WeightMatrix::Adapted { base, .. } | WeightMatrix::Folded { base, .. } => base.rows(),
875        }
876    }
877
878    /// The block format, or `None` for the two non-block storages
879    /// (`F32`, safetensors-pair `Mxfp4`). This is the key every
880    /// kernel-availability table is indexed by.
881    pub fn quant_kind(&self) -> Option<QuantKind> {
882        match self {
883            WeightMatrix::Quantized { kind, .. } => Some(*kind),
884            WeightMatrix::F32(_) | WeightMatrix::Mxfp4 { .. } => None,
885            WeightMatrix::Adapted { base, .. } | WeightMatrix::Folded { base, .. } => {
886                base.quant_kind()
887            }
888        }
889    }
890
891    pub fn cols(&self) -> usize {
892        match self {
893            WeightMatrix::F32(t) => t.cols(),
894            WeightMatrix::Quantized { cols, .. } => *cols,
895            WeightMatrix::Mxfp4 { cols, .. } => *cols,
896            WeightMatrix::Adapted { base, .. } | WeightMatrix::Folded { base, .. } => base.cols(),
897        }
898    }
899
900    fn block_bytes_per_row(&self, kind: QuantKind, cols: usize) -> usize {
901        match kind {
902            QuantKind::Q8_0 => {
903                (cols / frink_quant::Q8_0_BLOCK_ELEMS) * frink_quant::Q8_0_BLOCK_BYTES
904            }
905            QuantKind::Q4_0 => {
906                (cols / frink_quant::Q4_0_BLOCK_ELEMS) * frink_quant::Q4_0_BLOCK_BYTES
907            }
908            QuantKind::Q4K => {
909                (cols / frink_quant::Q4_K_BLOCK_ELEMS) * frink_quant::Q4_K_BLOCK_BYTES
910            }
911            QuantKind::Q5K => {
912                (cols / frink_quant::Q5_K_BLOCK_ELEMS) * frink_quant::Q5_K_BLOCK_BYTES
913            }
914            QuantKind::Q6K => {
915                (cols / frink_quant::Q6_K_BLOCK_ELEMS) * frink_quant::Q6_K_BLOCK_BYTES
916            }
917            QuantKind::Q2K => {
918                (cols / frink_quant::Q2_K_BLOCK_ELEMS) * frink_quant::Q2_K_BLOCK_BYTES
919            }
920            QuantKind::Q3K => {
921                (cols / frink_quant::Q3_K_BLOCK_ELEMS) * frink_quant::Q3_K_BLOCK_BYTES
922            }
923            QuantKind::Q4_1 => {
924                (cols / frink_quant::Q4_1_BLOCK_ELEMS) * frink_quant::Q4_1_BLOCK_BYTES
925            }
926            QuantKind::Q5_0 => {
927                (cols / frink_quant::Q5_0_BLOCK_ELEMS) * frink_quant::Q5_0_BLOCK_BYTES
928            }
929            QuantKind::Q5_1 => {
930                (cols / frink_quant::Q5_1_BLOCK_ELEMS) * frink_quant::Q5_1_BLOCK_BYTES
931            }
932            QuantKind::Q8_1 => {
933                (cols / frink_quant::Q8_1_BLOCK_ELEMS) * frink_quant::Q8_1_BLOCK_BYTES
934            }
935            QuantKind::IQ4NL => {
936                (cols / frink_quant::IQ4_NL_BLOCK_ELEMS) * frink_quant::IQ4_NL_BLOCK_BYTES
937            }
938            QuantKind::IQ4XS => {
939                (cols / frink_quant::IQ4_XS_BLOCK_ELEMS) * frink_quant::IQ4_XS_BLOCK_BYTES
940            }
941            QuantKind::IQ1S => {
942                (cols / frink_quant::IQ1_S_BLOCK_ELEMS) * frink_quant::IQ1_S_BLOCK_BYTES
943            }
944            QuantKind::IQ2XXS => {
945                (cols / frink_quant::IQ2_XXS_BLOCK_ELEMS) * frink_quant::IQ2_XXS_BLOCK_BYTES
946            }
947            QuantKind::IQ3XXS => {
948                (cols / frink_quant::IQ3_XXS_BLOCK_ELEMS) * frink_quant::IQ3_XXS_BLOCK_BYTES
949            }
950            QuantKind::IQ2XS => {
951                (cols / frink_quant::IQ2_XS_BLOCK_ELEMS) * frink_quant::IQ2_XS_BLOCK_BYTES
952            }
953            QuantKind::IQ2S => {
954                (cols / frink_quant::IQ2_S_BLOCK_ELEMS) * frink_quant::IQ2_S_BLOCK_BYTES
955            }
956            QuantKind::IQ3S => {
957                (cols / frink_quant::IQ3_S_BLOCK_ELEMS) * frink_quant::IQ3_S_BLOCK_BYTES
958            }
959            QuantKind::IQ1M => {
960                (cols / frink_quant::IQ1_M_BLOCK_ELEMS) * frink_quant::IQ1_M_BLOCK_BYTES
961            }
962            QuantKind::Mxfp4Gguf => {
963                (cols / frink_quant::MXFP4_GGUF_BLOCK_ELEMS) * frink_quant::MXFP4_GGUF_BLOCK_BYTES
964            }
965            QuantKind::Tq1_0 => {
966                (cols / frink_quant::ternary::TQ1_0_BLOCK_ELEMS)
967                    * frink_quant::ternary::TQ1_0_BLOCK_BYTES
968            }
969            QuantKind::Ptq1_0 => {
970                (cols / frink_quant::ternary::PTQ1_0_BLOCK_ELEMS)
971                    * frink_quant::ternary::PTQ1_0_BLOCK_BYTES
972            }
973        }
974    }
975
976    /// A reasonable minimum number of rows for one rayon task to
977    /// process, to avoid rayon's work-stealing splitter fragmenting a
978    /// matmul into tasks so small that scheduling/synchronization
979    /// overhead dominates the real per-row work (a fused dequant+dot,
980    /// not free). This is a real, measured fix, not speculative
981    /// tuning: naive per-row splitting (rayon's default) caused a
982    /// 13-16x throughput regression on a host configured with far more
983    /// rayon threads than a small model's matrices have useful
984    /// parallelism for (observed directly on a shared-core rented
985    /// host, where auto-detected high thread counts collapsed
986    /// throughput ~13-16x on a small model). Aims for ~4 tasks per thread
987    /// -- enough that rayon's work-stealing can still load-balance
988    /// across threads that finish early, without going all the way
989    /// down to one task per row.
990    ///
991    /// Floor of 8 avoids Rayon thrash on tiny mats (SmolLM2 attn_kv
992    /// has 192 rows → without a floor, ~48 one-row tasks on 10 cores).
993    ///
994    /// The floor is also **work-aware**, which matters for decode. A row
995    /// count alone says nothing about how much arithmetic a task carries:
996    /// SmolLM2's 576-wide projections split into ~24 tasks of ~14K MACs
997    /// each, far too little to pay for a fork-join. Measured on this host
998    /// (both engines back to back, thread count as the only variable):
999    /// frink scales 1.40x / 2.93x from 1 to 6 threads on TinyLlama /
1000    /// Mistral-7B where llama.cpp scales 1.99x / 4.39x, and the deficit
1001    /// grows as the model shrinks -- the signature of tasks too small to
1002    /// amortise their own scheduling, not of slow kernels (frink is
1003    /// *ahead* of llama at one thread on Mistral-7B).
1004    ///
1005    /// [`crate::par::with_op_work`] supplies the elements-per-row so a
1006    /// task can be required to carry at least [`MIN_TASK_MACS`]
1007    /// multiply-accumulates. Zero (unset) keeps the old row-only
1008    /// behaviour, so any call site that has not opted in is unchanged.
1009    ///
1010    /// Nothing here needs to ask which scheduler won this operation.
1011    /// What this returns is a `min_len`, and `min_len` is read only by
1012    /// the fork-join arm of [`crate::par`] -- the persistent pool's arm
1013    /// chunks by width alone, which
1014    /// `par::tests::the_spin_arm_chunks_by_pool_width_with_no_work_threshold`
1015    /// asserts. A second `Backend::Spin` check here was written and
1016    /// removed: deleting it changed no result, which is the definition
1017    /// of a gate that cannot fire.
1018    fn min_rows_per_task(rows: usize) -> usize {
1019        let threads = crate::par::num_threads();
1020        let by_threads = (rows / (threads * 4)).max(8.min(rows.max(1)));
1021        let per_row = crate::par::macs_per_row();
1022        if per_row == 0 {
1023            return by_threads;
1024        }
1025        let need = MIN_TASK_MACS.div_ceil(per_row.max(1));
1026        by_threads.max(need.min(rows.max(1)))
1027    }
1028
1029    /// Run `body(g, t0, t1)` for every row-group `g` and activation-tile
1030    /// range `[t0, t1)` of a llama-style 2D chunk grid over
1031    /// (row-groups × batch tiles).
1032    ///
1033    /// This is the port of `ggml_compute_forward_mul_mat`'s chunking
1034    /// (`ggml-cpu.c`): ~16 rows / 16 batch positions per chunk, and if
1035    /// that grid is smaller than `4 × threads`, re-chunk by thread along
1036    /// the larger dimension. llama walks the grid with an atomic
1037    /// `current_chunk` because its threadpool has no scheduler; Rayon
1038    /// already work-steals, so handing it the same chunks (`min_len 1`)
1039    /// gets the same load balancing. The point is the *batch* dimension:
1040    /// splitting only by rows leaves a 192-row projection with ~3 tasks
1041    /// no matter how many positions are in flight.
1042    fn par_chunked_groups(
1043        n_groups: usize,
1044        group_rows: usize,
1045        n_tiles: usize,
1046        tile_batch: usize,
1047        body: impl Fn(usize, usize, usize) + Sync,
1048    ) {
1049        if n_groups == 0 || n_tiles == 0 {
1050            return;
1051        }
1052        let nth = crate::par::num_threads();
1053        const CHUNK_ELEMS: usize = 16;
1054        let g_per_chunk = (CHUNK_ELEMS / group_rows).max(1);
1055        let t_per_chunk = (CHUNK_ELEMS / tile_batch).max(1);
1056        let mut nchunk_g = n_groups.div_ceil(g_per_chunk);
1057        let mut nchunk_t = n_tiles.div_ceil(t_per_chunk);
1058        if nchunk_g * nchunk_t < nth * 4 {
1059            // llama's fallback: one chunk per thread along the larger dim.
1060            if n_groups * group_rows > n_tiles * tile_batch {
1061                nchunk_g = nth.min(n_groups);
1062                nchunk_t = 1;
1063            } else {
1064                nchunk_g = 1;
1065                nchunk_t = nth.min(n_tiles);
1066            }
1067        }
1068        let dg = n_groups.div_ceil(nchunk_g);
1069        let dt = n_tiles.div_ceil(nchunk_t);
1070        crate::par::indices(nchunk_g * nchunk_t, 1, |chunk| {
1071            let g0 = (chunk % nchunk_g) * dg;
1072            let g1 = (g0 + dg).min(n_groups);
1073            let t0 = (chunk / nchunk_g) * dt;
1074            let t1 = (t0 + dt).min(n_tiles);
1075            for g in g0..g1 {
1076                body(g, t0, t1);
1077            }
1078        });
1079    }
1080
1081    /// Resolve the Q8_0-format activations (and the interleaved quads, if
1082    /// any) an [`Self::apply_batch_with_acts`] arm should read.
1083    ///
1084    /// Returns the shared batch when it matches this matrix -- same
1085    /// positions, same width -- and otherwise quantizes into `owned` and
1086    /// returns that with no quads, so the caller builds its own. A
1087    /// mismatched `shared` is silently ignored rather than trusted, which
1088    /// is what keeps a mixed-width projection group correct.
1089    ///
1090    /// The returned quads are only ever the *shared* ones. The empty slice
1091    /// therefore means "nobody prepared these for you", not "this host has
1092    /// no i8mm kernel" -- the caller still decides that with
1093    /// `q8_0x4_gemm_uses_acts_x4`.
1094    fn q8_acts<'a>(
1095        shared: Option<&'a BatchActs>,
1096        x_batch: &[f32],
1097        batch_size: usize,
1098        cols: usize,
1099        owned: &'a mut Vec<frink_quant::Q8Activations>,
1100    ) -> (
1101        &'a [frink_quant::Q8Activations],
1102        &'a [frink_quant::Q8ActsX4],
1103    ) {
1104        if let Some(BatchActs::Q8 {
1105            acts,
1106            tiles,
1107            cols: c,
1108        }) = shared
1109        {
1110            if acts.len() == batch_size && *c == cols {
1111                return (acts, tiles);
1112            }
1113        }
1114        *owned = (0..batch_size)
1115            .into_par_iter()
1116            .map(|b| frink_quant::quantize_activations_q8(&x_batch[b * cols..(b + 1) * cols]))
1117            .collect();
1118        (owned, &[])
1119    }
1120
1121    /// [`Self::q8_acts`] for the Q8_K format the K-quants consume.
1122    fn q8k_acts<'a>(
1123        shared: Option<&'a BatchActs>,
1124        x_batch: &[f32],
1125        batch_size: usize,
1126        cols: usize,
1127        owned: &'a mut Vec<frink_quant::Q8KActivations>,
1128    ) -> (
1129        &'a [frink_quant::Q8KActivations],
1130        &'a [frink_quant::Q8KActsX4],
1131    ) {
1132        if let Some(BatchActs::Q8K {
1133            acts,
1134            tiles,
1135            cols: c,
1136        }) = shared
1137        {
1138            if acts.len() == batch_size && *c == cols {
1139                return (acts, tiles);
1140            }
1141        }
1142        *owned = (0..batch_size)
1143            .into_par_iter()
1144            .map(|b| frink_quant::quantize_activations_q8_k(&x_batch[b * cols..(b + 1) * cols]))
1145            .collect();
1146        (owned, &[])
1147    }
1148
1149    /// Prefer serial when the mat is too small for fork-join to pay off.
1150    fn prefer_serial_matvec(rows: usize, cols: usize) -> bool {
1151        // ~256k f32-equivalent ops: below this, Rayon overhead dominates
1152        // on Host B-class cores for Q8/Q4 decode GEMVs.
1153        rows.saturating_mul(cols) < 256_000
1154    }
1155
1156    fn dot(kind: QuantKind, row: &[u8], x: &[f32]) -> f32 {
1157        match kind {
1158            QuantKind::Q8_0 => frink_quant::dot_q8_0_f32(row, x),
1159            QuantKind::Q4_0 => frink_quant::dot_q4_0_f32(row, x),
1160            QuantKind::Q4K => frink_quant::dot_q4_k_f32(row, x),
1161            QuantKind::Q5K => frink_quant::dot_q5_k_f32(row, x),
1162            QuantKind::Q6K => frink_quant::dot_q6_k_f32(row, x),
1163            QuantKind::Q2K => frink_quant::dot_q2_k_f32(row, x),
1164            QuantKind::Q3K => frink_quant::dot_q3_k_f32(row, x),
1165            QuantKind::Q4_1 => frink_quant::dot_q4_1_f32(row, x),
1166            QuantKind::Q5_0 => frink_quant::dot_q5_0_f32(row, x),
1167            QuantKind::Q5_1 => frink_quant::dot_q5_1_f32(row, x),
1168            QuantKind::Q8_1 => frink_quant::dot_q8_1_f32(row, x),
1169            QuantKind::IQ4NL => frink_quant::dot_iq4_nl_f32(row, x),
1170            QuantKind::IQ4XS => frink_quant::dot_iq4_xs_f32(row, x),
1171            QuantKind::IQ1S => frink_quant::dot_iq1_s_f32(row, x),
1172            QuantKind::IQ2XXS => frink_quant::dot_iq2_xxs_f32(row, x),
1173            QuantKind::IQ3XXS => frink_quant::dot_iq3_xxs_f32(row, x),
1174            QuantKind::IQ2XS => frink_quant::dot_iq2_xs_f32(row, x),
1175            QuantKind::IQ2S => frink_quant::dot_iq2_s_f32(row, x),
1176            QuantKind::IQ3S => frink_quant::dot_iq3_s_f32(row, x),
1177            QuantKind::IQ1M => frink_quant::dot_iq1_m_f32(row, x),
1178            QuantKind::Mxfp4Gguf => frink_quant::dot_mxfp4_gguf_f32(row, x),
1179            QuantKind::Tq1_0 => {
1180                frink_quant::ternary::dot_trits_f32(row, frink_quant::ternary::TQ1_0, x)
1181            }
1182            QuantKind::Ptq1_0 => {
1183                frink_quant::ternary::dot_trits_f32(row, frink_quant::ternary::PTQ1_0, x)
1184            }
1185        }
1186    }
1187
1188    /// Per-kind full-buffer dequantization -- the row-lookup counterpart
1189    /// of `dot`'s fused per-kind dispatch below.
1190    fn dequant(kind: QuantKind, bytes: &[u8]) -> Vec<f32> {
1191        let out = match kind {
1192            QuantKind::Q8_0 => frink_quant::dequant_q8_0(bytes),
1193            QuantKind::Q4_0 => frink_quant::dequant_q4_0(bytes),
1194            QuantKind::Q4K => frink_quant::dequant_q4_k(bytes),
1195            QuantKind::Q5K => frink_quant::dequant_q5_k(bytes),
1196            QuantKind::Q6K => frink_quant::dequant_q6_k(bytes),
1197            QuantKind::Q2K => frink_quant::dequant_q2_k(bytes),
1198            QuantKind::Q3K => frink_quant::dequant_q3_k(bytes),
1199            QuantKind::Q4_1 => frink_quant::dequant_q4_1(bytes),
1200            QuantKind::Q5_0 => frink_quant::dequant_q5_0(bytes),
1201            QuantKind::Q5_1 => frink_quant::dequant_q5_1(bytes),
1202            QuantKind::Q8_1 => frink_quant::dequant_q8_1(bytes),
1203            QuantKind::IQ4NL => frink_quant::dequant_iq4_nl(bytes),
1204            QuantKind::IQ4XS => frink_quant::dequant_iq4_xs(bytes),
1205            QuantKind::IQ1S => frink_quant::dequant_iq1_s(bytes),
1206            QuantKind::IQ2XXS => frink_quant::dequant_iq2_xxs(bytes),
1207            QuantKind::IQ3XXS => frink_quant::dequant_iq3_xxs(bytes),
1208            QuantKind::IQ2XS => frink_quant::dequant_iq2_xs(bytes),
1209            QuantKind::IQ2S => frink_quant::dequant_iq2_s(bytes),
1210            QuantKind::IQ3S => frink_quant::dequant_iq3_s(bytes),
1211            QuantKind::IQ1M => frink_quant::dequant_iq1_m(bytes),
1212            QuantKind::Mxfp4Gguf => frink_quant::dequant_mxfp4_gguf(bytes),
1213            QuantKind::Tq1_0 => {
1214                frink_quant::ternary::dequant_trits(bytes, frink_quant::ternary::TQ1_0)
1215            }
1216            QuantKind::Ptq1_0 => {
1217                frink_quant::ternary::dequant_trits(bytes, frink_quant::ternary::PTQ1_0)
1218            }
1219        };
1220        out.expect("row byte length is block-aligned by construction (block_bytes_per_row)")
1221    }
1222
1223    /// Dequantizes exactly one row to f32, without touching any other
1224    /// row's bytes. This is what makes a *quantized* embedding table
1225    /// usable directly: token lookup reads `row_bytes` bytes and
1226    /// dequantizes `cols` values, instead of the whole vocabulary
1227    /// tensor ever being widened to f32 (which for a large-vocab model
1228    /// is a multi-GB allocation that exists only to be indexed one row
1229    /// at a time).
1230    pub fn dequant_row(&self, r: usize) -> Vec<f32> {
1231        assert!(r < self.rows(), "row {r} out of range ({})", self.rows());
1232        match self {
1233            WeightMatrix::F32(t) => t.row(r).to_vec(),
1234            WeightMatrix::Quantized {
1235                data, cols, kind, ..
1236            } => {
1237                let row_bytes = self.block_bytes_per_row(*kind, *cols);
1238                let bytes = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1239                let out = Self::dequant(*kind, bytes);
1240                debug_assert_eq!(out.len(), *cols);
1241                out
1242            }
1243            WeightMatrix::Mxfp4 {
1244                packed,
1245                scale,
1246                cols,
1247                ..
1248            } => {
1249                let packed_per_row = cols / 2;
1250                let scales_per_row = cols / frink_quant::MXFP4_GROUP_SIZE;
1251                let p = &packed.as_slice()[r * packed_per_row..(r + 1) * packed_per_row];
1252                let sc = &scale.as_slice()[r * scales_per_row..(r + 1) * scales_per_row];
1253                frink_quant::dequant_mxfp4_row(p, sc)
1254                    .expect("row slices are group-aligned by construction")
1255            }
1256            WeightMatrix::Adapted { base, lora } => {
1257                let mut row = base.dequant_row(r);
1258                lora.add_row_to(r, &mut row);
1259                row
1260            }
1261            // A stored row of a lookup table comes back to the primal
1262            // basis; a row of an input-folded weight stays folded, which
1263            // is what a caller splitting or widening the matrix needs.
1264            WeightMatrix::Folded { base, fold } => {
1265                let mut row = base.dequant_row(r);
1266                if fold.site == hadamard::FoldSite::RowLookup {
1267                    fold.restore_row(&mut row);
1268                }
1269                row
1270            }
1271        }
1272    }
1273
1274    /// Whether batching this matrix during prefill beats running the
1275    /// fused per-position dense-FFN launch once per token.
1276    ///
1277    /// Measured, not assumed. Every kind with a simdgroup GEMM
1278    /// (`*_mul_mm_sg`) batches: Q4_K, Q5_K, Q6_K, Q8_0, Q4_0, IQ4_XS.
1279    /// The remaining IQ codebook kinds have no GEMM, and their batched
1280    /// *matvec* loses to the fused per-position launch — IQ4_XS
1281    /// regressed 72.1 -> 33.2 on Llama-3.2-1B while it was in that
1282    /// state — so they keep the per-position path until a GEMM exists
1283    /// for them too.
1284    /// This matrix as a Metal simdgroup-GEMM descriptor, or `None` if
1285    /// its quant kind has no GEMM (so it must stay on the matvec path).
1286    /// Lets several matmuls be encoded into one command buffer instead
1287    /// of one launch each.
1288    #[cfg(feature = "metal")]
1289    pub fn mul_mm_sg_launch(&self) -> Option<frink_metal::gpu::MulMmSgLaunch<'_>> {
1290        let WeightMatrix::Quantized {
1291            data,
1292            rows,
1293            cols,
1294            kind,
1295        } = self
1296        else {
1297            return None;
1298        };
1299        let kind_name = match kind {
1300            QuantKind::Q8_0 => "Q8_0",
1301            QuantKind::Q4_0 => "Q4_0",
1302            QuantKind::Q5_0 => "Q5_0",
1303            QuantKind::Q4K => "Q4_K",
1304            QuantKind::Q5K => "Q5_K",
1305            QuantKind::Q6K => "Q6_K",
1306            QuantKind::IQ4XS => "IQ4_XS",
1307            QuantKind::Ptq1_0 => "PTQ1_0",
1308            _ => return None,
1309        };
1310        let (fn_name, block_bytes, block_elems) = frink_metal::gpu::mul_mm_sg_meta(kind_name)?;
1311        Some(frink_metal::gpu::MulMmSgLaunch {
1312            weights: data.as_slice(),
1313            rows: *rows,
1314            row_bytes: self.block_bytes_per_row(*kind, *cols),
1315            fn_name,
1316            block_bytes,
1317            block_elems,
1318        })
1319    }
1320
1321    /// This matrix as the CUDA batched GEMM takes it, or `None` for an
1322    /// adapted matrix (a LoRA delta lives in `WeightMatrix::Adapted` and
1323    /// no raw-bytes launch serves it) or a kind with no `mul_mm` row.
1324    /// The CUDA twin of [`Self::mul_mm_sg_launch`], and the ONE
1325    /// constructor of [`frink_cuda::prefill::MulMmWeights`]: the row
1326    /// byte count comes from the same `block_bytes_per_row` the matvec
1327    /// seam is held to, so a kind added there is a kind added here.
1328    #[cfg(feature = "cuda")]
1329    pub fn cuda_mul_mm_view(&self) -> Option<frink_cuda::prefill::MulMmWeights<'_>> {
1330        let WeightMatrix::Quantized {
1331            data,
1332            rows,
1333            cols,
1334            kind,
1335        } = self
1336        else {
1337            return None;
1338        };
1339        if !cuda_mul_mm_kind_supported(*kind) {
1340            return None;
1341        }
1342        let mm_kind = frink_cuda::mul_mm::kind_by_name(kind.name())?;
1343        Some(frink_cuda::prefill::MulMmWeights {
1344            kind: mm_kind,
1345            data: data.as_slice(),
1346            rows: *rows,
1347            cols: *cols,
1348            row_bytes: self.block_bytes_per_row(*kind, *cols),
1349        })
1350    }
1351
1352    #[cfg(any(feature = "metal", feature = "cuda"))]
1353    pub fn prefers_gpu_batch(&self) -> bool {
1354        !matches!(
1355            self.base(),
1356            WeightMatrix::Quantized {
1357                kind: QuantKind::IQ4NL
1358                    | QuantKind::IQ1S
1359                    | QuantKind::IQ2XXS
1360                    | QuantKind::IQ3XXS
1361                    | QuantKind::IQ2XS
1362                    | QuantKind::IQ2S
1363                    | QuantKind::IQ3S
1364                    | QuantKind::IQ1M,
1365                ..
1366            }
1367        )
1368    }
1369
1370    /// Computes `W @ x` for a single activation vector `x` of length
1371    /// `self.cols()`, returning a vector of length `self.rows()`.
1372    /// Parallelized over output rows with rayon, same decomposition as
1373    /// `matmul_f32`.
1374    ///
1375    /// With `--features metal` / `--features cuda`, when the matching
1376    /// dense GPU env selects a device (see [`metal_dense_enabled`] /
1377    /// [`cuda_dense_enabled`]), quantized kinds that have a GPU kernel
1378    /// go through [`Self::apply_gpu`] first so dense Llama-class
1379    /// decode uses the GPU instead of only MoE expert placement.
1380    pub fn apply(&self, x: &[f32]) -> Vec<f32> {
1381        assert_eq!(
1382            x.len(),
1383            self.cols(),
1384            "activation length must match matrix column count"
1385        );
1386        crate::activation_tap::observe(self, x, 1);
1387        if let WeightMatrix::Adapted { base, lora } = self {
1388            let mut out = base.apply(x);
1389            lora.add_to(x, &mut out);
1390            return out;
1391        }
1392        if let WeightMatrix::Folded { base, fold } = self {
1393            return base.apply(&fold.transform_input(x));
1394        }
1395        #[cfg(feature = "cuda")]
1396        {
1397            if cuda_dense_enabled() {
1398                if let Some(out) = self.apply_gpu(x) {
1399                    return out;
1400                }
1401            }
1402        }
1403        #[cfg(feature = "metal")]
1404        {
1405            if metal_dense_enabled() {
1406                if let Some(out) = self.apply_gpu(x) {
1407                    return out;
1408                }
1409            }
1410        }
1411        self.apply_cpu(x)
1412    }
1413
1414    /// [`Self::apply`] followed by `softcap_inplace(.., softcap)`, as
1415    /// ONE operation: Gemma-2's lm_head with its `final_logit_softcap`.
1416    ///
1417    /// On Metal the cap runs as an epilogue in the matvec's own command
1418    /// buffer (`frink_metal::gpu::MatvecEpilogue`), so the host never
1419    /// walks the 256k logits before sampling them: that walk was
1420    /// 0.65 ms per token, more than the whole encode phase (PR #202).
1421    /// Everywhere else, and whenever the Metal launch is refused or
1422    /// fails, it is the host multiply-tanh it always was. Either way
1423    /// the caller gets capped logits and never has to remember the cap.
1424    pub fn apply_softcapped(&self, x: &[f32], softcap: f32) -> Vec<f32> {
1425        #[cfg(feature = "metal")]
1426        if metal_dense_enabled() {
1427            if let WeightMatrix::Quantized {
1428                data,
1429                rows,
1430                cols,
1431                kind,
1432            } = self
1433            {
1434                if let Some(kind_name) = Metal::matvec_kernel(*kind) {
1435                    crate::activation_tap::observe(self, x, 1);
1436                    let row_bytes = self.block_bytes_per_row(*kind, *cols);
1437                    let epilogue = frink_metal::gpu::MatvecEpilogue {
1438                        softcap: Some(softcap),
1439                    };
1440                    match frink_metal::gpu::launch_matvec_kind_with(
1441                        kind_name,
1442                        data.as_slice(),
1443                        x,
1444                        *rows,
1445                        row_bytes,
1446                        epilogue,
1447                    ) {
1448                        Some(Ok(out)) => return out,
1449                        Some(Err(e)) => {
1450                            eprintln!(
1451                                "frink: Metal softcapped matvec failed, falling back to CPU: {e}"
1452                            );
1453                        }
1454                        None => {}
1455                    }
1456                }
1457            }
1458        }
1459        let mut out = self.apply(x);
1460        crate::matmul::softcap_inplace(&mut out, softcap);
1461        out
1462    }
1463
1464    /// CPU-only matvec (NEON/AVX/scalar via `frink-quant`). Used by
1465    /// [`Self::apply`] after Metal miss/disable, and by GPU parity tests
1466    /// that must not recurse into [`Self::apply_gpu`].
1467    /// Applies three independent matrices to the same activation,
1468    /// overlapping their parallel regions instead of running them one
1469    /// after another.
1470    ///
1471    /// Decode opens one rayon fork-join per weight matrix -- roughly
1472    /// seven per layer -- and the measured CPU decode deficit is
1473    /// scheduling, not kernels (frink scales 1.40x/2.93x from 1 to 6
1474    /// threads where llama.cpp scales 1.99x/4.39x, while *beating* llama
1475    /// at one thread). q/k/v share an input and are independent, so
1476    /// their regions can coexist and let rayon's work-stealing fill
1477    /// threads that would otherwise idle at the tail of each one.
1478    ///
1479    /// Under [`crate::par::Backend::Spin`] the three run one after the
1480    /// other instead: each already spreads across the whole persistent
1481    /// pool, and the reason to overlap them was to hide a fork-join that
1482    /// the persistent pool does not pay. That choice lives in
1483    /// [`crate::par::join3`], not here, so it cannot drift from the one
1484    /// in `frink-moe`'s gate/up pair.
1485    ///
1486    /// On a GPU backend the three go into ONE command buffer
1487    /// ([`Self::apply_gpu_multi`]: one upload of `x`, one wait) and fall
1488    /// back to three sequential launches when the fused path declines
1489    /// (a kind without a kernel, folds that differ). Each round trip a
1490    /// per-matvec model pays is ~0.2 ms of latency beyond the kernel
1491    /// (measured on an M2 Pro with `FRINK_METAL_GPU_TIMING`), which
1492    /// is what this saves.
1493    pub fn apply_three(a: &Self, b: &Self, c: &Self, x: &[f32]) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
1494        if Self::gpu_dense_active() {
1495            #[cfg(any(feature = "cuda", feature = "metal"))]
1496            if let Some(mut outs) = Self::apply_gpu_multi(&[a, b, c], x) {
1497                let c = outs.pop().unwrap();
1498                let b = outs.pop().unwrap();
1499                let a = outs.pop().unwrap();
1500                return (a, b, c);
1501            }
1502            return (a.apply(x), b.apply(x), c.apply(x));
1503        }
1504        crate::par::join3(|| a.apply(x), || b.apply(x), || c.apply(x))
1505    }
1506
1507    /// Two projections of one activation: [`Self::apply_three`] for a
1508    /// pair (a gated delta-net's `qkv` and `z`, a GLU's gate and up).
1509    pub fn apply_pair(a: &Self, b: &Self, x: &[f32]) -> (Vec<f32>, Vec<f32>) {
1510        if Self::gpu_dense_active() {
1511            #[cfg(any(feature = "cuda", feature = "metal"))]
1512            if let Some(mut outs) = Self::apply_gpu_multi(&[a, b], x) {
1513                let b = outs.pop().unwrap();
1514                let a = outs.pop().unwrap();
1515                return (a, b);
1516            }
1517            return (a.apply(x), b.apply(x));
1518        }
1519        crate::par::join2(|| a.apply(x), || b.apply(x))
1520    }
1521
1522    /// Whether a dense matvec would go to an accelerator right now.
1523    fn gpu_dense_active() -> bool {
1524        #[cfg(feature = "metal")]
1525        let gpu = metal_dense_enabled();
1526        #[cfg(not(feature = "metal"))]
1527        let gpu = false;
1528        #[cfg(feature = "cuda")]
1529        let gpu = gpu || cuda_dense_enabled();
1530        gpu
1531    }
1532
1533    pub fn apply_cpu(&self, x: &[f32]) -> Vec<f32> {
1534        assert_eq!(
1535            x.len(),
1536            self.cols(),
1537            "activation length must match matrix column count"
1538        );
1539        // Decode: one activation, so this operation is `rows x cols`
1540        // MACs and a task's share of it is (rows in task) x cols.
1541        // Publishing the shape is what lets both the scheduler choice
1542        // and the task floor be work-aware rather than row-count-aware.
1543        crate::par::with_op_work(self.rows(), x.len(), || self.apply_cpu_inner(x))
1544    }
1545
1546    fn apply_cpu_inner(&self, x: &[f32]) -> Vec<f32> {
1547        match self {
1548            WeightMatrix::F32(t) => {
1549                let xt = Tensor::new(x.to_vec(), vec![1, x.len()]);
1550                crate::matmul::matmul_f32(&xt, t).data
1551            }
1552            WeightMatrix::Quantized {
1553                data,
1554                rows,
1555                cols,
1556                kind,
1557            } => {
1558                let row_bytes = self.block_bytes_per_row(*kind, *cols);
1559                let mut out = vec![0f32; *rows];
1560                // FRINK_CPU_INT_DOT=1: quantize the shared activation once,
1561                // then every row dot is int8×int8 → i32 (llama.cpp CPU matmul).
1562                // Q8_0/Q4_0 use 32-elem Q8_0 acts; Q4_K/Q5_K/Q6_K use Q8_K.
1563                if cpu_int_dot_for(IntDotShape::Matvec) {
1564                    match *kind {
1565                        QuantKind::Q8_0 if x.len().is_multiple_of(32) => {
1566                            let act = frink_quant::quantize_activations_q8(x);
1567                            let n_groups = *rows / frink_quant::Q8_0X4_NROWS;
1568                            let serial = Self::prefer_serial_matvec(*rows, *cols);
1569                            // Probed once per matvec, not once per row-group:
1570                            // `q*_interleave` reads a CPU feature bit, and LLVM
1571                            // cannot hoist that relaxed atomic load out of the
1572                            // caller's loop. `is_aarch64_feature_detected!` ran
1573                            // 131k times in one Mistral-7B projection before the
1574                            // last one of these was hoisted.
1575                            let interleave = frink_quant::q8_0x4_interleave();
1576                            if n_groups > 0 {
1577                                let packed = get_or_repack_q8x4(data, *rows, *cols);
1578                                if serial {
1579                                    for (g, chunk) in out[..n_groups * frink_quant::Q8_0X4_NROWS]
1580                                        .chunks_mut(frink_quant::Q8_0X4_NROWS)
1581                                        .enumerate()
1582                                    {
1583                                        frink_quant::gemv_q8_0x4_group(
1584                                            &packed, g, &act, *cols, interleave, chunk,
1585                                        );
1586                                    }
1587                                } else {
1588                                    crate::par::chunks_mut(
1589                                        &mut out[..n_groups * frink_quant::Q8_0X4_NROWS],
1590                                        frink_quant::Q8_0X4_NROWS,
1591                                        Self::min_rows_per_task(n_groups).max(1),
1592                                        |g, chunk| {
1593                                            frink_quant::gemv_q8_0x4_group(
1594                                                &packed, g, &act, *cols, interleave, chunk,
1595                                            );
1596                                        },
1597                                    );
1598                                }
1599                                let data_slice = data.as_slice();
1600                                let tail_len = *rows - n_groups * frink_quant::Q8_0X4_NROWS;
1601                                if tail_len > 0 {
1602                                    let tail = &mut out[n_groups * frink_quant::Q8_0X4_NROWS..];
1603                                    if serial || Self::prefer_serial_matvec(tail_len, *cols) {
1604                                        for (i, o) in tail.iter_mut().enumerate() {
1605                                            let r = n_groups * frink_quant::Q8_0X4_NROWS + i;
1606                                            let row =
1607                                                &data_slice[r * row_bytes..(r + 1) * row_bytes];
1608                                            *o = frink_quant::dot_q8_0_q8(row, &act);
1609                                        }
1610                                    } else {
1611                                        let min_len = Self::min_rows_per_task(tail_len);
1612                                        crate::par::items_mut(tail, min_len, |i, o| {
1613                                            let r = n_groups * frink_quant::Q8_0X4_NROWS + i;
1614                                            let row =
1615                                                &data_slice[r * row_bytes..(r + 1) * row_bytes];
1616                                            *o = frink_quant::dot_q8_0_q8(row, &act);
1617                                        });
1618                                    }
1619                                }
1620                                return out;
1621                            }
1622                            if serial {
1623                                for (r, o) in out.iter_mut().enumerate() {
1624                                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1625                                    *o = frink_quant::dot_q8_0_q8(row, &act);
1626                                }
1627                            } else {
1628                                crate::par::items_mut(
1629                                    &mut out,
1630                                    Self::min_rows_per_task(*rows),
1631                                    |r, o| {
1632                                        let row =
1633                                            &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1634                                        *o = frink_quant::dot_q8_0_q8(row, &act);
1635                                    },
1636                                );
1637                            }
1638                            return out;
1639                        }
1640                        QuantKind::Q4_0 if x.len().is_multiple_of(32) => {
1641                            let act = frink_quant::quantize_activations_q8(x);
1642                            let n_groups = *rows / frink_quant::Q4_0X4_NROWS;
1643                            let serial = Self::prefer_serial_matvec(*rows, *cols);
1644                            // Probed once per matvec, not once per row-group:
1645                            // `q*_interleave` reads a CPU feature bit, and LLVM
1646                            // cannot hoist that relaxed atomic load out of the
1647                            // caller's loop. `is_aarch64_feature_detected!` ran
1648                            // 131k times in one Mistral-7B projection before the
1649                            // last one of these was hoisted.
1650                            let interleave = frink_quant::q4_0x4_interleave();
1651                            if n_groups > 0 {
1652                                let packed = get_or_repack_q4_0x4(data, *rows, *cols);
1653                                if serial {
1654                                    for (g, chunk) in out[..n_groups * frink_quant::Q4_0X4_NROWS]
1655                                        .chunks_mut(frink_quant::Q4_0X4_NROWS)
1656                                        .enumerate()
1657                                    {
1658                                        frink_quant::gemv_q4_0x4_group(
1659                                            &packed, g, &act, *cols, interleave, chunk,
1660                                        );
1661                                    }
1662                                } else {
1663                                    crate::par::chunks_mut(
1664                                        &mut out[..n_groups * frink_quant::Q4_0X4_NROWS],
1665                                        frink_quant::Q4_0X4_NROWS,
1666                                        Self::min_rows_per_task(n_groups).max(1),
1667                                        |g, chunk| {
1668                                            frink_quant::gemv_q4_0x4_group(
1669                                                &packed, g, &act, *cols, interleave, chunk,
1670                                            );
1671                                        },
1672                                    );
1673                                }
1674                                let data_slice = data.as_slice();
1675                                let tail_len = *rows - n_groups * frink_quant::Q4_0X4_NROWS;
1676                                if tail_len > 0 {
1677                                    let tail = &mut out[n_groups * frink_quant::Q4_0X4_NROWS..];
1678                                    if serial || Self::prefer_serial_matvec(tail_len, *cols) {
1679                                        for (i, o) in tail.iter_mut().enumerate() {
1680                                            let r = n_groups * frink_quant::Q4_0X4_NROWS + i;
1681                                            let row =
1682                                                &data_slice[r * row_bytes..(r + 1) * row_bytes];
1683                                            *o = frink_quant::dot_q4_0_q8(row, &act);
1684                                        }
1685                                    } else {
1686                                        let min_len = Self::min_rows_per_task(tail_len);
1687                                        crate::par::items_mut(tail, min_len, |i, o| {
1688                                            let r = n_groups * frink_quant::Q4_0X4_NROWS + i;
1689                                            let row =
1690                                                &data_slice[r * row_bytes..(r + 1) * row_bytes];
1691                                            *o = frink_quant::dot_q4_0_q8(row, &act);
1692                                        });
1693                                    }
1694                                }
1695                                return out;
1696                            }
1697                            if serial {
1698                                for (r, o) in out.iter_mut().enumerate() {
1699                                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1700                                    *o = frink_quant::dot_q4_0_q8(row, &act);
1701                                }
1702                            } else {
1703                                crate::par::items_mut(
1704                                    &mut out,
1705                                    Self::min_rows_per_task(*rows),
1706                                    |r, o| {
1707                                        let row =
1708                                            &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1709                                        *o = frink_quant::dot_q4_0_q8(row, &act);
1710                                    },
1711                                );
1712                            }
1713                            return out;
1714                        }
1715                        QuantKind::Q4K if x.len().is_multiple_of(256) => {
1716                            let act = frink_quant::quantize_activations_q8_k(x);
1717                            let n_groups = *rows / frink_quant::Q4_KX8_NROWS;
1718                            if n_groups > 0 {
1719                                let interleave = frink_quant::q4_kx8_interleave();
1720                                let packed = get_or_repack_q4k(data, *rows, *cols);
1721                                crate::par::chunks_mut(
1722                                    &mut out[..n_groups * frink_quant::Q4_KX8_NROWS],
1723                                    frink_quant::Q4_KX8_NROWS,
1724                                    Self::min_rows_per_task(n_groups).max(1),
1725                                    |g, chunk| {
1726                                        frink_quant::gemv_q4_kx8_group(
1727                                            &packed, g, &act, *cols, interleave, chunk,
1728                                        );
1729                                    },
1730                                );
1731                                let data_slice = data.as_slice();
1732                                crate::par::items_mut(
1733                                    &mut out[n_groups * frink_quant::Q4_KX8_NROWS..],
1734                                    Self::min_rows_per_task(
1735                                        *rows - n_groups * frink_quant::Q4_KX8_NROWS,
1736                                    ),
1737                                    |i, o| {
1738                                        let r = n_groups * frink_quant::Q4_KX8_NROWS + i;
1739                                        let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
1740                                        *o = frink_quant::dot_q4_k_q8(row, &act);
1741                                    },
1742                                );
1743                                return out;
1744                            }
1745                            crate::par::items_mut(
1746                                &mut out,
1747                                Self::min_rows_per_task(*rows),
1748                                |r, o| {
1749                                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1750                                    *o = frink_quant::dot_q4_k_q8(row, &act);
1751                                },
1752                            );
1753                            return out;
1754                        }
1755                        QuantKind::Q5K if x.len().is_multiple_of(256) => {
1756                            let act = frink_quant::quantize_activations_q8_k(x);
1757                            let n_groups = *rows / frink_quant::Q5_KX8_NROWS;
1758                            if n_groups > 0 {
1759                                let interleave = frink_quant::q5_kx8_interleave();
1760                                let packed = get_or_repack_q5k(data, *rows, *cols);
1761                                crate::par::chunks_mut(
1762                                    &mut out[..n_groups * frink_quant::Q5_KX8_NROWS],
1763                                    frink_quant::Q5_KX8_NROWS,
1764                                    Self::min_rows_per_task(n_groups).max(1),
1765                                    |g, chunk| {
1766                                        frink_quant::gemv_q5_kx8_group(
1767                                            &packed, g, &act, *cols, interleave, chunk,
1768                                        );
1769                                    },
1770                                );
1771                                let data_slice = data.as_slice();
1772                                crate::par::items_mut(
1773                                    &mut out[n_groups * frink_quant::Q5_KX8_NROWS..],
1774                                    Self::min_rows_per_task(
1775                                        *rows - n_groups * frink_quant::Q5_KX8_NROWS,
1776                                    ),
1777                                    |i, o| {
1778                                        let r = n_groups * frink_quant::Q5_KX8_NROWS + i;
1779                                        let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
1780                                        *o = frink_quant::dot_q5_k_q8(row, &act);
1781                                    },
1782                                );
1783                                return out;
1784                            }
1785                            crate::par::items_mut(
1786                                &mut out,
1787                                Self::min_rows_per_task(*rows),
1788                                |r, o| {
1789                                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1790                                    *o = frink_quant::dot_q5_k_q8(row, &act);
1791                                },
1792                            );
1793                            return out;
1794                        }
1795                        // IQ4_XS over Q8_K activations, llama.cpp's
1796                        // `ggml_vec_dot_iq4_xs_q8_K` (`frink_quant::
1797                        // iq4_xs_q8`): the same int8 lane the K-quants
1798                        // take, so decode and prefill agree on the
1799                        // activation quantization.
1800                        QuantKind::IQ4XS if x.len().is_multiple_of(256) => {
1801                            let act = frink_quant::quantize_activations_q8_k(x);
1802                            crate::par::items_mut(
1803                                &mut out,
1804                                Self::min_rows_per_task(*rows),
1805                                |r, o| {
1806                                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1807                                    *o = frink_quant::dot_iq4_xs_q8_k(row, &act);
1808                                },
1809                            );
1810                            return out;
1811                        }
1812                        QuantKind::Q6K if x.len().is_multiple_of(256) => {
1813                            let act = frink_quant::quantize_activations_q8_k(x);
1814                            let n_groups = *rows / frink_quant::Q6_KX8_NROWS;
1815                            if n_groups > 0 {
1816                                let interleave = frink_quant::q6_kx8_interleave();
1817                                let packed = get_or_repack_q6k(data, *rows, *cols);
1818                                crate::par::chunks_mut(
1819                                    &mut out[..n_groups * frink_quant::Q6_KX8_NROWS],
1820                                    frink_quant::Q6_KX8_NROWS,
1821                                    Self::min_rows_per_task(n_groups).max(1),
1822                                    |g, out8| {
1823                                        frink_quant::gemv_q6_kx8_group(
1824                                            &packed, g, &act, *cols, interleave, out8,
1825                                        );
1826                                    },
1827                                );
1828                                crate::par::items_mut(
1829                                    &mut out[n_groups * frink_quant::Q6_KX8_NROWS..],
1830                                    Self::min_rows_per_task(
1831                                        *rows - n_groups * frink_quant::Q6_KX8_NROWS,
1832                                    ),
1833                                    |i, o| {
1834                                        let r = n_groups * frink_quant::Q6_KX8_NROWS + i;
1835                                        let row =
1836                                            &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1837                                        *o = frink_quant::dot_q6_k_q8(row, &act);
1838                                    },
1839                                );
1840                                return out;
1841                            }
1842                            crate::par::items_mut(
1843                                &mut out,
1844                                Self::min_rows_per_task(*rows),
1845                                |r, o| {
1846                                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1847                                    *o = frink_quant::dot_q6_k_q8(row, &act);
1848                                },
1849                            );
1850                            return out;
1851                        }
1852                        _ => {}
1853                    }
1854                }
1855                crate::par::items_mut(&mut out, Self::min_rows_per_task(*rows), |r, o| {
1856                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1857                    *o = Self::dot(*kind, row, x);
1858                });
1859                out
1860            }
1861            WeightMatrix::Mxfp4 {
1862                packed,
1863                scale,
1864                rows,
1865                cols,
1866            } => {
1867                let packed_row_bytes = cols / 2;
1868                let scale_row_bytes = cols / frink_quant::MXFP4_GROUP_SIZE;
1869                let mut out = vec![0f32; *rows];
1870                crate::par::items_mut(&mut out, Self::min_rows_per_task(*rows), |r, o| {
1871                    let prow = &packed.as_slice()[r * packed_row_bytes..(r + 1) * packed_row_bytes];
1872                    let srow = &scale.as_slice()[r * scale_row_bytes..(r + 1) * scale_row_bytes];
1873                    *o = frink_quant::dot_mxfp4_row_f32(prow, srow, x);
1874                });
1875                out
1876            }
1877            WeightMatrix::Adapted { base, lora } => {
1878                let mut out = base.apply_cpu_inner(x);
1879                lora.add_to(x, &mut out);
1880                out
1881            }
1882            WeightMatrix::Folded { base, fold } => base.apply_cpu_inner(&fold.transform_input(x)),
1883        }
1884    }
1885
1886    /// INT_DOT matvec against a pre-quantized Q8_0 activation (shared gate/up).
1887    ///
1888    /// Publishes this operation's shape for exactly the same reason
1889    /// [`Self::apply_cpu`] does, and it matters more here: the dense FFN
1890    /// gate and up projections are the widest matvecs in a decode step,
1891    /// so they are the ones the scheduler rule is deciding about.
1892    pub fn apply_cpu_q8(&self, act: &frink_quant::Q8Activations) -> Option<Vec<f32>> {
1893        crate::par::with_op_work(self.rows(), self.cols(), || self.apply_cpu_q8_inner(act))
1894    }
1895
1896    /// [`Self::apply_cpu_q8`] with the operation's shape already
1897    /// published. Split only so the publish wraps every return path.
1898    fn apply_cpu_q8_inner(&self, act: &frink_quant::Q8Activations) -> Option<Vec<f32>> {
1899        let WeightMatrix::Quantized {
1900            data,
1901            rows,
1902            cols,
1903            kind,
1904        } = self
1905        else {
1906            return None;
1907        };
1908        if !matches!(*kind, QuantKind::Q8_0 | QuantKind::Q4_0)
1909            || !cpu_int_dot_for(IntDotShape::Matvec)
1910        {
1911            return None;
1912        }
1913        if act.q.len() != *cols || !cols.is_multiple_of(32) {
1914            return None;
1915        }
1916        let row_bytes = self.block_bytes_per_row(*kind, *cols);
1917        let mut out = vec![0f32; *rows];
1918        let kind = *kind;
1919        let bytes = data.as_slice();
1920        // Q8_0×4 / Q4_0×4 interleaved GEMV — same paths as `apply_cpu` so
1921        // dense FFN gate+up hit the fast kernels, not per-row int dots.
1922        if matches!(kind, QuantKind::Q8_0) {
1923            let n_groups = *rows / frink_quant::Q8_0X4_NROWS;
1924            if n_groups > 0 {
1925                let packed = get_or_repack_q8x4(data, *rows, *cols);
1926                let serial = Self::prefer_serial_matvec(*rows, *cols);
1927                // Probed once per matvec, not once per row-group:
1928                // `q*_interleave` reads a CPU feature bit, and LLVM
1929                // cannot hoist that relaxed atomic load out of the
1930                // caller's loop. `is_aarch64_feature_detected!` ran
1931                // 131k times in one Mistral-7B projection before the
1932                // last one of these was hoisted.
1933                let interleave = frink_quant::q8_0x4_interleave();
1934                let body = |g: usize, chunk: &mut [f32]| {
1935                    frink_quant::gemv_q8_0x4_group(&packed, g, act, *cols, interleave, chunk);
1936                };
1937                if serial {
1938                    for (g, chunk) in out[..n_groups * frink_quant::Q8_0X4_NROWS]
1939                        .chunks_mut(frink_quant::Q8_0X4_NROWS)
1940                        .enumerate()
1941                    {
1942                        body(g, chunk);
1943                    }
1944                } else {
1945                    crate::par::chunks_mut(
1946                        &mut out[..n_groups * frink_quant::Q8_0X4_NROWS],
1947                        frink_quant::Q8_0X4_NROWS,
1948                        Self::min_rows_per_task(n_groups).max(1),
1949                        |g, chunk| body(g, chunk),
1950                    );
1951                }
1952                let tail_len = *rows - n_groups * frink_quant::Q8_0X4_NROWS;
1953                if tail_len > 0 {
1954                    let tail = &mut out[n_groups * frink_quant::Q8_0X4_NROWS..];
1955                    if serial || Self::prefer_serial_matvec(tail_len, *cols) {
1956                        for (i, o) in tail.iter_mut().enumerate() {
1957                            let r = n_groups * frink_quant::Q8_0X4_NROWS + i;
1958                            *o = frink_quant::dot_q8_0_q8(
1959                                &bytes[r * row_bytes..(r + 1) * row_bytes],
1960                                act,
1961                            );
1962                        }
1963                    } else {
1964                        let min_len = Self::min_rows_per_task(tail_len);
1965                        crate::par::items_mut(tail, min_len, |i, o| {
1966                            let r = n_groups * frink_quant::Q8_0X4_NROWS + i;
1967                            *o = frink_quant::dot_q8_0_q8(
1968                                &bytes[r * row_bytes..(r + 1) * row_bytes],
1969                                act,
1970                            );
1971                        });
1972                    }
1973                }
1974                return Some(out);
1975            }
1976        }
1977        if matches!(kind, QuantKind::Q4_0) {
1978            let n_groups = *rows / frink_quant::Q4_0X4_NROWS;
1979            if n_groups > 0 {
1980                let packed = get_or_repack_q4_0x4(data, *rows, *cols);
1981                let serial = Self::prefer_serial_matvec(*rows, *cols);
1982                // Probed once per matvec, not once per row-group:
1983                // `q*_interleave` reads a CPU feature bit, and LLVM
1984                // cannot hoist that relaxed atomic load out of the
1985                // caller's loop. `is_aarch64_feature_detected!` ran
1986                // 131k times in one Mistral-7B projection before the
1987                // last one of these was hoisted.
1988                let interleave = frink_quant::q4_0x4_interleave();
1989                let body = |g: usize, chunk: &mut [f32]| {
1990                    frink_quant::gemv_q4_0x4_group(&packed, g, act, *cols, interleave, chunk);
1991                };
1992                if serial {
1993                    for (g, chunk) in out[..n_groups * frink_quant::Q4_0X4_NROWS]
1994                        .chunks_mut(frink_quant::Q4_0X4_NROWS)
1995                        .enumerate()
1996                    {
1997                        body(g, chunk);
1998                    }
1999                } else {
2000                    crate::par::chunks_mut(
2001                        &mut out[..n_groups * frink_quant::Q4_0X4_NROWS],
2002                        frink_quant::Q4_0X4_NROWS,
2003                        Self::min_rows_per_task(n_groups).max(1),
2004                        |g, chunk| body(g, chunk),
2005                    );
2006                }
2007                let tail_len = *rows - n_groups * frink_quant::Q4_0X4_NROWS;
2008                if tail_len > 0 {
2009                    let tail = &mut out[n_groups * frink_quant::Q4_0X4_NROWS..];
2010                    if serial || Self::prefer_serial_matvec(tail_len, *cols) {
2011                        for (i, o) in tail.iter_mut().enumerate() {
2012                            let r = n_groups * frink_quant::Q4_0X4_NROWS + i;
2013                            *o = frink_quant::dot_q4_0_q8(
2014                                &bytes[r * row_bytes..(r + 1) * row_bytes],
2015                                act,
2016                            );
2017                        }
2018                    } else {
2019                        let min_len = Self::min_rows_per_task(tail_len);
2020                        crate::par::items_mut(tail, min_len, |i, o| {
2021                            let r = n_groups * frink_quant::Q4_0X4_NROWS + i;
2022                            *o = frink_quant::dot_q4_0_q8(
2023                                &bytes[r * row_bytes..(r + 1) * row_bytes],
2024                                act,
2025                            );
2026                        });
2027                    }
2028                }
2029                return Some(out);
2030            }
2031        }
2032        if Self::prefer_serial_matvec(*rows, *cols) {
2033            for (r, o) in out.iter_mut().enumerate() {
2034                let row = &bytes[r * row_bytes..(r + 1) * row_bytes];
2035                *o = match kind {
2036                    QuantKind::Q8_0 => frink_quant::dot_q8_0_q8(row, act),
2037                    QuantKind::Q4_0 => frink_quant::dot_q4_0_q8(row, act),
2038                    _ => unreachable!(),
2039                };
2040            }
2041            return Some(out);
2042        }
2043        crate::par::items_mut(&mut out, Self::min_rows_per_task(*rows), |r, o| {
2044            let row = &bytes[r * row_bytes..(r + 1) * row_bytes];
2045            *o = match kind {
2046                QuantKind::Q8_0 => frink_quant::dot_q8_0_q8(row, act),
2047                QuantKind::Q4_0 => frink_quant::dot_q4_0_q8(row, act),
2048                _ => unreachable!(),
2049            };
2050        });
2051        Some(out)
2052    }
2053
2054    /// Two contiguous rows × one Q8 act (shared act loads). Q4_0 uses
2055    /// [`frink_quant::dot_q4_0_q8_2row`]; Q8_0 falls back to two singles.
2056    pub fn dot_pair_cpu_q8(
2057        &self,
2058        row: usize,
2059        act: &frink_quant::Q8Activations,
2060    ) -> Option<(f32, f32)> {
2061        let WeightMatrix::Quantized {
2062            data,
2063            rows,
2064            cols,
2065            kind,
2066        } = self
2067        else {
2068            return None;
2069        };
2070        if !matches!(*kind, QuantKind::Q8_0 | QuantKind::Q4_0)
2071            || !cpu_int_dot_for(IntDotShape::Matvec)
2072        {
2073            return None;
2074        }
2075        if act.q.len() != *cols || !cols.is_multiple_of(32) || row + 1 >= *rows {
2076            return None;
2077        }
2078        let row_bytes = self.block_bytes_per_row(*kind, *cols);
2079        let bytes = data.as_slice();
2080        let r0 = &bytes[row * row_bytes..(row + 1) * row_bytes];
2081        let r1 = &bytes[(row + 1) * row_bytes..(row + 2) * row_bytes];
2082        Some(match *kind {
2083            QuantKind::Q4_0 => frink_quant::dot_q4_0_q8_2row(r0, r1, act),
2084            QuantKind::Q8_0 => (
2085                frink_quant::dot_q8_0_q8(r0, act),
2086                frink_quant::dot_q8_0_q8(r1, act),
2087            ),
2088            _ => unreachable!(),
2089        })
2090    }
2091
2092    /// Single-row INT_DOT against pre-quantized Q8_0 acts (llama `mul_mat_id`
2093    /// inner loop). Returns `None` if this matrix is not Q4_0/Q8_0 INT_DOT.
2094    pub fn dot_row_cpu_q8(&self, row: usize, act: &frink_quant::Q8Activations) -> Option<f32> {
2095        let WeightMatrix::Quantized {
2096            data,
2097            rows,
2098            cols,
2099            kind,
2100        } = self
2101        else {
2102            return None;
2103        };
2104        if row >= *rows
2105            || !matches!(*kind, QuantKind::Q8_0 | QuantKind::Q4_0)
2106            || !cpu_int_dot_for(IntDotShape::Matvec)
2107            || act.q.len() != *cols
2108            || !cols.is_multiple_of(32)
2109        {
2110            return None;
2111        }
2112        let row_bytes = self.block_bytes_per_row(*kind, *cols);
2113        let bytes = &data.as_slice()[row * row_bytes..(row + 1) * row_bytes];
2114        Some(match *kind {
2115            QuantKind::Q8_0 => frink_quant::dot_q8_0_q8(bytes, act),
2116            QuantKind::Q4_0 => frink_quant::dot_q4_0_q8(bytes, act),
2117            _ => unreachable!(),
2118        })
2119    }
2120
2121    /// Computes `W @ X` for a *batch* of activation vectors at once:
2122    /// `x_batch` is `batch_size` rows of `self.cols()` elements each,
2123    /// flattened row-major; returns `batch_size` rows of
2124    /// `self.rows()` elements each, flattened row-major (`[batch,
2125    /// rows]`, matching the layout `Tensor`/`Decoder` expect for
2126    /// chaining into further matmuls).
2127    ///
2128    /// This is not just a convenience wrapper: for a quantized matrix,
2129    /// each weight row's bytes are read from memory *once* and dotted
2130    /// against every activation in the batch, instead of once per
2131    /// `apply` call. For a memory-bandwidth-bound quantized matmul --
2132    /// which fused Q8_0/Q4_0 dot products are, since the whole point of
2133    /// keeping weights quantized is that reading them is the
2134    /// bottleneck, not the arithmetic -- processing `batch_size`
2135    /// positions this way costs roughly the same *memory traffic* as
2136    /// processing one position, not `batch_size` times as much. This
2137    /// is the same reason speculative-decoding verification and batched
2138    /// prefill are faster per-token than sequential single-token decode
2139    /// on real hardware: it turns `batch_size` separate reads of the
2140    /// same weights into one.
2141    ///
2142    /// With Metal dense enabled, dispatches a single batched Metal
2143    /// command buffer — Q4_0/Q4_K/Q6_K/Q8_0 reuse the weights through a
2144    /// simdgroup `mul_mm` at `batch_size >= 4`; every other kind, and
2145    /// every smaller batch, uses
2146    /// [`frink_metal::gpu::launch_matvec_batch`]. Falls back to
2147    /// per-row [`Self::apply`] if the batch launch fails.
2148    pub fn apply_batch(&self, x_batch: &[f32], batch_size: usize) -> Vec<f32> {
2149        self.apply_batch_with_acts(x_batch, batch_size, None)
2150    }
2151
2152    /// Quantize `x_batch` once, in the activation format this matrix's
2153    /// INT_DOT batch path consumes, for sharing across every projection
2154    /// that reads the same input (q/k/v on one normed batch; gate/up on
2155    /// another). Returns `None` when [`Self::apply_batch`] would not use
2156    /// quantized activations for this matrix — GPU dispatch, INT_DOT off,
2157    /// unsupported kind or width — so callers can pass the result straight
2158    /// to [`Self::apply_batch_with_acts`] unconditionally.
2159    pub fn quantize_batch_acts(&self, x_batch: &[f32], batch_size: usize) -> Option<BatchActs> {
2160        if let WeightMatrix::Adapted { base, .. } = self {
2161            // The activations the BASE consumes; the delta reads the
2162            // f32 batch itself.
2163            return base.quantize_batch_acts(x_batch, batch_size);
2164        }
2165        if let WeightMatrix::Folded { .. } = self {
2166            // The base consumes the TRANSFORMED batch, which no sibling
2167            // matrix shares unless it shares the fold; `apply_batch`
2168            // quantizes its own.
2169            return None;
2170        }
2171        #[cfg(feature = "metal")]
2172        {
2173            if metal_dense_enabled()
2174                && matches!(
2175                    self,
2176                    WeightMatrix::Quantized { kind, .. } if Self::metal_kind_supported(*kind)
2177                )
2178            {
2179                return None;
2180            }
2181        }
2182        #[cfg(feature = "cuda")]
2183        {
2184            if cuda_dense_enabled() && matches!(self, WeightMatrix::Quantized { .. }) {
2185                return None;
2186            }
2187        }
2188        let WeightMatrix::Quantized { cols, kind, .. } = self else {
2189            return None;
2190        };
2191        if !cpu_int_dot_for(IntDotShape::BatchGemm) || x_batch.len() != batch_size * cols {
2192            return None;
2193        }
2194        let cols = *cols;
2195        match kind {
2196            QuantKind::Q8_0 | QuantKind::Q4_0 if cols.is_multiple_of(32) => {
2197                let acts: Vec<_> = (0..batch_size)
2198                    .into_par_iter()
2199                    .map(|b| {
2200                        frink_quant::quantize_activations_q8(&x_batch[b * cols..(b + 1) * cols])
2201                    })
2202                    .collect();
2203                // Q8_0 and Q4_0 agree on both the interleave width and the
2204                // predicate, so one tile set serves either consumer.
2205                let tiles =
2206                    if frink_quant::q8_0x4_gemm_uses_acts_x4(frink_quant::q8_0x4_interleave()) {
2207                        acts.par_chunks(frink_quant::Q8K_ACTS_X4_NC)
2208                            .map(|chunk| frink_quant::prepare_q8_acts_x4(chunk, cols))
2209                            .collect()
2210                    } else {
2211                        Vec::new()
2212                    };
2213                Some(BatchActs::Q8 { acts, tiles, cols })
2214            }
2215            QuantKind::Q4K | QuantKind::Q5K | QuantKind::Q6K if cols.is_multiple_of(256) => {
2216                let acts: Vec<_> = (0..batch_size)
2217                    .into_par_iter()
2218                    .map(|b| {
2219                        frink_quant::quantize_activations_q8_k(&x_batch[b * cols..(b + 1) * cols])
2220                    })
2221                    .collect();
2222                // All three K-quants share the predicate and the quad
2223                // width, so the set a Q4_K gate builds is exactly what a
2224                // Q5_K or Q6_K sibling would have built for itself.
2225                let tiles =
2226                    if frink_quant::q4_kx8_gemm_uses_acts_x4(frink_quant::q4_kx8_interleave()) {
2227                        acts.par_chunks(frink_quant::Q8K_ACTS_X4_NC)
2228                            .map(|chunk| frink_quant::prepare_q8_k_acts_x4(chunk, cols))
2229                            .collect()
2230                    } else {
2231                        Vec::new()
2232                    };
2233                Some(BatchActs::Q8K { acts, tiles, cols })
2234            }
2235            _ => None,
2236        }
2237    }
2238
2239    /// [`Self::apply_batch`], optionally reusing a shared pre-quantized
2240    /// activation batch from [`Self::quantize_batch_acts`]. A `shared`
2241    /// value whose format or length does not match this matrix is simply
2242    /// ignored (the activations are re-quantized locally), so mixed-kind
2243    /// projection groups stay correct.
2244    /// Two matrices of one batch, with a folded rotation applied ONCE
2245    /// when they share it.
2246    ///
2247    /// `gate` and `up` read the same batch, and so do a gated
2248    /// delta-net's `qkv` and `z`; each call to
2249    /// [`Self::apply_batch_with_acts`] rotates the batch for itself, so
2250    /// a folded pair transformed the same `[rows][n_embd]` block twice.
2251    /// The butterfly is the second-largest cost of a Bonsai prefill
2252    /// step (`sample`, beside the recurrence), and half of it was that
2253    /// repeat.
2254    ///
2255    /// Falls back to two independent calls whenever the pair does not
2256    /// share one fold, which is every unfolded model: there the cost
2257    /// being saved does not exist.
2258    pub fn apply_batch_pair_with_acts(
2259        a: &Self,
2260        b: &Self,
2261        x_batch: &[f32],
2262        batch_size: usize,
2263        shared: Option<&BatchActs>,
2264    ) -> (Vec<f32>, Vec<f32>) {
2265        if let (
2266            WeightMatrix::Folded {
2267                base: base_a,
2268                fold: fold_a,
2269            },
2270            WeightMatrix::Folded {
2271                base: base_b,
2272                fold: fold_b,
2273            },
2274        ) = (a, b)
2275        {
2276            if std::sync::Arc::ptr_eq(fold_a, fold_b) && batch_size > 0 {
2277                let transformed = fold_a.transform_rows(x_batch, batch_size);
2278                // `shared` describes the UNROTATED batch, so it cannot
2279                // be handed to a matrix reading the rotated one.
2280                return (
2281                    base_a.apply_batch_with_acts(&transformed, batch_size, None),
2282                    base_b.apply_batch_with_acts(&transformed, batch_size, None),
2283                );
2284            }
2285        }
2286        (
2287            a.apply_batch_with_acts(x_batch, batch_size, shared),
2288            b.apply_batch_with_acts(x_batch, batch_size, shared),
2289        )
2290    }
2291
2292    pub fn apply_batch_with_acts(
2293        &self,
2294        x_batch: &[f32],
2295        batch_size: usize,
2296        shared: Option<&BatchActs>,
2297    ) -> Vec<f32> {
2298        let cols = self.cols();
2299        assert_eq!(
2300            x_batch.len(),
2301            batch_size * cols,
2302            "x_batch length must be batch_size * cols"
2303        );
2304        if batch_size == 0 {
2305            return Vec::new();
2306        }
2307        crate::activation_tap::observe(self, x_batch, batch_size);
2308        if let WeightMatrix::Adapted { base, lora } = self {
2309            let mut out = base.apply_batch_with_acts(x_batch, batch_size, shared);
2310            lora.add_batch_to(x_batch, batch_size, &mut out);
2311            return out;
2312        }
2313        if let WeightMatrix::Folded { base, fold } = self {
2314            let transformed = fold.transform_rows(x_batch, batch_size);
2315            return base.apply_batch_with_acts(&transformed, batch_size, None);
2316        }
2317
2318        /// Raw pointer to this function's `[batch][rows]` output, shared
2319        /// across rayon tasks.
2320        ///
2321        /// Parallelism is over weight rows, but a row's `batch_size` output
2322        /// slots (`out[b * rows + r]` for every `b`) interleave with every
2323        /// other row's, so they cannot be handed out as disjoint `&mut`
2324        /// chunks. Each task writes only the rows it owns, which keeps the
2325        /// writes race-free; this wrapper just carries the pointer across
2326        /// the `Send`/`Sync` boundary. Writing straight into the final
2327        /// layout kills what used to be here: a `[rows][batch]` staging vec
2328        /// (zeroed every call) plus a serial rows × batch transpose after
2329        /// the parallel section had already finished.
2330        #[derive(Clone, Copy)]
2331        struct BatchOut(*mut f32);
2332        unsafe impl Send for BatchOut {}
2333        unsafe impl Sync for BatchOut {}
2334        impl BatchOut {
2335            /// Safety: `idx` in bounds, and concurrent tasks never pass
2336            /// the same `idx` (they own disjoint row sets).
2337            #[inline]
2338            unsafe fn set(self, idx: usize, v: f32) {
2339                *self.0.add(idx) = v;
2340            }
2341        }
2342
2343        #[cfg(feature = "metal")]
2344        {
2345            if metal_dense_enabled()
2346                && matches!(
2347                    self,
2348                    WeightMatrix::Quantized { kind, .. } if Self::metal_kind_supported(*kind)
2349                )
2350            {
2351                if let Some(out) = self.apply_gpu_batch(x_batch, batch_size) {
2352                    return out;
2353                }
2354                // The kind is Metal-supported, so reaching here means a
2355                // launch failed and the batch degrades to `batch_size`
2356                // separate `apply` calls -- each its own command buffer,
2357                // commit and wait.
2358                crate::kernel_registry::miss(
2359                    crate::kernel_registry::Lookup::new(
2360                        crate::kernel_registry::Backend::Metal,
2361                        crate::kernel_registry::op::GEMM_PREFILL,
2362                        self.quant_kind(),
2363                    ),
2364                    "N x apply (one command buffer each)",
2365                );
2366                let rows = self.rows();
2367                let mut out = vec![0f32; batch_size * rows];
2368                for b in 0..batch_size {
2369                    let y = self.apply(&x_batch[b * cols..(b + 1) * cols]);
2370                    out[b * rows..(b + 1) * rows].copy_from_slice(&y);
2371                }
2372                return out;
2373            } else if metal_dense_enabled() {
2374                // Metal is on but this matrix has no Metal kernel at
2375                // all, so the whole GEMM runs on the CPU. For a
2376                // quantized weight that is the IQ4_XS shape exactly; for
2377                // an F32 one it is the documented host GEMM.
2378                let look = crate::kernel_registry::Lookup::new(
2379                    crate::kernel_registry::Backend::Metal,
2380                    crate::kernel_registry::op::GEMM_PREFILL,
2381                    self.quant_kind(),
2382                );
2383                if self.quant_kind().is_some() {
2384                    crate::kernel_registry::miss(look, "CPU apply_batch");
2385                } else {
2386                    crate::kernel_registry::miss_by_design(look, "CPU f32 GEMM");
2387                }
2388            }
2389        }
2390
2391        // CUDA has a batched GEMM for every kind in
2392        // `frink_cuda::mul_mm::KINDS` (`cuda_mul_mm_kind_supported`),
2393        // and NO PART OF IT HAS RUN ON A GPU. Every other kind still
2394        // takes the per-position matvec loop below, which is the arm
2395        // that has -- for the six kinds that predate 2026-09-09.
2396        //
2397        // That loop is why this arm exists at all: without it a batched
2398        // prefill fell through to the CPU branch and never touched the
2399        // GPU -- measured on an RTX 4090, SmolLM2 `pp512` ran at 28
2400        // tok/s against llama.cpp's 57466. Per-position matvec is still
2401        // the wrong shape for a wide prefill, but it is the GPU rather
2402        // than 26 idle SMs, and the fallback now records a
2403        // `GEMM_PREFILL` miss instead of degrading silently.
2404        #[cfg(feature = "cuda")]
2405        {
2406            // The batched GEMM first, when the kind has one and the
2407            // batch is wide enough to pay for it. Below that threshold a
2408            // single token stays on the matvec kernels, which are the
2409            // arm that has actually run on a GPU.
2410            if cuda_dense_enabled() {
2411                if let WeightMatrix::Quantized { data, kind, .. } = self {
2412                    if cuda_mul_mm_kind_supported(*kind)
2413                        && frink_cuda::mul_mm::worth_a_gemm(batch_size)
2414                    {
2415                        let mm_kind = frink_cuda::mul_mm::kind_by_name(kind.name())
2416                            .expect("cuda_mul_mm_kind_supported agreed");
2417                        let row_bytes = self.block_bytes_per_row(*kind, cols);
2418                        match frink_cuda::mul_mm_launch::launch_mul_mm(
2419                            mm_kind,
2420                            data.as_slice(),
2421                            x_batch,
2422                            self.rows(),
2423                            cols,
2424                            batch_size,
2425                            row_bytes,
2426                        ) {
2427                            Ok(out) => return out,
2428                            Err(_) => {
2429                                // The kind HAS a GEMM, so reaching here is a
2430                                // launch failure rather than an unsupported
2431                                // kind, and the batch degrades to per-position
2432                                // matvecs. This call site used to be the one
2433                                // SILENT fallback in the registry's table.
2434                                crate::kernel_registry::miss(
2435                                    crate::kernel_registry::Lookup::new(
2436                                        crate::kernel_registry::Backend::Cuda,
2437                                        crate::kernel_registry::op::GEMM_PREFILL,
2438                                        self.quant_kind(),
2439                                    ),
2440                                    "N x matvec (the GEMM launch failed)",
2441                                );
2442                            }
2443                        }
2444                    }
2445                }
2446            }
2447            if cuda_dense_enabled()
2448                && matches!(self, WeightMatrix::Quantized { .. })
2449                && self.apply_gpu(&x_batch[..cols]).is_some()
2450            {
2451                let rows = self.rows();
2452                let mut out = vec![0f32; batch_size * rows];
2453                for b in 0..batch_size {
2454                    match self.apply_gpu(&x_batch[b * cols..(b + 1) * cols]) {
2455                        Some(y) => out[b * rows..(b + 1) * rows].copy_from_slice(&y),
2456                        None => {
2457                            let y = self.apply(&x_batch[b * cols..(b + 1) * cols]);
2458                            out[b * rows..(b + 1) * rows].copy_from_slice(&y);
2459                        }
2460                    }
2461                }
2462                return out;
2463            }
2464        }
2465
2466        match self {
2467            WeightMatrix::F32(t) => {
2468                let xt = Tensor::new(x_batch.to_vec(), vec![batch_size, cols]);
2469                crate::matmul::matmul_f32(&xt, t).data
2470            }
2471            WeightMatrix::Quantized {
2472                data,
2473                rows,
2474                cols: _,
2475                kind,
2476            } => {
2477                let row_bytes = self.block_bytes_per_row(*kind, cols);
2478                // Written directly in the [batch, rows] layout the function
2479                // returns: each parallel task owns a disjoint set of rows
2480                // `r` and scatters `out[b * rows + r]` for every `b`
2481                // through `BatchOut`.
2482                let mut out = vec![0f32; batch_size * rows];
2483                let out_w = BatchOut(out.as_mut_ptr());
2484
2485                // Prefill INT_DOT: quantize each activation once, then
2486                // reuse Q8 packs across all weight rows (llama CPU path).
2487                if cpu_int_dot_for(IntDotShape::BatchGemm) {
2488                    match *kind {
2489                        QuantKind::Q8_0 if cols.is_multiple_of(32) => {
2490                            let mut acts_owned = Vec::new();
2491                            let (acts, shared_tiles) =
2492                                Self::q8_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2493                            let n_groups = *rows / frink_quant::Q8_0X4_NROWS;
2494                            if n_groups > 0 {
2495                                let packed = get_or_repack_q8x4(data, *rows, cols);
2496                                let nrows_g = frink_quant::Q8_0X4_NROWS;
2497                                let interleave = frink_quant::q8_0x4_interleave();
2498                                if frink_quant::q8_0x4_gemm_uses_acts_x4(interleave) {
2499                                    // i8mm: interleave each quad of
2500                                    // activations once per matmul (llama.cpp
2501                                    // `ggml_quantize_mat_q8_0_4x8` into
2502                                    // `wdata`); every row-group reuses it.
2503                                    let nc = frink_quant::Q8K_ACTS_X4_NC;
2504                                    let tiles_owned: Vec<frink_quant::Q8ActsX4>;
2505                                    let act_tiles: &[frink_quant::Q8ActsX4] =
2506                                        if shared_tiles.is_empty() {
2507                                            tiles_owned = acts
2508                                                .par_chunks(nc)
2509                                                .map(|chunk| {
2510                                                    frink_quant::prepare_q8_acts_x4(chunk, cols)
2511                                                })
2512                                                .collect();
2513                                            &tiles_owned
2514                                        } else {
2515                                            shared_tiles
2516                                        };
2517                                    // One runtime i8mm probe per matmul, not
2518                                    // one per (row-group x quad); see
2519                                    // `frink_quant::AccelX4`.
2520                                    let accel = frink_quant::AccelX4::detect();
2521                                    Self::par_chunked_groups(
2522                                        n_groups,
2523                                        nrows_g,
2524                                        act_tiles.len(),
2525                                        nc,
2526                                        |g, t0, t1| {
2527                                            let mut tmp = [0f32;
2528                                                frink_quant::Q8_0X4_NROWS
2529                                                    * frink_quant::Q8K_ACTS_X4_NC];
2530                                            for (t, tile) in act_tiles[t0..t1].iter().enumerate() {
2531                                                let t = t0 + t;
2532                                                let n = tile.na;
2533                                                let tmp = &mut tmp[..nrows_g * n];
2534                                                frink_quant::gemm_q8_0x4_group_x4_on(
2535                                                    &packed, g, tile, cols, interleave, accel, tmp,
2536                                                );
2537                                                for j in 0..n {
2538                                                    let col = (t * nc + j) * rows + g * nrows_g;
2539                                                    for r in 0..nrows_g {
2540                                                        unsafe {
2541                                                            out_w.set(col + r, tmp[r * n + j]);
2542                                                        }
2543                                                    }
2544                                                }
2545                                            }
2546                                        },
2547                                    );
2548                                } else {
2549                                    // GEMM, not a GEMV per position: the
2550                                    // batched kernel writes a `[row][batch]`
2551                                    // span, and the group's weight vectors
2552                                    // stay in registers across a tile of
2553                                    // activations. The span is then scattered
2554                                    // into the [batch][rows] output right
2555                                    // here, in parallel.
2556                                    let span = frink_quant::Q8_0X4_GEMM_NC;
2557                                    let n_tiles = batch_size.div_ceil(span);
2558                                    Self::par_chunked_groups(
2559                                        n_groups,
2560                                        nrows_g,
2561                                        n_tiles,
2562                                        span,
2563                                        |g, t0, t1| {
2564                                            let b0 = t0 * span;
2565                                            let b1 = (t1 * span).min(batch_size);
2566                                            let n = b1 - b0;
2567                                            let mut group = vec![0f32; nrows_g * n];
2568                                            frink_quant::gemm_q8_0x4_group(
2569                                                &packed,
2570                                                g,
2571                                                &acts[b0..b1],
2572                                                cols,
2573                                                interleave,
2574                                                &mut group,
2575                                            );
2576                                            for (bi, b) in (b0..b1).enumerate() {
2577                                                for r in 0..nrows_g {
2578                                                    unsafe {
2579                                                        out_w.set(
2580                                                            b * rows + g * nrows_g + r,
2581                                                            group[r * n + bi],
2582                                                        );
2583                                                    }
2584                                                }
2585                                            }
2586                                        },
2587                                    );
2588                                }
2589                                let data_slice = data.as_slice();
2590                                let tail = *rows - n_groups * frink_quant::Q8_0X4_NROWS;
2591                                crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
2592                                    let r = n_groups * frink_quant::Q8_0X4_NROWS + i;
2593                                    let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2594                                    for (b, act) in acts.iter().enumerate() {
2595                                        unsafe {
2596                                            out_w.set(
2597                                                b * rows + r,
2598                                                frink_quant::dot_q8_0_q8(row, act),
2599                                            );
2600                                        }
2601                                    }
2602                                });
2603                            } else {
2604                                crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2605                                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
2606                                    for (b, act) in acts.iter().enumerate() {
2607                                        unsafe {
2608                                            out_w.set(
2609                                                b * rows + r,
2610                                                frink_quant::dot_q8_0_q8(row, act),
2611                                            );
2612                                        }
2613                                    }
2614                                });
2615                            }
2616                            return out;
2617                        }
2618                        QuantKind::Q4_0 if cols.is_multiple_of(32) => {
2619                            let mut acts_owned = Vec::new();
2620                            let (acts, shared_tiles) =
2621                                Self::q8_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2622                            let n_groups = *rows / frink_quant::Q4_0X4_NROWS;
2623                            if n_groups > 0 {
2624                                let packed = get_or_repack_q4_0x4(data, *rows, cols);
2625                                let nrows_g = frink_quant::Q4_0X4_NROWS;
2626                                let interleave = frink_quant::q4_0x4_interleave();
2627                                if frink_quant::q4_0x4_gemm_uses_acts_x4(interleave) {
2628                                    // i8mm: same once-per-matmul activation
2629                                    // quad hoist as the Q8_0 arm above.
2630                                    let nc = frink_quant::Q8K_ACTS_X4_NC;
2631                                    let tiles_owned: Vec<frink_quant::Q8ActsX4>;
2632                                    let act_tiles: &[frink_quant::Q8ActsX4] =
2633                                        if shared_tiles.is_empty() {
2634                                            tiles_owned = acts
2635                                                .par_chunks(nc)
2636                                                .map(|chunk| {
2637                                                    frink_quant::prepare_q8_acts_x4(chunk, cols)
2638                                                })
2639                                                .collect();
2640                                            &tiles_owned
2641                                        } else {
2642                                            shared_tiles
2643                                        };
2644                                    let accel = frink_quant::AccelX4::detect();
2645                                    Self::par_chunked_groups(
2646                                        n_groups,
2647                                        nrows_g,
2648                                        act_tiles.len(),
2649                                        nc,
2650                                        |g, t0, t1| {
2651                                            let mut tmp = [0f32;
2652                                                frink_quant::Q4_0X4_NROWS
2653                                                    * frink_quant::Q8K_ACTS_X4_NC];
2654                                            for (t, tile) in act_tiles[t0..t1].iter().enumerate() {
2655                                                let t = t0 + t;
2656                                                let n = tile.na;
2657                                                let tmp = &mut tmp[..nrows_g * n];
2658                                                frink_quant::gemm_q4_0x4_group_x4_on(
2659                                                    &packed, g, tile, cols, interleave, accel, tmp,
2660                                                );
2661                                                for j in 0..n {
2662                                                    let col = (t * nc + j) * rows + g * nrows_g;
2663                                                    for r in 0..nrows_g {
2664                                                        unsafe {
2665                                                            out_w.set(col + r, tmp[r * n + j]);
2666                                                        }
2667                                                    }
2668                                                }
2669                                            }
2670                                        },
2671                                    );
2672                                } else {
2673                                    // GEMM, not a GEMV per position: the
2674                                    // batched kernel writes a `[row][batch]`
2675                                    // span, and the group's weight vectors
2676                                    // stay in registers across a tile of
2677                                    // activations. The span is then scattered
2678                                    // into the [batch][rows] output right
2679                                    // here, in parallel.
2680                                    let span = frink_quant::Q8_0X4_GEMM_NC;
2681                                    let n_tiles = batch_size.div_ceil(span);
2682                                    Self::par_chunked_groups(
2683                                        n_groups,
2684                                        nrows_g,
2685                                        n_tiles,
2686                                        span,
2687                                        |g, t0, t1| {
2688                                            let b0 = t0 * span;
2689                                            let b1 = (t1 * span).min(batch_size);
2690                                            let n = b1 - b0;
2691                                            let mut group = vec![0f32; nrows_g * n];
2692                                            frink_quant::gemm_q4_0x4_group(
2693                                                &packed,
2694                                                g,
2695                                                &acts[b0..b1],
2696                                                cols,
2697                                                interleave,
2698                                                &mut group,
2699                                            );
2700                                            for (bi, b) in (b0..b1).enumerate() {
2701                                                for r in 0..nrows_g {
2702                                                    unsafe {
2703                                                        out_w.set(
2704                                                            b * rows + g * nrows_g + r,
2705                                                            group[r * n + bi],
2706                                                        );
2707                                                    }
2708                                                }
2709                                            }
2710                                        },
2711                                    );
2712                                }
2713                                let data_slice = data.as_slice();
2714                                let tail = *rows - n_groups * frink_quant::Q4_0X4_NROWS;
2715                                crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
2716                                    let r = n_groups * frink_quant::Q4_0X4_NROWS + i;
2717                                    let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2718                                    for (b, act) in acts.iter().enumerate() {
2719                                        unsafe {
2720                                            out_w.set(
2721                                                b * rows + r,
2722                                                frink_quant::dot_q4_0_q8(row, act),
2723                                            );
2724                                        }
2725                                    }
2726                                });
2727                            } else {
2728                                crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2729                                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
2730                                    for (b, act) in acts.iter().enumerate() {
2731                                        unsafe {
2732                                            out_w.set(
2733                                                b * rows + r,
2734                                                frink_quant::dot_q4_0_q8(row, act),
2735                                            );
2736                                        }
2737                                    }
2738                                });
2739                            }
2740                            return out;
2741                        }
2742                        QuantKind::Q4K if cols.is_multiple_of(256) => {
2743                            let mut acts_owned = Vec::new();
2744                            let (acts, shared_tiles) =
2745                                Self::q8k_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2746                            let n_groups = *rows / frink_quant::Q4_KX8_NROWS;
2747                            if n_groups > 0 {
2748                                let interleave = frink_quant::q4_kx8_interleave();
2749                                let packed = get_or_repack_q4k(data, *rows, cols);
2750                                let nc = frink_quant::Q4_KX8_GEMM_NC;
2751                                // On the i8mm path, interleave each quad of
2752                                // activations once per matmul (llama.cpp
2753                                // `ggml_quantize_mat_q8_K_4x8` into `wdata`);
2754                                // the kernel used to redo it per row-group.
2755                                // A `shared` batch has already paid for this
2756                                // on behalf of every sibling projection. The
2757                                // predicate is asked first either way: it,
2758                                // not the donor, decides whether this matrix
2759                                // has an x4 kernel at all.
2760                                let tiles_owned: Vec<frink_quant::Q8KActsX4>;
2761                                let act_tiles: &[frink_quant::Q8KActsX4] =
2762                                    if !frink_quant::q4_kx8_gemm_uses_acts_x4(interleave) {
2763                                        &[]
2764                                    } else if !shared_tiles.is_empty() {
2765                                        shared_tiles
2766                                    } else {
2767                                        tiles_owned = acts
2768                                            .par_chunks(nc)
2769                                            .map(|chunk| {
2770                                                frink_quant::prepare_q8_k_acts_x4(chunk, cols)
2771                                            })
2772                                            .collect();
2773                                        &tiles_owned
2774                                    };
2775                                let accel = frink_quant::AccelX4::detect();
2776                                let n_tiles = batch_size.div_ceil(nc);
2777                                Self::par_chunked_groups(
2778                                    n_groups,
2779                                    frink_quant::Q4_KX8_NROWS,
2780                                    n_tiles,
2781                                    nc,
2782                                    |g, t0, t1| {
2783                                        let mut tile = [0f32;
2784                                            frink_quant::Q4_KX8_NROWS * frink_quant::Q4_KX8_GEMM_NC];
2785                                        for t in t0..t1 {
2786                                            let chunk =
2787                                                &acts[t * nc..((t + 1) * nc).min(batch_size)];
2788                                            let n = chunk.len();
2789                                            let tile = &mut tile[..frink_quant::Q4_KX8_NROWS * n];
2790                                            if act_tiles.is_empty() {
2791                                                frink_quant::gemm_q4_kx8_group(
2792                                                    &packed, g, chunk, cols, interleave, tile,
2793                                                );
2794                                            } else {
2795                                                frink_quant::gemm_q4_kx8_group_x4_on(
2796                                                    &packed,
2797                                                    g,
2798                                                    &act_tiles[t],
2799                                                    cols,
2800                                                    interleave,
2801                                                    accel,
2802                                                    tile,
2803                                                );
2804                                            }
2805                                            for j in 0..n {
2806                                                let col = (t * nc + j) * rows
2807                                                    + g * frink_quant::Q4_KX8_NROWS;
2808                                                for r in 0..frink_quant::Q4_KX8_NROWS {
2809                                                    unsafe {
2810                                                        out_w.set(col + r, tile[r * n + j]);
2811                                                    }
2812                                                }
2813                                            }
2814                                        }
2815                                    },
2816                                );
2817                                let data_slice = data.as_slice();
2818                                let tail = *rows - n_groups * frink_quant::Q4_KX8_NROWS;
2819                                crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
2820                                    let r = n_groups * frink_quant::Q4_KX8_NROWS + i;
2821                                    let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2822                                    for (b, act) in acts.iter().enumerate() {
2823                                        unsafe {
2824                                            out_w.set(
2825                                                b * rows + r,
2826                                                frink_quant::dot_q4_k_q8(row, act),
2827                                            );
2828                                        }
2829                                    }
2830                                });
2831                            } else {
2832                                crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2833                                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
2834                                    for (b, act) in acts.iter().enumerate() {
2835                                        unsafe {
2836                                            out_w.set(
2837                                                b * rows + r,
2838                                                frink_quant::dot_q4_k_q8(row, act),
2839                                            );
2840                                        }
2841                                    }
2842                                });
2843                            }
2844                            return out;
2845                        }
2846                        QuantKind::Q5K if cols.is_multiple_of(256) => {
2847                            let mut acts_owned = Vec::new();
2848                            let (acts, shared_tiles) =
2849                                Self::q8k_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2850                            // The Kx8 batch path: every aarch64 host (i8mm,
2851                            // dotprod, or the scalar Kx8 body, as the Q4_K arm
2852                            // takes it), and any other host whose `x4` GEMM
2853                            // has a SIMD kernel at this width (AVX2 since
2854                            // #159). This read `cfg!(target_arch = "aarch64")`
2855                            // alone until 2026-09-15, which sent every x86
2856                            // Q5_K prefill through the per-row GEMM below:
2857                            // 44.2 against llama.cpp's 378.7 tok/s on
2858                            // Llama-3.2-1B Q5_K_M (8.56x) on a Ryzen 5950X,
2859                            // beside Q4_K at 1.19x and Q6_K at 1.19x on the
2860                            // same host, because those two arms asked the
2861                            // kernels and this one asked the architecture.
2862                            let interleave = frink_quant::q5_kx8_interleave();
2863                            let use_kx8 = q5k_batch_takes_kx8(interleave);
2864                            let n_groups = if use_kx8 {
2865                                *rows / frink_quant::Q5_KX8_NROWS
2866                            } else {
2867                                0
2868                            };
2869                            if n_groups > 0 {
2870                                let packed = get_or_repack_q5k(data, *rows, cols);
2871                                let nc = frink_quant::Q5_KX8_GEMM_NC;
2872                                // On the i8mm path, interleave each quad of
2873                                // activations once per matmul; the kernel
2874                                // consumes it for every row-group. A `shared`
2875                                // batch has already paid for it. Predicate
2876                                // first, as in the Q4_K arm.
2877                                let tiles_owned: Vec<frink_quant::Q8KActsX4>;
2878                                let act_tiles: &[frink_quant::Q8KActsX4] =
2879                                    if !frink_quant::q5_kx8_gemm_uses_acts_x4(interleave) {
2880                                        &[]
2881                                    } else if !shared_tiles.is_empty() {
2882                                        shared_tiles
2883                                    } else {
2884                                        tiles_owned = acts
2885                                            .par_chunks(nc)
2886                                            .map(|chunk| {
2887                                                frink_quant::prepare_q8_k_acts_x4(chunk, cols)
2888                                            })
2889                                            .collect();
2890                                        &tiles_owned
2891                                    };
2892                                let accel = frink_quant::AccelX4::detect();
2893                                let n_tiles = batch_size.div_ceil(nc);
2894                                Self::par_chunked_groups(
2895                                    n_groups,
2896                                    frink_quant::Q5_KX8_NROWS,
2897                                    n_tiles,
2898                                    nc,
2899                                    |g, t0, t1| {
2900                                        let mut tile = [0f32;
2901                                            frink_quant::Q5_KX8_NROWS * frink_quant::Q5_KX8_GEMM_NC];
2902                                        for t in t0..t1 {
2903                                            let chunk =
2904                                                &acts[t * nc..((t + 1) * nc).min(batch_size)];
2905                                            let n = chunk.len();
2906                                            let tile = &mut tile[..frink_quant::Q5_KX8_NROWS * n];
2907                                            if act_tiles.is_empty() {
2908                                                frink_quant::gemm_q5_kx8_group(
2909                                                    &packed, g, chunk, cols, interleave, tile,
2910                                                );
2911                                            } else {
2912                                                frink_quant::gemm_q5_kx8_group_x4_on(
2913                                                    &packed,
2914                                                    g,
2915                                                    &act_tiles[t],
2916                                                    cols,
2917                                                    interleave,
2918                                                    accel,
2919                                                    tile,
2920                                                );
2921                                            }
2922                                            for j in 0..n {
2923                                                let col = (t * nc + j) * rows
2924                                                    + g * frink_quant::Q5_KX8_NROWS;
2925                                                for r in 0..frink_quant::Q5_KX8_NROWS {
2926                                                    unsafe {
2927                                                        out_w.set(col + r, tile[r * n + j]);
2928                                                    }
2929                                                }
2930                                            }
2931                                        }
2932                                    },
2933                                );
2934                                let data_slice = data.as_slice();
2935                                let tail = *rows - n_groups * frink_quant::Q5_KX8_NROWS;
2936                                crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
2937                                    let r = n_groups * frink_quant::Q5_KX8_NROWS + i;
2938                                    let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2939                                    for (b, act) in acts.iter().enumerate() {
2940                                        unsafe {
2941                                            out_w.set(
2942                                                b * rows + r,
2943                                                frink_quant::dot_q5_k_q8(row, act),
2944                                            );
2945                                        }
2946                                    }
2947                                });
2948                            } else {
2949                                let data_slice = data.as_slice();
2950                                crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2951                                    let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2952                                    let nc = frink_quant::Q5_K_GEMM_NC;
2953                                    for (t, chunk) in acts.chunks(nc).enumerate() {
2954                                        let n = chunk.len();
2955                                        let mut tmp = [0f32; frink_quant::Q5_K_GEMM_NC];
2956                                        frink_quant::gemm_q5_k_q8_row(row, chunk, &mut tmp[..n]);
2957                                        for (j, v) in tmp[..n].iter().enumerate() {
2958                                            unsafe {
2959                                                out_w.set((t * nc + j) * rows + r, *v);
2960                                            }
2961                                        }
2962                                    }
2963                                });
2964                            }
2965                            return out;
2966                        }
2967                        QuantKind::Q6K if cols.is_multiple_of(256) => {
2968                            let mut acts_owned = Vec::new();
2969                            let (acts, shared_tiles) =
2970                                Self::q8k_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2971                            // Kx8 batch path only where the i8mm GEMM
2972                            // exists (the scalar Kx8 GEMM measured slower
2973                            // than the per-row NEON dot on Phi ffn_down,
2974                            // so everything else keeps the row path).
2975                            let interleave = frink_quant::q6_kx8_interleave();
2976                            let use_kx8 = frink_quant::q6_kx8_gemm_uses_acts_x4(interleave);
2977                            let n_groups = if use_kx8 {
2978                                *rows / frink_quant::Q6_KX8_NROWS
2979                            } else {
2980                                0
2981                            };
2982                            if n_groups > 0 {
2983                                let packed = get_or_repack_q6k(data, *rows, cols);
2984                                // Quads of 4 (the i8mm tile shape), not
2985                                // [`Q6_KX8_GEMM_NC`].
2986                                let nc = frink_quant::Q8K_ACTS_X4_NC;
2987                                let tiles_owned: Vec<frink_quant::Q8KActsX4>;
2988                                let act_tiles: &[frink_quant::Q8KActsX4] = if shared_tiles
2989                                    .is_empty()
2990                                {
2991                                    tiles_owned = acts
2992                                        .par_chunks(nc)
2993                                        .map(|chunk| frink_quant::prepare_q8_k_acts_x4(chunk, cols))
2994                                        .collect();
2995                                    &tiles_owned
2996                                } else {
2997                                    shared_tiles
2998                                };
2999                                let accel = frink_quant::AccelX4::detect();
3000                                let n_tiles = batch_size.div_ceil(nc);
3001                                Self::par_chunked_groups(
3002                                    n_groups,
3003                                    frink_quant::Q6_KX8_NROWS,
3004                                    n_tiles,
3005                                    nc,
3006                                    |g, t0, t1| {
3007                                        let mut tile = [0f32;
3008                                            frink_quant::Q6_KX8_NROWS * frink_quant::Q8K_ACTS_X4_NC];
3009                                        for t in t0..t1 {
3010                                            let chunk =
3011                                                &acts[t * nc..((t + 1) * nc).min(batch_size)];
3012                                            let n = chunk.len();
3013                                            let tile = &mut tile[..frink_quant::Q6_KX8_NROWS * n];
3014                                            frink_quant::gemm_q6_kx8_group_x4_on(
3015                                                &packed,
3016                                                g,
3017                                                &act_tiles[t],
3018                                                cols,
3019                                                interleave,
3020                                                accel,
3021                                                tile,
3022                                            );
3023                                            for j in 0..n {
3024                                                let col = (t * nc + j) * rows
3025                                                    + g * frink_quant::Q6_KX8_NROWS;
3026                                                for r in 0..frink_quant::Q6_KX8_NROWS {
3027                                                    unsafe {
3028                                                        out_w.set(col + r, tile[r * n + j]);
3029                                                    }
3030                                                }
3031                                            }
3032                                        }
3033                                    },
3034                                );
3035                                let data_slice = data.as_slice();
3036                                let tail = *rows - n_groups * frink_quant::Q6_KX8_NROWS;
3037                                crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
3038                                    let r = n_groups * frink_quant::Q6_KX8_NROWS + i;
3039                                    let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
3040                                    for (b, act) in acts.iter().enumerate() {
3041                                        unsafe {
3042                                            out_w.set(
3043                                                b * rows + r,
3044                                                frink_quant::dot_q6_k_q8(row, act),
3045                                            );
3046                                        }
3047                                    }
3048                                });
3049                            } else {
3050                                let data_slice = data.as_slice();
3051                                crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
3052                                    let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
3053                                    let nc = frink_quant::Q6_K_GEMM_NC;
3054                                    for (t, chunk) in acts.chunks(nc).enumerate() {
3055                                        let mut tmp = [0f32; frink_quant::Q6_K_GEMM_NC];
3056                                        let n = chunk.len();
3057                                        frink_quant::gemm_q6_k_q8_row(row, chunk, &mut tmp[..n]);
3058                                        for (j, v) in tmp[..n].iter().enumerate() {
3059                                            unsafe {
3060                                                out_w.set((t * nc + j) * rows + r, *v);
3061                                            }
3062                                        }
3063                                    }
3064                                });
3065                            }
3066                            return out;
3067                        }
3068                        // IQ4_XS: quantize the activations to Q8_K ONCE
3069                        // per matmul and run the int8 dot per (row,
3070                        // activation). No Kx8 tier, so the row's nibbles
3071                        // are still decoded per activation, as llama.cpp's
3072                        // own IQ4_XS prefill decodes them; what the f32
3073                        // fallback below paid on top was an f32 FMA per
3074                        // element and a per-activation f32 read of the
3075                        // row, measured 4.45x behind llama.cpp on a Ryzen
3076                        // 9 3900X (2026-09-15) where every K-quant on the
3077                        // same host was 1.0x to 1.4x.
3078                        QuantKind::IQ4XS if cols.is_multiple_of(256) => {
3079                            let mut acts_owned = Vec::new();
3080                            let (acts, _) =
3081                                Self::q8k_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
3082                            let data_slice = data.as_slice();
3083                            crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
3084                                let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
3085                                for (b, act) in acts.iter().enumerate() {
3086                                    unsafe {
3087                                        out_w.set(
3088                                            b * rows + r,
3089                                            frink_quant::dot_iq4_xs_q8_k(row, act),
3090                                        );
3091                                    }
3092                                }
3093                            });
3094                            return out;
3095                        }
3096                        QuantKind::Q5K | QuantKind::Q6K => {}
3097                        _ => {}
3098                    }
3099                }
3100
3101                crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
3102                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
3103                    for b in 0..batch_size {
3104                        let x = &x_batch[b * cols..(b + 1) * cols];
3105                        unsafe {
3106                            out_w.set(b * rows + r, Self::dot(*kind, row, x));
3107                        }
3108                    }
3109                });
3110                out
3111            }
3112            WeightMatrix::Mxfp4 {
3113                packed,
3114                scale,
3115                rows,
3116                cols: _,
3117            } => {
3118                let packed_row_bytes = cols / 2;
3119                let scale_row_bytes = cols / frink_quant::MXFP4_GROUP_SIZE;
3120                let mut out = vec![0f32; batch_size * rows];
3121                let out_w = BatchOut(out.as_mut_ptr());
3122                crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
3123                    let prow = &packed.as_slice()[r * packed_row_bytes..(r + 1) * packed_row_bytes];
3124                    let srow = &scale.as_slice()[r * scale_row_bytes..(r + 1) * scale_row_bytes];
3125                    for b in 0..batch_size {
3126                        let x = &x_batch[b * cols..(b + 1) * cols];
3127                        unsafe {
3128                            out_w.set(b * rows + r, frink_quant::dot_mxfp4_row_f32(prow, srow, x));
3129                        }
3130                    }
3131                });
3132                out
3133            }
3134            WeightMatrix::Adapted { .. } | WeightMatrix::Folded { .. } => {
3135                unreachable!("handled before dispatch")
3136            }
3137        }
3138    }
3139
3140    /// Bytes actually resident in memory for this matrix -- the number
3141    /// that matters for "can this model's weights fit in RAM/VRAM at
3142    /// all," as opposed to the always-4x-larger f32-expanded size.
3143    pub fn resident_bytes(&self) -> usize {
3144        match self {
3145            WeightMatrix::F32(t) => t.len() * 4,
3146            WeightMatrix::Quantized { data, .. } => data.len(),
3147            WeightMatrix::Mxfp4 { packed, scale, .. } => packed.len() + scale.len(),
3148            WeightMatrix::Adapted { base, lora } => base.resident_bytes() + lora.resident_bytes(),
3149            WeightMatrix::Folded { base, .. } => base.resident_bytes(),
3150        }
3151    }
3152
3153    /// Dispatches a single matvec through a real GPU kernel when a GPU
3154    /// feature is compiled in (`cuda` and/or `metal`) and this matrix
3155    /// is one of the five GPU-accelerated quant kinds (Q8_0, Q4_0,
3156    /// Q4_K, Q5_K, Q6_K). Returns `None` for every other case (no GPU
3157    /// feature, `F32`/`Mxfp4`/`Mxfp4Gguf`, or a `Quantized` kind other
3158    /// than the five below), so the caller falls back to `apply()` on
3159    /// the CPU -- this is a real dispatch decision
3160    /// (`frink_moe::run_expert_placed` uses it exactly this way), not
3161    /// a stub. Metal weight buffers are process-resident after the first
3162    /// upload (`frink_metal::gpu` weight cache); activations still
3163    /// upload per call. When both `cuda` and `metal` are enabled, CUDA
3164    /// is tried first and Metal is the fallback.
3165    #[cfg(any(feature = "cuda", feature = "metal", feature = "vulkan"))]
3166    pub fn apply_gpu(&self, x: &[f32]) -> Option<Vec<f32>> {
3167        assert_eq!(
3168            x.len(),
3169            self.cols(),
3170            "activation length must match matrix column count"
3171        );
3172        if let WeightMatrix::Adapted { base, lora } = self {
3173            let mut out = base.apply_gpu(x)?;
3174            lora.add_to(x, &mut out);
3175            return Some(out);
3176        }
3177        if let WeightMatrix::Folded { base, fold } = self {
3178            // The rotation goes into the matvec's own command buffer
3179            // when Metal can serve it, so the host neither runs the
3180            // butterfly nor ships a second vector. Every other backend
3181            // takes the host transform, which is the definition both
3182            // sides are checked against.
3183            #[cfg(feature = "metal")]
3184            if metal_dense_enabled() {
3185                if let Some(plan) = fold.metal_plan(x.len()) {
3186                    if let Some(out) = base.apply_gpu_folded(x, Some(&plan)) {
3187                        return Some(out);
3188                    }
3189                }
3190            }
3191            return base.apply_gpu(&fold.transform_input(x));
3192        }
3193
3194        // F32 stays on CPU in apply_gpu: a lone small router matvec is
3195        // faster as host GEMV than a Metal sync. F32 Metal launches are
3196        // used when fused into MoE resident decode (encode_matvec).
3197        let WeightMatrix::Quantized {
3198            data,
3199            rows,
3200            cols,
3201            kind,
3202        } = self
3203        else {
3204            // Deliberate, and recorded rather than hidden: an MoE
3205            // router is a lone small F32 matvec that costs more to ship
3206            // to the GPU than to compute on the host.
3207            let backend = active_backend();
3208            if backend.is_accelerator() {
3209                crate::kernel_registry::miss_by_design(
3210                    crate::kernel_registry::Lookup::new(
3211                        backend,
3212                        crate::kernel_registry::op::MATVEC,
3213                        None,
3214                    ),
3215                    "host GEMV",
3216                );
3217            }
3218            return None;
3219        };
3220        let row_bytes = self.block_bytes_per_row(*kind, *cols);
3221
3222        // One body per backend, expanded over the one ordered list, in
3223        // place of the two hand-kept `match kind` tables this used to
3224        // hold -- which differed in arity, in error type, and (silently)
3225        // by one entry. A third backend adds no code here.
3226        #[allow(unused_macros)]
3227        macro_rules! try_matvec {
3228            ($b:ty) => {
3229                if let Some(result) = <$b as BackendDispatch>::launch_matvec(
3230                    *kind,
3231                    data.as_slice(),
3232                    x,
3233                    *rows,
3234                    row_bytes,
3235                ) {
3236                    match result {
3237                        Ok(out) => return Some(out),
3238                        Err(e) => {
3239                            eprintln!(
3240                                "frink: {} matvec dispatch failed, {}: {e}",
3241                                <$b as BackendCaps>::NAME,
3242                                <$b as BackendDispatch>::MATVEC_FALLBACK
3243                            );
3244                        }
3245                    }
3246                }
3247            };
3248        }
3249        with_gpu_backends!(try_matvec);
3250
3251        // Reached only on a miss or a launch error, i.e. only when the
3252        // caller is about to run the whole matvec on the host anyway --
3253        // so recording it here costs nothing measurable and is the only
3254        // signal that a GPU run is quietly not one.
3255        let backend = active_backend();
3256        if backend.is_accelerator() {
3257            crate::kernel_registry::miss(
3258                crate::kernel_registry::Lookup::new(
3259                    backend,
3260                    crate::kernel_registry::op::MATVEC,
3261                    Some(*kind),
3262                ),
3263                "CPU apply_cpu",
3264            );
3265        }
3266        None
3267    }
3268
3269    /// Runs several independent matvecs that share the same activation
3270    /// `x` in one GPU dispatch (one upload of `x`, one wait). Tries
3271    /// CUDA first (when `cuda_dense_enabled()`), then Metal (when
3272    /// `metal_dense_enabled()`). Intended for Q/K/V (and similar)
3273    /// projections. Returns `None` if no GPU backend is enabled, any
3274    /// matrix lacks a GPU kernel, or all fused launches fail — caller
3275    /// should fall back to sequential [`Self::apply`].
3276    #[cfg(any(feature = "cuda", feature = "metal"))]
3277    pub fn apply_gpu_multi(mats: &[&WeightMatrix], x: &[f32]) -> Option<Vec<Vec<f32>>> {
3278        Self::apply_gpu_multi_folded(mats, x, None)
3279    }
3280
3281    /// One matrix through the fused path, with an optional device-side
3282    /// Hadamard prologue. The single-matrix caller of
3283    /// [`Self::apply_gpu_multi_folded`], so a folded projection and a
3284    /// folded q/k/v triple take the same launch.
3285    #[cfg(feature = "metal")]
3286    pub(crate) fn apply_gpu_folded(
3287        &self,
3288        x: &[f32],
3289        fold: Option<&frink_metal::hadamard::FoldPlan<'_>>,
3290    ) -> Option<Vec<f32>> {
3291        Self::apply_gpu_multi_folded(&[self], x, fold).map(|mut outs| outs.pop().unwrap())
3292    }
3293
3294    /// [`Self::apply_gpu_multi`] with PrismML's folded rotation applied
3295    /// to `x` on the device before the matvecs read it.
3296    ///
3297    /// `fold` is Metal-only: the rotation kernel is
3298    /// `frink_metal::hadamard`, and a CUDA or Vulkan caller takes the
3299    /// host butterfly rather than a rotation nothing there implements.
3300    #[cfg(any(feature = "cuda", feature = "metal"))]
3301    pub(crate) fn apply_gpu_multi_folded(
3302        mats: &[&WeightMatrix],
3303        x: &[f32],
3304        #[cfg(feature = "metal")] fold: Option<&frink_metal::hadamard::FoldPlan<'_>>,
3305        #[cfg(not(feature = "metal"))] fold: Option<&()>,
3306    ) -> Option<Vec<Vec<f32>>> {
3307        // Off Metal there is no rotation kernel, so `None` is the only
3308        // value this can take and nothing below reads it. Bound here
3309        // rather than renamed, because the parameter is part of one
3310        // signature both builds call.
3311        #[cfg(not(feature = "metal"))]
3312        let _ = &fold;
3313        if mats.is_empty() {
3314            return None;
3315        }
3316        assert_eq!(
3317            x.len(),
3318            mats[0].cols(),
3319            "activation length must match matrix column count"
3320        );
3321        if mats.iter().any(|m| m.lora().is_some()) {
3322            // The fused launch runs over the bases; each adapter's
3323            // delta is added to its own output on the host.
3324            let bases: Vec<&WeightMatrix> = mats.iter().map(|m| m.base()).collect();
3325            let mut outs = Self::apply_gpu_multi(&bases, x)?;
3326            for (m, out) in mats.iter().zip(outs.iter_mut()) {
3327                if let Some(lora) = m.lora() {
3328                    lora.add_to(x, out);
3329                }
3330            }
3331            return Some(outs);
3332        }
3333        if let Some(fold) = mats[0].hadamard_fold() {
3334            // One fold shared by every matrix (q/k/v split from one folded
3335            // fused projection): transform once, launch the bases.
3336            // Different folds, or a fold on only some, is not a shared
3337            // activation any more, and the caller runs them one by one.
3338            if !mats.iter().all(|m| {
3339                m.hadamard_fold()
3340                    .is_some_and(|f| std::sync::Arc::ptr_eq(f, fold))
3341            }) {
3342                return None;
3343            }
3344            let bases: Vec<&WeightMatrix> = mats
3345                .iter()
3346                .map(|m| match m {
3347                    WeightMatrix::Folded { base, .. } => base.as_ref(),
3348                    _ => unreachable!("every matrix carries the fold"),
3349                })
3350                .collect();
3351            let xt = fold.transform_input(x);
3352            return Self::apply_gpu_multi(&bases, &xt);
3353        }
3354        if mats.iter().any(|m| m.hadamard_fold().is_some()) {
3355            return None;
3356        }
3357
3358        // Try CUDA first if enabled.
3359        #[cfg(feature = "cuda")]
3360        if cuda_dense_enabled() {
3361            let mut launches = Vec::with_capacity(mats.len());
3362            for m in mats {
3363                assert_eq!(m.cols(), mats[0].cols());
3364                let WeightMatrix::Quantized {
3365                    data,
3366                    rows,
3367                    cols,
3368                    kind,
3369                } = m
3370                else {
3371                    return None;
3372                };
3373                // One table, in `frink-cuda`, exactly as the Metal arm
3374                // below asks `matvec_launch_meta`. This match was
3375                // written out here and again in
3376                // `apply_gpu_dense_ffn_swiglu`, three copies of one
3377                // five-row list with nothing holding them together --
3378                // and a kind added to the capability table but not to a
3379                // copy loses its fused launch silently, which is the
3380                // failure this file has paid for twice.
3381                let (kernel_src, module_name, fn_name) =
3382                    frink_cuda::gpu::matvec_launch_meta(kind.name())?;
3383                let row_bytes = m.block_bytes_per_row(*kind, *cols);
3384                let n_blocks_per_row = row_bytes / Self::block_bytes_for_kind(*kind);
3385                launches.push(frink_cuda::gpu::MatvecLaunch {
3386                    kernel_src,
3387                    module_name,
3388                    fn_name,
3389                    // Borrow mmap/owned storage — never to_vec() (breaks
3390                    // resident_cuda_weights pointer cache; re-uploads GB).
3391                    weights: data.as_slice(),
3392                    rows: *rows,
3393                    row_bytes,
3394                    n_blocks_per_row,
3395                });
3396            }
3397            match frink_cuda::gpu::launch_matvec_multi(x, &launches) {
3398                Ok(outs) => return Some(outs),
3399                Err(e) => {
3400                    eprintln!("frink: CUDA multi-matvec failed, trying next backend: {e}");
3401                }
3402            }
3403        }
3404
3405        // Try Metal if CUDA didn't return or failed.
3406        #[cfg(feature = "metal")]
3407        if metal_dense_enabled() {
3408            let mut launches = Vec::with_capacity(mats.len());
3409            let mut held: Vec<(&[u8], usize, usize, &'static str)> = Vec::with_capacity(mats.len());
3410            for m in mats {
3411                assert_eq!(m.cols(), mats[0].cols());
3412                let WeightMatrix::Quantized {
3413                    data,
3414                    rows,
3415                    cols,
3416                    kind,
3417                } = m
3418                else {
3419                    return None;
3420                };
3421                // The one Metal kind table (`Metal::matvec_kernel`).
3422                // This was a hand-written six-row match that lacked
3423                // Q5_0 and PTQ1_0, so every gate/up and q/k/v pair of
3424                // those kinds ran as separate launches: correct, and
3425                // one GPU round trip per layer slower than the single
3426                // path for the kinds the copy happened to name.
3427                let kind_name = Metal::matvec_kernel(*kind)?;
3428                let row_bytes = m.block_bytes_per_row(*kind, *cols);
3429                held.push((data.as_slice(), *rows, row_bytes, kind_name));
3430            }
3431            for (weights, rows, row_bytes, kind_name) in &held {
3432                let (src, fn_name, block_bytes, block_elems, rows_per_tg) =
3433                    frink_metal::gpu::matvec_launch_meta(kind_name)?;
3434                launches.push(frink_metal::gpu::MatvecLaunch {
3435                    kernel_src: src,
3436                    fn_name,
3437                    block_bytes,
3438                    block_elems,
3439                    weights,
3440                    rows: *rows,
3441                    row_bytes: *row_bytes,
3442                    rows_per_tg,
3443                });
3444            }
3445            match frink_metal::gpu::launch_matvec_fused_folded(
3446                x,
3447                &launches,
3448                frink_metal::gpu::MatvecEpilogue::default(),
3449                fold,
3450            ) {
3451                Ok(outs) => return Some(outs),
3452                Err(e) => {
3453                    eprintln!("frink: Metal fused matvec failed, falling back to CPU: {e}");
3454                }
3455            }
3456        }
3457
3458        None
3459    }
3460
3461    /// Dense SwiGLU FFN on GPU with device-resident activations:
3462    /// one upload of `x`, gate+up+silu×up+down on device, one download.
3463    /// Tries CUDA first when enabled, then Metal. Returns `None` if
3464    /// no GPU path applies — caller falls back to [`Self::apply`] /
3465    /// multi-matvec.
3466    #[cfg(any(feature = "cuda", feature = "metal"))]
3467    pub fn apply_gpu_dense_ffn_swiglu(
3468        gate: &WeightMatrix,
3469        up: &WeightMatrix,
3470        down: &WeightMatrix,
3471        x: &[f32],
3472    ) -> Option<Vec<f32>> {
3473        #[cfg(feature = "cuda")]
3474        {
3475            if cuda_dense_enabled() {
3476                fn cuda_launch(m: &WeightMatrix) -> Option<frink_cuda::gpu::MatvecLaunch<'_>> {
3477                    let WeightMatrix::Quantized {
3478                        data,
3479                        rows,
3480                        cols,
3481                        kind,
3482                    } = m
3483                    else {
3484                        return None;
3485                    };
3486                    // The second of the two copies this used to hold.
3487                    // See the note in `apply_gpu_multi`.
3488                    let (kernel_src, module_name, fn_name) =
3489                        frink_cuda::gpu::matvec_launch_meta(kind.name())?;
3490                    let row_bytes = m.block_bytes_per_row(*kind, *cols);
3491                    let n_blocks_per_row = row_bytes / WeightMatrix::block_bytes_for_kind(*kind);
3492                    Some(frink_cuda::gpu::MatvecLaunch {
3493                        kernel_src,
3494                        module_name,
3495                        fn_name,
3496                        weights: data.as_slice(),
3497                        rows: *rows,
3498                        row_bytes,
3499                        n_blocks_per_row,
3500                    })
3501                }
3502                if let (Some(g), Some(u), Some(d)) =
3503                    (cuda_launch(gate), cuda_launch(up), cuda_launch(down))
3504                {
3505                    assert_eq!(gate.cols(), x.len());
3506                    assert_eq!(up.cols(), x.len());
3507                    assert_eq!(down.cols(), gate.rows());
3508                    match frink_cuda::gpu::launch_dense_ffn_swiglu(&g, &u, &d, x) {
3509                        Ok(out) => return Some(out),
3510                        Err(e) => {
3511                            eprintln!("frink: CUDA dense FFN fuse failed, trying next: {e}");
3512                        }
3513                    }
3514                }
3515            }
3516        }
3517        #[cfg(feature = "metal")]
3518        {
3519            if metal_dense_enabled() {
3520                fn metal_launch(m: &WeightMatrix) -> Option<frink_metal::gpu::MatvecLaunch<'_>> {
3521                    let WeightMatrix::Quantized {
3522                        data,
3523                        rows,
3524                        cols: _,
3525                        kind,
3526                        // Through the fold, which travels as a plan beside
3527                        // the launch rather than as a transformed vector.
3528                        // `base()` unwraps a LoRA only, deliberately: an
3529                        // adapter is arithmetic this kernel cannot do.
3530                    } = (match m {
3531                        WeightMatrix::Folded { base, .. } => base.as_ref(),
3532                        other => other,
3533                    })
3534                    else {
3535                        return None;
3536                    };
3537                    // The one Metal kind table again: this was the
3538                    // THIRD hand-written copy of it, and it lacked
3539                    // Q5_0 and PTQ1_0, so those kinds ran their FFN as
3540                    // three submissions with a host SwiGLU between.
3541                    let kind_name = Metal::matvec_kernel(*kind)?;
3542                    let (src, fn_name, block_bytes, block_elems, rows_per_tg) =
3543                        frink_metal::gpu::matvec_launch_meta(kind_name)?;
3544                    // A zero-row matrix has no rows to stride over, so
3545                    // there is no meaningful row size; `checked_div`
3546                    // says that once instead of splitting it across a
3547                    // guard and a bare division.
3548                    let row_bytes = data.as_slice().len().checked_div(*rows).unwrap_or(0);
3549                    Some(frink_metal::gpu::MatvecLaunch {
3550                        kernel_src: src,
3551                        fn_name,
3552                        block_bytes,
3553                        block_elems,
3554                        weights: data.as_slice(),
3555                        rows: *rows,
3556                        row_bytes,
3557                        rows_per_tg,
3558                    })
3559                }
3560                // A folded FFN reads TWO rotated activations: `x`
3561                // before gate/up, and the SwiGLU output before `down`.
3562                // They are different widths and therefore different
3563                // folds, so both travel with the launch rather than
3564                // one standing in for the other.
3565                let fold_x = gate.hadamard_fold().and_then(|f| f.metal_plan(x.len()));
3566                let fold_act = down.hadamard_fold().and_then(|f| f.metal_plan(gate.rows()));
3567                let folds_agree = match (gate.hadamard_fold(), up.hadamard_fold()) {
3568                    (Some(g), Some(u)) => std::sync::Arc::ptr_eq(g, u) && fold_x.is_some(),
3569                    (None, None) => true,
3570                    _ => false,
3571                } && (down.hadamard_fold().is_none() == fold_act.is_none());
3572                if let (Some(g), Some(u), Some(d), true) = (
3573                    metal_launch(gate),
3574                    metal_launch(up),
3575                    metal_launch(down),
3576                    folds_agree,
3577                ) {
3578                    assert_eq!(gate.cols(), x.len());
3579                    assert_eq!(up.cols(), x.len());
3580                    assert_eq!(down.cols(), gate.rows());
3581                    match frink_metal::gpu::launch_dense_ffn_swiglu_folded(
3582                        &g,
3583                        &u,
3584                        &d,
3585                        x,
3586                        fold_x.as_ref(),
3587                        fold_act.as_ref(),
3588                    ) {
3589                        Ok(out) => return Some(out),
3590                        Err(e) => {
3591                            eprintln!("frink: Metal dense FFN fuse failed, falling back: {e}");
3592                        }
3593                    }
3594                }
3595            }
3596        }
3597        None
3598    }
3599
3600    /// Runs one weight matrix against `batch_size` activations in a
3601    /// single Metal command buffer (shared resident weights, one
3602    /// upload of `x_batch`, one GPU wait). `x_batch` / return layout
3603    /// match [`Self::apply_batch`]: `[batch, cols]` → `[batch, rows]`.
3604    /// Returns `None` if Metal dense is off, the kind lacks a Metal
3605    /// kernel, or the launch fails.
3606    ///
3607    /// `batch_size >= 4` takes the weight-reuse `mul_mm` path where the
3608    /// kind has one; everything else falls through to
3609    /// [`frink_metal::gpu::launch_matvec_batch`].
3610    #[cfg(feature = "metal")]
3611    pub fn apply_gpu_batch(&self, x_batch: &[f32], batch_size: usize) -> Option<Vec<f32>> {
3612        if !metal_dense_enabled() || batch_size == 0 {
3613            return None;
3614        }
3615        if let WeightMatrix::Adapted { base, lora } = self {
3616            let mut out = base.apply_gpu_batch(x_batch, batch_size)?;
3617            lora.add_batch_to(x_batch, batch_size, &mut out);
3618            return Some(out);
3619        }
3620        if let WeightMatrix::Folded { base, fold } = self {
3621            // A BATCH keeps the host rotation, and that is a
3622            // measurement rather than an omission: the same device
3623            // prologue that pays for itself on a decode token
3624            // (`apply_gpu`) cost 6% of Bonsai prefill, because
3625            // `transform_rows` is already parallel across cores and the
3626            // kernel serialises into the GEMM's own command buffer
3627            // instead. `pp128` 33.96 -> 32.04 tok/s on an M2 Pro, three
3628            // reps each, which is why `apply_gpu_batch` takes no fold.
3629            let transformed = fold.transform_rows(x_batch, batch_size);
3630            return base.apply_gpu_batch(&transformed, batch_size);
3631        }
3632        let WeightMatrix::Quantized {
3633            data,
3634            rows,
3635            cols,
3636            kind,
3637        } = self
3638        else {
3639            return None;
3640        };
3641        let Some(kind_name) = metal_matvec_kind_name(*kind) else {
3642            crate::kernel_registry::miss(
3643                crate::kernel_registry::Lookup::new(
3644                    crate::kernel_registry::Backend::Metal,
3645                    crate::kernel_registry::op::GEMM_PREFILL,
3646                    Some(*kind),
3647                ),
3648                "CPU apply_batch",
3649            );
3650            return None;
3651        };
3652        let (src, fn_name, block_bytes, block_elems, rows_per_tg) =
3653            frink_metal::gpu::matvec_launch_meta(kind_name)?;
3654        let row_bytes = self.block_bytes_per_row(*kind, *cols);
3655        // Weight-reuse simdgroup GEMM for prefill batch >= 4: each 64x32
3656        // output tile reads its weight slice once into threadgroup
3657        // memory instead of once per token. The batched matvec below is
3658        // the fallback it replaces -- correct, but it re-reads the whole
3659        // matrix for every token, which is why Metal `pp512` was 14-99x
3660        // behind llama.cpp. Threshold 4 (was 8) covers shorter prompts
3661        // without changing the decode path (batch_size == 1 is matvec).
3662        //
3663        // ONE launcher keyed by the kind's name, read off the same table
3664        // `gemm_supported` is derived from. This was a seven-arm match
3665        // naming each kind's wrapper, and the arms lagged the table:
3666        // Q5_0 and PTQ1_0 had a GEMM the table admitted and the match
3667        // did not reach, so their prefill ran N matvecs while the kernel
3668        // registry recorded a GEMM hit.
3669        if batch_size >= 4 {
3670            if !metal_mul_mm_kind_supported(*kind) {
3671                // Observation only: a kind with a matvec kernel but no
3672                // simdgroup GEMM still runs on Metal, as `batch`
3673                // separate matvecs over the same weights. That is the
3674                // shape that cost IQ4_XS 13.7x, and it is invisible in
3675                // the output.
3676                crate::kernel_registry::miss(
3677                    crate::kernel_registry::Lookup::new(
3678                        crate::kernel_registry::Backend::Metal,
3679                        crate::kernel_registry::op::GEMM_PREFILL,
3680                        Some(*kind),
3681                    ),
3682                    "Metal N x matvec batch",
3683                );
3684            }
3685            match frink_metal::gpu::launch_mul_mm_sg(
3686                kind.name(),
3687                data.as_slice(),
3688                x_batch,
3689                *rows,
3690                row_bytes,
3691                batch_size,
3692            ) {
3693                Ok(Some(out)) => return Some(out),
3694                Ok(None) => {}
3695                Err(e) => {
3696                    eprintln!(
3697                        "frink: Metal {} simdgroup mul_mm failed, batched fallback: {e}",
3698                        kind.name()
3699                    );
3700                }
3701            }
3702            // The older non-simdgroup GEMMs, kept for the two kinds that
3703            // have one, between the simdgroup kernel and the matvec.
3704            let older = match kind {
3705                QuantKind::Q4_0 => Some(frink_metal::gpu::launch_q4_0_mul_mm(
3706                    data.as_slice(),
3707                    x_batch,
3708                    *rows,
3709                    row_bytes,
3710                    batch_size,
3711                )),
3712                QuantKind::Q4K => Some(frink_metal::gpu::launch_q4_k_mul_mm(
3713                    data.as_slice(),
3714                    x_batch,
3715                    *rows,
3716                    row_bytes,
3717                    batch_size,
3718                )),
3719                _ => None,
3720            };
3721            match older {
3722                Some(Ok(out)) => return Some(out),
3723                Some(Err(e)) => {
3724                    eprintln!(
3725                        "frink: Metal {} mul_mm failed, matvec fallback: {e}",
3726                        kind.name()
3727                    );
3728                }
3729                None => {}
3730            }
3731        }
3732        let launch = frink_metal::gpu::MatvecLaunch {
3733            kernel_src: src,
3734            fn_name,
3735            block_bytes,
3736            block_elems,
3737            weights: data.as_slice(),
3738            rows: *rows,
3739            row_bytes,
3740            rows_per_tg,
3741        };
3742        match frink_metal::gpu::launch_matvec_batch(&launch, x_batch, batch_size) {
3743            Ok(out) => Some(out),
3744            Err(e) => {
3745                eprintln!("frink: Metal batch matvec failed, falling back: {e}");
3746                None
3747            }
3748        }
3749    }
3750
3751    /// Delegates to [`metal_matvec_kind_name`]. Kept as a method because
3752    /// the call sites read better, but it must never grow a list of its
3753    /// own again — a second copy of this list is what sent IQ4_XS
3754    /// batched prefill to the CPU.
3755    #[cfg(feature = "metal")]
3756    fn metal_kind_supported(kind: QuantKind) -> bool {
3757        metal_matvec_kind_name(kind).is_some()
3758    }
3759
3760    /// Eagerly resolve, and record, every kernel lookup this matrix's
3761    /// dispatch paths will make later, without dispatching anything.
3762    ///
3763    /// Call once per weight while the model is being built, with `role`
3764    /// naming the tensor (`"attn_q"`, `"ffn_down"`, ...). The predicates
3765    /// consulted here are the *same functions* the hot path consults, so
3766    /// the recorded prediction cannot drift from the decision. See
3767    /// [`crate::kernel_registry`] for why this exists and
3768    /// [`crate::kernel_registry::seal`] for what is done with it.
3769    ///
3770    /// Observation only: nothing here influences a later dispatch.
3771    #[track_caller]
3772    pub fn probe_kernels(&self, role: &'static str) {
3773        if !crate::kernel_registry::enabled() {
3774            return;
3775        }
3776        self.probe_kernels_into(
3777            crate::kernel_registry::global(),
3778            role,
3779            std::panic::Location::caller(),
3780        );
3781    }
3782
3783    /// [`Self::probe_kernels`] against an explicit registry and call
3784    /// site, so tests can probe into an instance of their own instead of
3785    /// the process-wide one.
3786    pub fn probe_kernels_into(
3787        &self,
3788        reg: &crate::kernel_registry::Registry,
3789        role: &'static str,
3790        loc: &'static std::panic::Location<'static>,
3791    ) {
3792        self.probe_kernels_for(reg, active_backend(), role, loc)
3793    }
3794
3795    /// [`Self::probe_kernels_into`] against an explicit backend rather
3796    /// than [`active_backend`]. Lets a test on a CPU-only build ask what
3797    /// a Metal or CUDA run would resolve -- which is the only way the
3798    /// kernel-coverage tests can run under plain
3799    /// `cargo test --workspace`, where every GPU feature is off.
3800    pub fn probe_kernels_for(
3801        &self,
3802        reg: &crate::kernel_registry::Registry,
3803        backend: crate::kernel_registry::Backend,
3804        role: &'static str,
3805        loc: &'static std::panic::Location<'static>,
3806    ) {
3807        use crate::kernel_registry::{op, Backend, Lookup, Outcome};
3808
3809        let kind = self.quant_kind();
3810        let cols = self.cols();
3811        let look = |op: &'static str| Lookup {
3812            backend,
3813            op,
3814            role,
3815            kind,
3816        };
3817
3818        // Whether the accelerator, if one is selected, can run this
3819        // matrix at all -- and if so, whether prefill gets a real GEMM
3820        // or `batch` matvecs over the same weights.
3821        //
3822        // Read off `BackendCaps` over the ungated backend table rather
3823        // than from a `match backend` written out here. The two are
3824        // NOT interchangeable: the hand-written match had a `Backend::
3825        // Cpu => (false, false)` arm and no `_`, so it was exhaustive
3826        // by luck -- a third variant broke it, which is the good case.
3827        // A fourth backend added while a `_` arm existed would have
3828        // silently reported "no kernels" for a backend that had them.
3829        //
3830        // Ungated on purpose: a CPU-only build must be able to ask what
3831        // CUDA would resolve, which is what every kernel-coverage test
3832        // below does. `BackendDispatch` is unavailable here for exactly
3833        // that reason.
3834        //
3835        // The predicate sets are per backend and genuinely different:
3836        // CUDA has a batched GEMM for a SUBSET of the kinds it has
3837        // matvecs for (Q8_0, Q4_0) and decomposes the rest into
3838        // per-position matvecs; Vulkan has one matvec and no GEMM at
3839        // all. `GEMM_FALLBACK` is what each of those decompositions is
3840        // actually called.
3841        let (matvec, gemm, gemm_fallback) = {
3842            let mut found = (false, false, "");
3843            macro_rules! caps_of {
3844                ($b:ty) => {
3845                    if backend == <$b as BackendCaps>::ID {
3846                        found = (
3847                            kind.is_some_and(|k| <$b as BackendCaps>::matvec_kernel(k).is_some()),
3848                            kind.is_some_and(<$b as BackendCaps>::gemm_supported),
3849                            <$b as BackendCaps>::GEMM_FALLBACK,
3850                        );
3851                    }
3852                };
3853            }
3854            with_gpu_backend_caps!(caps_of);
3855            found
3856        };
3857
3858        if backend.is_accelerator() {
3859            reg.record_build_at(
3860                loc,
3861                look(op::MATVEC),
3862                match kind {
3863                    // An accelerator kernel exists for this format.
3864                    _ if matvec => Outcome::Hit,
3865                    // No kernel: the whole matvec runs on the host.
3866                    Some(_) => Outcome::slow_path("CPU apply_cpu"),
3867                    // F32 has no quantized kernel by construction, and a
3868                    // lone small F32 matvec (an MoE router) is host work
3869                    // on purpose -- see `apply_gpu`.
3870                    None => Outcome::by_design("host GEMV"),
3871                },
3872            );
3873            reg.record_build_at(
3874                loc,
3875                look(op::GEMM_PREFILL),
3876                match (gemm, matvec, kind) {
3877                    (true, ..) => Outcome::Hit,
3878                    // A matvec but no GEMM. What that costs is per
3879                    // backend -- Metal re-reads the whole weight matrix
3880                    // once per position but stays on the GPU (the 13.7x
3881                    // shape), CUDA does the same through a different
3882                    // entry point, and Vulkan has no batch path at all
3883                    // so the prefill lands on the host -- so the name
3884                    // comes from the backend instead of from an arm
3885                    // here that a new variant would fall through.
3886                    (false, true, _) => Outcome::slow_path(gemm_fallback),
3887                    (false, false, Some(_)) => Outcome::slow_path("CPU apply_batch"),
3888                    (false, false, None) => Outcome::by_design("CPU f32 GEMM"),
3889                },
3890            );
3891        }
3892
3893        // The host path is what every accelerator miss lands on, so
3894        // record its tier too: integer vec_dot, or the much slower f32
3895        // dequant-dot.
3896        if !matvec || !gemm {
3897            let int_dot = cpu_int_dot_for(IntDotShape::Matvec)
3898                && kind.is_some_and(|k| cpu_int_dot_kind_supported(k, cols));
3899            reg.record_build_at(
3900                loc,
3901                Lookup {
3902                    backend: Backend::Cpu,
3903                    op: op::MATVEC,
3904                    role,
3905                    kind,
3906                },
3907                match kind {
3908                    _ if int_dot => Outcome::Hit,
3909                    // A quantized weight with no integer vec_dot kernel
3910                    // dequantizes to f32 first: a much slower engine,
3911                    // and invisible in the output.
3912                    Some(_) => Outcome::slow_path("f32 dequant-dot"),
3913                    None => Outcome::by_design("f32 GEMM"),
3914                },
3915            );
3916        }
3917    }
3918
3919    /// The block size (in bytes) for exactly the quant kinds
3920    /// `apply_gpu` dispatches to a real CUDA or Vulkan kernel for -- a
3921    /// small, deliberately partial mirror of `block_bytes_per_row`'s
3922    /// per-kind match.
3923    ///
3924    /// Partial means this `unreachable!()` is reachable by a mistake:
3925    /// widening `Cuda::matvec_kernel` without adding the row here
3926    /// turns a decode into a panic in a rayon worker rather than a
3927    /// fallback. `every_cuda_or_vulkan_matvec_kind_has_a_block_size`
3928    /// calls it for every claimed kind so that lands as a red test
3929    /// instead.
3930    #[cfg(any(feature = "cuda", feature = "vulkan"))]
3931    pub(crate) fn block_bytes_for_kind(kind: QuantKind) -> usize {
3932        match kind {
3933            QuantKind::Q8_0 => frink_quant::Q8_0_BLOCK_BYTES,
3934            QuantKind::Q4_0 => frink_quant::Q4_0_BLOCK_BYTES,
3935            QuantKind::Q5_0 => frink_quant::Q5_0_BLOCK_BYTES,
3936            QuantKind::Q4K => frink_quant::Q4_K_BLOCK_BYTES,
3937            QuantKind::Q5K => frink_quant::Q5_K_BLOCK_BYTES,
3938            QuantKind::Q6K => frink_quant::Q6_K_BLOCK_BYTES,
3939            QuantKind::Q2K => frink_quant::Q2_K_BLOCK_BYTES,
3940            QuantKind::Q3K => frink_quant::Q3_K_BLOCK_BYTES,
3941            QuantKind::IQ4NL => frink_quant::IQ4_NL_BLOCK_BYTES,
3942            QuantKind::IQ4XS => frink_quant::IQ4_XS_BLOCK_BYTES,
3943            QuantKind::Mxfp4Gguf => frink_quant::MXFP4_GGUF_BLOCK_BYTES,
3944            _ => unreachable!(
3945                "apply_gpu only calls this for the CUDA/Vulkan-dispatchable kinds, not {kind:?}"
3946            ),
3947        }
3948    }
3949}
3950#[cfg(test)]
3951mod tests {
3952
3953    /// The task floor is **work-aware**, which is the whole reason
3954    /// [`crate::par::with_op_work`] exists: a row count alone cannot
3955    /// tell a 64-wide matrix from a 256-wide one, and rayon splitting
3956    /// the narrow one by rows alone is the measured 13-16x small-model
3957    /// regression.
3958    ///
3959    /// Both shapes here sit under [`crate::par::policy::SPIN_MIN_OP_MACS`]
3960    /// so both are decided by the fork-join arm, which is the only arm
3961    /// that reads a `min_len` at all.
3962    ///
3963    /// Sabotage: drop the `MIN_TASK_MACS` term from `min_rows_per_task`
3964    /// and this goes red, because both shapes then collapse onto the
3965    /// same row-count floor.
3966    #[test]
3967    fn the_task_floor_demands_more_rows_of_a_narrower_matrix() {
3968        if crate::par::policy::pinned().is_some() {
3969            return; // pinned: not the arm this floor belongs to
3970        }
3971        let rows = 4096usize;
3972        let narrow = crate::par::with_op_work(rows, 64, || WeightMatrix::min_rows_per_task(rows));
3973        let wider = crate::par::with_op_work(rows, 256, || WeightMatrix::min_rows_per_task(rows));
3974        assert_eq!(narrow, MIN_TASK_MACS.div_ceil(64));
3975        assert!(
3976            narrow > wider,
3977            "a 64-wide row carries a quarter of a 256-wide row's work, so a \
3978             task must hold four times as many of them: {narrow} vs {wider}"
3979        );
3980    }
3981
3982    /// The four dtypes the drifted copies were missing.
3983    ///
3984    /// Three of the six loaders stopped at IQ1_M, so `IQ1_S`,
3985    /// `IQ2_XXS`, `IQ3_XXS` and `MXFP4` mapped to `None` there -- and a
3986    /// `None` is `LoadError::UnsupportedDtype`, not a slower path. A
3987    /// DeepSeek-MLA checkpoint at `IQ2_XXS`, an ordinary quant for a
3988    /// model that size, was refused outright while the same quant
3989    /// loaded on the generic path. One table is what stops that
3990    /// recurring.
3991    #[test]
3992    fn the_four_dtypes_the_duplicated_tables_disagreed_about_all_map() {
3993        assert_eq!(quant_kind_for(GgmlType::IQ1S), Some(QuantKind::IQ1S));
3994        assert_eq!(quant_kind_for(GgmlType::IQ2XXS), Some(QuantKind::IQ2XXS));
3995        assert_eq!(quant_kind_for(GgmlType::IQ3XXS), Some(QuantKind::IQ3XXS));
3996        assert_eq!(quant_kind_for(GgmlType::MXFP4), Some(QuantKind::Mxfp4Gguf));
3997    }
3998
3999    /// Every dtype with a CPU dequant kernel must be reachable through
4000    /// this map, or the kernel exists and no loader can ever hand it a
4001    /// tensor. Checked against the two backend tables rather than a
4002    /// hand-written list, so adding a kernel without a mapping fails
4003    /// here instead of at a user's load.
4004    #[test]
4005    fn every_dtype_with_a_gpu_kernel_is_reachable_through_the_map() {
4006        let mapped: Vec<QuantKind> = [
4007            GgmlType::Q8_0,
4008            GgmlType::Q4_0,
4009            GgmlType::Q4K,
4010            GgmlType::Q5K,
4011            GgmlType::Q6K,
4012            GgmlType::IQ4XS,
4013        ]
4014        .into_iter()
4015        .map(|d| quant_kind_for(d).expect("a dtype with a GPU kernel must map"))
4016        .collect();
4017        for kind in mapped {
4018            assert!(
4019                metal_mul_mm_kind_supported(kind) || cuda_matvec_kind_supported(kind),
4020                "{kind:?} was listed as having a GPU kernel"
4021            );
4022        }
4023    }
4024
4025    /// The CUDA capability predicates and the *launch* table must name
4026    /// the same set, for every kind.
4027    ///
4028    /// `Cuda::matvec_kernel` and `Cuda::gemm_supported` are DERIVED
4029    /// from `frink-cuda`'s own kernel tables now, so the two pairs
4030    /// that used to be checked here cannot disagree -- those tests were
4031    /// deleted rather than left comparing a table to itself, which
4032    /// reads as coverage and is not.
4033    ///
4034    /// This one still matters. [`cuda_matvec_launch`] is a table of
4035    /// FUNCTION POINTERS, which only exist under `--features cuda`, so
4036    /// it cannot be derived from a table of strings. Over-claiming in
4037    /// the capability predicate sends a decode to a launcher that does
4038    /// not exist; under-claiming leaves a kernel nothing calls. The
4039    /// dispatch seam only `debug_assert!`s the agreement at the moment
4040    /// a matmul happens to run, which in release is no check at all.
4041    #[cfg(feature = "cuda")]
4042    #[test]
4043    fn every_cuda_matvec_kind_has_a_launcher() {
4044        use super::gpu_backend::cuda_matvec_launch;
4045        for &kind in QuantKind::ALL {
4046            assert_eq!(
4047                cuda_matvec_kind_supported(kind),
4048                cuda_matvec_launch(kind).is_some(),
4049                "{kind:?}: the capability table and the launch table disagree"
4050            );
4051        }
4052    }
4053
4054    /// `block_bytes_for_kind` is deliberately partial, so every kind
4055    /// CUDA or Vulkan claims a matvec for has to be one of its arms.
4056    ///
4057    /// Calling it IS the assertion: the arm it lacks is an
4058    /// `unreachable!()`, and reaching that in a rayon worker is a panic
4059    /// rather than the fallback the seam promises. `Metal` is
4060    /// deliberately not checked -- it claims IQ4_XS, asks
4061    /// `frink_metal::gpu::matvec_launch_meta` for its block size, and
4062    /// never touches this function.
4063    #[cfg(any(feature = "cuda", feature = "vulkan"))]
4064    #[test]
4065    fn every_cuda_or_vulkan_matvec_kind_has_a_block_size() {
4066        use super::gpu_backend::{BackendCaps, Cuda, Vulkan};
4067        for &kind in QuantKind::ALL {
4068            if Cuda::matvec_kernel(kind).is_none() && Vulkan::matvec_kernel(kind).is_none() {
4069                continue;
4070            }
4071            let block_bytes = WeightMatrix::block_bytes_for_kind(kind);
4072            assert!(
4073                block_bytes > 0,
4074                "{kind:?}: a claimed matvec kind needs a real block size"
4075            );
4076        }
4077    }
4078
4079    /// `block_bytes_for_kind` and `block_bytes_per_row` are two
4080    /// functions that must agree about one format's geometry, and the
4081    /// matvec seam DIVIDES one by the other.
4082    ///
4083    /// `Cuda::launch_matvec` derives `n_blocks_per_row` as
4084    /// `block_bytes_per_row(kind, cols) / block_bytes_for_kind(kind)`
4085    /// and hands it to a kernel that strides the row by a byte count
4086    /// written as a literal in CUDA C. If the two disagreed by so much
4087    /// as one byte the division would silently truncate, the kernel
4088    /// would read fewer blocks than the row holds, and every output
4089    /// would be a partial dot product -- plausible numbers, no error,
4090    /// no panic, and nothing in the suite red.
4091    ///
4092    /// Both are also held to `frink-cuda`'s own `MulMmKind` row, which
4093    /// is where that CUDA C literal comes from, so all three agree or
4094    /// this fails.
4095    ///
4096    /// Nothing checked any of it. That was survivable while the two
4097    /// tables were edited together by one person on one day; five kinds
4098    /// joined on 2026-09-09 and each needed a row in both.
4099    ///
4100    /// Sabotage: give any kind the wrong constant in either function
4101    /// and this names it.
4102    ///
4103    /// Gated like its neighbour: `block_bytes_for_kind` itself only
4104    /// exists when a backend that calls it is compiled in.
4105    #[cfg(any(feature = "cuda", feature = "vulkan"))]
4106    #[test]
4107    fn the_two_block_size_functions_agree_for_every_gpu_kind() {
4108        use super::gpu_backend::{BackendCaps, Cuda, Vulkan};
4109        for &kind in QuantKind::ALL {
4110            if Cuda::matvec_kernel(kind).is_none() && Vulkan::matvec_kernel(kind).is_none() {
4111                continue;
4112            }
4113            let block_bytes = WeightMatrix::block_bytes_for_kind(kind);
4114            let mm = frink_cuda::mul_mm::kind_by_name(kind.name())
4115                .unwrap_or_else(|| panic!("{kind:?}: claims a GPU matvec with no mul_mm row"));
4116            assert_eq!(
4117                block_bytes, mm.block_bytes,
4118                "{kind:?}: frink-core's block size is not the one the kernel strides by"
4119            );
4120
4121            // `block_bytes_per_row` takes `&self` but reads only its
4122            // arguments, so any matrix of the right kind will do.
4123            let probe = WeightMatrix::Quantized {
4124                data: WeightBytes::Owned(Vec::new()),
4125                rows: 1,
4126                cols: mm.block_elems,
4127                kind,
4128            };
4129            // Three, four and five whole blocks: a per-row function
4130            // that had dropped the multiply would still pass at one.
4131            for blocks in 3..=5usize {
4132                let cols = mm.block_elems * blocks;
4133                let row_bytes = probe.block_bytes_per_row(kind, cols);
4134                assert_eq!(
4135                    row_bytes,
4136                    blocks * block_bytes,
4137                    "{kind:?}: block_bytes_per_row({cols}) is not {blocks} x {block_bytes}"
4138                );
4139                assert_eq!(
4140                    row_bytes / block_bytes,
4141                    blocks,
4142                    "{kind:?}: the n_blocks_per_row the matvec seam derives is wrong"
4143                );
4144            }
4145        }
4146    }
4147
4148    /// F32 and F16 are not quantized, so `None` is the right answer and
4149    /// not a gap: the loader builds a plain `WeightMatrix::F32` for
4150    /// them rather than reporting an unsupported dtype.
4151    #[test]
4152    fn an_unquantized_dtype_maps_to_nothing() {
4153        assert_eq!(quant_kind_for(GgmlType::F32), None);
4154        assert_eq!(quant_kind_for(GgmlType::F16), None);
4155    }
4156    use super::*;
4157
4158    /// Forces [`cpu_int_dot_enabled`] for the lifetime of the guard, so a
4159    /// test can drive the quantized-activation batch kernels (the
4160    /// interleaved `block_q*_Kx8` / `block_q*_0x4` repack tier and the
4161    /// NEON i8mm GEMMs behind it) that every shipped binary turns on via
4162    /// `default_cpu_int_dot_on` but `cargo test` otherwise leaves off.
4163    ///
4164    /// The override is process-global, so the guard serializes on a
4165    /// mutex: two tests forcing opposite values concurrently would
4166    /// otherwise see each other's setting.
4167    pub(super) struct ForceIntDot {
4168        _lock: std::sync::MutexGuard<'static, ()>,
4169    }
4170
4171    /// Hoisted out of `new` so the guard's OWN test can hold it for the
4172    /// whole of its body.
4173    ///
4174    /// It could not before, and that was a real flake rather than a
4175    /// theoretical one: the test asserted the override was cleared
4176    /// AFTER its guard dropped, which is after the lock was released,
4177    /// so any other test taking the guard in between stored its value
4178    /// first and the assertion read that. Seen once in a full-workspace
4179    /// run and green on its own and on rerun, which is the signature.
4180    static INT_DOT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
4181
4182    impl ForceIntDot {
4183        pub(super) fn new(on: bool) -> Self {
4184            let lock = INT_DOT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
4185            Self::set(on);
4186            Self { _lock: lock }
4187        }
4188
4189        /// The store the guard makes. Separate so the guard's test can
4190        /// drive it under a lock it holds itself, rather than restating
4191        /// the constant in a second place.
4192        fn set(on: bool) {
4193            INT_DOT_TEST_OVERRIDE.store(i8::from(on), std::sync::atomic::Ordering::Release);
4194        }
4195
4196        /// The store the guard makes on drop.
4197        fn clear() {
4198            INT_DOT_TEST_OVERRIDE.store(-1, std::sync::atomic::Ordering::Release);
4199        }
4200    }
4201
4202    impl Drop for ForceIntDot {
4203        fn drop(&mut self) {
4204            // Before `_lock` drops: a struct's fields drop after its
4205            // `Drop` body, so the override is cleared while this still
4206            // holds the lock and no other guard can be between.
4207            Self::clear();
4208        }
4209    }
4210
4211    /// The guard has to actually move the getter, in both directions --
4212    /// otherwise every test built on it silently exercises one path
4213    /// twice, which is exactly the hole it exists to close.
4214    #[test]
4215    fn force_int_dot_moves_the_getter_and_restores_it() {
4216        // Held for the WHOLE body, which is the fix: the old version
4217        // read the process-global after its guard had released the
4218        // lock, so a concurrent test's store could be what it read.
4219        let _lock = INT_DOT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
4220
4221        ForceIntDot::set(true);
4222        assert!(cpu_int_dot_enabled(), "forcing on must enable int dot");
4223        ForceIntDot::set(false);
4224        assert!(!cpu_int_dot_enabled(), "forcing off must disable int dot");
4225
4226        ForceIntDot::clear();
4227        assert_eq!(
4228            INT_DOT_TEST_OVERRIDE.load(std::sync::atomic::Ordering::Acquire),
4229            -1,
4230            "clearing must restore the unforced getter"
4231        );
4232        assert!(
4233            cpu_int_dot_enabled() == cpu_int_dot_enabled(),
4234            "the unforced getter must be readable"
4235        );
4236    }
4237
4238    /// `dequant_row` must reproduce exactly the values a full-buffer
4239    /// dequantization of the same row produces, for every storage
4240    /// variant -- and read only that row's bytes (each row here has
4241    /// distinct values, so an off-by-one-row slice fails loudly).
4242    #[test]
4243    fn dequant_row_matches_full_dequant_per_row() {
4244        // F32 variant.
4245        let rows = 3;
4246        let cols = 64;
4247        let f32_data: Vec<f32> = (0..rows * cols).map(|i| (i as f32) * 0.1 - 5.0).collect();
4248        let m = WeightMatrix::F32(Tensor::new(f32_data.clone(), vec![rows, cols]));
4249        for r in 0..rows {
4250            assert_eq!(m.dequant_row(r), &f32_data[r * cols..(r + 1) * cols]);
4251        }
4252
4253        // Quantized (Q8_0) variant: quantize each row independently and
4254        // compare dequant_row against dequantizing that row's bytes.
4255        let mut packed = Vec::new();
4256        for r in 0..rows {
4257            packed.extend(make_q8_0_row(&f32_data[r * cols..(r + 1) * cols]));
4258        }
4259        let row_bytes = packed.len() / rows;
4260        let q = WeightMatrix::Quantized {
4261            data: WeightBytes::Owned(packed.clone()),
4262            rows,
4263            cols,
4264            kind: QuantKind::Q8_0,
4265        };
4266        for r in 0..rows {
4267            let expected =
4268                frink_quant::dequant_q8_0(&packed[r * row_bytes..(r + 1) * row_bytes]).unwrap();
4269            assert_eq!(q.dequant_row(r), expected, "Q8_0 row {r}");
4270        }
4271
4272        // Mxfp4 (two-buffer) variant: arbitrary valid bytes, compare
4273        // against the row-level reference dequantizer directly.
4274        let cols = 64;
4275        let packed: Vec<u8> = pseudo_bytes(7, rows * cols / 2);
4276        let scales: Vec<u8> = pseudo_bytes(11, rows * cols / 32);
4277        let m = WeightMatrix::Mxfp4 {
4278            packed: WeightBytes::Owned(packed.clone()),
4279            scale: WeightBytes::Owned(scales.clone()),
4280            rows,
4281            cols,
4282        };
4283        for r in 0..rows {
4284            let expected = frink_quant::dequant_mxfp4_row(
4285                &packed[r * cols / 2..(r + 1) * cols / 2],
4286                &scales[r * cols / 32..(r + 1) * cols / 32],
4287            )
4288            .unwrap();
4289            assert_eq!(m.dequant_row(r), expected, "Mxfp4 row {r}");
4290        }
4291    }
4292
4293    /// A quantized matrix used as an embedding table: `dequant_row`
4294    /// then a dot product must agree with `apply` against a one-hot...
4295    /// no -- more directly, with the fused `dot` of that row, proving
4296    /// row lookup and matmul read identical bytes.
4297    #[test]
4298    fn dequant_row_agrees_with_fused_dot_on_the_same_row() {
4299        let rows = 4;
4300        let cols = 64;
4301        let f32_data: Vec<f32> = (0..rows * cols)
4302            .map(|i| ((i as f32) * 0.13).sin())
4303            .collect();
4304        let mut packed = Vec::new();
4305        for r in 0..rows {
4306            packed.extend(make_q8_0_row(&f32_data[r * cols..(r + 1) * cols]));
4307        }
4308        let q = WeightMatrix::Quantized {
4309            data: WeightBytes::Owned(packed),
4310            rows,
4311            cols,
4312            kind: QuantKind::Q8_0,
4313        };
4314        let x: Vec<f32> = (0..cols).map(|i| ((i as f32) * 0.031).cos()).collect();
4315        let applied = q.apply(&x);
4316        // With `FRINK_CPU_INT_DOT` on, `apply` quantizes the ACTIVATION to
4317        // int8 as well, so the two sides no longer differ only by float
4318        // summation order and a fixed 1e-4 is not the right bar -- it fired
4319        // at 6.5e-3 on a result of 5.25, which is the activation error, not
4320        // a byte disagreement. The worst case is derivable rather than
4321        // guessed: `quantize_activations_q8` rounds to `d = amax/127`, so
4322        // each element moves by at most `d/2`, and the dot's error is
4323        // bounded by that times the row's L1 norm.
4324        let bound = |row: &[f32]| {
4325            if !cpu_int_dot_for(IntDotShape::Matvec) {
4326                return 1e-4;
4327            }
4328            let amax = x.iter().fold(0f32, |m, v| m.max(v.abs()));
4329            let l1: f32 = row.iter().map(|w| w.abs()).sum();
4330            (amax / 127.0 / 2.0) * l1
4331        };
4332        for (r, &got) in applied.iter().enumerate() {
4333            let row = q.dequant_row(r);
4334            let via_row: f32 = row.iter().zip(&x).map(|(a, b)| a * b).sum();
4335            let bound = bound(&row);
4336            assert!(
4337                (got - via_row).abs() < bound,
4338                "row {r}: apply={got} via dequant_row={via_row} (bound {bound:e})"
4339            );
4340        }
4341    }
4342
4343    fn make_q8_0_row(values: &[f32]) -> Vec<u8> {
4344        frink_quant::quantize_q8_0(values)
4345    }
4346
4347    /// Deterministic byte generator for MXFP4 test fixtures (no
4348    /// quantizer exists in `frink_quant` -- MXFP4 is only ever a
4349    /// real, already-quantized checkpoint format, never produced by
4350    /// frink -- so tests build arbitrary-but-valid-shaped bytes
4351    /// directly, same convention as `frink-models::kimi_loader`'s
4352    /// tests).
4353    fn pseudo_bytes(seed: u32, len: usize) -> Vec<u8> {
4354        let mut state = seed.wrapping_mul(2654435761).wrapping_add(1);
4355        (0..len)
4356            .map(|_| {
4357                state = state.wrapping_mul(1103515245).wrapping_add(12345);
4358                (state >> 16) as u8
4359            })
4360            .collect()
4361    }
4362
4363    /// Clamped to a realistic E8M0 scale range -- see
4364    /// `frink-models::kimi_loader`'s identical helper for why (byte
4365    /// 255 is OCP-spec-reserved for NaN, and bytes above ~252 can
4366    /// legitimately overflow f32::MAX when combined with E2M1's max
4367    /// magnitude; neither is representative of a real trained weight).
4368    fn pseudo_mxfp4_scale_bytes(seed: u32, len: usize) -> Vec<u8> {
4369        pseudo_bytes(seed, len)
4370            .into_iter()
4371            .map(|b| b % 180)
4372            .collect()
4373    }
4374
4375    #[test]
4376    fn f32_and_mxfp4_paths_agree() {
4377        let rows = 2;
4378        let cols = 64; // 2 MXFP4 groups of 32 per row
4379        let packed = pseudo_bytes(1, rows * (cols / 2));
4380        let scale = pseudo_mxfp4_scale_bytes(2, rows * (cols / frink_quant::MXFP4_GROUP_SIZE));
4381        let x: Vec<f32> = (0..cols).map(|i| (i as f32) * 0.01 - 0.3).collect();
4382
4383        // Independent reference: dequantize each row to plain f32 (the
4384        // already-tested `dequant_mxfp4_row`), then use the ordinary
4385        // F32 matmul path.
4386        let mut f32_weights = Vec::with_capacity(rows * cols);
4387        for r in 0..rows {
4388            let prow = &packed[r * (cols / 2)..(r + 1) * (cols / 2)];
4389            let srow = &scale[r * (cols / frink_quant::MXFP4_GROUP_SIZE)
4390                ..(r + 1) * (cols / frink_quant::MXFP4_GROUP_SIZE)];
4391            f32_weights.extend(frink_quant::dequant_mxfp4_row(prow, srow).unwrap());
4392        }
4393        let f32_matrix = WeightMatrix::F32(Tensor::new(f32_weights, vec![rows, cols]));
4394        let f32_out = f32_matrix.apply(&x);
4395
4396        let mxfp4_matrix = WeightMatrix::Mxfp4 {
4397            packed: WeightBytes::Owned(packed),
4398            scale: WeightBytes::Owned(scale),
4399            rows,
4400            cols,
4401        };
4402        let mxfp4_out = mxfp4_matrix.apply(&x);
4403
4404        assert_eq!(f32_out.len(), rows);
4405        assert_eq!(mxfp4_out.len(), rows);
4406        for (f, m) in f32_out.iter().zip(mxfp4_out.iter()) {
4407            assert!((f - m).abs() < 1e-3, "f32={f} mxfp4={m}");
4408        }
4409    }
4410
4411    #[test]
4412    fn mxfp4_apply_batch_matches_sequential_apply_calls() {
4413        let rows = 3;
4414        let cols = 64;
4415        let packed = pseudo_bytes(3, rows * (cols / 2));
4416        let scale = pseudo_mxfp4_scale_bytes(4, rows * (cols / frink_quant::MXFP4_GROUP_SIZE));
4417        let matrix = WeightMatrix::Mxfp4 {
4418            packed: WeightBytes::Owned(packed),
4419            scale: WeightBytes::Owned(scale),
4420            rows,
4421            cols,
4422        };
4423
4424        let batch_size = 4;
4425        let x_batch: Vec<f32> = (0..batch_size * cols)
4426            .map(|i| ((i % 13) as f32) * 0.02 - 0.15)
4427            .collect();
4428
4429        let batched = matrix.apply_batch(&x_batch, batch_size);
4430        assert_eq!(batched.len(), batch_size * rows);
4431
4432        for b in 0..batch_size {
4433            let x = &x_batch[b * cols..(b + 1) * cols];
4434            let sequential = matrix.apply(x);
4435            let from_batch = &batched[b * rows..(b + 1) * rows];
4436            assert_eq!(
4437                sequential, from_batch,
4438                "batch row {b} disagrees with sequential apply()"
4439            );
4440        }
4441    }
4442
4443    #[test]
4444    fn mxfp4_resident_bytes_matches_the_packed_plus_scale_byte_count_not_eager_f32() {
4445        let rows = 2;
4446        let cols = 64;
4447        let packed = pseudo_bytes(5, rows * (cols / 2));
4448        let scale = pseudo_mxfp4_scale_bytes(6, rows * (cols / frink_quant::MXFP4_GROUP_SIZE));
4449        let packed_len = packed.len();
4450        let scale_len = scale.len();
4451        let matrix = WeightMatrix::Mxfp4 {
4452            packed: WeightBytes::Owned(packed),
4453            scale: WeightBytes::Owned(scale),
4454            rows,
4455            cols,
4456        };
4457
4458        assert_eq!(matrix.resident_bytes(), packed_len + scale_len);
4459        // Real MXFP4 packs 2 values/byte plus 1 scale byte per 32
4460        // values -- resident_bytes should be far below the 4-bytes-
4461        // per-value eager-f32 footprint.
4462        let eager_f32_bytes = rows * cols * 4;
4463        assert!(
4464            matrix.resident_bytes() * 4 < eager_f32_bytes,
4465            "expected MXFP4 resident bytes well under 1/4 of eager f32: got {} vs {}",
4466            matrix.resident_bytes(),
4467            eager_f32_bytes
4468        );
4469    }
4470
4471    #[test]
4472    fn f32_and_quantized_paths_agree_within_quant_error() {
4473        // 1 row, 32 cols, values chosen to keep Q8_0 error small.
4474        let weights: Vec<f32> = (0..32).map(|i| ((i as f32) - 16.0) * 0.2).collect();
4475        let x: Vec<f32> = (0..32).map(|i| (i as f32) * 0.05 - 0.8).collect();
4476
4477        let f32_matrix = WeightMatrix::F32(Tensor::new(weights.clone(), vec![1, 32]));
4478        let f32_out = f32_matrix.apply(&x);
4479
4480        let packed = make_q8_0_row(&weights);
4481        let quant_matrix = WeightMatrix::Quantized {
4482            data: WeightBytes::Owned(packed),
4483            rows: 1,
4484            cols: 32,
4485            kind: QuantKind::Q8_0,
4486        };
4487        let quant_out = quant_matrix.apply(&x);
4488
4489        assert_eq!(f32_out.len(), 1);
4490        assert_eq!(quant_out.len(), 1);
4491        assert!(
4492            (f32_out[0] - quant_out[0]).abs() < 0.05,
4493            "f32={} quant={}",
4494            f32_out[0],
4495            quant_out[0]
4496        );
4497    }
4498
4499    #[test]
4500    fn quantized_resident_bytes_is_smaller_than_f32() {
4501        let weights = vec![0.1f32; 64]; // 2 rows x 32 cols
4502        let f32_matrix = WeightMatrix::F32(Tensor::new(weights.clone(), vec![2, 32]));
4503
4504        let mut packed = Vec::new();
4505        for chunk in weights.chunks(32) {
4506            packed.extend(frink_quant::quantize_q8_0(chunk));
4507        }
4508        let quant_matrix = WeightMatrix::Quantized {
4509            data: WeightBytes::Owned(packed),
4510            rows: 2,
4511            cols: 32,
4512            kind: QuantKind::Q8_0,
4513        };
4514
4515        assert_eq!(f32_matrix.resident_bytes(), 64 * 4); // 256 bytes
4516        assert_eq!(quant_matrix.resident_bytes(), 2 * 34); // 68 bytes
4517        assert!(quant_matrix.resident_bytes() < f32_matrix.resident_bytes());
4518        // Q8_0 should be close to the theoretical ~4x reduction vs f32.
4519        let ratio = f32_matrix.resident_bytes() as f32 / quant_matrix.resident_bytes() as f32;
4520        assert!(ratio > 3.5, "expected ~4x reduction, got {ratio}x");
4521    }
4522
4523    #[test]
4524    fn rows_and_cols_report_correctly_for_both_variants() {
4525        let f32_matrix = WeightMatrix::F32(Tensor::new(vec![0.0; 6], vec![2, 3]));
4526        assert_eq!(f32_matrix.rows(), 2);
4527        assert_eq!(f32_matrix.cols(), 3);
4528
4529        let quant_matrix = WeightMatrix::Quantized {
4530            data: WeightBytes::Owned(vec![0u8; 34]),
4531            rows: 1,
4532            cols: 32,
4533            kind: QuantKind::Q8_0,
4534        };
4535        assert_eq!(quant_matrix.rows(), 1);
4536        assert_eq!(quant_matrix.cols(), 32);
4537    }
4538
4539    #[test]
4540    #[should_panic]
4541    fn apply_panics_on_activation_length_mismatch() {
4542        let f32_matrix = WeightMatrix::F32(Tensor::new(vec![0.0; 6], vec![2, 3]));
4543        f32_matrix.apply(&[1.0, 2.0]); // wrong length (needs 3)
4544    }
4545
4546    #[test]
4547    fn apply_batch_with_batch_size_one_matches_apply() {
4548        // Pinned, not inherited. This asserts `apply` and `apply_batch`
4549        // are BIT-identical, which is only true while both take the same
4550        // kernel -- and since #152 they do not on x86, where the batch
4551        // half of the int-dot tier is taken and the matvec half is not.
4552        // The override is process-global, so without the guard a
4553        // concurrent test holding it on decides this one's result.
4554        let _int_dot = ForceIntDot::new(false);
4555        let weights: Vec<f32> = (0..32).map(|i| (i as f32 - 16.0) * 0.13).collect();
4556        let x: Vec<f32> = (0..32).map(|i| (i as f32) * 0.02 - 0.3).collect();
4557
4558        let f32_matrix = WeightMatrix::F32(Tensor::new(weights.clone(), vec![1, 32]));
4559        let single = f32_matrix.apply(&x);
4560        let batched = f32_matrix.apply_batch(&x, 1);
4561        assert_eq!(single, batched);
4562
4563        let packed = frink_quant::quantize_q8_0(&weights);
4564        let quant_matrix = WeightMatrix::Quantized {
4565            data: WeightBytes::Owned(packed),
4566            rows: 1,
4567            cols: 32,
4568            kind: QuantKind::Q8_0,
4569        };
4570        let single_q = quant_matrix.apply(&x);
4571        let batched_q = quant_matrix.apply_batch(&x, 1);
4572        assert_eq!(single_q, batched_q);
4573    }
4574
4575    #[test]
4576    fn apply_batch_matches_sequential_apply_calls_for_each_row_f32() {
4577        let rows = 3;
4578        let cols = 32;
4579        let weights: Vec<f32> = (0..rows * cols)
4580            .map(|i| ((i % 17) as f32 - 8.0) * 0.05)
4581            .collect();
4582        let matrix = WeightMatrix::F32(Tensor::new(weights, vec![rows, cols]));
4583
4584        let batch_size = 4;
4585        let x_batch: Vec<f32> = (0..batch_size * cols)
4586            .map(|i| ((i % 13) as f32) * 0.03 - 0.2)
4587            .collect();
4588
4589        let batched = matrix.apply_batch(&x_batch, batch_size);
4590        assert_eq!(batched.len(), batch_size * rows);
4591
4592        for b in 0..batch_size {
4593            let x = &x_batch[b * cols..(b + 1) * cols];
4594            let sequential = matrix.apply(x);
4595            let from_batch = &batched[b * rows..(b + 1) * rows];
4596            assert_eq!(
4597                sequential, from_batch,
4598                "batch row {b} disagrees with sequential apply()"
4599            );
4600        }
4601    }
4602
4603    #[test]
4604    fn apply_batch_matches_sequential_apply_calls_for_each_row_quantized() {
4605        let rows = 3;
4606        let cols = 32;
4607        let weights: Vec<f32> = (0..rows * cols)
4608            .map(|i| ((i % 19) as f32 - 9.0) * 0.07)
4609            .collect();
4610        let mut packed = Vec::new();
4611        for row in weights.chunks(cols) {
4612            packed.extend(frink_quant::quantize_q8_0(row));
4613        }
4614        let matrix = WeightMatrix::Quantized {
4615            data: WeightBytes::Owned(packed),
4616            rows,
4617            cols,
4618            kind: QuantKind::Q8_0,
4619        };
4620
4621        let batch_size = 5;
4622        let x_batch: Vec<f32> = (0..batch_size * cols)
4623            .map(|i| ((i % 11) as f32) * 0.04 - 0.25)
4624            .collect();
4625
4626        let batched = matrix.apply_batch(&x_batch, batch_size);
4627        assert_eq!(batched.len(), batch_size * rows);
4628
4629        for b in 0..batch_size {
4630            let x = &x_batch[b * cols..(b + 1) * cols];
4631            let sequential = matrix.apply(x);
4632            let from_batch = &batched[b * rows..(b + 1) * rows];
4633            assert_batch_row_matches(QuantKind::Q8_0, "", b, &sequential, from_batch);
4634        }
4635    }
4636
4637    /// Minimal f16 encode for small positive normals (test fixtures only).
4638    pub(super) fn f16_le(x: f32) -> [u8; 2] {
4639        let bits = x.to_bits();
4640        let exp = ((bits >> 23) & 0xff) as i32 - 127 + 15;
4641        let mant = (bits >> 13) & 0x3ff;
4642        (((exp as u16) << 10) | mant as u16).to_le_bytes()
4643    }
4644
4645    /// Deterministic pseudo-random quantized matrix: every byte pattern is
4646    /// a valid weight block, only the f16 scale fields need sane values.
4647    /// Compare one row of `apply_batch` against `apply`, scaled by the
4648    /// magnitude of the row rather than of each element.
4649    ///
4650    /// The element-wise denominator (`err / s.abs().max(1.0)`) is wrong
4651    /// for a dot product over random data: the sums cancel, so a result
4652    /// that lands near zero turns a normal rounding difference into a
4653    /// relative error of 30%. Measured on Metal, the divergence is a
4654    /// uniform 5.5e-4 of the row's own scale across every quant kind
4655    /// and batch index, and up to 2.9e-1 of the individual result. The
4656    /// first number describes the arithmetic; the second describes
4657    /// which results happened to cancel.
4658    ///
4659    /// This matters because `apply_batch` is not `apply` on a GPU
4660    /// build: `apply_batch` dispatches to Metal while `apply` stays on
4661    /// the CPU, so this compares two backends. The bound stays tight on
4662    /// CPU, where both sides are the same code and must agree closely.
4663    fn assert_batch_row_matches(
4664        kind: QuantKind,
4665        ctx: &str,
4666        b: usize,
4667        sequential: &[f32],
4668        from_batch: &[f32],
4669    ) {
4670        let scale = sequential
4671            .iter()
4672            .fold(0.0f32, |a, v| a.max(v.abs()))
4673            .max(1.0);
4674        // A GPU build compares Metal against the CPU; a CPU build
4675        // compares the CPU against itself -- UNLESS this host takes only
4676        // one half of the int-dot tier, in which case `apply` and
4677        // `apply_batch` are not the same arithmetic at all.
4678        //
4679        // That is x86 since #152: the batch half runs the AVX2
4680        // interleaved GEMM over an int8-quantized activation while the
4681        // matvec half stays on the f32 AVX2 dot, because the int8 matvec
4682        // measured 4x to 8.8x slower there. The gap between the two
4683        // sides is then the ACTIVATION quantization floor -- each element
4684        // of `x` moves by up to `d/2` at `d = amax/127` -- not float
4685        // summation order, and a 1e-4 bar describes the wrong thing.
4686        //
4687        // Measured across every shape in these tests on a linux/amd64
4688        // container with real AVX2 (2026-09-09): worst 7.9e-3 of the row
4689        // scale. 6e-2 keeps a 7.6x margin, the same discipline as
4690        // `int_dot_batch_matches_dequant_dot_reference`, and is still far
4691        // inside a mis-pack, which decorrelates the two outputs entirely.
4692        let mixed = cpu_int_dot_for(IntDotShape::Matvec) != cpu_int_dot_for(IntDotShape::BatchGemm);
4693        let bound = if cfg!(any(feature = "metal", feature = "cuda")) {
4694            5e-3
4695        } else if mixed {
4696            6e-2
4697        } else {
4698            1e-4
4699        };
4700        for (r, (s, got)) in sequential.iter().zip(from_batch.iter()).enumerate() {
4701            let err = (s - got).abs() / scale;
4702            assert!(
4703                err < bound,
4704                "{kind:?} {ctx} batch {b} row {r}: apply()={s} apply_batch={got} \
4705                 (err {err:e} of row scale {scale}, bound {bound:e})"
4706            );
4707        }
4708    }
4709
4710    fn synth_quant_matrix(kind: QuantKind, rows: usize, cols: usize) -> WeightMatrix {
4711        let mut state = 0x1234_5678u32;
4712        let mut next = move || {
4713            state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
4714            (state >> 24) as u8
4715        };
4716        let mut data = Vec::new();
4717        match kind {
4718            QuantKind::Q8_0 | QuantKind::Q4_0 => {
4719                let qs = if kind == QuantKind::Q8_0 { 32 } else { 16 };
4720                for _ in 0..rows * (cols / 32) {
4721                    data.extend_from_slice(&f16_le(0.02 + f32::from(next()) * 0.0004));
4722                    for _ in 0..qs {
4723                        data.push(next());
4724                    }
4725                }
4726            }
4727            QuantKind::Q4K | QuantKind::Q5K => {
4728                let body = if kind == QuantKind::Q4K {
4729                    12 + 128
4730                } else {
4731                    12 + 32 + 128
4732                };
4733                for _ in 0..rows * (cols / 256) {
4734                    data.extend_from_slice(&f16_le(0.01 + f32::from(next()) * 0.0002));
4735                    data.extend_from_slice(&f16_le(0.005 + f32::from(next()) * 0.0001));
4736                    for _ in 0..body {
4737                        data.push(next());
4738                    }
4739                }
4740            }
4741            QuantKind::Q6K => {
4742                for _ in 0..rows * (cols / 256) {
4743                    for _ in 0..128 + 64 + 16 {
4744                        data.push(next());
4745                    }
4746                    data.extend_from_slice(&f16_le(0.01 + f32::from(next()) * 0.0002));
4747                }
4748            }
4749            _ => unreachable!("synth_quant_matrix: unsupported kind"),
4750        }
4751        WeightMatrix::Quantized {
4752            data: WeightBytes::Owned(data),
4753            rows,
4754            cols,
4755            kind,
4756        }
4757    }
4758    /// One `apply_batch` vs per-row `apply` sweep, parameterized by shape
4759    /// so the shape tests below differ only in the numbers they pass.
4760    fn assert_apply_batch_matches_apply(
4761        kind: QuantKind,
4762        rows: usize,
4763        cols: usize,
4764        batch_size: usize,
4765        seed: usize,
4766    ) {
4767        let x_batch: Vec<f32> = (0..batch_size * cols)
4768            .map(|i| (((i * 31 + seed) % 97) as f32) * 0.021 - 1.0)
4769            .collect();
4770        let matrix = synth_quant_matrix(kind, rows, cols);
4771        let batched = matrix.apply_batch(&x_batch, batch_size);
4772        assert_eq!(batched.len(), batch_size * rows);
4773        let ctx = format!(
4774            "rows {rows} cols {cols} batch_size {batch_size} int_dot {}",
4775            cpu_int_dot_for(IntDotShape::BatchGemm)
4776        );
4777        for b in 0..batch_size {
4778            let x = &x_batch[b * cols..(b + 1) * cols];
4779            let sequential = matrix.apply(x);
4780            let from_batch = &batched[b * rows..(b + 1) * rows];
4781            // Delegates rather than restating the bound. The first
4782            // version of this helper compared each element against
4783            // `s.abs().max(1.0)`, which is a bare 1e-4 ABSOLUTE bound
4784            // for any row whose value is small -- and a dot product of
4785            // 512 terms that cancels to -0.76 carries the rounding of
4786            // the terms, not of the result. It passed on aarch64 and
4787            // failed on x86_64 CI at 1.07e-4, on one row out of 17094.
4788            // `assert_batch_row_matches` already divides by the row
4789            // vector's own scale, which is the invariant that makes the
4790            // comparison meaningful, and it is now the only place the
4791            // tolerance is written down.
4792            assert_batch_row_matches(kind, &ctx, b, &sequential, from_batch);
4793        }
4794    }
4795
4796    const BATCH_SHAPE_KINDS: [QuantKind; 5] = [
4797        QuantKind::Q8_0,
4798        QuantKind::Q4_0,
4799        QuantKind::Q4K,
4800        QuantKind::Q5K,
4801        QuantKind::Q6K,
4802    ];
4803
4804    /// `apply_batch` writes straight into the `[batch][rows]` output from
4805    /// parallel tasks (no staging transpose); the shapes here force every
4806    /// write pattern: full row-groups, a tail of leftover rows, and both
4807    /// full and partial activation tiles.
4808    ///
4809    /// Run under both settings of [`cpu_int_dot_enabled`]. With int-dot
4810    /// off, `apply_batch` dequantizes and the repack tier is skipped
4811    /// entirely; with it on -- which is what every shipped binary does,
4812    /// via `default_cpu_int_dot_on` -- the interleaved `block_q*_Kx8` /
4813    /// `block_q*_0x4` kernels and, on an i8mm host, the SMMLA GEMMs are
4814    /// the code under test. `cargo test` leaves the env var unset, so
4815    /// without [`ForceIntDot`] only the first of those two ever ran.
4816    #[test]
4817    fn apply_batch_matches_apply_across_kinds_with_groups_and_tail() {
4818        for int_dot in [false, true] {
4819            let _g = ForceIntDot::new(int_dot);
4820            for kind in BATCH_SHAPE_KINDS {
4821                // 19 rows = 2x8-row groups + 3 tail (4x4-row groups + 3
4822                // for Q8_0/Q4_0); 6 activations = one full 4-tile + a
4823                // partial one.
4824                assert_apply_batch_matches_apply(kind, 19, 512, 6, 7);
4825            }
4826        }
4827    }
4828
4829    /// Shapes too small to fill one interleaved row-group, which the
4830    /// tests around this one never reach: they use `rows` big enough that
4831    /// `n_groups > 0` for every kind. At `rows < 8` the K-quant arms take
4832    /// their `else` branch (per-row `gemm_q*_k_q8_row`) with the repack
4833    /// path completely bypassed, and at `rows < 4` the Q8_0/Q4_0 arms do
4834    /// the same with `dot_q*_q8`. `rows = 5` is the mixed case: one full
4835    /// `block_q*_0x4` group plus a 1-row tail for Q8_0/Q4_0, zero groups
4836    /// for the Kx8 kinds. `rows = 1` is the single-row case.
4837    ///
4838    /// `cols = 256` is also the minimum K for a K-quant -- a single
4839    /// super-block, so every kernel's block loop runs exactly one trip.
4840    /// `batch_size = 1` is the single-column case: one activation in the
4841    /// quad, `na = 1` with three zero-padded lanes in `Q8KActsX4` /
4842    /// `Q8ActsX4`.
4843    #[test]
4844    fn apply_batch_matches_apply_for_sub_tile_shapes() {
4845        for int_dot in [false, true] {
4846            let _g = ForceIntDot::new(int_dot);
4847            for kind in BATCH_SHAPE_KINDS {
4848                for rows in [1, 2, 3, 5, 7] {
4849                    for batch_size in [1, 2, 5] {
4850                        assert_apply_batch_matches_apply(kind, rows, 256, batch_size, 13);
4851                    }
4852                }
4853            }
4854        }
4855    }
4856
4857    /// `apply_batch` under int-dot against an f32 dequantize-and-dot
4858    /// reference that never touches the packed buffer.
4859    ///
4860    /// Every other batch test compares `apply_batch` against `apply`,
4861    /// which under int-dot is the packed **GEMV** against the packed
4862    /// **GEMM** -- two kernels reading the *same* interleaved bytes. That
4863    /// catches a bad kernel but is structurally blind to a bad
4864    /// `pack_q*_matrix_x*`: both sides read the same wrong bytes and
4865    /// agree. `dequant_row` is the only reference in the tree that
4866    /// re-derives the weights from the canonical GGUF blocks, so it is
4867    /// the only one that can see a mis-interleave.
4868    ///
4869    /// The bound is the Q8/Q8_K *activation* quantization floor, not the
4870    /// kernel's, and it is scaled by the RMS of the reference outputs
4871    /// rather than per element: these synthetic weights are uniform
4872    /// random bytes, so individual dots cancel to near zero and a
4873    /// per-element relative bound would be meaningless. Worst deviation
4874    /// measured across every shape below, on an M2 Pro (i8mm), is 0.016 x
4875    /// RMS; 0.12 keeps a 7x margin. Coarse on purpose -- a mis-pack
4876    /// decorrelates the output from the reference entirely (measured at
4877    /// 2.07 x RMS for a one-row shift in the Q5_K `qh` interleave), an
4878    /// order of magnitude past this bound.
4879    #[test]
4880    fn int_dot_batch_matches_dequant_dot_reference() {
4881        let _g = ForceIntDot::new(true);
4882        // The batch half needs a SIMD `x4` GEMM, so a host without
4883        // one (an x86 box with no AVX2, Rosetta included) has no
4884        // packed path to test. Skipping is honest; asserting would
4885        // make the suite red for a host that is behaving correctly.
4886        assert!(cpu_int_dot_enabled(), "forcing on must enable int dot");
4887        if !cpu_int_dot_for(IntDotShape::BatchGemm) {
4888            return;
4889        }
4890        for kind in BATCH_SHAPE_KINDS {
4891            // Rows straddle both tile widths: below the tile, one short
4892            // of it, exactly it, one past it, and multi-group with a
4893            // tail. Batch straddles the 4-wide activation quad. cols 256
4894            // is the minimum K for a K-quant (one super-block).
4895            for rows in [1, 3, 5, 7, 8, 9, 19] {
4896                for cols in [256, 512] {
4897                    for batch_size in [1, 3, 4, 9] {
4898                        assert_int_dot_matches_dequant_dot(kind, rows, cols, batch_size, 23);
4899                    }
4900                }
4901            }
4902        }
4903        // Q8_0/Q4_0 alone can go down to a single 32-element block.
4904        for kind in [QuantKind::Q8_0, QuantKind::Q4_0] {
4905            for rows in [1, 3, 4, 5, 11] {
4906                for batch_size in [1, 3, 4, 9] {
4907                    assert_int_dot_matches_dequant_dot(kind, rows, 32, batch_size, 29);
4908                }
4909            }
4910        }
4911    }
4912
4913    fn assert_int_dot_matches_dequant_dot(
4914        kind: QuantKind,
4915        rows: usize,
4916        cols: usize,
4917        batch_size: usize,
4918        seed: usize,
4919    ) {
4920        let x_batch: Vec<f32> = (0..batch_size * cols)
4921            .map(|i| (((i * 37 + seed) % 89) as f32) * 0.019 - 0.8)
4922            .collect();
4923        let matrix = synth_quant_matrix(kind, rows, cols);
4924        let got = matrix.apply_batch(&x_batch, batch_size);
4925        assert_eq!(got.len(), batch_size * rows);
4926
4927        let mut want = vec![0f32; batch_size * rows];
4928        for r in 0..rows {
4929            let w = matrix.dequant_row(r);
4930            assert_eq!(w.len(), cols);
4931            for b in 0..batch_size {
4932                let x = &x_batch[b * cols..(b + 1) * cols];
4933                want[b * rows + r] = w.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
4934            }
4935        }
4936        let rms = (want.iter().map(|v| v * v).sum::<f32>() / want.len() as f32).sqrt();
4937        for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
4938            let err = (g - w).abs();
4939            assert!(
4940                err < 0.12 * rms.max(1e-3),
4941                "{kind:?} rows {rows} cols {cols} batch_size {batch_size} [flat {i}]: \
4942                 int-dot={g} dequant-dot={w} (err {err}, rms {rms})"
4943            );
4944        }
4945    }
4946
4947    /// Large enough that `par_chunked_groups` builds a real 2D chunk grid
4948    /// (32 row-groups × 17 activation tiles) instead of falling back to
4949    /// one-chunk-per-thread — every (group, tile-range) seam in the
4950    /// chunked scatter is crossed. The smaller cross-kind test above
4951    /// covers the fallback path. Both int-dot settings, for the same
4952    /// reason as that test.
4953    #[test]
4954    fn apply_batch_chunked_grid_matches_apply() {
4955        for int_dot in [false, true] {
4956            let _g = ForceIntDot::new(int_dot);
4957            for kind in BATCH_SHAPE_KINDS {
4958                // 259 rows = 32 groups of 8 + 3 tail (64 of 4 + 3 for
4959                // Q8_0/Q4_0); 66 activations = 16 full 4-tiles + a
4960                // partial one.
4961                assert_apply_batch_matches_apply(kind, 259, 512, 66, 5);
4962            }
4963        }
4964    }
4965
4966    /// Sharing one quantized activation batch across projections must be
4967    /// invisible in the results: a matching `BatchActs` produces exactly
4968    /// what `apply_batch` produces (same quantization, same interleaved
4969    /// quads, same kernels), and a mismatched variant is ignored rather
4970    /// than misused.
4971    #[test]
4972    fn apply_batch_with_shared_acts_matches_apply_batch() {
4973        // Shared quads are built under one setting and consumed under
4974        // another if a concurrent test flips the global mid-run; pin it
4975        // on, which is also the setting that gives this test something
4976        // to compare.
4977        let _int_dot = ForceIntDot::new(true);
4978        let rows = 19;
4979        let cols = 512;
4980        let batch_size = 6;
4981        let x_batch: Vec<f32> = (0..batch_size * cols)
4982            .map(|i| (((i * 29 + 11) % 89) as f32) * 0.023 - 1.0)
4983            .collect();
4984        for kind in [
4985            QuantKind::Q8_0,
4986            QuantKind::Q4_0,
4987            QuantKind::Q4K,
4988            QuantKind::Q6K,
4989        ] {
4990            let matrix = synth_quant_matrix(kind, rows, cols);
4991            let baseline = matrix.apply_batch(&x_batch, batch_size);
4992
4993            let shared = matrix.quantize_batch_acts(&x_batch, batch_size);
4994            let with_shared = matrix.apply_batch_with_acts(&x_batch, batch_size, shared.as_ref());
4995            assert_eq!(
4996                baseline, with_shared,
4997                "{kind:?}: shared acts changed the result"
4998            );
4999
5000            let wrong = match kind {
5001                QuantKind::Q8_0 | QuantKind::Q4_0 => BatchActs::Q8K {
5002                    acts: Vec::new(),
5003                    tiles: Vec::new(),
5004                    cols,
5005                },
5006                _ => BatchActs::Q8 {
5007                    acts: Vec::new(),
5008                    tiles: Vec::new(),
5009                    cols,
5010                },
5011            };
5012            let with_wrong = matrix.apply_batch_with_acts(&x_batch, batch_size, Some(&wrong));
5013            assert_eq!(
5014                baseline, with_wrong,
5015                "{kind:?}: mismatched shared acts were not ignored"
5016            );
5017        }
5018    }
5019
5020    /// The interleaved quads now ride along with the activations, so the
5021    /// guard that decides whether a `shared` batch is usable has to cover
5022    /// them too -- and that guard is the one thing here that is not gated
5023    /// on `FRINK_CPU_INT_DOT`, so it is tested directly.
5024    ///
5025    /// A stale set is not a panic. The quads are indexed by super-block, so
5026    /// a batch prepared at another width either reads past its own end or
5027    /// silently dots the wrong columns; both surface as a wrong answer.
5028    /// What must happen instead is a local re-quantization with no quads,
5029    /// which is what the fresh-fallback assertions below pin.
5030    #[test]
5031    fn shared_acts_are_reused_only_at_the_matching_length_and_width() {
5032        let cols = 512;
5033        let batch_size = 7;
5034        let x_batch: Vec<f32> = (0..batch_size * cols)
5035            .map(|i| (((i * 37 + 5) % 83) as f32) * 0.019 - 0.9)
5036            .collect();
5037
5038        let acts: Vec<_> = (0..batch_size)
5039            .map(|b| frink_quant::quantize_activations_q8_k(&x_batch[b * cols..(b + 1) * cols]))
5040            .collect();
5041        let tiles: Vec<_> = acts
5042            .chunks(frink_quant::Q8K_ACTS_X4_NC)
5043            .map(|c| frink_quant::prepare_q8_k_acts_x4(c, cols))
5044            .collect();
5045        let n_tiles = tiles.len();
5046        let shared = BatchActs::Q8K { acts, tiles, cols };
5047
5048        let mut owned = Vec::new();
5049        let (got, quads) =
5050            WeightMatrix::q8k_acts(Some(&shared), &x_batch, batch_size, cols, &mut owned);
5051        assert_eq!(got.len(), batch_size);
5052        assert_eq!(
5053            quads.len(),
5054            n_tiles,
5055            "matching batch did not reuse its quads"
5056        );
5057        assert!(owned.is_empty(), "matching batch was re-quantized anyway");
5058
5059        // Same positions, another width: refuse and re-quantize.
5060        let mut owned = Vec::new();
5061        let (got, quads) =
5062            WeightMatrix::q8k_acts(Some(&shared), &x_batch, batch_size, 256, &mut owned);
5063        assert!(quads.is_empty(), "quads from another width were accepted");
5064        assert_eq!(got.len(), batch_size);
5065        assert_eq!(got[0].n_blocks(), 1, "fallback did not quantize at 256");
5066
5067        // Same width, another position count: refuse and re-quantize.
5068        let mut owned = Vec::new();
5069        let (got, quads) =
5070            WeightMatrix::q8k_acts(Some(&shared), &x_batch[..cols], 1, cols, &mut owned);
5071        assert!(quads.is_empty(), "quads for another batch were accepted");
5072        assert_eq!(got.len(), 1);
5073
5074        // The Q8_0 half of the same guard.
5075        let acts: Vec<_> = (0..batch_size)
5076            .map(|b| frink_quant::quantize_activations_q8(&x_batch[b * cols..(b + 1) * cols]))
5077            .collect();
5078        let tiles: Vec<_> = acts
5079            .chunks(frink_quant::Q8K_ACTS_X4_NC)
5080            .map(|c| frink_quant::prepare_q8_acts_x4(c, cols))
5081            .collect();
5082        let n_tiles = tiles.len();
5083        let shared = BatchActs::Q8 { acts, tiles, cols };
5084
5085        let mut owned = Vec::new();
5086        let (got, quads) =
5087            WeightMatrix::q8_acts(Some(&shared), &x_batch, batch_size, cols, &mut owned);
5088        assert_eq!(got.len(), batch_size);
5089        assert_eq!(
5090            quads.len(),
5091            n_tiles,
5092            "matching batch did not reuse its quads"
5093        );
5094
5095        let mut owned = Vec::new();
5096        let (got, quads) =
5097            WeightMatrix::q8_acts(Some(&shared), &x_batch, batch_size, 256, &mut owned);
5098        assert!(quads.is_empty(), "quads from another width were accepted");
5099        assert_eq!(got[0].n_blocks(), 8, "fallback did not quantize at 256");
5100    }
5101
5102    /// Whatever a projection would have built for itself, a sibling's
5103    /// shared batch must hand it the same thing. Q4_K, Q5_K and Q6_K read
5104    /// one Q8_K quad set between them, and Q8_0 and Q4_0 one Q8_0 set, so
5105    /// the donor's kind must not show through.
5106    ///
5107    /// Gated the same way the path itself is: with `FRINK_CPU_INT_DOT`
5108    /// off (the library default) `quantize_batch_acts` returns `None` and
5109    /// no projection consumes quads at all, so this asserts against the
5110    /// INT_DOT build. Run the suite both ways.
5111    #[test]
5112    fn shared_quads_are_what_each_consumer_would_have_built_itself() {
5113        // The early return below reads a process-global, so it has to be
5114        // pinned or a neighbour can turn the tier off between the check
5115        // and the assertions it guards.
5116        let _int_dot = ForceIntDot::new(true);
5117        if !cpu_int_dot_for(IntDotShape::BatchGemm) {
5118            return;
5119        }
5120        let rows = 24;
5121        let cols = 512;
5122        let batch_size = 7;
5123        let x_batch: Vec<f32> = (0..batch_size * cols)
5124            .map(|i| (((i * 37 + 5) % 83) as f32) * 0.019 - 0.9)
5125            .collect();
5126
5127        for (donor, consumers) in [
5128            (QuantKind::Q4K, &[QuantKind::Q5K, QuantKind::Q6K][..]),
5129            (QuantKind::Q8_0, &[QuantKind::Q4_0][..]),
5130        ] {
5131            let shared = synth_quant_matrix(donor, rows, cols)
5132                .quantize_batch_acts(&x_batch, batch_size)
5133                .expect("INT_DOT is on and this kind/width is eligible");
5134            for kind in consumers {
5135                let matrix = synth_quant_matrix(*kind, rows, cols);
5136                let baseline = matrix.apply_batch(&x_batch, batch_size);
5137                let shared_out = matrix.apply_batch_with_acts(&x_batch, batch_size, Some(&shared));
5138                assert_eq!(
5139                    baseline, shared_out,
5140                    "{kind:?} consuming {donor:?} quads changed the result"
5141                );
5142            }
5143        }
5144    }
5145
5146    #[test]
5147    fn apply_batch_with_zero_batch_size_returns_empty() {
5148        let matrix = WeightMatrix::F32(Tensor::new(vec![0.0; 6], vec![2, 3]));
5149        let out = matrix.apply_batch(&[], 0);
5150        assert!(out.is_empty());
5151    }
5152
5153    #[cfg(any(feature = "cuda", feature = "metal", feature = "vulkan"))]
5154    mod gpu_dispatch {
5155        use super::*;
5156
5157        /// `apply_gpu` must return `None` for `F32` -- and, crucially,
5158        /// without ever touching the CUDA driver at all (this runs on
5159        /// every CI machine, none of which have a GPU): the `let ...
5160        /// else { return None }` pattern match happens before any
5161        /// `frink_cuda` call, so this is a real, meaningful assertion
5162        /// about dispatch behavior, not a stub.
5163        #[test]
5164        fn apply_gpu_returns_none_for_f32() {
5165            let matrix = WeightMatrix::F32(Tensor::new(vec![0.0; 6], vec![2, 3]));
5166            assert!(matrix.apply_gpu(&[0.0, 0.0, 0.0]).is_none());
5167        }
5168
5169        #[test]
5170        fn apply_gpu_returns_none_for_mxfp4() {
5171            let matrix = WeightMatrix::Mxfp4 {
5172                packed: WeightBytes::Owned(vec![0u8; 32]),
5173                scale: WeightBytes::Owned(vec![0u8; 2]),
5174                rows: 1,
5175                cols: 64,
5176            };
5177            assert!(matrix.apply_gpu(&vec![0.0; 64]).is_none());
5178        }
5179
5180        /// A `Quantized` matrix whose `kind` has no GPU kernel on any
5181        /// compiled backend must fall back to `None`, not panic on the
5182        /// `unreachable!()` in `block_bytes_for_kind` -- proving the
5183        /// two match arms (`apply_gpu`'s launch table,
5184        /// `block_bytes_for_kind`'s partial one) stay in sync.
5185        ///
5186        /// The probe was `Q2_K` until 2026-09-09, when Q2_K gained a
5187        /// CUDA matvec and a GEMM and stopped being unsupported. `Q4_1`
5188        /// has neither on any backend and is the hole now. Moving it
5189        /// found a real defect rather than being bookkeeping: with the
5190        /// `cuda` feature on and no driver present, the first real
5191        /// dispatch through `Cuda::launch_matvec` aborted the process
5192        /// inside `cudarc`'s library loader, which that arm's
5193        /// `Result` could never have reported.
5194        #[test]
5195        fn apply_gpu_returns_none_for_an_unsupported_quant_kind() {
5196            let matrix = WeightMatrix::Quantized {
5197                data: WeightBytes::Owned(vec![0u8; frink_quant::Q4_1_BLOCK_BYTES]),
5198                rows: 1,
5199                cols: frink_quant::Q4_1_BLOCK_ELEMS,
5200                kind: QuantKind::Q4_1,
5201            };
5202            assert!(matrix
5203                .apply_gpu(&[0.0; frink_quant::Q4_1_BLOCK_ELEMS])
5204                .is_none());
5205        }
5206
5207        #[test]
5208        #[ignore = "requires real GPU hardware (CUDA or Metal) -- run with --ignored"]
5209        fn apply_gpu_matches_apply_for_q8_0_on_real_hardware() {
5210            let weights: Vec<f32> = (0..64).map(|i| ((i as f32) - 32.0) * 0.05).collect();
5211            let x: Vec<f32> = (0..64).map(|i| (i as f32) * 0.01 - 0.3).collect();
5212            let packed = frink_quant::quantize_q8_0(&weights);
5213            let matrix = WeightMatrix::Quantized {
5214                data: WeightBytes::Owned(packed),
5215                rows: 1,
5216                cols: 64,
5217                kind: QuantKind::Q8_0,
5218            };
5219
5220            let cpu = matrix.apply_cpu(&x);
5221            let gpu = matrix
5222                .apply_gpu(&x)
5223                .expect("Q8_0 must dispatch to a real GPU kernel");
5224            assert_eq!(cpu.len(), gpu.len());
5225            for (c, g) in cpu.iter().zip(gpu.iter()) {
5226                assert!((c - g).abs() < 1e-2, "cpu={c} gpu={g}");
5227            }
5228        }
5229    }
5230
5231    // ---- kernel-lookup registry coverage -------------------------------
5232    //
5233    // These are the tests that would have caught the IQ4_XS silent CPU
5234    // prefill at `cargo test` time instead of via a 13.7x benchmark row.
5235
5236    /// A quantized matrix of `kind` with `cols` columns, filled with
5237    /// arbitrary bytes -- the probe reads only shape and kind, never the
5238    /// weights, so the contents are irrelevant.
5239    fn shaped(kind: QuantKind, rows: usize, cols: usize) -> WeightMatrix {
5240        let per_row = match kind {
5241            QuantKind::Q8_0 => cols / 32 * 34,
5242            _ => cols,
5243        };
5244        WeightMatrix::Quantized {
5245            data: WeightBytes::Owned(vec![0u8; rows * per_row.max(1)]),
5246            rows,
5247            cols,
5248            kind,
5249        }
5250    }
5251
5252    /// `QuantKind::ALL` must actually list every variant. `name()` is
5253    /// exhaustive by the compiler, so distinct names prove distinct
5254    /// variants; the count pins that none was dropped from the list.
5255    #[test]
5256    fn quant_kind_all_lists_every_variant_exactly_once() {
5257        let mut names: Vec<&str> = QuantKind::ALL.iter().map(|k| k.name()).collect();
5258        let total = names.len();
5259        names.sort_unstable();
5260        names.dedup();
5261        assert_eq!(names.len(), total, "QuantKind::ALL has a duplicate");
5262        assert_eq!(
5263            total, 23,
5264            "a QuantKind variant was added without updating ALL"
5265        );
5266    }
5267
5268    /// The invariant that keeps prefill honest: every kind with a Metal
5269    /// matvec also has a Metal batched GEMM. Break it and the kind still
5270    /// "runs on Metal" -- as `batch` separate matvecs over the same
5271    /// weights, which is exactly the shape that put IQ4_XS 13.7x behind
5272    /// with no symptom other than a slow benchmark.
5273    #[test]
5274    fn every_metal_matvec_kind_also_has_a_metal_gemm() {
5275        for &k in QuantKind::ALL {
5276            assert_eq!(
5277                metal_matvec_kind_name(k).is_some(),
5278                metal_mul_mm_kind_supported(k),
5279                "{}: matvec and mul_mm kernel tables disagree -- one of the two \
5280                 is a silent slow path",
5281                k.name()
5282            );
5283        }
5284    }
5285
5286    /// The kind tables are pure lookups over the name, so a kind that
5287    /// claims a kernel must name itself the way the Metal launch meta
5288    /// table is keyed.
5289    #[test]
5290    fn metal_kind_names_match_the_quant_kind_names() {
5291        for &k in QuantKind::ALL {
5292            if let Some(name) = metal_matvec_kind_name(k) {
5293                assert_eq!(name, k.name());
5294            }
5295        }
5296    }
5297
5298    /// THE registry test: a kind with no accelerator kernel, probed
5299    /// while the model is built, must be recorded as a miss and must be
5300    /// a seal-time violation -- not silently absorbed by a fallback.
5301    ///
5302    /// Runs on any build: the backend is passed explicitly, so it does
5303    /// not need `--features metal` to ask what Metal would resolve.
5304    #[test]
5305    fn a_deliberately_unsupported_kind_trips_the_registry() {
5306        use crate::kernel_registry::{Backend, Outcome};
5307
5308        let reg = crate::kernel_registry::Registry::new();
5309        let loc = std::panic::Location::caller();
5310
5311        // Supported: Q4_K has both a Metal matvec and a Metal GEMM.
5312        shaped(QuantKind::Q4K, 64, 256).probe_kernels_for(&reg, Backend::Metal, "ffn_down", loc);
5313        // Unsupported: no Metal kernel of any kind for IQ2_XXS.
5314        shaped(QuantKind::IQ2XXS, 64, 256).probe_kernels_for(&reg, Backend::Metal, "ffn_up", loc);
5315
5316        let report = reg.seal();
5317        let violations = &report.violations;
5318        assert_eq!(
5319            violations.len(),
5320            2,
5321            "expected matvec + gemm misses for IQ2_XXS only, got: {:?}",
5322            report
5323                .entries
5324                .iter()
5325                .map(|e| e.to_string())
5326                .collect::<Vec<_>>()
5327        );
5328        assert!(
5329            violations
5330                .iter()
5331                .all(|v| v.key.kind == Some(QuantKind::IQ2XXS)),
5332            "Q4_K must not be flagged"
5333        );
5334        assert!(
5335            violations.iter().any(|v| matches!(
5336                v.outcome,
5337                Outcome::Miss { fallback, .. } if fallback == "CPU apply_batch"
5338            )),
5339            "the report must name the fallback that will actually run"
5340        );
5341        let rendered = report.render_violations();
5342        assert!(rendered.contains("IQ2_XXS"), "{rendered}");
5343        assert!(rendered.contains("weight_matrix.rs"), "{rendered}");
5344
5345        // And the host tier it lands on is recorded too: IQ2_XXS has no
5346        // integer vec_dot either, so it is f32 dequant-dot.
5347        assert!(
5348            report.entries.iter().any(|e| e.key.backend == Backend::Cpu
5349                && e.key.kind == Some(QuantKind::IQ2XXS)
5350                && matches!(e.outcome, Outcome::Miss { fallback, .. } if fallback == "f32 dequant-dot")),
5351            "{:?}",
5352            report.entries.iter().map(|e| e.to_string()).collect::<Vec<_>>()
5353        );
5354    }
5355
5356    /// A supported kind on a selected accelerator produces no violation
5357    /// at all -- otherwise the signal is noise and gets ignored.
5358    #[test]
5359    fn a_fully_supported_model_seals_clean() {
5360        use crate::kernel_registry::Backend;
5361
5362        let reg = crate::kernel_registry::Registry::new();
5363        let loc = std::panic::Location::caller();
5364        for kind in [QuantKind::Q4K, QuantKind::Q6K, QuantKind::Q8_0] {
5365            shaped(kind, 64, 256).probe_kernels_for(&reg, Backend::Metal, "ffn_down", loc);
5366        }
5367        let report = reg.seal();
5368        assert!(report.violations.is_empty(), "{}", report.render());
5369    }
5370
5371    /// A kind CUDA cannot run at all must be RECORDED as leaving the
5372    /// GPU, by name, rather than left to a comment in
5373    /// `apply_batch_with_acts`.
5374    ///
5375    /// This test used to probe `Q4K` and expect the fallback
5376    /// `"CUDA per-position matvec"`, which is what a kind gets when it
5377    /// has a matvec but no GEMM. **That combination no longer exists on
5378    /// CUDA.** The K-quants gained a GEMM on 2026-09-04, motivated by
5379    /// Llama-3.2-3B Q4_K_M running pp512 at 4.88 tok/s against
5380    /// llama.cpp's 1586.80, and the invariant below now forbids the
5381    /// combination from coming back.
5382    ///
5383    /// So the probe moved to a kind with neither kernel, and the
5384    /// expected fallback moved with it: with no matvec to loop over
5385    /// there is no per-position loop, and the whole matmul leaves for
5386    /// the host.
5387    ///
5388    /// It has moved three times. `Q5_0` was that kind until
5389    /// 2026-09-05, when it gained both; `Q2_K` was until 2026-09-09,
5390    /// when it and Q3_K did. `Q4_1` is the hole now, and the next row
5391    /// of the coverage table in `docs/plans/cpu-cuda-parity.md` §6 --
5392    /// which is the point: the test names a real hole and stops
5393    /// compiling a comment. When Q4_1 lands, this probe moves again.
5394    #[test]
5395    fn a_kind_cuda_cannot_run_is_recorded_as_leaving_the_gpu() {
5396        use crate::kernel_registry::{op, Backend, Outcome};
5397
5398        let reg = crate::kernel_registry::Registry::new();
5399        let loc = std::panic::Location::caller();
5400        shaped(QuantKind::Q4_1, 64, 256).probe_kernels_for(&reg, Backend::Cuda, "ffn_down", loc);
5401        let report = reg.seal();
5402        assert!(
5403            report.entries.iter().any(|e| e.key.backend == Backend::Cuda
5404                && e.key.op == op::GEMM_PREFILL
5405                && matches!(
5406                    e.outcome,
5407                    Outcome::Miss { fallback, .. } if fallback == "CPU apply_batch"
5408                )),
5409            "{}",
5410            report.render()
5411        );
5412    }
5413
5414    /// CUDA's matvec set and its GEMM set are now the same, and that is
5415    /// worth pinning: a kind that can be decoded on the GPU but not
5416    /// prefilled there is the shape that cost 325x, and it went
5417    /// unnoticed because a fallback still answers correctly.
5418    ///
5419    /// If a future kind gains a matvec without a GEMM, this fails and
5420    /// names it, rather than a benchmark noticing months later.
5421    #[test]
5422    fn a_cuda_kind_with_a_matvec_also_has_a_gemm() {
5423        for kind in QuantKind::ALL {
5424            if cuda_matvec_kind_supported(*kind) {
5425                assert!(
5426                    cuda_mul_mm_kind_supported(*kind),
5427                    "{kind:?} can be decoded on CUDA but not prefilled there, \
5428                     which decomposes a prefill into one matvec launch per position"
5429                );
5430            }
5431        }
5432    }
5433
5434    /// An F32 weight has no quantized kernel by construction; the probe
5435    /// records the host GEMV but must not call it a violation, or every
5436    /// MoE router would fail a strict run.
5437    #[test]
5438    fn an_f32_weight_is_recorded_without_being_a_violation() {
5439        use crate::kernel_registry::Backend;
5440
5441        let reg = crate::kernel_registry::Registry::new();
5442        let m = WeightMatrix::F32(Tensor::new(vec![0.0; 64 * 32], vec![64, 32]));
5443        m.probe_kernels_for(
5444            &reg,
5445            Backend::Metal,
5446            "moe_router",
5447            std::panic::Location::caller(),
5448        );
5449        let report = reg.seal();
5450        assert!(!report.misses.is_empty());
5451        assert!(report.violations.is_empty(), "{}", report.render());
5452    }
5453}
5454
5455#[cfg(test)]
5456mod int_dot_default_tests {
5457    use super::{IntDotShape, IntDotTier};
5458
5459    /// The int-dot rule follows the kernels that exist, per workload,
5460    /// not the wish that every architecture had every kernel.
5461    ///
5462    /// Taking the MATVEC half where the interleaved kernels do not exist
5463    /// selects a scalar integer loop and skips the AVX2 f32 dot that
5464    /// does, which measured 4x to 8.8x of x86 decode (#127). Adding AVX2
5465    /// GEMMs (#152) does not change that: they are batch kernels, and
5466    /// the matvec half of x86 is still the f32 dot's.
5467    #[test]
5468    fn the_matvec_half_is_taken_only_where_its_kernels_are() {
5469        assert_eq!(
5470            super::int_dot_tier_here().matvec,
5471            cfg!(target_arch = "aarch64"),
5472            "the matvec half is aarch64's (i8mm, interleave-8 NEON) and nowhere else; \
5473             x86 measured 4x to 8.8x slower with it on"
5474        );
5475    }
5476
5477    /// The BATCH half is not a `cfg!` claim: it asks the kernels.
5478    ///
5479    /// A host may only be told the batch tier is a win if `frink_quant`
5480    /// reports a SIMD batch GEMM at the width this host packs with. That
5481    /// is what stops the two structures — the list of architectures
5482    /// believed to have kernels, and the kernels — from drifting apart,
5483    /// which is how the 4x-to-8.8x regression happened in the first
5484    /// place.
5485    ///
5486    /// The probe is [`frink_quant::batch_gemm_is_accelerated`] and not
5487    /// `interleaved_gemm_is_accelerated`, because those are different
5488    /// questions and this one asked the narrower of the two. The
5489    /// interleave-8 predicate is about the quad kernels; a pre-i8mm
5490    /// aarch64 host runs a width-4 `dotprod` GEMM for four of the five
5491    /// kinds, so it has a SIMD batch GEMM while the interleave-8
5492    /// predicate says it does not.
5493    #[test]
5494    fn the_batch_half_is_taken_only_where_a_simd_gemm_answers_for_it() {
5495        assert_eq!(
5496            super::int_dot_tier_here().batch_gemm,
5497            frink_quant::batch_gemm_is_accelerated(frink_quant::preferred_interleave())
5498                && cfg!(any(target_arch = "aarch64", target_arch = "x86_64")),
5499            "the batch half must agree with the kernel probe, not with a written-down list"
5500        );
5501    }
5502
5503    /// The Q5_K batch gate asks the kernels: wherever the Q5_K `x4`
5504    /// GEMM has a SIMD kernel the Kx8 path is taken, whatever the
5505    /// architecture. A `cfg!` alone here is the defect this test exists
5506    /// for (8.56x on x86 prefill, 2026-09-15).
5507    #[test]
5508    fn the_q5k_batch_path_is_taken_wherever_its_simd_gemm_exists() {
5509        let interleave = frink_quant::q5_kx8_interleave();
5510        if frink_quant::q5_kx8_gemm_uses_acts_x4(interleave) {
5511            assert!(super::q5k_batch_takes_kx8(interleave));
5512        }
5513        // And on a host with no such kernel, the gate agrees with the
5514        // Q4_K arm's rule, which is "aarch64 always".
5515        assert!(super::q5k_batch_takes_kx8(interleave) || !cfg!(target_arch = "aarch64"));
5516    }
5517
5518    /// The two probes are not interchangeable, and this pins the
5519    /// difference so neither can quietly be swapped for the other.
5520    ///
5521    /// On a host with a width-4 SIMD batch GEMM and no interleave-8 one
5522    /// — every pre-i8mm aarch64 host, which is every M1 Mac, every
5523    /// A14-and-earlier iPhone, and the Cortex-A55-class cores that are
5524    /// still most of the Android fleet — the wider predicate says yes
5525    /// and the narrower says no. Reading the narrower as "is the batch
5526    /// tier a win" is what made this test fail on an M1 while the code
5527    /// was running a SIMD GEMM the whole time.
5528    #[test]
5529    fn the_batch_probe_is_wider_than_the_interleave_8_one() {
5530        for width in [4usize, 8] {
5531            assert!(
5532                frink_quant::batch_gemm_is_accelerated(width)
5533                    || !frink_quant::interleaved_gemm_is_accelerated(width),
5534                "the batch probe must answer yes wherever the interleave-8 one does"
5535            );
5536        }
5537        #[cfg(target_arch = "aarch64")]
5538        if std::arch::is_aarch64_feature_detected!("dotprod") {
5539            assert!(
5540                frink_quant::batch_gemm_is_accelerated(4),
5541                "a dotprod host runs the width-4 sdot GEMM for Q4_K/Q5_K/Q8_0/Q4_0"
5542            );
5543            assert!(
5544                !frink_quant::interleaved_gemm_is_accelerated(4),
5545                "the interleave-8 predicate is about the quad kernels only"
5546            );
5547        }
5548    }
5549
5550    /// `int_dot_is_a_win_here` — the thing `default_cpu_int_dot_on`
5551    /// consults — is the OR of the two halves, so a host with only the
5552    /// batch half still gets the env default it needs to reach it.
5553    #[test]
5554    fn the_default_is_on_when_either_half_is_a_win() {
5555        let tier = super::int_dot_tier_here();
5556        assert_eq!(
5557            super::int_dot_is_a_win_here(),
5558            tier.matvec || tier.batch_gemm
5559        );
5560    }
5561
5562    /// `covers` must actually separate the two shapes, in both
5563    /// directions — otherwise every call site below asks a question with
5564    /// one answer and the split is decoration.
5565    #[test]
5566    fn covers_answers_per_shape_rather_than_per_host() {
5567        let matvec_only = IntDotTier {
5568            matvec: true,
5569            batch_gemm: false,
5570        };
5571        let batch_only = IntDotTier {
5572            matvec: false,
5573            batch_gemm: true,
5574        };
5575        assert!(matvec_only.covers(IntDotShape::Matvec));
5576        assert!(!matvec_only.covers(IntDotShape::BatchGemm));
5577        assert!(!batch_only.covers(IntDotShape::Matvec));
5578        assert!(batch_only.covers(IntDotShape::BatchGemm));
5579    }
5580}