Skip to main content

ferrox_core/weight_matrix/
gpu_backend.rs

1//! The seam every GPU backend goes through.
2//!
3//! Four things used to exist once per backend as free functions in
4//! `weight_matrix.rs`, with no shape holding them together:
5//!
6//! | Axis | Metal | CUDA |
7//! |---|---|---|
8//! | kind → matvec | `metal_matvec_kind_name` → `Option<&str>` | `cuda_matvec_kind_supported` → `bool` |
9//! | kind → GEMM | `metal_mul_mm_kind_supported` | `cuda_mul_mm_kind_supported` |
10//! | launch alias | `MetalMatvecLaunchFn`, 4 args | `CudaMatvecLaunchFn`, 5 args |
11//! | enable probe | `metal_dense_enabled` | `cuda_dense_enabled`, byte-identical body |
12//!
13//! and they were re-selected by hand at every dispatch site, so
14//! `apply_gpu` carried two near-identical `match kind` tables that could
15//! only be kept honest by a `debug_assert!`. A third backend would have
16//! copied all four. This module is the shape they now share.
17//!
18//! # What a third backend has to provide
19//!
20//! Exactly this, and nothing else:
21//!
22//! 1. A unit type (`pub struct Vulkan;`).
23//! 2. [`BackendCaps`] — the two capability tables plus an id and a
24//!    display name. **Compiled unconditionally**, with no dependency on
25//!    the backend crate, because the tables are a property of the kernel
26//!    set rather than of the build, and gating them would make them
27//!    untestable on the CPU builds that run `cargo test --workspace`.
28//!    This is the rule that kept `metal_matvec_kind_name` un-`cfg`'d and
29//!    it is load-bearing: `probe_kernels_for` asks what Metal *would*
30//!    resolve from a build with no Metal.
31//! 3. [`BackendDispatch`] under `#[cfg(feature = "…")]` — the enable
32//!    probe, a device-free launch table, and one launch entry point.
33//! 4. One line in [`gpu_backend_table`], which is **the** ordered list:
34//!    enum variant, cargo feature / registry name, and seam type, once.
35//!
36//! `Vulkan` is what that recipe looks like when it is followed: a
37//! one-kernel backend (Q8_0 matvec, no GEMM, no batch path) added
38//! without touching `apply_gpu` or `active_backend`.
39//!
40//! # The one list, and its three consumers
41//!
42//! [`gpu_backend_table`] is expanded by exactly three things, so a
43//! backend cannot exist in one of them and not the others:
44//!
45//! - [`crate::kernel_registry::Backend`]'s variants and their names.
46//!   This is verdict point 4 — "a backend cannot be dispatched to
47//!   without being reportable" — and it is now structural rather than
48//!   hand-kept. It works because the registry and this module are the
49//!   same crate; a `macro_rules!` cannot generate an enum in a
50//!   *different* crate, so a backend crate could never own its own
51//!   variant.
52//! - [`with_gpu_backends`], the `#[cfg]`-gated dispatch order, which
53//!   [`crate::weight_matrix::WeightMatrix::apply_gpu`] and
54//!   [`crate::weight_matrix::active_backend`] both expand.
55//! - [`with_gpu_backend_caps`], the **ungated** one, which
56//!   `probe_kernels_for` expands so a CPU-only build can still ask what
57//!   Metal or Vulkan *would* resolve.
58//!
59//! # The launch signature, and the fifth argument
60//!
61//! `ferrox-cuda`'s `launch_*_matvec` takes
62//! `(weights, x, rows, row_bytes, n_blocks_per_row)`; `ferrox-metal`'s
63//! takes the first four. [`BackendDispatch::launch_matvec`] takes the
64//! four plus the [`QuantKind`], and every backend derives the rest.
65//!
66//! That is deliberate, and it is not the wider arity the beachhead
67//! verdict proposed. `n_blocks_per_row` is **redundant information**,
68//! not missing information: it is `row_bytes / block_bytes(kind)`, and
69//! Metal already recomputes exactly that inside
70//! `ferrox_metal::gpu::matvec_launch_meta`, which hands back the block
71//! size for the kind. Hoisting it into the shared signature would buy a
72//! backend nothing it cannot derive, and would cost a
73//! `block_bytes(kind)` that is total over all 21 `QuantKind`s — the
74//! existing one, `WeightMatrix::block_bytes_for_kind`, is deliberately
75//! partial and `unreachable!()`s outside the five CUDA kinds, and
76//! Metal's `IQ4_XS` is not one of them. So the seam passes the kind and
77//! lets each backend ask its own table.
78
79use crate::kernel_registry::Backend;
80use crate::weight_matrix::QuantKind;
81
82/// A backend launch failure, flattened to its rendered message.
83///
84/// `ferrox_metal::gpu::MetalError` and `ferrox_cuda::gpu::CudaError` are
85/// different types living behind different features, and the only thing
86/// any caller does with either is print it before falling back — so the
87/// seam carries the message rather than an enum that would have to grow
88/// a variant per backend crate.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct BackendError(String);
91
92impl BackendError {
93    /// Renders any backend error into the one shape the seam carries.
94    pub fn new(e: impl std::fmt::Display) -> Self {
95        BackendError(e.to_string())
96    }
97}
98
99impl std::fmt::Display for BackendError {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.write_str(&self.0)
102    }
103}
104
105/// What a backend can run, asked without the backend crate present.
106///
107/// Every member is an associated function with no receiver, which is
108/// what the free functions this replaced already were, so implementing
109/// it is a lift rather than a redesign.
110pub trait BackendCaps {
111    /// How [`crate::kernel_registry`] reports this backend. Dispatch and
112    /// observability read the same constant, so a backend cannot be
113    /// dispatched to under one name and reported under another.
114    const ID: Backend;
115
116    /// Human-readable name, for the one message a dispatch failure
117    /// prints.
118    const NAME: &'static str;
119
120    /// What a batched prefill actually runs on for a kind this backend
121    /// has a matvec but no GEMM for — the string
122    /// `probe_kernels_for` records as the fallback.
123    ///
124    /// It lives here because it is a property of the backend and it was
125    /// previously a `match backend` arm inside `probe_kernels_for`: a
126    /// third `Backend` variant would have silently inherited Metal's
127    /// wording (`"Metal N x matvec batch"`) by falling through `_`, and
128    /// the registry's whole job is to name the path that will really
129    /// run. Ungated, like the rest of [`BackendCaps`], because the
130    /// probe is ungated.
131    const GEMM_FALLBACK: &'static str;
132
133    /// Which quant kinds have a **matvec** kernel (the decode path), as
134    /// the kernel name the backend's own launch-meta table is keyed by,
135    /// or `None` for a kind with no kernel.
136    ///
137    /// Returning the name rather than a `bool` is what let the two
138    /// backends share one member: CUDA only ever needed the `bool`
139    /// (`.is_some()`), Metal needs the name to look up
140    /// `ferrox_metal::gpu::matvec_launch_meta`, and a `bool` cannot be
141    /// widened after the fact without another table.
142    fn matvec_kernel(kind: QuantKind) -> Option<&'static str>;
143
144    /// Which quant kinds have a **batched GEMM** (the prefill path). A
145    /// kind with a matvec but no GEMM still runs on the accelerator — as
146    /// `batch` separate matvecs over the same weights, which is the
147    /// 13.7x shape, and which is why these are two predicates and not
148    /// one.
149    fn gemm_supported(kind: QuantKind) -> bool;
150}
151
152/// What a backend can actually do, which needs its crate compiled in.
153pub trait BackendDispatch: BackendCaps {
154    /// What [`crate::weight_matrix::WeightMatrix::apply_gpu`] will do
155    /// next if this backend's launch fails, named for the log line.
156    /// A property of this backend's position in
157    /// [`with_gpu_backends`], not of the backend itself.
158    const MATVEC_FALLBACK: &'static str;
159
160    /// Whether dense matmuls should try this backend in this process.
161    /// Decided once from the environment, then cached for the process
162    /// lifetime. See `env_or_probe` for the grammar, which is shared.
163    fn dense_enabled() -> bool;
164
165    /// Whether a launch **function** exists for `kind`, asked without a
166    /// device and without launching anything.
167    ///
168    /// This is the question [`BackendCaps::matvec_kernel`] claims to
169    /// answer, asked of the code that actually runs. They are two
170    /// structures that must agree about one thing, and they cannot be
171    /// merged: the kernel NAMES are needed on builds where the backend
172    /// crate is not a dependency and these function pointers do not
173    /// exist. So the agreement is a test —
174    /// `every_kind_a_compiled_backend_claims_can_actually_be_launched`,
175    /// over every backend and all 21 kinds — rather than the
176    /// `debug_assert!` that used to guard it, which fired only for
177    /// kinds a run actually reached and only in debug.
178    ///
179    /// `Q5_0` is why. It was in Metal's capability table with no launch
180    /// function behind it from the day it was added, so batched prefill
181    /// ran on the GPU while single-token decode silently fell to the
182    /// CPU, and a release build just ran slower.
183    fn has_launch(kind: QuantKind) -> bool;
184
185    /// One matvec. `None` means "this backend has no kernel for `kind`",
186    /// which is a different answer from `Some(Err(_))`, "the kernel
187    /// exists and the launch failed" — the caller logs only the second.
188    fn launch_matvec(
189        kind: QuantKind,
190        weights: &[u8],
191        x: &[f32],
192        rows: usize,
193        row_bytes: usize,
194    ) -> Option<Result<Vec<f32>, BackendError>>;
195}
196
197/// The per-kind Metal launch table, split out of
198/// [`BackendDispatch::launch_matvec`] so it can be checked for EVERY
199/// kind without a device.
200///
201/// It has to agree with [`Metal::matvec_kernel`], and it cannot be the
202/// same table: the kernel NAMES are needed on builds where
203/// `ferrox-metal` is not a dependency and these function pointers do not
204/// exist. So the agreement is asserted, and asserting it only inside
205/// `launch_matvec` was not enough -- that fires just for kinds a run
206/// actually reaches, in debug. `Q5_0` was in the capability table and
207/// missing here from the day it was added, and the symptom was
208/// single-token decode silently falling to the CPU while batched
209/// prefill ran on the GPU.
210#[cfg(feature = "metal")]
211fn metal_matvec_launch(kind: QuantKind) -> Option<MetalMatvecLaunchFn> {
212    match kind {
213        QuantKind::Q8_0 => Some(ferrox_metal::gpu::launch_q8_0_matvec),
214        QuantKind::Q4_0 => Some(ferrox_metal::gpu::launch_q4_0_matvec),
215        QuantKind::Q4K => Some(ferrox_metal::gpu::launch_q4_k_matvec),
216        QuantKind::Q5_0 => Some(ferrox_metal::gpu::launch_q5_0_matvec),
217        QuantKind::Q5K => Some(ferrox_metal::gpu::launch_q5_k_matvec),
218        QuantKind::Q6K => Some(ferrox_metal::gpu::launch_q6_k_matvec),
219        QuantKind::IQ4XS => Some(ferrox_metal::gpu::launch_iq4_xs_matvec),
220        _ => None,
221    }
222}
223
224/// The per-kind CUDA launch table, split out of
225/// [`BackendDispatch::launch_matvec`] for the same reason
226/// [`metal_matvec_launch`] was: so
227/// [`BackendDispatch::has_launch`] can check it against
228/// [`Cuda::matvec_kernel`] for EVERY kind, without a GPU. It was inline
229/// in `launch_matvec` and therefore had no guard at all — the hole that
230/// cost Metal a Q5_0 decode path.
231#[cfg(feature = "cuda")]
232fn cuda_matvec_launch(kind: QuantKind) -> Option<CudaMatvecLaunchFn> {
233    match kind {
234        QuantKind::Q8_0 => Some(ferrox_cuda::gpu::launch_q8_0_matvec),
235        QuantKind::Q4_0 => Some(ferrox_cuda::gpu::launch_q4_0_matvec),
236        QuantKind::Q4K => Some(ferrox_cuda::gpu::launch_q4_k_matvec),
237        QuantKind::Q5K => Some(ferrox_cuda::gpu::launch_q5_k_matvec),
238        QuantKind::Q6K => Some(ferrox_cuda::gpu::launch_q6_k_matvec),
239        _ => None,
240    }
241}
242
243/// The per-kind Vulkan launch table. One row, and the guard test is
244/// what keeps it one row: adding a kind to [`Vulkan::matvec_kernel`]
245/// without a shader here fails
246/// `every_kind_a_compiled_backend_claims_can_actually_be_launched`.
247#[cfg(feature = "vulkan")]
248fn vulkan_matvec_launch(kind: QuantKind) -> Option<VulkanMatvecLaunchFn> {
249    match kind {
250        QuantKind::Q8_0 => Some(ferrox_vulkan::dispatch::q8_0_matvec),
251        _ => None,
252    }
253}
254
255/// The Metal backend (`ferrox-metal`).
256pub struct Metal;
257
258/// The CUDA backend (`ferrox-cuda`).
259pub struct Cuda;
260
261/// The Vulkan backend (`ferrox-vulkan`) — **one kernel wide**.
262///
263/// `ferrox-vulkan` is the `vulkan-beachhead` GO/NO-GO slice, not a
264/// backend: a single hand-emitted SPIR-V Q8_0 matvec, checked against a
265/// scalar twin and run on a real device through MoltenVK. See
266/// `docs/plans/vulkan-beachhead-verdict.md`.
267///
268/// This impl is what wiring that slice into the seam costs, and it is
269/// deliberately not more than the slice supports:
270///
271/// - **Q8_0 and nothing else.** [`Vulkan::matvec_kernel`] names one
272///   kind; every other kind reports no kernel, which is the honest
273///   answer and is what makes the registry say "NO KERNEL … falls back
274///   to CPU apply_cpu" instead of quietly running slow.
275/// - **No GEMM at all.** [`Vulkan::gemm_supported`] is false for every
276///   kind. There is no `mul_mm` shader, and `apply_batch_with_acts` has
277///   no Vulkan arm, so a batched prefill runs on the host —
278///   [`Vulkan::GEMM_FALLBACK`] says exactly that.
279/// - **No performance claim.** `q8_0_matvec` rebuilds its entire
280///   pipeline per call. Nothing here may be reported as a measured
281///   capability; the verdict says so and this comment repeats it
282///   because the code is now reachable.
283pub struct Vulkan;
284
285impl BackendCaps for Metal {
286    const ID: Backend = Backend::Metal;
287    const NAME: &'static str = "Metal";
288    /// `apply_gpu_batch` re-reads the whole weight matrix once per
289    /// position, on the GPU. Still Metal, still the 13.7x shape.
290    const GEMM_FALLBACK: &'static str = "Metal N x matvec batch";
291
292    /// As the kernel name [`ferrox_metal::gpu::matvec_launch_meta`]
293    /// resolves.
294    ///
295    /// This is the single source of truth for that question. It is *not*
296    /// `#[cfg(feature = "metal")]`-gated deliberately: the table is a
297    /// property of the kernel set, and gating it would make it
298    /// untestable on the builds that run `cargo test --workspace`.
299    ///
300    /// Duplicating this list is how IQ4_XS batched prefill silently ran
301    /// on the CPU — `metal_kind_supported` and `apply_gpu_batch`'s kind
302    /// table disagreed by exactly one entry, and the only symptom was a
303    /// benchmark row 13.7x behind. Every Metal-kind question now routes
304    /// through here.
305    fn matvec_kernel(kind: QuantKind) -> Option<&'static str> {
306        match kind {
307            QuantKind::Q8_0
308            | QuantKind::Q4_0
309            | QuantKind::Q5_0
310            | QuantKind::Q4K
311            | QuantKind::Q5K
312            | QuantKind::Q6K
313            | QuantKind::IQ4XS => Some(kind.name()),
314            _ => None,
315        }
316    }
317
318    /// The `*_mul_mm_sg` simdgroup GEMMs.
319    ///
320    /// The invariant that this set equals [`Metal::matvec_kernel`]'s is
321    /// asserted by a test, so adding a matvec kernel without a GEMM
322    /// fails the suite instead of a benchmark.
323    fn gemm_supported(kind: QuantKind) -> bool {
324        // Q5_0 JOINED 2026-09-01, and the two-year-old comment this
325        // replaced named the exact condition: "the honest close is a
326        // `q5_0_matvec` plus a Q5_0 row in the bench suite, not a sixth
327        // entry in this list."
328        //
329        // The matvec now exists (`Q5_0_MATVEC_KERNEL_SRC`), so the split
330        // this list was protecting against is gone: Q5_0 was already
331        // getting GPU prefill through `mul_mm_sg_launch` and `mapped_sg`,
332        // which never consulted this table, while every decode step fell
333        // back to the CPU for want of the matvec. That is the mixed
334        // CPU/GPU path the old comment feared, and it was live rather
335        // than hypothetical.
336        //
337        // The bench row is still owed: there is no Q5_0 checkpoint in
338        // `benchmarks/suite.json`, so this path is
339        // CORRECT-BY-CONSTRUCTION and UNMEASURED.
340        // `Llama-3.2-1B-Instruct-Q5_K_M` is Q5_K, not Q5_0.
341        matches!(
342            kind,
343            QuantKind::Q8_0
344                | QuantKind::Q4_0
345                | QuantKind::Q5_0
346                | QuantKind::Q4K
347                | QuantKind::Q5K
348                | QuantKind::Q6K
349                | QuantKind::IQ4XS
350        )
351    }
352}
353
354impl BackendCaps for Cuda {
355    const ID: Backend = Backend::Cuda;
356    const NAME: &'static str = "CUDA";
357    /// `apply_batch_with_acts` decomposes a CUDA prefill into one
358    /// matvec per position for every kind off [`Cuda::gemm_supported`].
359    const GEMM_FALLBACK: &'static str = "CUDA per-position matvec";
360
361    /// The decode path, and the arm that has actually run on a GPU.
362    ///
363    /// Wider than [`Cuda::gemm_supported`]. The name is returned only to
364    /// share [`BackendCaps::matvec_kernel`]'s shape with Metal; nothing
365    /// on the CUDA path reads it, because `ferrox-cuda`'s launchers are
366    /// named functions rather than entries in a string-keyed table.
367    fn matvec_kernel(kind: QuantKind) -> Option<&'static str> {
368        match kind {
369            QuantKind::Q8_0
370            | QuantKind::Q4_0
371            | QuantKind::Q4K
372            | QuantKind::Q5K
373            | QuantKind::Q6K => Some(kind.name()),
374            _ => None,
375        }
376    }
377
378    /// The `mul_mm` prefill path.
379    ///
380    /// Deliberately narrower than [`Cuda::matvec_kernel`]: `ferrox-cuda`
381    /// had no matrix-matrix product at all until Q8_0 and Q4_0 landed,
382    /// so every other kind still decomposes a prefill into per-position
383    /// matvecs.
384    ///
385    /// Stated here rather than delegating to
386    /// `ferrox_cuda::mul_mm::kind_by_name`, because `ferrox-cuda` is
387    /// only a dependency under the `cuda` feature and this predicate is
388    /// compiled unconditionally (the capability report reads it on every
389    /// build).
390    ///
391    /// Two tables that must agree about one set is the failure this
392    /// codebase keeps paying for, so the agreement is a TEST rather than
393    /// a hope: `the_cuda_gemm_kinds_match_the_kernel_table` runs under
394    /// `--features cuda` and compares this against `kind_by_name` for
395    /// every `QuantKind`.
396    ///
397    /// **UNRUN ON HARDWARE.** The kernel is checked against a scalar
398    /// twin and by executing the emitted CUDA C on the host, and has
399    /// never executed on a GPU. See `crates/ferrox-cuda/src/mul_mm.rs`.
400    fn gemm_supported(kind: QuantKind) -> bool {
401        matches!(kind, QuantKind::Q8_0 | QuantKind::Q4_0)
402    }
403}
404
405impl BackendCaps for Vulkan {
406    const ID: Backend = Backend::Vulkan;
407    const NAME: &'static str = "Vulkan";
408    /// There is no Vulkan batch entry point of any kind:
409    /// `apply_gpu_batch` is `#[cfg(feature = "metal")]` and
410    /// `apply_batch_with_acts` has a CUDA arm and a Metal arm. So a
411    /// prefill against a Vulkan-resident kind runs on the host, and
412    /// this names the host path rather than inventing a GPU one.
413    const GEMM_FALLBACK: &'static str = "CPU apply_batch";
414
415    /// Exactly one kind, because there is exactly one shader:
416    /// `ferrox_vulkan::q8_0_shader`.
417    ///
418    /// Everything else must report `None` rather than something
419    /// plausible. A capability table that over-claims is how a kind ends
420    /// up "supported" with no kernel behind it, which this repo has now
421    /// paid for twice (IQ4_XS prefill, Q5_0 decode). The guard test
422    /// checks this against [`vulkan_matvec_launch`] for all 21 kinds.
423    fn matvec_kernel(kind: QuantKind) -> Option<&'static str> {
424        match kind {
425            QuantKind::Q8_0 => Some(kind.name()),
426            _ => None,
427        }
428    }
429
430    /// No kind, for any kind. The beachhead emitted one matvec shader
431    /// and deliberately no `mul_mm`; the verdict puts a real GEMM in
432    /// `vulkan-prefill-gemm`, which is where the backend decision
433    /// actually lives.
434    ///
435    /// This is the one place the Metal invariant
436    /// (`every_metal_matvec_kind_also_has_a_metal_gemm`: matvec set ==
437    /// GEMM set) is knowingly not held, and it is held open rather than
438    /// papered over: Q8_0 decodes on Vulkan and prefills on the CPU,
439    /// the registry records the split by name, and `ferrox bench` would
440    /// show it.
441    fn gemm_supported(_kind: QuantKind) -> bool {
442        false
443    }
444}
445
446/// The `FERROX_METAL` / `FERROX_CUDA` grammar, which was written out
447/// twice in bodies that were byte-identical apart from the alias:
448///
449/// - `0|false|off|cpu` — force CPU
450/// - `1|true|on|<alias>` — force this backend
451/// - unset / anything else — whatever `probe` says
452///
453/// `probe` is only called when the environment did not decide, which is
454/// what keeps a forced-off build from opening a device.
455///
456/// Compiled when a backend needs it, and under `test` so the grammar
457/// stays checked on the CPU-only builds that run `cargo test`.
458#[cfg(any(feature = "metal", feature = "cuda", feature = "vulkan", test))]
459fn env_or_probe(value: Option<&str>, on_alias: &str, probe: impl FnOnce() -> bool) -> bool {
460    match value {
461        Some("0") | Some("false") | Some("off") | Some("cpu") => false,
462        Some("1") | Some("true") | Some("on") => true,
463        Some(v) if v == on_alias => true,
464        _ => probe(),
465    }
466}
467
468/// A `ferrox_metal::gpu::launch_*_matvec` function pointer's signature
469/// (`weights`/`x` borrowed; row block count is derived inside
470/// `ferrox_metal::gpu`).
471#[cfg(feature = "metal")]
472type MetalMatvecLaunchFn =
473    fn(&[u8], &[f32], usize, usize) -> Result<Vec<f32>, ferrox_metal::gpu::MetalError>;
474
475/// A `ferrox_cuda::gpu::launch_*_matvec` function pointer's signature
476/// (all five real kernels share it exactly).
477#[cfg(feature = "cuda")]
478type CudaMatvecLaunchFn =
479    fn(&[u8], &[f32], usize, usize, usize) -> Result<Vec<f32>, ferrox_cuda::gpu::CudaError>;
480
481/// A `ferrox_vulkan::dispatch` matvec's signature. Same five arguments
482/// as [`CudaMatvecLaunchFn`] -- `ferrox-vulkan` was written to this
483/// list on purpose -- plus the borrowed [`ferrox_vulkan::device::Context`]
484/// in front, because Vulkan keeps no process-global device inside its
485/// own crate the way `ferrox_metal::gpu` and `ferrox_cuda::gpu` do.
486/// [`vulkan_context`] is that global, and it lives here so the beachhead
487/// crate stays a beachhead.
488#[cfg(feature = "vulkan")]
489type VulkanMatvecLaunchFn = fn(
490    &ferrox_vulkan::device::Context,
491    &[u8],
492    &[f32],
493    usize,
494    usize,
495    usize,
496) -> Result<Vec<f32>, ferrox_vulkan::device::VulkanError>;
497
498#[cfg(feature = "metal")]
499impl BackendDispatch for Metal {
500    const MATVEC_FALLBACK: &'static str = "falling back to CPU";
501
502    fn has_launch(kind: QuantKind) -> bool {
503        metal_matvec_launch(kind).is_some()
504    }
505
506    fn dense_enabled() -> bool {
507        use std::sync::OnceLock;
508        // A `static` inside a generic function is shared across every
509        // monomorphization, so this cache cannot be hoisted into a
510        // default trait method: each backend needs its own cell.
511        static ENABLED: OnceLock<bool> = OnceLock::new();
512        *ENABLED.get_or_init(|| {
513            let v = std::env::var("FERROX_METAL").ok();
514            env_or_probe(v.as_deref(), "metal", || {
515                ferrox_metal::gpu::probe().is_some()
516            })
517        })
518    }
519
520    fn launch_matvec(
521        kind: QuantKind,
522        weights: &[u8],
523        x: &[f32],
524        rows: usize,
525        row_bytes: usize,
526    ) -> Option<Result<Vec<f32>, BackendError>> {
527        let launch = metal_matvec_launch(kind);
528        // This table and `Metal::matvec_kernel` answer the same question
529        // and must never diverge; when they did, IQ4_XS prefill silently
530        // moved to the CPU. They CANNOT be one table -- the names are
531        // needed on builds where `ferrox-metal` is not a dependency and
532        // these function pointers do not exist -- so the agreement stays
533        // asserted rather than structural.
534        debug_assert_eq!(
535            launch.is_some(),
536            Self::matvec_kernel(kind).is_some(),
537            "apply_gpu's Metal launch table disagrees with metal_matvec_kind_name for {:?}",
538            kind
539        );
540        let launch = launch?;
541        Some(launch(weights, x, rows, row_bytes).map_err(BackendError::new))
542    }
543}
544
545#[cfg(feature = "cuda")]
546impl BackendDispatch for Cuda {
547    const MATVEC_FALLBACK: &'static str = "trying next backend / CPU";
548
549    fn has_launch(kind: QuantKind) -> bool {
550        cuda_matvec_launch(kind).is_some()
551    }
552
553    fn dense_enabled() -> bool {
554        use std::sync::OnceLock;
555        // See the note on `Metal::dense_enabled` for why this cell is
556        // not shared through a default method.
557        static ENABLED: OnceLock<bool> = OnceLock::new();
558        *ENABLED.get_or_init(|| {
559            let v = std::env::var("FERROX_CUDA").ok();
560            env_or_probe(v.as_deref(), "cuda", || ferrox_cuda::gpu::probe().is_some())
561        })
562    }
563
564    fn launch_matvec(
565        kind: QuantKind,
566        weights: &[u8],
567        x: &[f32],
568        rows: usize,
569        row_bytes: usize,
570    ) -> Option<Result<Vec<f32>, BackendError>> {
571        let launch = cuda_matvec_launch(kind)?;
572        // Derived here rather than at the seam: `block_bytes_for_kind`
573        // is `unreachable!()` outside these five kinds, and reaching it
574        // is gated on the match above having named one of them.
575        let n_blocks_per_row =
576            row_bytes / crate::weight_matrix::WeightMatrix::block_bytes_for_kind(kind);
577        Some(launch(weights, x, rows, row_bytes, n_blocks_per_row).map_err(BackendError::new))
578    }
579}
580
581/// The process-wide Vulkan device, opened at most once.
582///
583/// `ferrox_metal::gpu` and `ferrox_cuda::gpu` each keep their device
584/// inside their own crate, so their launch functions take no context.
585/// `ferrox-vulkan` deliberately does not: it is a beachhead whose
586/// `Context` is created and dropped by its own tests, and giving it a
587/// hidden global would have made the GO/NO-GO slice into infrastructure.
588/// So the global lives here, on the seam's side of the boundary.
589///
590/// `Mutex`, not a bare `Context`: a `vk::Queue` must be externally
591/// synchronized, and `apply_gpu` is called from rayon workers.
592/// Serializing them is correct and is not a regression, because
593/// `q8_0_matvec` rebuilds its entire pipeline per call and is not a
594/// performance path in the first place — see the verdict.
595///
596/// `None` means the device could not be opened. That is reported once,
597/// here, rather than once per matvec.
598#[cfg(feature = "vulkan")]
599fn vulkan_context() -> Option<&'static std::sync::Mutex<ferrox_vulkan::device::Context>> {
600    use std::sync::{Mutex, OnceLock};
601    static CTX: OnceLock<Option<Mutex<ferrox_vulkan::device::Context>>> = OnceLock::new();
602    CTX.get_or_init(|| match ferrox_vulkan::device::Context::new() {
603        Ok(ctx) => Some(Mutex::new(ctx)),
604        Err(e) => {
605            eprintln!(
606                "ferrox: Vulkan device unavailable, {}: {e}",
607                Vulkan::MATVEC_FALLBACK
608            );
609            None
610        }
611    })
612    .as_ref()
613}
614
615#[cfg(feature = "vulkan")]
616impl BackendDispatch for Vulkan {
617    /// Last in [`gpu_backend_table`], so there is nothing after it.
618    const MATVEC_FALLBACK: &'static str = "falling back to CPU";
619
620    fn has_launch(kind: QuantKind) -> bool {
621        vulkan_matvec_launch(kind).is_some()
622    }
623
624    fn dense_enabled() -> bool {
625        use std::sync::OnceLock;
626        // See the note on `Metal::dense_enabled` for why this cell is
627        // not shared through a default method.
628        static ENABLED: OnceLock<bool> = OnceLock::new();
629        *ENABLED.get_or_init(|| {
630            let v = std::env::var("FERROX_VULKAN").ok();
631            env_or_probe(v.as_deref(), "vulkan", || {
632                ferrox_vulkan::device::probe().is_ok()
633            })
634        })
635    }
636
637    fn launch_matvec(
638        kind: QuantKind,
639        weights: &[u8],
640        x: &[f32],
641        rows: usize,
642        row_bytes: usize,
643    ) -> Option<Result<Vec<f32>, BackendError>> {
644        let launch = vulkan_matvec_launch(kind)?;
645
646        // Unlike Metal and CUDA, whose launchers no-op into an error
647        // when their device is absent, `ferrox-vulkan` has no global to
648        // consult -- so the env grammar is honoured here or not at all.
649        // `FERROX_VULKAN=0` must mean the CPU, not "open a device
650        // anyway". Returning `None` (rather than an error) is right:
651        // "this backend is not running here" is the same answer as "no
652        // kernel", and both mean try the next backend.
653        if !Self::dense_enabled() {
654            return None;
655        }
656        let ctx = vulkan_context()?;
657
658        // `ferrox_vulkan::dispatch::q8_0_matvec` asserts its shape
659        // invariants, which is right for a test-driven beachhead and
660        // wrong for a dispatch path: a panic in a rayon worker is not a
661        // fallback. Checked here so a mismatch is an error the caller
662        // logs and recovers from.
663        let block_bytes = crate::weight_matrix::WeightMatrix::block_bytes_for_kind(kind);
664        let n_blocks_per_row = row_bytes / block_bytes;
665        if weights.len() != rows * row_bytes
666            || row_bytes != n_blocks_per_row * block_bytes
667            || x.len() != n_blocks_per_row * ferrox_vulkan::q8_0_shader::BLOCK_ELEMS
668            || rows == 0
669            || n_blocks_per_row == 0
670        {
671            return Some(Err(BackendError::new(format!(
672                "Vulkan {} matvec shape rejected: {} weight bytes, {} activations, \
673                 rows={rows} row_bytes={row_bytes}",
674                kind.name(),
675                weights.len(),
676                x.len(),
677            ))));
678        }
679
680        let guard = match ctx.lock() {
681            Ok(g) => g,
682            // A poisoned mutex means another thread panicked mid-
683            // dispatch; the device may be mid-submission, so refuse
684            // rather than reuse it.
685            Err(_) => {
686                return Some(Err(BackendError::new(
687                    "Vulkan context poisoned by an earlier panic",
688                )))
689            }
690        };
691        Some(
692            launch(&guard, weights, x, rows, row_bytes, n_blocks_per_row)
693                .map_err(BackendError::new),
694        )
695    }
696}
697
698/// **The** backend table: one row per GPU backend, in dispatch
699/// precedence order — CUDA first, then Metal, then Vulkan, then the CPU
700/// fallthrough the caller supplies.
701///
702/// Vulkan is last on purpose. On the only machine that can run all
703/// three it reaches the GPU through MoltenVK, i.e. through Metal, and
704/// it has one kernel; a native backend must win over a translation
705/// layer wrapping it.
706///
707/// Each row is `(enum variant, feature / registry name, seam type)`.
708/// The three are the same string in three grammars, and that is exactly
709/// why they are written once: the cargo feature, the
710/// [`crate::kernel_registry::Backend`] variant and the type were three
711/// hand-kept lists, and "two structures that must agree about one
712/// thing" is the dominant bug shape in this repo.
713///
714/// It expands `$mac!` ONCE with every row, so a consumer can build a
715/// single item (an `enum`) from it and not just a sequence of
716/// statements. `$extra` is passed through in brackets ahead of the rows
717/// so a consumer that needs its own callback — [`with_gpu_backends`] —
718/// can forward one without re-listing the table.
719macro_rules! gpu_backend_table {
720    ($mac:path $(, $extra:tt)*) => {
721        $mac! {
722            [$($extra),*]
723            (Cuda, "cuda", Cuda),
724            (Metal, "metal", Metal),
725            (Vulkan, "vulkan", Vulkan),
726        }
727    };
728}
729pub(crate) use gpu_backend_table;
730
731/// Expands `$mac!(Backend)` once per **compiled-in** backend, in
732/// [`gpu_backend_table`] order.
733///
734/// This exists because the order was hand-copied at every dispatch site
735/// and in `active_backend`, and a macro is the only way to keep static
736/// dispatch, per-backend `#[cfg]`, and one written-down order at the
737/// same time. A third backend is one line in the table, not here.
738///
739/// `$mac` must tolerate being expanded zero times: on a CPU-only build
740/// this produces nothing, so define it `#[allow(unused_macros)]`.
741macro_rules! with_gpu_backends {
742    ($mac:ident) => {
743        $crate::weight_matrix::gpu_backend::gpu_backend_table!(
744            $crate::weight_matrix::gpu_backend::gpu_backend_dispatch_rows,
745            $mac
746        );
747    };
748}
749pub(crate) use with_gpu_backends;
750
751/// [`with_gpu_backends`]'s row expander. The `#[cfg(feature = …)]` is
752/// built from the table's own name column, so a backend cannot be in
753/// the list under one feature and gated on another.
754macro_rules! gpu_backend_dispatch_rows {
755    ([$mac:ident] $(($variant:ident, $feature:literal, $ty:ident)),* $(,)?) => {
756        $(
757            #[cfg(feature = $feature)]
758            $mac!($crate::weight_matrix::gpu_backend::$ty);
759        )*
760    };
761}
762pub(crate) use gpu_backend_dispatch_rows;
763
764/// Expands `$mac!(Backend)` once per backend **whether or not it is
765/// compiled in**, in [`gpu_backend_table`] order.
766///
767/// The ungated twin of [`with_gpu_backends`], and the reason
768/// [`BackendCaps`] is ungated: `probe_kernels_for` has to answer "what
769/// would Metal resolve for this kind" on a build with no Metal, which
770/// is the only way the kernel-coverage tests run under a plain
771/// `cargo test`. A consumer of this must therefore stay inside
772/// [`BackendCaps`] — [`BackendDispatch`] does not exist for a backend
773/// whose feature is off.
774///
775/// Never expands zero times, so `$mac` needs no `unused_macros` cover.
776macro_rules! with_gpu_backend_caps {
777    ($mac:ident) => {
778        $crate::weight_matrix::gpu_backend::gpu_backend_table!(
779            $crate::weight_matrix::gpu_backend::gpu_backend_caps_rows,
780            $mac
781        );
782    };
783}
784pub(crate) use with_gpu_backend_caps;
785
786/// [`with_gpu_backend_caps`]'s row expander.
787macro_rules! gpu_backend_caps_rows {
788    ([$mac:ident] $(($variant:ident, $feature:literal, $ty:ident)),* $(,)?) => {
789        $(
790            $mac!($crate::weight_matrix::gpu_backend::$ty);
791        )*
792    };
793}
794pub(crate) use gpu_backend_caps_rows;
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799
800    /// The env grammar both enable probes share. `probe` must not be
801    /// consulted when the environment already decided — a forced-off
802    /// build must never open a device.
803    #[test]
804    fn env_decides_before_the_probe_is_consulted() {
805        for forced_off in ["0", "false", "off", "cpu"] {
806            assert!(!env_or_probe(Some(forced_off), "metal", || panic!(
807                "probed after {forced_off}"
808            )));
809        }
810        for forced_on in ["1", "true", "on"] {
811            assert!(env_or_probe(Some(forced_on), "metal", || panic!(
812                "probed after {forced_on}"
813            )));
814        }
815    }
816
817    /// Each backend's own alias forces it on; the *other* backend's
818    /// alias is not a value it understands, so it falls through to the
819    /// probe rather than silently forcing.
820    #[test]
821    fn the_alias_is_per_backend() {
822        assert!(env_or_probe(Some("metal"), "metal", || false));
823        assert!(env_or_probe(Some("cuda"), "cuda", || false));
824        assert!(!env_or_probe(Some("cuda"), "metal", || false));
825        assert!(!env_or_probe(Some("metal"), "cuda", || false));
826    }
827
828    /// Unset, or a value the grammar does not name, defers to the probe.
829    #[test]
830    fn an_unrecognised_value_defers_to_the_probe() {
831        assert!(env_or_probe(None, "metal", || true));
832        assert!(!env_or_probe(None, "metal", || false));
833        assert!(env_or_probe(Some("auto"), "metal", || true));
834        assert!(!env_or_probe(Some("auto"), "metal", || false));
835    }
836
837    /// A backend cannot be dispatched to under one name and reported
838    /// under another: dispatch and the registry read the same constant.
839    ///
840    /// [`Backend`]'s variants are generated from the same table these
841    /// impls are listed in, so a *missing* variant is now impossible —
842    /// but `const ID` is still written by hand in each impl, so naming
843    /// another backend's variant is not. That is what the distinctness
844    /// check catches, and the count check catches a variant with no
845    /// backend behind it.
846    #[test]
847    fn every_backend_id_is_distinct_and_an_accelerator() {
848        let mut ids = Vec::new();
849        macro_rules! collect_id {
850            ($b:ty) => {
851                assert!(
852                    <$b as BackendCaps>::ID.is_accelerator(),
853                    "{} is reported as the CPU",
854                    <$b as BackendCaps>::NAME
855                );
856                ids.push((<$b as BackendCaps>::ID, <$b as BackendCaps>::NAME));
857            };
858        }
859        with_gpu_backend_caps!(collect_id);
860
861        for (i, (id, name)) in ids.iter().enumerate() {
862            for (other_id, other_name) in &ids[i + 1..] {
863                assert_ne!(
864                    id, other_id,
865                    "{name} and {other_name} both report as {id} -- one of them is \
866                     dispatched to under a registry identity that is not its own"
867                );
868            }
869        }
870        assert_eq!(
871            ids.len() + 1,
872            Backend::ALL.len(),
873            "the registry has a backend variant no BackendCaps impl claims: {:?} vs {ids:?}",
874            Backend::ALL
875        );
876    }
877
878    /// Vulkan is **one kernel wide** and the seam must keep saying so.
879    ///
880    /// `ferrox-vulkan` has exactly one shader, `q8_0_shader`. If this
881    /// table ever grows a kind, either a shader landed with it (and this
882    /// test is the place to say so) or the table now over-claims — which
883    /// is how IQ4_XS prefill and Q5_0 decode each silently moved to the
884    /// CPU while the capability report said "GPU".
885    ///
886    /// Ungated on purpose, like [`BackendCaps`] itself: this is a
887    /// property of the kernel set, so it is checked on every build,
888    /// including the CPU-only one that runs `cargo test --workspace`.
889    #[test]
890    fn vulkan_claims_exactly_one_matvec_kind_and_no_gemm() {
891        let claimed: Vec<QuantKind> = QuantKind::ALL
892            .iter()
893            .copied()
894            .filter(|&k| Vulkan::matvec_kernel(k).is_some())
895            .collect();
896        assert_eq!(
897            claimed,
898            vec![QuantKind::Q8_0],
899            "ferrox-vulkan has one shader (q8_0_shader); the capability table claims {claimed:?}"
900        );
901        for &k in QuantKind::ALL {
902            assert!(
903                !Vulkan::gemm_supported(k),
904                "{k:?}: there is no Vulkan mul_mm shader, so a claimed GEMM would send \
905                 prefill to a kernel that does not exist"
906            );
907        }
908    }
909
910    /// Every kind a **compiled-in** backend's capability table CLAIMS
911    /// must have a launch function behind it, for all 21 kinds and
912    /// without a device.
913    ///
914    /// `Q5_0` did not, on Metal, from the day it was added. The kernel
915    /// source and the `matvec_launch_meta` row landed together and both
916    /// capability tables were widened on the strength of them — but
917    /// `apply_gpu`'s single-matvec decode path dispatches through a
918    /// per-kind `launch_*_matvec` FUNCTION, and there was no Q5_0 one.
919    /// So batched prefill ran on the GPU while single-token decode
920    /// silently fell to the CPU: exactly the mixed CPU/GPU split that
921    /// widening was supposed to close.
922    ///
923    /// The `debug_assert_eq!` in `Metal::launch_matvec` did guard this,
924    /// but only for kinds a run actually reaches, and only in debug. A
925    /// release build just ran slower. This checks the whole table up
926    /// front — and, since it expands over
927    /// [`with_gpu_backends`] rather than naming Metal, CUDA and Vulkan
928    /// each get it for free instead of Metal getting it three times.
929    /// CUDA had no such guard at all before its launch table was split
930    /// out of `launch_matvec`; Vulkan gets one on its first day.
931    #[test]
932    fn every_kind_a_compiled_backend_claims_can_actually_be_launched() {
933        #[allow(unused_macros)]
934        macro_rules! check_launch_table {
935            ($b:ty) => {
936                let mut claimed_without_launch = Vec::new();
937                let mut launchable_unclaimed = Vec::new();
938                for &kind in QuantKind::ALL {
939                    let claimed = <$b as BackendCaps>::matvec_kernel(kind).is_some();
940                    let launchable = <$b as BackendDispatch>::has_launch(kind);
941                    if claimed && !launchable {
942                        claimed_without_launch.push(kind);
943                    }
944                    if launchable && !claimed {
945                        launchable_unclaimed.push(kind);
946                    }
947                }
948                assert!(
949                    claimed_without_launch.is_empty(),
950                    "{} claims a matvec nothing can launch: {claimed_without_launch:?} -- \
951                     decode falls to the CPU for these while batched prefill runs on the GPU",
952                    <$b as BackendCaps>::NAME
953                );
954                assert!(
955                    launchable_unclaimed.is_empty(),
956                    "{} has a launch for {launchable_unclaimed:?} that its capability \
957                     table does not claim, so nothing will ever call it",
958                    <$b as BackendCaps>::NAME
959                );
960            };
961        }
962        with_gpu_backends!(check_launch_table);
963    }
964
965    /// A kind that claims a matvec must name itself the way the
966    /// backend's launch-meta table is keyed, for every backend and not
967    /// just Metal. Expanded over the ungated table, so it holds on a
968    /// CPU-only build and a third backend gets it for free — which it
969    /// did not when the body hand-listed `[Metal, Cuda]`.
970    #[test]
971    fn a_claimed_matvec_kernel_is_named_after_its_kind() {
972        macro_rules! check_names {
973            ($b:ty) => {
974                for &k in QuantKind::ALL {
975                    if let Some(name) = <$b as BackendCaps>::matvec_kernel(k) {
976                        assert_eq!(
977                            name,
978                            k.name(),
979                            "{} names {k:?} {name:?}",
980                            <$b as BackendCaps>::NAME
981                        );
982                    }
983                }
984            };
985        }
986        with_gpu_backend_caps!(check_names);
987    }
988
989    /// The GPU matvec against the CPU one, on a real device.
990    ///
991    /// This is the only test here that opens a device, and it is the
992    /// only one that can catch the seam wiring the right kernel to the
993    /// wrong arguments — a `row_bytes` that is not `n_blocks * 34`, or
994    /// an activation length derived from the wrong block size, are both
995    /// legal calls that produce a wrong number. `ferrox-vulkan`'s own
996    /// twin test proves the shader; this proves the *call*.
997    ///
998    /// Skips, loudly, when no device is reachable, so it is not a test
999    /// that cannot fail: on a host with Vulkan it asserts, and it was
1000    /// checked by sabotage (feeding `rows + 1`) before being committed.
1001    #[cfg(feature = "vulkan")]
1002    #[test]
1003    fn the_vulkan_seam_matvec_matches_the_cpu_matvec() {
1004        use crate::weight_matrix::{WeightBytes, WeightMatrix};
1005
1006        if !Vulkan::dense_enabled() {
1007            eprintln!("no Vulkan device reachable; the seam matvec was NOT checked");
1008            return;
1009        }
1010
1011        // Three blocks per row: 3 * 34 = 102 bytes, deliberately not a
1012        // multiple of 4, which is the alignment case the shader's byte
1013        // extraction exists for.
1014        let (rows, blocks) = (9usize, 3usize);
1015        let cols = blocks * 32;
1016        let f32_weights: Vec<f32> = (0..rows * cols)
1017            .map(|i| ((i % 37) as f32 - 18.0) / 11.0)
1018            .collect();
1019        let mut data = Vec::new();
1020        for r in 0..rows {
1021            data.extend_from_slice(&ferrox_quant::quantize_q8_0(
1022                &f32_weights[r * cols..(r + 1) * cols],
1023            ));
1024        }
1025        let x: Vec<f32> = (0..cols).map(|i| ((i % 13) as f32 - 6.0) / 5.0).collect();
1026
1027        let m = WeightMatrix::Quantized {
1028            data: WeightBytes::Owned(data),
1029            rows,
1030            cols,
1031            kind: QuantKind::Q8_0,
1032        };
1033        let WeightMatrix::Quantized { data, .. } = &m else {
1034            unreachable!()
1035        };
1036        let got = Vulkan::launch_matvec(QuantKind::Q8_0, data.as_slice(), &x, rows, blocks * 34)
1037            .expect("Q8_0 has a Vulkan kernel")
1038            .expect("the launch must succeed once a device is open");
1039        let want = m.apply(&x);
1040
1041        assert_eq!(got.len(), want.len());
1042        for (r, (g, w)) in got.iter().zip(&want).enumerate() {
1043            // The same 1e-4 relative tolerance ferrox-cuda's hardware
1044            // test uses, and for the same reason: a GPU may contract
1045            // `acc + a * b` into an FMA.
1046            assert!(
1047                (g - w).abs() <= 1e-4 * w.abs().max(1.0),
1048                "row {r}: vulkan {g} vs cpu {w}"
1049            );
1050        }
1051    }
1052
1053    /// A shape the kernel cannot honour must come back as an ERROR the
1054    /// caller logs, never as a panic in a rayon worker.
1055    /// `ferrox_vulkan::dispatch::q8_0_matvec` asserts its invariants,
1056    /// which is right for a beachhead and fatal on a dispatch path, so
1057    /// the seam checks them first. Needs no device: the check runs
1058    /// before the context is used.
1059    #[cfg(feature = "vulkan")]
1060    #[test]
1061    fn a_mismatched_vulkan_shape_is_an_error_not_a_panic() {
1062        if !Vulkan::dense_enabled() {
1063            eprintln!("no Vulkan device reachable; the shape guard was NOT checked");
1064            return;
1065        }
1066        // 2 rows of 1 block each, but an activation sized for 2 blocks.
1067        let weights = vec![0u8; 2 * 34];
1068        let x = vec![0.0f32; 64];
1069        let out = Vulkan::launch_matvec(QuantKind::Q8_0, &weights, &x, 2, 34);
1070        assert!(
1071            matches!(out, Some(Err(_))),
1072            "a mismatched shape must be a reported error, got {out:?}"
1073        );
1074    }
1075}