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