use crate::kernel_registry::Backend;
use crate::weight_matrix::QuantKind;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendError(String);
impl BackendError {
pub fn new(e: impl std::fmt::Display) -> Self {
BackendError(e.to_string())
}
}
impl std::fmt::Display for BackendError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
pub trait BackendCaps {
const ID: Backend;
const NAME: &'static str;
const GEMM_FALLBACK: &'static str;
fn matvec_kernel(kind: QuantKind) -> Option<&'static str>;
fn gemm_supported(kind: QuantKind) -> bool;
}
pub trait BackendDispatch: BackendCaps {
const MATVEC_FALLBACK: &'static str;
fn dense_enabled() -> bool;
fn has_launch(kind: QuantKind) -> bool;
fn launch_matvec(
kind: QuantKind,
weights: &[u8],
x: &[f32],
rows: usize,
row_bytes: usize,
) -> Option<Result<Vec<f32>, BackendError>>;
}
#[cfg(feature = "metal")]
fn metal_matvec_launch(kind: QuantKind) -> Option<MetalMatvecLaunchFn> {
match kind {
QuantKind::Q8_0 => Some(ferrox_metal::gpu::launch_q8_0_matvec),
QuantKind::Q4_0 => Some(ferrox_metal::gpu::launch_q4_0_matvec),
QuantKind::Q4K => Some(ferrox_metal::gpu::launch_q4_k_matvec),
QuantKind::Q5_0 => Some(ferrox_metal::gpu::launch_q5_0_matvec),
QuantKind::Q5K => Some(ferrox_metal::gpu::launch_q5_k_matvec),
QuantKind::Q6K => Some(ferrox_metal::gpu::launch_q6_k_matvec),
QuantKind::IQ4XS => Some(ferrox_metal::gpu::launch_iq4_xs_matvec),
_ => None,
}
}
#[cfg(feature = "cuda")]
fn cuda_matvec_launch(kind: QuantKind) -> Option<CudaMatvecLaunchFn> {
match kind {
QuantKind::Q8_0 => Some(ferrox_cuda::gpu::launch_q8_0_matvec),
QuantKind::Q4_0 => Some(ferrox_cuda::gpu::launch_q4_0_matvec),
QuantKind::Q4K => Some(ferrox_cuda::gpu::launch_q4_k_matvec),
QuantKind::Q5K => Some(ferrox_cuda::gpu::launch_q5_k_matvec),
QuantKind::Q6K => Some(ferrox_cuda::gpu::launch_q6_k_matvec),
_ => None,
}
}
#[cfg(feature = "vulkan")]
fn vulkan_matvec_launch(kind: QuantKind) -> Option<VulkanMatvecLaunchFn> {
match kind {
QuantKind::Q8_0 => Some(ferrox_vulkan::dispatch::q8_0_matvec),
_ => None,
}
}
pub struct Metal;
pub struct Cuda;
pub struct Vulkan;
impl BackendCaps for Metal {
const ID: Backend = Backend::Metal;
const NAME: &'static str = "Metal";
const GEMM_FALLBACK: &'static str = "Metal N x matvec batch";
fn matvec_kernel(kind: QuantKind) -> Option<&'static str> {
match kind {
QuantKind::Q8_0
| QuantKind::Q4_0
| QuantKind::Q5_0
| QuantKind::Q4K
| QuantKind::Q5K
| QuantKind::Q6K
| QuantKind::IQ4XS => Some(kind.name()),
_ => None,
}
}
fn gemm_supported(kind: QuantKind) -> bool {
matches!(
kind,
QuantKind::Q8_0
| QuantKind::Q4_0
| QuantKind::Q5_0
| QuantKind::Q4K
| QuantKind::Q5K
| QuantKind::Q6K
| QuantKind::IQ4XS
)
}
}
impl BackendCaps for Cuda {
const ID: Backend = Backend::Cuda;
const NAME: &'static str = "CUDA";
const GEMM_FALLBACK: &'static str = "CUDA per-position matvec";
fn matvec_kernel(kind: QuantKind) -> Option<&'static str> {
match kind {
QuantKind::Q8_0
| QuantKind::Q4_0
| QuantKind::Q4K
| QuantKind::Q5K
| QuantKind::Q6K => Some(kind.name()),
_ => None,
}
}
fn gemm_supported(kind: QuantKind) -> bool {
matches!(kind, QuantKind::Q8_0 | QuantKind::Q4_0)
}
}
impl BackendCaps for Vulkan {
const ID: Backend = Backend::Vulkan;
const NAME: &'static str = "Vulkan";
const GEMM_FALLBACK: &'static str = "CPU apply_batch";
fn matvec_kernel(kind: QuantKind) -> Option<&'static str> {
match kind {
QuantKind::Q8_0 => Some(kind.name()),
_ => None,
}
}
fn gemm_supported(_kind: QuantKind) -> bool {
false
}
}
#[cfg(any(feature = "metal", feature = "cuda", feature = "vulkan", test))]
fn env_or_probe(value: Option<&str>, on_alias: &str, probe: impl FnOnce() -> bool) -> bool {
match value {
Some("0") | Some("false") | Some("off") | Some("cpu") => false,
Some("1") | Some("true") | Some("on") => true,
Some(v) if v == on_alias => true,
_ => probe(),
}
}
#[cfg(feature = "metal")]
type MetalMatvecLaunchFn =
fn(&[u8], &[f32], usize, usize) -> Result<Vec<f32>, ferrox_metal::gpu::MetalError>;
#[cfg(feature = "cuda")]
type CudaMatvecLaunchFn =
fn(&[u8], &[f32], usize, usize, usize) -> Result<Vec<f32>, ferrox_cuda::gpu::CudaError>;
#[cfg(feature = "vulkan")]
type VulkanMatvecLaunchFn = fn(
&ferrox_vulkan::device::Context,
&[u8],
&[f32],
usize,
usize,
usize,
) -> Result<Vec<f32>, ferrox_vulkan::device::VulkanError>;
#[cfg(feature = "metal")]
impl BackendDispatch for Metal {
const MATVEC_FALLBACK: &'static str = "falling back to CPU";
fn has_launch(kind: QuantKind) -> bool {
metal_matvec_launch(kind).is_some()
}
fn dense_enabled() -> bool {
use std::sync::OnceLock;
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| {
let v = std::env::var("FERROX_METAL").ok();
env_or_probe(v.as_deref(), "metal", || {
ferrox_metal::gpu::probe().is_some()
})
})
}
fn launch_matvec(
kind: QuantKind,
weights: &[u8],
x: &[f32],
rows: usize,
row_bytes: usize,
) -> Option<Result<Vec<f32>, BackendError>> {
let launch = metal_matvec_launch(kind);
debug_assert_eq!(
launch.is_some(),
Self::matvec_kernel(kind).is_some(),
"apply_gpu's Metal launch table disagrees with metal_matvec_kind_name for {:?}",
kind
);
let launch = launch?;
Some(launch(weights, x, rows, row_bytes).map_err(BackendError::new))
}
}
#[cfg(feature = "cuda")]
impl BackendDispatch for Cuda {
const MATVEC_FALLBACK: &'static str = "trying next backend / CPU";
fn has_launch(kind: QuantKind) -> bool {
cuda_matvec_launch(kind).is_some()
}
fn dense_enabled() -> bool {
use std::sync::OnceLock;
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| {
let v = std::env::var("FERROX_CUDA").ok();
env_or_probe(v.as_deref(), "cuda", || ferrox_cuda::gpu::probe().is_some())
})
}
fn launch_matvec(
kind: QuantKind,
weights: &[u8],
x: &[f32],
rows: usize,
row_bytes: usize,
) -> Option<Result<Vec<f32>, BackendError>> {
let launch = cuda_matvec_launch(kind)?;
let n_blocks_per_row =
row_bytes / crate::weight_matrix::WeightMatrix::block_bytes_for_kind(kind);
Some(launch(weights, x, rows, row_bytes, n_blocks_per_row).map_err(BackendError::new))
}
}
#[cfg(feature = "vulkan")]
fn vulkan_context() -> Option<&'static std::sync::Mutex<ferrox_vulkan::device::Context>> {
use std::sync::{Mutex, OnceLock};
static CTX: OnceLock<Option<Mutex<ferrox_vulkan::device::Context>>> = OnceLock::new();
CTX.get_or_init(|| match ferrox_vulkan::device::Context::new() {
Ok(ctx) => Some(Mutex::new(ctx)),
Err(e) => {
eprintln!(
"ferrox: Vulkan device unavailable, {}: {e}",
Vulkan::MATVEC_FALLBACK
);
None
}
})
.as_ref()
}
#[cfg(feature = "vulkan")]
impl BackendDispatch for Vulkan {
const MATVEC_FALLBACK: &'static str = "falling back to CPU";
fn has_launch(kind: QuantKind) -> bool {
vulkan_matvec_launch(kind).is_some()
}
fn dense_enabled() -> bool {
use std::sync::OnceLock;
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| {
let v = std::env::var("FERROX_VULKAN").ok();
env_or_probe(v.as_deref(), "vulkan", || {
ferrox_vulkan::device::probe().is_ok()
})
})
}
fn launch_matvec(
kind: QuantKind,
weights: &[u8],
x: &[f32],
rows: usize,
row_bytes: usize,
) -> Option<Result<Vec<f32>, BackendError>> {
let launch = vulkan_matvec_launch(kind)?;
if !Self::dense_enabled() {
return None;
}
let ctx = vulkan_context()?;
let block_bytes = crate::weight_matrix::WeightMatrix::block_bytes_for_kind(kind);
let n_blocks_per_row = row_bytes / block_bytes;
if weights.len() != rows * row_bytes
|| row_bytes != n_blocks_per_row * block_bytes
|| x.len() != n_blocks_per_row * ferrox_vulkan::q8_0_shader::BLOCK_ELEMS
|| rows == 0
|| n_blocks_per_row == 0
{
return Some(Err(BackendError::new(format!(
"Vulkan {} matvec shape rejected: {} weight bytes, {} activations, \
rows={rows} row_bytes={row_bytes}",
kind.name(),
weights.len(),
x.len(),
))));
}
let guard = match ctx.lock() {
Ok(g) => g,
Err(_) => {
return Some(Err(BackendError::new(
"Vulkan context poisoned by an earlier panic",
)))
}
};
Some(
launch(&guard, weights, x, rows, row_bytes, n_blocks_per_row)
.map_err(BackendError::new),
)
}
}
macro_rules! gpu_backend_table {
($mac:path $(, $extra:tt)*) => {
$mac! {
[$($extra),*]
(Cuda, "cuda", Cuda),
(Metal, "metal", Metal),
(Vulkan, "vulkan", Vulkan),
}
};
}
pub(crate) use gpu_backend_table;
macro_rules! with_gpu_backends {
($mac:ident) => {
$crate::weight_matrix::gpu_backend::gpu_backend_table!(
$crate::weight_matrix::gpu_backend::gpu_backend_dispatch_rows,
$mac
);
};
}
pub(crate) use with_gpu_backends;
macro_rules! gpu_backend_dispatch_rows {
([$mac:ident] $(($variant:ident, $feature:literal, $ty:ident)),* $(,)?) => {
$(
#[cfg(feature = $feature)]
$mac!($crate::weight_matrix::gpu_backend::$ty);
)*
};
}
pub(crate) use gpu_backend_dispatch_rows;
macro_rules! with_gpu_backend_caps {
($mac:ident) => {
$crate::weight_matrix::gpu_backend::gpu_backend_table!(
$crate::weight_matrix::gpu_backend::gpu_backend_caps_rows,
$mac
);
};
}
pub(crate) use with_gpu_backend_caps;
macro_rules! gpu_backend_caps_rows {
([$mac:ident] $(($variant:ident, $feature:literal, $ty:ident)),* $(,)?) => {
$(
$mac!($crate::weight_matrix::gpu_backend::$ty);
)*
};
}
pub(crate) use gpu_backend_caps_rows;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_decides_before_the_probe_is_consulted() {
for forced_off in ["0", "false", "off", "cpu"] {
assert!(!env_or_probe(Some(forced_off), "metal", || panic!(
"probed after {forced_off}"
)));
}
for forced_on in ["1", "true", "on"] {
assert!(env_or_probe(Some(forced_on), "metal", || panic!(
"probed after {forced_on}"
)));
}
}
#[test]
fn the_alias_is_per_backend() {
assert!(env_or_probe(Some("metal"), "metal", || false));
assert!(env_or_probe(Some("cuda"), "cuda", || false));
assert!(!env_or_probe(Some("cuda"), "metal", || false));
assert!(!env_or_probe(Some("metal"), "cuda", || false));
}
#[test]
fn an_unrecognised_value_defers_to_the_probe() {
assert!(env_or_probe(None, "metal", || true));
assert!(!env_or_probe(None, "metal", || false));
assert!(env_or_probe(Some("auto"), "metal", || true));
assert!(!env_or_probe(Some("auto"), "metal", || false));
}
#[test]
fn every_backend_id_is_distinct_and_an_accelerator() {
let mut ids = Vec::new();
macro_rules! collect_id {
($b:ty) => {
assert!(
<$b as BackendCaps>::ID.is_accelerator(),
"{} is reported as the CPU",
<$b as BackendCaps>::NAME
);
ids.push((<$b as BackendCaps>::ID, <$b as BackendCaps>::NAME));
};
}
with_gpu_backend_caps!(collect_id);
for (i, (id, name)) in ids.iter().enumerate() {
for (other_id, other_name) in &ids[i + 1..] {
assert_ne!(
id, other_id,
"{name} and {other_name} both report as {id} -- one of them is \
dispatched to under a registry identity that is not its own"
);
}
}
assert_eq!(
ids.len() + 1,
Backend::ALL.len(),
"the registry has a backend variant no BackendCaps impl claims: {:?} vs {ids:?}",
Backend::ALL
);
}
#[test]
fn vulkan_claims_exactly_one_matvec_kind_and_no_gemm() {
let claimed: Vec<QuantKind> = QuantKind::ALL
.iter()
.copied()
.filter(|&k| Vulkan::matvec_kernel(k).is_some())
.collect();
assert_eq!(
claimed,
vec![QuantKind::Q8_0],
"ferrox-vulkan has one shader (q8_0_shader); the capability table claims {claimed:?}"
);
for &k in QuantKind::ALL {
assert!(
!Vulkan::gemm_supported(k),
"{k:?}: there is no Vulkan mul_mm shader, so a claimed GEMM would send \
prefill to a kernel that does not exist"
);
}
}
#[test]
fn every_kind_a_compiled_backend_claims_can_actually_be_launched() {
#[allow(unused_macros)]
macro_rules! check_launch_table {
($b:ty) => {
let mut claimed_without_launch = Vec::new();
let mut launchable_unclaimed = Vec::new();
for &kind in QuantKind::ALL {
let claimed = <$b as BackendCaps>::matvec_kernel(kind).is_some();
let launchable = <$b as BackendDispatch>::has_launch(kind);
if claimed && !launchable {
claimed_without_launch.push(kind);
}
if launchable && !claimed {
launchable_unclaimed.push(kind);
}
}
assert!(
claimed_without_launch.is_empty(),
"{} claims a matvec nothing can launch: {claimed_without_launch:?} -- \
decode falls to the CPU for these while batched prefill runs on the GPU",
<$b as BackendCaps>::NAME
);
assert!(
launchable_unclaimed.is_empty(),
"{} has a launch for {launchable_unclaimed:?} that its capability \
table does not claim, so nothing will ever call it",
<$b as BackendCaps>::NAME
);
};
}
with_gpu_backends!(check_launch_table);
}
#[test]
fn a_claimed_matvec_kernel_is_named_after_its_kind() {
macro_rules! check_names {
($b:ty) => {
for &k in QuantKind::ALL {
if let Some(name) = <$b as BackendCaps>::matvec_kernel(k) {
assert_eq!(
name,
k.name(),
"{} names {k:?} {name:?}",
<$b as BackendCaps>::NAME
);
}
}
};
}
with_gpu_backend_caps!(check_names);
}
#[cfg(feature = "vulkan")]
#[test]
fn the_vulkan_seam_matvec_matches_the_cpu_matvec() {
use crate::weight_matrix::{WeightBytes, WeightMatrix};
if !Vulkan::dense_enabled() {
eprintln!("no Vulkan device reachable; the seam matvec was NOT checked");
return;
}
let (rows, blocks) = (9usize, 3usize);
let cols = blocks * 32;
let f32_weights: Vec<f32> = (0..rows * cols)
.map(|i| ((i % 37) as f32 - 18.0) / 11.0)
.collect();
let mut data = Vec::new();
for r in 0..rows {
data.extend_from_slice(&ferrox_quant::quantize_q8_0(
&f32_weights[r * cols..(r + 1) * cols],
));
}
let x: Vec<f32> = (0..cols).map(|i| ((i % 13) as f32 - 6.0) / 5.0).collect();
let m = WeightMatrix::Quantized {
data: WeightBytes::Owned(data),
rows,
cols,
kind: QuantKind::Q8_0,
};
let WeightMatrix::Quantized { data, .. } = &m else {
unreachable!()
};
let got = Vulkan::launch_matvec(QuantKind::Q8_0, data.as_slice(), &x, rows, blocks * 34)
.expect("Q8_0 has a Vulkan kernel")
.expect("the launch must succeed once a device is open");
let want = m.apply(&x);
assert_eq!(got.len(), want.len());
for (r, (g, w)) in got.iter().zip(&want).enumerate() {
assert!(
(g - w).abs() <= 1e-4 * w.abs().max(1.0),
"row {r}: vulkan {g} vs cpu {w}"
);
}
}
#[cfg(feature = "vulkan")]
#[test]
fn a_mismatched_vulkan_shape_is_an_error_not_a_panic() {
if !Vulkan::dense_enabled() {
eprintln!("no Vulkan device reachable; the shape guard was NOT checked");
return;
}
let weights = vec![0u8; 2 * 34];
let x = vec![0.0f32; 64];
let out = Vulkan::launch_matvec(QuantKind::Q8_0, &weights, &x, 2, 34);
assert!(
matches!(out, Some(Err(_))),
"a mismatched shape must be a reported error, got {out:?}"
);
}
}