Skip to main content

ferrox_cuda/matvec_kinds/
mod.rs

1//! The per-quant-kind matvec table: which formats CUDA can DECODE, and
2//! the three strings a launch needs for each.
3//!
4//! This is the decode counterpart of [`crate::mul_mm::KINDS`], and it
5//! is a table for the same reason. The same five rows were once written
6//! out three times -- here, inline in `ferrox-core`'s `apply_gpu_multi`
7//! and again in `apply_gpu_dense_ffn_swiglu` -- and a kind added to one
8//! and not the others silently lost the fused launch while the
9//! capability report kept saying GPU.
10//!
11//! # Always compiled
12//!
13//! Deliberately outside the `cuda` feature gate, like `mul_mm`. The
14//! rows are CUDA C *text* and three `&'static str`s; nothing here needs
15//! `cudarc`. That buys two things: `cargo test -p ferrox-cuda` on a
16//! GPU-less host still checks that every row names an entry point its
17//! source defines and that every embedded codebook agrees with the
18//! GEMM's, and `ferrox-core` can ask "does CUDA decode this kind?" on
19//! a build with no CUDA feature at all -- which is what lets
20//! `Cuda::matvec_kernel` DERIVE from this table instead of restating
21//! it. Restating it is how IQ4_XS ended up "supported" with no kernel.
22//!
23//! The launch itself ([`crate::gpu::matvec_launch_meta`] and the
24//! `launch_*_matvec` functions) stays feature-gated.
25
26pub mod codebook;
27pub mod kquant;
28pub mod legacy;
29
30/// One quantized weight format the CUDA matvec path can consume.
31///
32/// The `__global__` entry point named by [`Self::fn_name`] must have
33/// the signature every kernel in this directory shares:
34///
35/// ```text
36/// void <fn_name>(const unsigned char* weights, const float* x,
37///                float* out, int rows, int row_bytes,
38///                int n_blocks_per_row)
39/// ```
40///
41/// one threadblock per output row, 256 threads striding the row's
42/// blocks, a tree reduction into `out[row]`. `launch_matvec` supplies
43/// exactly that geometry, so a kernel written to a different one
44/// returns wrong numbers rather than failing.
45#[derive(Debug, Clone, Copy)]
46pub struct MatvecKind {
47    /// GGUF quant name -- the key `ferrox-core` looks a kind up by,
48    /// which is `QuantKind::name()`.
49    pub name: &'static str,
50    /// NVRTC module cache key. Must be unique per kind.
51    pub module_name: &'static str,
52    /// The `__global__` entry point inside that module.
53    pub fn_name: &'static str,
54    /// The complete translation unit defining it.
55    pub src: &'static str,
56}
57
58/// The dispatch table. A new format is one row here and nothing else.
59///
60/// Order is Q8_0, Q4_0, Q5_0, then the K-quants, then the codebook
61/// kinds, matching [`crate::mul_mm::KINDS`] so the two read as the same
62/// list -- which is what
63/// `ferrox_core`'s `a_cuda_kind_with_a_matvec_also_has_a_gemm` requires
64/// them to be.
65pub const KINDS: &[MatvecKind] = &[
66    MatvecKind {
67        name: "Q8_0",
68        module_name: "ferrox_q8_0",
69        fn_name: "q8_0_matvec",
70        src: legacy::Q8_0_MATVEC_KERNEL_SRC,
71    },
72    MatvecKind {
73        name: "Q4_0",
74        module_name: "ferrox_q4_0",
75        fn_name: "q4_0_matvec",
76        src: legacy::Q4_0_MATVEC_KERNEL_SRC,
77    },
78    MatvecKind {
79        name: "Q5_0",
80        module_name: "ferrox_q5_0",
81        fn_name: "q5_0_matvec",
82        src: legacy::Q5_0_MATVEC_KERNEL_SRC,
83    },
84    MatvecKind {
85        name: "Q2_K",
86        module_name: "ferrox_q2_k",
87        fn_name: "q2_k_matvec",
88        src: kquant::Q2_K_MATVEC_KERNEL_SRC,
89    },
90    MatvecKind {
91        name: "Q3_K",
92        module_name: "ferrox_q3_k",
93        fn_name: "q3_k_matvec",
94        src: kquant::Q3_K_MATVEC_KERNEL_SRC,
95    },
96    MatvecKind {
97        name: "Q4_K",
98        module_name: "ferrox_q4_k",
99        fn_name: "q4_k_matvec",
100        src: kquant::Q4_K_MATVEC_KERNEL_SRC,
101    },
102    MatvecKind {
103        name: "Q5_K",
104        module_name: "ferrox_q5_k",
105        fn_name: "q5_k_matvec",
106        src: kquant::Q5_K_MATVEC_KERNEL_SRC,
107    },
108    MatvecKind {
109        name: "Q6_K",
110        module_name: "ferrox_q6_k",
111        fn_name: "q6_k_matvec",
112        src: kquant::Q6_K_MATVEC_KERNEL_SRC,
113    },
114    MatvecKind {
115        name: "IQ4_NL",
116        module_name: "ferrox_iq4_nl",
117        fn_name: "iq4_nl_matvec",
118        src: codebook::IQ4_NL_MATVEC_KERNEL_SRC,
119    },
120    MatvecKind {
121        name: "IQ4_XS",
122        module_name: "ferrox_iq4_xs",
123        fn_name: "iq4_xs_matvec",
124        src: codebook::IQ4_XS_MATVEC_KERNEL_SRC,
125    },
126    MatvecKind {
127        name: "MXFP4",
128        module_name: "ferrox_mxfp4",
129        fn_name: "mxfp4_matvec",
130        src: codebook::MXFP4_MATVEC_KERNEL_SRC,
131    },
132];
133
134/// Looks up a kind by its GGUF quant name. `None` means CUDA has no
135/// matvec for that format and the caller must fall back and say so,
136/// never compute something else.
137pub fn kind_by_name(name: &str) -> Option<&'static MatvecKind> {
138    KINDS.iter().find(|k| k.name == name)
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    /// A row whose `fn_name` its source does not define is a
146    /// `KernelCompile` error at a user's first token. Runnable without
147    /// a device, because that failure needs no GPU to see.
148    #[test]
149    fn every_row_names_an_entry_point_its_source_defines() {
150        for k in KINDS {
151            assert!(
152                k.src.contains(&format!("void {}(", k.fn_name)),
153                "{}: the source in {} does not define {}",
154                k.name,
155                k.module_name,
156                k.fn_name
157            );
158            assert!(
159                k.src.contains("int n_blocks_per_row"),
160                "{}: does not take the launch geometry every caller supplies",
161                k.name
162            );
163        }
164        for (i, a) in KINDS.iter().enumerate() {
165            for b in &KINDS[i + 1..] {
166                assert_ne!(
167                    a.module_name, b.module_name,
168                    "{} and {} collide in the process-wide NVRTC module cache",
169                    a.name, b.name
170                );
171                assert_ne!(a.fn_name, b.fn_name, "{} vs {}", a.name, b.name);
172            }
173        }
174    }
175
176    /// A kind with no kernel must not resolve. Resolving sends a decode
177    /// to a module that cannot compile, and the caller has no way to
178    /// fall back honestly.
179    #[test]
180    fn a_kind_with_no_matvec_does_not_resolve() {
181        for absent in ["Q5_1", "Q4_1", "Q8_1", "IQ1_S", "IQ2_XXS", "IQ3_S"] {
182            assert!(
183                kind_by_name(absent).is_none(),
184                "{absent} resolved to a CUDA matvec that does not exist"
185            );
186        }
187    }
188
189    /// Every matvec kernel strides its row by a byte count written as
190    /// a LITERAL in CUDA C (`row_ptr + (size_t)b * 84`), and that
191    /// literal has to be the block size the format actually has.
192    ///
193    /// A wrong stride walks the row past the first block and every
194    /// value after it is garbage -- the failure Q5_0 got a bespoke test
195    /// for on 2026-09-05 (`the_q5_0_matvec_strides_by_the_real_block_
196    /// geometry`), one kind at a time. This is that test for the whole
197    /// table, driven from [`crate::mul_mm::KINDS`], which is where the
198    /// GEMM takes the same number from. Five kinds landed on
199    /// 2026-09-09 and none of them would have had one otherwise.
200    ///
201    /// The activation step is checked the same way: a kernel that steps
202    /// the INPUT by the wrong count pairs every quant after the first
203    /// block with the wrong activation.
204    ///
205    /// Matched per line rather than on one exact spelling, because the
206    /// six kernels that predate this test write the same offset three
207    /// ways (`b * 34`, `(size_t)b * 22`, `blk * 144`). Normalising them
208    /// is a separate change; a test that only accepted one spelling
209    /// would have to be weakened or would fail on code that is correct.
210    ///
211    /// Sabotage: change any stride or activation-step literal in a
212    /// kernel source and this names the kind.
213    #[test]
214    fn every_matvec_strides_by_the_real_block_geometry() {
215        for k in KINDS {
216            let mm = crate::mul_mm::kind_by_name(k.name)
217                .unwrap_or_else(|| panic!("{}: a matvec with no mul_mm row", k.name));
218
219            let row_step = k
220                .src
221                .lines()
222                .find(|l| l.contains("row_ptr +"))
223                .unwrap_or_else(|| panic!("{}: no row-pointer arithmetic", k.name));
224            assert!(
225                row_step.contains(&format!("* {};", mm.block_bytes)),
226                "{}: strides the row by something other than {} bytes: {}",
227                k.name,
228                mm.block_bytes,
229                row_step.trim()
230            );
231
232            let x_step = k
233                .src
234                .lines()
235                .find(|l| l.contains("base = ") && l.trim_end().ends_with(';'))
236                .unwrap_or_else(|| panic!("{}: no activation-base arithmetic", k.name));
237            assert!(
238                x_step.contains(&format!("* {};", mm.block_elems)),
239                "{}: steps the activation by something other than {} elements: {}",
240                k.name,
241                mm.block_elems,
242                x_step.trim()
243            );
244        }
245    }
246
247    /// The codebook a matvec kernel embeds as a literal has to be the
248    /// codebook the GEMM emits from its [`Codebook`] row.
249    ///
250    /// Two structures that must agree about sixteen arbitrary numbers,
251    /// with the emitted half generated and the literal half typed by
252    /// hand. Nothing else would catch a transposed pair: every value is
253    /// plausible, every tensor would still decode, and only the
254    /// arithmetic would be quietly wrong.
255    ///
256    /// The numbers are parsed back out of the kernel text rather than
257    /// compared to a second copy of the formatting, so this fails on a
258    /// bad literal and not merely on a bad `format!`.
259    ///
260    /// Sabotage: change one entry of either literal below and this
261    /// names the kind and the index.
262    #[test]
263    fn every_embedded_codebook_is_the_mul_mm_codebook() {
264        let mut checked = 0usize;
265        for mm in crate::mul_mm::KINDS {
266            let Some(cb) = mm.codebook else { continue };
267            let Some(mv) = kind_by_name(mm.name) else {
268                panic!(
269                    "{}: has a mul_mm codebook kernel but no matvec, which is the \
270                     split that decomposes a prefill into one launch per position",
271                    mm.name
272                );
273            };
274            let decl = format!("__constant__ float {}[16] = {{", cb.c_name);
275            let at = mv.src.find(&decl).unwrap_or_else(|| {
276                panic!("{}: the matvec does not declare {}", mm.name, cb.c_name)
277            });
278            let body = &mv.src[at + decl.len()..];
279            let body = &body[..body.find('}').expect("unterminated codebook")];
280            let got: Vec<f32> = body
281                .split(',')
282                .map(|t| {
283                    t.trim()
284                        .trim_end_matches('f')
285                        .parse::<f32>()
286                        .unwrap_or_else(|e| panic!("{}: {t:?}: {e}", mm.name))
287                })
288                .collect();
289            assert_eq!(got.len(), 16, "{}: codebook is not 16 entries", mm.name);
290            for (i, (g, w)) in got.iter().zip(cb.values.iter()).enumerate() {
291                // Bit comparison: MXFP4's code 8 is negative zero, and
292                // `-0.0 == 0.0` would let a sign flip through.
293                assert_eq!(
294                    g.to_bits(),
295                    w.to_bits(),
296                    "{}: codebook entry {i}: matvec has {g}, mul_mm emits {w}",
297                    mm.name
298                );
299            }
300            checked += 1;
301        }
302        assert!(checked >= 3, "the codebook kinds stopped being checked");
303    }
304
305    /// A kind that can be prefilled on the GPU but not decoded there
306    /// (or the reverse) splits a forward pass across two devices, which
307    /// is the shape that cost Metal a Q5_0 decode path and CUDA a 325x
308    /// K-quant prefill.
309    ///
310    /// `ferrox-core` asserts the same thing over `QuantKind`; this
311    /// asserts it over the two kernel tables themselves, so it holds
312    /// even for a format `QuantKind` has no variant for.
313    #[test]
314    fn the_matvec_table_and_the_mul_mm_table_name_the_same_kinds() {
315        let mm: Vec<&str> = crate::mul_mm::KINDS.iter().map(|k| k.name).collect();
316        let mv: Vec<&str> = KINDS.iter().map(|k| k.name).collect();
317        assert_eq!(mm, mv, "the CUDA decode and prefill tables have diverged");
318    }
319}
320
321/// The on-device check for every matvec kernel, in one loop.
322///
323/// Gated on `cuda` because it calls the launchers, and `#[ignore]`d
324/// because it needs a device. It is deliberately ONE test over
325/// [`KINDS`] rather than one per kernel: `gpu.rs` grew a hand-written
326/// hardware test per kind, and the kinds that arrived later
327/// (`Q5_0`, and everything added on 2026-09-09) each needed someone to
328/// remember. A kind added to the table is on this list the moment it
329/// exists.
330#[cfg(all(test, feature = "cuda"))]
331mod hardware_tests {
332    /// Every CUDA matvec against `ferrox_quant`'s fused dot, on weights
333    /// built by the shared `mul_mm` fixtures -- the same bytes the GEMM
334    /// twin and the GEMM hardware test use, so a disagreement between
335    /// decode and prefill shows up as one of them failing on data the
336    /// other passed.
337    ///
338    /// Run on a machine with an actual CUDA device:
339    ///   cargo test -p ferrox-cuda --features cuda -- --ignored
340    #[test]
341    #[ignore = "requires real CUDA hardware. Q2_K, Q3_K, IQ4_NL, IQ4_XS and MXFP4 have NEVER executed on a GPU; Q5_0 has not either. Run with --ignored on a CUDA-capable machine and record the result before any doc claims those kinds decode on CUDA"]
342    fn every_cuda_matvec_matches_the_cpu_reference() {
343        type Dot = fn(&[u8], &[f32]) -> f32;
344        type Launch =
345            fn(&[u8], &[f32], usize, usize, usize) -> Result<Vec<f32>, crate::gpu::CudaError>;
346
347        // Keyed by name and checked to cover `KINDS`, so a kernel
348        // without a CPU oracle fails here instead of shipping unchecked.
349        let oracles: &[(&str, Dot, Launch)] = &[
350            (
351                "Q8_0",
352                ferrox_quant::dot_q8_0_f32_scalar,
353                crate::gpu::launch_q8_0_matvec,
354            ),
355            (
356                "Q4_0",
357                ferrox_quant::dot_q4_0_f32_scalar,
358                crate::gpu::launch_q4_0_matvec,
359            ),
360            (
361                "Q5_0",
362                ferrox_quant::dot_q5_0_f32_scalar,
363                crate::gpu::launch_q5_0_matvec,
364            ),
365            (
366                "Q2_K",
367                ferrox_quant::dot_q2_k_f32_scalar,
368                crate::gpu::launch_q2_k_matvec,
369            ),
370            (
371                "Q3_K",
372                ferrox_quant::dot_q3_k_f32_scalar,
373                crate::gpu::launch_q3_k_matvec,
374            ),
375            (
376                "Q4_K",
377                ferrox_quant::dot_q4_k_f32_scalar,
378                crate::gpu::launch_q4_k_matvec,
379            ),
380            (
381                "Q5_K",
382                ferrox_quant::dot_q5_k_f32_scalar,
383                crate::gpu::launch_q5_k_matvec,
384            ),
385            (
386                "Q6_K",
387                ferrox_quant::dot_q6_k_f32_scalar,
388                crate::gpu::launch_q6_k_matvec,
389            ),
390            (
391                "IQ4_NL",
392                ferrox_quant::dot_iq4_nl_f32_scalar,
393                crate::gpu::launch_iq4_nl_matvec,
394            ),
395            (
396                "IQ4_XS",
397                ferrox_quant::dot_iq4_xs_f32_scalar,
398                crate::gpu::launch_iq4_xs_matvec,
399            ),
400            (
401                "MXFP4",
402                ferrox_quant::dot_mxfp4_gguf_f32_scalar,
403                crate::gpu::launch_mxfp4_matvec,
404            ),
405        ];
406
407        for mm in crate::mul_mm::KINDS {
408            let (_, dot, launch) = oracles
409                .iter()
410                .find(|(name, _, _)| *name == mm.name)
411                .unwrap_or_else(|| panic!("{}: a CUDA matvec with no CPU oracle", mm.name));
412
413            let rows = 7;
414            // Two whole super-blocks, so a kernel that strides the row
415            // wrongly is visible rather than reading one block twice.
416            let cols = mm.block_elems * 2;
417            let blocks_per_row = cols / mm.block_elems;
418            let row_bytes = blocks_per_row * mm.block_bytes;
419            let weights = crate::mul_mm_ref::fixtures::weights(mm, rows, cols, 31337);
420            let x: Vec<f32> = (0..cols).map(|i| ((i as f32) * 0.09).sin()).collect();
421
422            let expected: Vec<f32> = (0..rows)
423                .map(|r| dot(&weights[r * row_bytes..(r + 1) * row_bytes], &x))
424                .collect();
425            let got = launch(&weights, &x, rows, row_bytes, blocks_per_row)
426                .expect("kernel launch must succeed on real CUDA hardware");
427
428            assert_eq!(got.len(), expected.len());
429            for (i, (g, w)) in got.iter().zip(expected.iter()).enumerate() {
430                // Relative, not absolute. Several of these kernels
431                // factor the scale out of the inner loop where
432                // `ferrox_quant`'s scalar dot multiplies it in per
433                // element, so the two reassociate differently over 256
434                // terms; an absolute 1e-2 would be a coin flip on a
435                // large scale.
436                let scale = w.abs().max(1.0);
437                assert!(
438                    (g - w).abs() <= 1e-3 * scale,
439                    "{} row {i}: GPU={g} CPU reference={w}",
440                    mm.name
441                );
442            }
443        }
444    }
445}