Skip to main content

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