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