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