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