use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
#[cfg(feature = "cuda")]
mod cuda;
#[cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
mod wgpu_probe;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BackendKind {
Cpu,
Cuda,
Wgpu,
Metal,
Hip,
}
impl BackendKind {
pub const ALL: [BackendKind; 5] = [Self::Cpu, Self::Cuda, Self::Wgpu, Self::Metal, Self::Hip];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Cpu => "cpu",
Self::Cuda => "cuda",
Self::Wgpu => "wgpu",
Self::Metal => "metal",
Self::Hip => "hip",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Api {
Cpu,
CudaDriver,
Wgpu,
Metal,
Hip,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "detail", rename_all = "kebab-case")]
pub enum Source {
CompiledIn,
Dlopen(String),
NotCompiled,
Fixture(String),
}
impl Source {
#[must_use]
pub fn text(&self) -> String {
match self {
Self::CompiledIn => "compiled-in".to_string(),
Self::Dlopen(p) => format!("dlopen({p})"),
Self::NotCompiled => "not-compiled".to_string(),
Self::Fixture(p) => format!("fixture({p})"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum Reason {
NotCompiled,
DriverNotFound { path: String },
NoDevice,
NoBackend { vendor: String },
ProbeFailed { error: String },
ReserveExceedsFree { reserve_bytes: u64, free_bytes: u64 },
}
impl Reason {
#[must_use]
pub fn text(&self) -> String {
match self {
Self::NotCompiled => "NotCompiled".to_string(),
Self::DriverNotFound { path } => format!("DriverNotFound({path})"),
Self::NoDevice => "NoDevice".to_string(),
Self::NoBackend { vendor } => format!("NoBackend({vendor})"),
Self::ProbeFailed { error } => format!("ProbeFailed({error})"),
Self::ReserveExceedsFree { reserve_bytes, free_bytes } => {
format!("ReserveExceedsFree{{reserve={reserve_bytes}, free={free_bytes}}}")
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "kebab-case")]
pub enum Status {
Ready,
Unavailable(Reason),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum MemKind {
Discrete,
Unified { working_set_limit: Option<u64> },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BackendEntry {
pub kind: BackendKind,
pub api: Api,
pub device_index: Option<u32>,
pub device_uid: Option<String>,
pub device_name: String,
pub vendor: String,
pub vendor_id: Option<u32>,
pub device_type: String,
pub mem_total: Option<u64>,
pub mem_free: Option<u64>,
pub mem_kind: MemKind,
pub compute_class: Option<String>,
pub caps: Vec<String>,
pub source: Source,
pub status: Status,
pub transport: Option<String>,
}
impl BackendEntry {
#[must_use]
pub fn unavailable(kind: BackendKind, api: Api, source: Source, reason: Reason) -> Self {
Self {
kind,
api,
device_index: None,
device_uid: None,
device_name: String::new(),
vendor: String::new(),
vendor_id: None,
device_type: String::new(),
mem_total: None,
mem_free: None,
mem_kind: MemKind::Discrete,
compute_class: None,
caps: Vec::new(),
source,
status: Status::Unavailable(reason),
transport: None,
}
}
fn is_ready(&self) -> bool {
self.status == Status::Ready
}
fn identity(&self) -> String {
self.device_uid
.clone()
.unwrap_or_else(|| format!("{}:{:?}", self.kind.as_str(), self.device_index))
}
}
pub trait BackendFactory: Send + Sync {
fn kind(&self) -> BackendKind;
fn discover(&self) -> Vec<BackendEntry>;
}
pub struct MockBackendFactory {
kind: BackendKind,
entries: Vec<BackendEntry>,
}
impl MockBackendFactory {
#[must_use]
pub fn new(kind: BackendKind, entries: Vec<BackendEntry>) -> Self {
Self { kind, entries }
}
}
impl BackendFactory for MockBackendFactory {
fn kind(&self) -> BackendKind {
self.kind
}
fn discover(&self) -> Vec<BackendEntry> {
self.entries.clone()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Selection {
pub kind: BackendKind,
pub device_index: Option<u32>,
pub device_uid: Option<String>,
pub reason: String,
}
pub const DEFAULT_RESERVE_BYTES: u64 = 3_584 * 1024 * 1024;
pub const DEFAULT_RESERVE_BASIS: &str = "[U] default until master row 6 measures vram_peak";
pub const SCHEMA: &str = "apr-devices-v1";
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BackendRegistry {
pub schema: String,
pub discovered_at_unix: u64,
pub source: String,
pub reserve_bytes: u64,
pub reserve_basis: String,
pub entries: Vec<BackendEntry>,
pub selected: Selection,
}
impl BackendRegistry {
#[must_use]
pub fn discover() -> Self {
Self::discover_with(&default_factories(), None)
}
#[must_use]
pub fn discover_with(
factories: &[Box<dyn BackendFactory>],
reserve_bytes: Option<u64>,
) -> Self {
let (reserve, basis) = match reserve_bytes {
Some(r) => (r, "APR_RESERVE_BYTES override".to_string()),
None => (DEFAULT_RESERVE_BYTES, DEFAULT_RESERVE_BASIS.to_string()),
};
let mut entries = vec![cpu_entry()];
for kind in [BackendKind::Cuda, BackendKind::Wgpu, BackendKind::Metal, BackendKind::Hip] {
let mut found: Vec<BackendEntry> =
factories.iter().filter(|f| f.kind() == kind).flat_map(|f| f.discover()).collect();
if found.is_empty() {
found.push(missing_entry(kind));
}
disambiguate_same_named(&mut found);
entries.extend(found);
}
apply_reserve(&mut entries, reserve);
let selected = select(&entries, reserve);
Self {
schema: SCHEMA.to_string(),
discovered_at_unix: now_unix(),
source: "machine".to_string(),
reserve_bytes: reserve,
reserve_basis: basis,
entries,
selected,
}
}
pub fn from_fixture_json(json: &str, path: &str) -> Result<Self, String> {
let mut reg: Self =
serde_json::from_str(json).map_err(|e| format!("fixture {path}: {e}"))?;
reg.source = format!("fixture({path})");
reg.selected = select(®.entries, reg.reserve_bytes);
Ok(reg)
}
#[must_use]
pub fn with_reserve(mut self, reserve_bytes: u64, basis: &str) -> Self {
self.reserve_bytes = reserve_bytes;
self.reserve_basis = basis.to_string();
apply_reserve(&mut self.entries, reserve_bytes);
self.selected = select(&self.entries, reserve_bytes);
self
}
pub fn ready(&self) -> impl Iterator<Item = &BackendEntry> {
self.entries.iter().filter(|e| e.is_ready())
}
#[must_use]
pub fn select_default(&self) -> Selection {
select(&self.entries, self.reserve_bytes)
}
#[must_use]
pub fn distinct_devices(&self) -> usize {
let mut seen: Vec<String> = Vec::new();
for e in self.entries.iter().filter(|e| e.is_ready() && e.kind != BackendKind::Cpu) {
let id = e.identity();
if !seen.contains(&id) {
seen.push(id);
}
}
seen.len()
}
pub fn to_json(&self) -> Result<String, String> {
serde_json::to_string_pretty(self).map_err(|e| e.to_string())
}
#[must_use]
pub fn render_block(&self, version: &str) -> String {
let mut out = format!(
"apr {version} discovery unix={} source={}\n",
self.discovered_at_unix, self.source
);
for e in &self.entries {
out.push_str(&render_entry(e));
out.push('\n');
}
let s = &self.selected;
let dev = s.device_index.map(|i| format!(" device[{i}]")).unwrap_or_default();
out.push_str(&format!(
"selected: {}{dev} reserve={}MiB basis={} ({})\n",
s.kind.as_str(),
self.reserve_bytes / (1024 * 1024),
self.reserve_basis,
s.reason
));
out
}
}
fn render_entry(e: &BackendEntry) -> String {
let kind = format!("{:<6}", e.kind.as_str());
match &e.status {
Status::Unavailable(r) => {
format!("backend: {kind} unavailable reason={} source={}", r.text(), e.source.text())
}
Status::Ready => {
let mut line = format!("backend: {kind} ready ");
if let Some(i) = e.device_index {
line.push_str(&format!(" device[{i}]=\"{}\"", e.device_name));
} else {
line.push_str(&format!(" {}", e.device_name));
}
if let Some(cc) = &e.compute_class {
line.push_str(&format!(" class={cc}"));
}
if let Some(t) = e.mem_total {
line.push_str(&format!(" mem={}MiB", t / (1024 * 1024)));
}
if let Some(f) = e.mem_free {
line.push_str(&format!(" free={}MiB", f / (1024 * 1024)));
}
line.push_str(match &e.mem_kind {
MemKind::Discrete => " kind=discrete",
MemKind::Unified { .. } => " kind=unified",
});
if let Some(t) = &e.transport {
line.push_str(&format!(" transport={t}"));
}
if !e.caps.is_empty() {
line.push_str(&format!(" caps={{{}}}", e.caps.join(",")));
}
line.push_str(&format!(" source={}", e.source.text()));
line
}
}
}
fn apply_reserve(entries: &mut [BackendEntry], reserve: u64) {
let mut refused: Vec<(String, u64)> = Vec::new();
for e in entries.iter_mut().filter(|e| e.kind != BackendKind::Cpu && e.is_ready()) {
if let Some(free) = e.mem_free {
if free < reserve {
e.status = Status::Unavailable(Reason::ReserveExceedsFree {
reserve_bytes: reserve,
free_bytes: free,
});
refused.push((e.identity(), free));
}
}
}
for e in entries
.iter_mut()
.filter(|e| e.kind != BackendKind::Cpu && e.is_ready() && e.mem_free.is_none())
{
let id = e.identity();
if let Some((_, free)) = refused.iter().find(|(r, _)| *r == id) {
e.status = Status::Unavailable(Reason::ReserveExceedsFree {
reserve_bytes: reserve,
free_bytes: *free,
});
}
}
}
fn select(entries: &[BackendEntry], reserve: u64) -> Selection {
if let Some(e) = entries.iter().find(|e| e.kind != BackendKind::Cpu && e.is_ready()) {
return Selection {
kind: e.kind,
device_index: e.device_index,
device_uid: e.device_uid.clone(),
reason: format!(
"first Ready non-cpu entry; {} physical device(s) Ready",
count_distinct(entries)
),
};
}
let why = entries
.iter()
.filter(|e| e.kind != BackendKind::Cpu)
.filter_map(|e| match &e.status {
Status::Unavailable(r) => Some(format!("{}={}", e.kind.as_str(), r.text())),
Status::Ready => None,
})
.collect::<Vec<_>>()
.join(", ");
let reserve_note = if why.contains("ReserveExceedsFree") {
format!("; reserve={reserve} B exceeds free memory")
} else {
String::new()
};
Selection {
kind: BackendKind::Cpu,
device_index: None,
device_uid: None,
reason: format!("no ready gpu: {why}{reserve_note}"),
}
}
fn count_distinct(entries: &[BackendEntry]) -> usize {
let mut seen: Vec<String> = Vec::new();
for e in entries.iter().filter(|e| e.is_ready() && e.kind != BackendKind::Cpu) {
let id = e.identity();
if !seen.contains(&id) {
seen.push(id);
}
}
seen.len()
}
fn disambiguate_same_named(found: &mut [BackendEntry]) {
let uids: Vec<Option<String>> = found.iter().map(|e| e.device_uid.clone()).collect();
for (i, e) in found.iter_mut().enumerate() {
let Some(uid) = uids[i].clone() else { continue };
let earlier = uids[..i].iter().filter(|u| u.as_deref() == Some(uid.as_str())).count();
let total = uids.iter().filter(|u| u.as_deref() == Some(uid.as_str())).count();
if total > 1 {
e.device_uid = Some(format!("{uid}#{earlier}"));
}
}
}
fn missing_entry(kind: BackendKind) -> BackendEntry {
match kind {
BackendKind::Cuda => BackendEntry::unavailable(
kind,
Api::CudaDriver,
Source::NotCompiled,
Reason::NotCompiled,
),
BackendKind::Wgpu => {
BackendEntry::unavailable(kind, Api::Wgpu, Source::NotCompiled, Reason::NotCompiled)
}
BackendKind::Metal => BackendEntry::unavailable(
kind,
Api::Metal,
Source::NotCompiled,
Reason::NoBackend {
vendor: "no native Metal backend in 0.66 (a Metal adapter appears under wgpu)"
.to_string(),
},
),
BackendKind::Hip => BackendEntry::unavailable(
kind,
Api::Hip,
Source::NotCompiled,
Reason::NoBackend { vendor: "no HIP backend in 0.66".to_string() },
),
BackendKind::Cpu => cpu_entry(),
}
}
fn now_unix() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn cpu_entry() -> BackendEntry {
let threads =
std::thread::available_parallelism().map(std::num::NonZeroUsize::get).unwrap_or(1);
BackendEntry {
kind: BackendKind::Cpu,
api: Api::Cpu,
device_index: None,
device_uid: Some("host-cpu".to_string()),
device_name: format!("{} host cpu, {threads} threads", std::env::consts::ARCH),
vendor: "host".to_string(),
vendor_id: None,
device_type: "cpu".to_string(),
mem_total: host_mem_total(),
mem_free: None,
mem_kind: MemKind::Unified { working_set_limit: None },
compute_class: Some(cpu_isa()),
caps: Vec::new(),
source: Source::CompiledIn,
status: Status::Ready,
transport: None,
}
}
fn cpu_isa() -> String {
#[cfg(target_arch = "x86_64")]
{
if std::arch::is_x86_feature_detected!("avx512f") {
return "avx512".to_string();
}
if std::arch::is_x86_feature_detected!("avx2") {
return "avx2".to_string();
}
return "sse2".to_string();
}
#[cfg(target_arch = "aarch64")]
{
return "neon".to_string();
}
#[allow(unreachable_code)]
std::env::consts::ARCH.to_string()
}
fn host_mem_total() -> Option<u64> {
let text = std::fs::read_to_string("/proc/meminfo").ok()?;
let line = text.lines().find(|l| l.starts_with("MemTotal:"))?;
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
Some(kb * 1024)
}
#[must_use]
pub fn default_factories() -> Vec<Box<dyn BackendFactory>> {
let v: Vec<Box<dyn BackendFactory>> = vec![
#[cfg(feature = "cuda")]
Box::new(cuda::CudaFactory),
#[cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
Box::new(wgpu_probe::WgpuFactory),
];
v
}
#[must_use]
pub fn device_uid(vendor: &str, name: &str) -> String {
let norm: String = name
.trim()
.to_ascii_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
format!("{}:{}", vendor.to_ascii_lowercase(), norm.trim_matches('-'))
}