1use serde::{Deserialize, Serialize};
20use std::time::{SystemTime, UNIX_EPOCH};
21
22#[cfg(feature = "cuda")]
23mod cuda;
24#[cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
25mod wgpu_probe;
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum BackendKind {
31 Cpu,
33 Cuda,
35 Wgpu,
37 Metal,
39 Hip,
41}
42
43impl BackendKind {
44 pub const ALL: [BackendKind; 5] = [Self::Cpu, Self::Cuda, Self::Wgpu, Self::Metal, Self::Hip];
46
47 #[must_use]
49 pub fn as_str(self) -> &'static str {
50 match self {
51 Self::Cpu => "cpu",
52 Self::Cuda => "cuda",
53 Self::Wgpu => "wgpu",
54 Self::Metal => "metal",
55 Self::Hip => "hip",
56 }
57 }
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "kebab-case")]
64pub enum Api {
65 Cpu,
67 CudaDriver,
69 Wgpu,
71 Metal,
73 Hip,
75}
76
77#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(tag = "kind", content = "detail", rename_all = "kebab-case")]
80pub enum Source {
81 CompiledIn,
83 Dlopen(String),
85 NotCompiled,
87 Fixture(String),
89}
90
91impl Source {
92 #[must_use]
94 pub fn text(&self) -> String {
95 match self {
96 Self::CompiledIn => "compiled-in".to_string(),
97 Self::Dlopen(p) => format!("dlopen({p})"),
98 Self::NotCompiled => "not-compiled".to_string(),
99 Self::Fixture(p) => format!("fixture({p})"),
100 }
101 }
102}
103
104#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(tag = "kind", rename_all = "kebab-case")]
107pub enum Reason {
108 NotCompiled,
110 DriverNotFound { path: String },
112 NoDevice,
114 NoBackend { vendor: String },
116 ProbeFailed { error: String },
118 ReserveExceedsFree { reserve_bytes: u64, free_bytes: u64 },
120}
121
122impl Reason {
123 #[must_use]
125 pub fn text(&self) -> String {
126 match self {
127 Self::NotCompiled => "NotCompiled".to_string(),
128 Self::DriverNotFound { path } => format!("DriverNotFound({path})"),
129 Self::NoDevice => "NoDevice".to_string(),
130 Self::NoBackend { vendor } => format!("NoBackend({vendor})"),
131 Self::ProbeFailed { error } => format!("ProbeFailed({error})"),
132 Self::ReserveExceedsFree { reserve_bytes, free_bytes } => {
133 format!("ReserveExceedsFree{{reserve={reserve_bytes}, free={free_bytes}}}")
134 }
135 }
136 }
137}
138
139#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(tag = "state", rename_all = "kebab-case")]
142pub enum Status {
143 Ready,
145 Unavailable(Reason),
147}
148
149#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(tag = "kind", rename_all = "kebab-case")]
152pub enum MemKind {
153 Discrete,
155 Unified { working_set_limit: Option<u64> },
158}
159
160#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
162pub struct BackendEntry {
163 pub kind: BackendKind,
165 pub api: Api,
167 pub device_index: Option<u32>,
169 pub device_uid: Option<String>,
171 pub device_name: String,
173 pub vendor: String,
175 pub vendor_id: Option<u32>,
177 pub device_type: String,
179 pub mem_total: Option<u64>,
181 pub mem_free: Option<u64>,
183 pub mem_kind: MemKind,
185 pub compute_class: Option<String>,
187 pub caps: Vec<String>,
189 pub source: Source,
191 pub status: Status,
193 pub transport: Option<String>,
195}
196
197impl BackendEntry {
198 #[must_use]
200 pub fn unavailable(kind: BackendKind, api: Api, source: Source, reason: Reason) -> Self {
201 Self {
202 kind,
203 api,
204 device_index: None,
205 device_uid: None,
206 device_name: String::new(),
207 vendor: String::new(),
208 vendor_id: None,
209 device_type: String::new(),
210 mem_total: None,
211 mem_free: None,
212 mem_kind: MemKind::Discrete,
213 compute_class: None,
214 caps: Vec::new(),
215 source,
216 status: Status::Unavailable(reason),
217 transport: None,
218 }
219 }
220
221 fn is_ready(&self) -> bool {
222 self.status == Status::Ready
223 }
224
225 fn identity(&self) -> String {
226 self.device_uid
227 .clone()
228 .unwrap_or_else(|| format!("{}:{:?}", self.kind.as_str(), self.device_index))
229 }
230}
231
232pub trait BackendFactory: Send + Sync {
235 fn kind(&self) -> BackendKind;
237 fn discover(&self) -> Vec<BackendEntry>;
240}
241
242pub struct MockBackendFactory {
244 kind: BackendKind,
245 entries: Vec<BackendEntry>,
246}
247
248impl MockBackendFactory {
249 #[must_use]
251 pub fn new(kind: BackendKind, entries: Vec<BackendEntry>) -> Self {
252 Self { kind, entries }
253 }
254}
255
256impl BackendFactory for MockBackendFactory {
257 fn kind(&self) -> BackendKind {
258 self.kind
259 }
260 fn discover(&self) -> Vec<BackendEntry> {
261 self.entries.clone()
262 }
263}
264
265#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
267pub struct Selection {
268 pub kind: BackendKind,
270 pub device_index: Option<u32>,
272 pub device_uid: Option<String>,
274 pub reason: String,
276}
277
278pub const DEFAULT_RESERVE_BYTES: u64 = 3_584 * 1024 * 1024;
280pub const DEFAULT_RESERVE_BASIS: &str = "[U] default until master row 6 measures vram_peak";
282pub const SCHEMA: &str = "apr-devices-v1";
284
285#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
287pub struct BackendRegistry {
288 pub schema: String,
290 pub discovered_at_unix: u64,
292 pub source: String,
294 pub reserve_bytes: u64,
296 pub reserve_basis: String,
298 pub entries: Vec<BackendEntry>,
300 pub selected: Selection,
302}
303
304impl BackendRegistry {
305 #[must_use]
307 pub fn discover() -> Self {
308 Self::discover_with(&default_factories(), None)
309 }
310
311 #[must_use]
314 pub fn discover_with(
315 factories: &[Box<dyn BackendFactory>],
316 reserve_bytes: Option<u64>,
317 ) -> Self {
318 let (reserve, basis) = match reserve_bytes {
319 Some(r) => (r, "APR_RESERVE_BYTES override".to_string()),
320 None => (DEFAULT_RESERVE_BYTES, DEFAULT_RESERVE_BASIS.to_string()),
321 };
322 let mut entries = vec![cpu_entry()];
323 for kind in [BackendKind::Cuda, BackendKind::Wgpu, BackendKind::Metal, BackendKind::Hip] {
324 let mut found: Vec<BackendEntry> =
325 factories.iter().filter(|f| f.kind() == kind).flat_map(|f| f.discover()).collect();
326 if found.is_empty() {
327 found.push(missing_entry(kind));
328 }
329 disambiguate_same_named(&mut found);
330 entries.extend(found);
331 }
332 apply_reserve(&mut entries, reserve);
333 let selected = select(&entries, reserve);
334 Self {
335 schema: SCHEMA.to_string(),
336 discovered_at_unix: now_unix(),
337 source: "machine".to_string(),
338 reserve_bytes: reserve,
339 reserve_basis: basis,
340 entries,
341 selected,
342 }
343 }
344
345 pub fn from_fixture_json(json: &str, path: &str) -> Result<Self, String> {
351 let mut reg: Self =
352 serde_json::from_str(json).map_err(|e| format!("fixture {path}: {e}"))?;
353 reg.source = format!("fixture({path})");
354 reg.selected = select(®.entries, reg.reserve_bytes);
355 Ok(reg)
356 }
357
358 #[must_use]
360 pub fn with_reserve(mut self, reserve_bytes: u64, basis: &str) -> Self {
361 self.reserve_bytes = reserve_bytes;
362 self.reserve_basis = basis.to_string();
363 apply_reserve(&mut self.entries, reserve_bytes);
364 self.selected = select(&self.entries, reserve_bytes);
365 self
366 }
367
368 pub fn ready(&self) -> impl Iterator<Item = &BackendEntry> {
370 self.entries.iter().filter(|e| e.is_ready())
371 }
372
373 #[must_use]
375 pub fn select_default(&self) -> Selection {
376 select(&self.entries, self.reserve_bytes)
377 }
378
379 #[must_use]
382 pub fn distinct_devices(&self) -> usize {
383 let mut seen: Vec<String> = Vec::new();
384 for e in self.entries.iter().filter(|e| e.is_ready() && e.kind != BackendKind::Cpu) {
385 let id = e.identity();
386 if !seen.contains(&id) {
387 seen.push(id);
388 }
389 }
390 seen.len()
391 }
392
393 pub fn to_json(&self) -> Result<String, String> {
398 serde_json::to_string_pretty(self).map_err(|e| e.to_string())
399 }
400
401 #[must_use]
403 pub fn render_block(&self, version: &str) -> String {
404 let mut out = format!(
405 "apr {version} discovery unix={} source={}\n",
406 self.discovered_at_unix, self.source
407 );
408 for e in &self.entries {
409 out.push_str(&render_entry(e));
410 out.push('\n');
411 }
412 let s = &self.selected;
413 let dev = s.device_index.map(|i| format!(" device[{i}]")).unwrap_or_default();
414 out.push_str(&format!(
415 "selected: {}{dev} reserve={}MiB basis={} ({})\n",
416 s.kind.as_str(),
417 self.reserve_bytes / (1024 * 1024),
418 self.reserve_basis,
419 s.reason
420 ));
421 out
422 }
423}
424
425fn render_entry(e: &BackendEntry) -> String {
426 let kind = format!("{:<6}", e.kind.as_str());
427 match &e.status {
428 Status::Unavailable(r) => {
429 format!("backend: {kind} unavailable reason={} source={}", r.text(), e.source.text())
430 }
431 Status::Ready => {
432 let mut line = format!("backend: {kind} ready ");
433 if let Some(i) = e.device_index {
434 line.push_str(&format!(" device[{i}]=\"{}\"", e.device_name));
435 } else {
436 line.push_str(&format!(" {}", e.device_name));
437 }
438 if let Some(cc) = &e.compute_class {
439 line.push_str(&format!(" class={cc}"));
440 }
441 if let Some(t) = e.mem_total {
442 line.push_str(&format!(" mem={}MiB", t / (1024 * 1024)));
443 }
444 if let Some(f) = e.mem_free {
445 line.push_str(&format!(" free={}MiB", f / (1024 * 1024)));
446 }
447 line.push_str(match &e.mem_kind {
448 MemKind::Discrete => " kind=discrete",
449 MemKind::Unified { .. } => " kind=unified",
450 });
451 if let Some(t) = &e.transport {
452 line.push_str(&format!(" transport={t}"));
453 }
454 if !e.caps.is_empty() {
455 line.push_str(&format!(" caps={{{}}}", e.caps.join(",")));
456 }
457 line.push_str(&format!(" source={}", e.source.text()));
458 line
459 }
460 }
461}
462
463fn apply_reserve(entries: &mut [BackendEntry], reserve: u64) {
464 let mut refused: Vec<(String, u64)> = Vec::new();
466 for e in entries.iter_mut().filter(|e| e.kind != BackendKind::Cpu && e.is_ready()) {
467 if let Some(free) = e.mem_free {
468 if free < reserve {
469 e.status = Status::Unavailable(Reason::ReserveExceedsFree {
470 reserve_bytes: reserve,
471 free_bytes: free,
472 });
473 refused.push((e.identity(), free));
474 }
475 }
476 }
477 for e in entries
482 .iter_mut()
483 .filter(|e| e.kind != BackendKind::Cpu && e.is_ready() && e.mem_free.is_none())
484 {
485 let id = e.identity();
486 if let Some((_, free)) = refused.iter().find(|(r, _)| *r == id) {
487 e.status = Status::Unavailable(Reason::ReserveExceedsFree {
488 reserve_bytes: reserve,
489 free_bytes: *free,
490 });
491 }
492 }
493}
494
495fn select(entries: &[BackendEntry], reserve: u64) -> Selection {
496 if let Some(e) = entries.iter().find(|e| e.kind != BackendKind::Cpu && e.is_ready()) {
497 return Selection {
498 kind: e.kind,
499 device_index: e.device_index,
500 device_uid: e.device_uid.clone(),
501 reason: format!(
502 "first Ready non-cpu entry; {} physical device(s) Ready",
503 count_distinct(entries)
504 ),
505 };
506 }
507 let why = entries
508 .iter()
509 .filter(|e| e.kind != BackendKind::Cpu)
510 .filter_map(|e| match &e.status {
511 Status::Unavailable(r) => Some(format!("{}={}", e.kind.as_str(), r.text())),
512 Status::Ready => None,
513 })
514 .collect::<Vec<_>>()
515 .join(", ");
516 let reserve_note = if why.contains("ReserveExceedsFree") {
517 format!("; reserve={reserve} B exceeds free memory")
518 } else {
519 String::new()
520 };
521 Selection {
522 kind: BackendKind::Cpu,
523 device_index: None,
524 device_uid: None,
525 reason: format!("no ready gpu: {why}{reserve_note}"),
526 }
527}
528
529fn count_distinct(entries: &[BackendEntry]) -> usize {
530 let mut seen: Vec<String> = Vec::new();
531 for e in entries.iter().filter(|e| e.is_ready() && e.kind != BackendKind::Cpu) {
532 let id = e.identity();
533 if !seen.contains(&id) {
534 seen.push(id);
535 }
536 }
537 seen.len()
538}
539
540fn disambiguate_same_named(found: &mut [BackendEntry]) {
546 let uids: Vec<Option<String>> = found.iter().map(|e| e.device_uid.clone()).collect();
547 for (i, e) in found.iter_mut().enumerate() {
548 let Some(uid) = uids[i].clone() else { continue };
549 let earlier = uids[..i].iter().filter(|u| u.as_deref() == Some(uid.as_str())).count();
550 let total = uids.iter().filter(|u| u.as_deref() == Some(uid.as_str())).count();
551 if total > 1 {
552 e.device_uid = Some(format!("{uid}#{earlier}"));
553 }
554 }
555}
556
557fn missing_entry(kind: BackendKind) -> BackendEntry {
558 match kind {
559 BackendKind::Cuda => BackendEntry::unavailable(
560 kind,
561 Api::CudaDriver,
562 Source::NotCompiled,
563 Reason::NotCompiled,
564 ),
565 BackendKind::Wgpu => {
566 BackendEntry::unavailable(kind, Api::Wgpu, Source::NotCompiled, Reason::NotCompiled)
567 }
568 BackendKind::Metal => BackendEntry::unavailable(
569 kind,
570 Api::Metal,
571 Source::NotCompiled,
572 Reason::NoBackend {
573 vendor: "no native Metal backend in 0.66 (a Metal adapter appears under wgpu)"
574 .to_string(),
575 },
576 ),
577 BackendKind::Hip => BackendEntry::unavailable(
578 kind,
579 Api::Hip,
580 Source::NotCompiled,
581 Reason::NoBackend { vendor: "no HIP backend in 0.66".to_string() },
582 ),
583 BackendKind::Cpu => cpu_entry(),
584 }
585}
586
587fn now_unix() -> u64 {
588 SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
589}
590
591fn cpu_entry() -> BackendEntry {
592 let threads =
593 std::thread::available_parallelism().map(std::num::NonZeroUsize::get).unwrap_or(1);
594 BackendEntry {
595 kind: BackendKind::Cpu,
596 api: Api::Cpu,
597 device_index: None,
598 device_uid: Some("host-cpu".to_string()),
599 device_name: format!("{} host cpu, {threads} threads", std::env::consts::ARCH),
600 vendor: "host".to_string(),
601 vendor_id: None,
602 device_type: "cpu".to_string(),
603 mem_total: host_mem_total(),
604 mem_free: None,
605 mem_kind: MemKind::Unified { working_set_limit: None },
606 compute_class: Some(cpu_isa()),
607 caps: Vec::new(),
608 source: Source::CompiledIn,
609 status: Status::Ready,
610 transport: None,
611 }
612}
613
614fn cpu_isa() -> String {
615 #[cfg(target_arch = "x86_64")]
616 {
617 if std::arch::is_x86_feature_detected!("avx512f") {
618 return "avx512".to_string();
619 }
620 if std::arch::is_x86_feature_detected!("avx2") {
621 return "avx2".to_string();
622 }
623 return "sse2".to_string();
624 }
625 #[cfg(target_arch = "aarch64")]
626 {
627 return "neon".to_string();
628 }
629 #[allow(unreachable_code)]
630 std::env::consts::ARCH.to_string()
631}
632
633fn host_mem_total() -> Option<u64> {
634 let text = std::fs::read_to_string("/proc/meminfo").ok()?;
635 let line = text.lines().find(|l| l.starts_with("MemTotal:"))?;
636 let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
637 Some(kb * 1024)
638}
639
640#[must_use]
643pub fn default_factories() -> Vec<Box<dyn BackendFactory>> {
644 let v: Vec<Box<dyn BackendFactory>> = vec![
645 #[cfg(feature = "cuda")]
646 Box::new(cuda::CudaFactory),
647 #[cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
648 Box::new(wgpu_probe::WgpuFactory),
649 ];
650 v
651}
652
653#[must_use]
656pub fn device_uid(vendor: &str, name: &str) -> String {
657 let norm: String = name
658 .trim()
659 .to_ascii_lowercase()
660 .chars()
661 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
662 .collect();
663 format!("{}:{}", vendor.to_ascii_lowercase(), norm.trim_matches('-'))
664}