Skip to main content

trueno/registry/
mod.rs

1//! BackendRegistry — probe → enumerate → print (PP-066 R-0a, #2904, PMAT-989).
2//!
3//! Hardware selection used to be bound to the BUILD (`cfg!(feature = "cuda")`
4//! read at decision time), so one binary had to serve every installer and a
5//! missing `libcuda.so.1` was a silent CPU run. This module discovers the
6//! machine at startup and represents every backend kind of the fixed list
7//! `{cpu, cuda, wgpu, metal, hip}` as an explicit entry — `Ready` or
8//! `Unavailable(reason)` — so the absence of a backend is a line the user
9//! reads (REG-11), never a silence. The resolution half (refusing a request
10//! that is not in the Ready set) is R-0b (#3002); this half exists and prints.
11//!
12//! Design decisions from the 2026-09-06 quorum (docs/audits/pp-066-r0-design-quorum.md):
13//! object-safe [`BackendFactory`] registered at startup (REG-13); entries carry
14//! `api` + `device_uid` so one physical GPU reachable through two APIs is two
15//! entries and ONE device for selection; `vendor_id` is optional (Apple silicon
16//! has no PCI id); unified memory carries its working-set limit and the REG-7
17//! reserve is checked against free memory; nothing is persisted (REG-12).
18
19use 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/// The fixed kind list every `apr devices` output carries (REG-11).
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum BackendKind {
31    /// Always present, always Ready.
32    Cpu,
33    /// NVIDIA through the driver API (`libcuda.so.1` / `nvcuda.dll`, dlopen).
34    Cuda,
35    /// wgpu adapters (Vulkan / Metal / DX12 / GL transports).
36    Wgpu,
37    /// Native Metal — no native backend in 0.66; a Metal adapter appears under `wgpu`.
38    Metal,
39    /// AMD HIP — no backend in 0.66; an AMD device is listed as `NoBackend`, not ignored (REG-4).
40    Hip,
41}
42
43impl BackendKind {
44    /// The fixed, ordered list (REG-11).
45    pub const ALL: [BackendKind; 5] = [Self::Cpu, Self::Cuda, Self::Wgpu, Self::Metal, Self::Hip];
46
47    /// The printed name.
48    #[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/// The API an entry was discovered through (lane 2: wgpu is a transport over a
61/// physical backend, so the API is a field, not a peer kind).
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "kebab-case")]
64pub enum Api {
65    /// Host CPU.
66    Cpu,
67    /// `libcuda.so.1` driver API.
68    CudaDriver,
69    /// wgpu adapter enumeration; `transport` names Vulkan/Metal/DX12/GL.
70    Wgpu,
71    /// Native Metal.
72    Metal,
73    /// HIP.
74    Hip,
75}
76
77/// Where the entry came from.
78#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(tag = "kind", content = "detail", rename_all = "kebab-case")]
80pub enum Source {
81    /// Linked into the binary.
82    CompiledIn,
83    /// Loaded at run time from this library path.
84    Dlopen(String),
85    /// The feature was not compiled into this binary.
86    NotCompiled,
87    /// Read from a fixture file (tests, dogfood); never silent.
88    Fixture(String),
89}
90
91impl Source {
92    /// One printable token.
93    #[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/// Why an entry is not Ready (REG-11: every non-Ready entry names its reason).
105#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(tag = "kind", rename_all = "kebab-case")]
107pub enum Reason {
108    /// The feature is not in this binary.
109    NotCompiled,
110    /// The driver library could not be loaded.
111    DriverNotFound { path: String },
112    /// The driver loaded but reports no device.
113    NoDevice,
114    /// A device exists but apr has no backend for it (e.g. AMD without HIP).
115    NoBackend { vendor: String },
116    /// The probe returned an error; the process stayed up (REG-1).
117    ProbeFailed { error: String },
118    /// REG-7: the reserve does not fit in free memory.
119    ReserveExceedsFree { reserve_bytes: u64, free_bytes: u64 },
120}
121
122impl Reason {
123    /// One printable token, `Name(detail)`.
124    #[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/// Ready, or not — with the reason.
140#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(tag = "state", rename_all = "kebab-case")]
142pub enum Status {
143    /// Usable now.
144    Ready,
145    /// Not usable; the reason is printed.
146    Unavailable(Reason),
147}
148
149/// Discrete VRAM, or a pool shared with the host (REG-6).
150#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(tag = "kind", rename_all = "kebab-case")]
152pub enum MemKind {
153    /// Dedicated device memory.
154    Discrete,
155    /// Shared pool (gx10 GB10, Apple silicon); the OS working-set limit, when
156    /// known, is what allocations are capped at — not physical RAM.
157    Unified { working_set_limit: Option<u64> },
158}
159
160/// One line of `apr devices`.
161#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
162pub struct BackendEntry {
163    /// Which of the five kinds.
164    pub kind: BackendKind,
165    /// The API this entry was discovered through.
166    pub api: Api,
167    /// Device ordinal within its API, if any.
168    pub device_index: Option<u32>,
169    /// Stable identity of the PHYSICAL device; two entries with the same uid are one device.
170    pub device_uid: Option<String>,
171    /// Human name.
172    pub device_name: String,
173    /// Vendor name ("NVIDIA", "AMD", "Apple", "Intel", "host").
174    pub vendor: String,
175    /// PCI vendor id where one exists.
176    pub vendor_id: Option<u32>,
177    /// "cpu", "discrete-gpu", "integrated-gpu", "virtual-gpu", "software".
178    pub device_type: String,
179    /// Bytes.
180    pub mem_total: Option<u64>,
181    /// Bytes, measured at discovery (never cached across runs).
182    pub mem_free: Option<u64>,
183    /// Discrete or unified.
184    pub mem_kind: MemKind,
185    /// "sm_89", "avx512", ...
186    pub compute_class: Option<String>,
187    /// ggml-style capability names.
188    pub caps: Vec<String>,
189    /// Where it came from.
190    pub source: Source,
191    /// Ready or the reason.
192    pub status: Status,
193    /// For wgpu: the transport ("vulkan", "metal", "dx12", "gl").
194    pub transport: Option<String>,
195}
196
197impl BackendEntry {
198    /// The placeholder line for a kind nothing discovered (REG-11: absence is a line).
199    #[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
232/// A backend that can discover its devices (REG-13: registered by trait, not
233/// by feature flag; object-safe on purpose — `ComputeBackend` is not).
234pub trait BackendFactory: Send + Sync {
235    /// The kind this factory reports.
236    fn kind(&self) -> BackendKind;
237    /// Every device this backend can see, Ready or not. Must never abort the
238    /// process (REG-1): a driver fault is an `Unavailable(ProbeFailed)` entry.
239    fn discover(&self) -> Vec<BackendEntry>;
240}
241
242/// A factory that returns canned entries (tests, the CI case table, REG-13's mock).
243pub struct MockBackendFactory {
244    kind: BackendKind,
245    entries: Vec<BackendEntry>,
246}
247
248impl MockBackendFactory {
249    /// Canned entries for `kind`.
250    #[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/// REG-8: what `apr` would run on with no flag, and why.
266#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
267pub struct Selection {
268    /// The selected kind (cpu when no GPU entry is Ready).
269    pub kind: BackendKind,
270    /// The device ordinal within that kind, if any.
271    pub device_index: Option<u32>,
272    /// The device identity, if any.
273    pub device_uid: Option<String>,
274    /// Always printed: why this one.
275    pub reason: String,
276}
277
278/// The default reserve until PP-LLAMA-001 master row 6 measures `vram_peak` (REG-7).
279pub const DEFAULT_RESERVE_BYTES: u64 = 3_584 * 1024 * 1024;
280/// The basis tag printed beside the default reserve.
281pub const DEFAULT_RESERVE_BASIS: &str = "[U] default until master row 6 measures vram_peak";
282/// The JSON schema id `apr devices --json` carries.
283pub const SCHEMA: &str = "apr-devices-v1";
284
285/// The machine as discovered at startup. Never cached, never persisted (REG-12).
286#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
287pub struct BackendRegistry {
288    /// Schema id.
289    pub schema: String,
290    /// Unix seconds at discovery.
291    pub discovered_at_unix: u64,
292    /// "machine" or "fixture(path)".
293    pub source: String,
294    /// REG-7 reserve applied to every Ready GPU entry.
295    pub reserve_bytes: u64,
296    /// Where the reserve number comes from.
297    pub reserve_basis: String,
298    /// Every entry, in kind order; at least one per kind.
299    pub entries: Vec<BackendEntry>,
300    /// REG-8 default selection (recomputed on every discovery).
301    pub selected: Selection,
302}
303
304impl BackendRegistry {
305    /// Discover the machine with the built-in factories.
306    #[must_use]
307    pub fn discover() -> Self {
308        Self::discover_with(&default_factories(), None)
309    }
310
311    /// Discover with explicit factories (tests, mocks) and an optional reserve
312    /// override (`APR_RESERVE_BYTES`; the CLI prints the override — REG-8).
313    #[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    /// Build from a fixture document (the JSON `to_json` writes). The source
346    /// names the file so a fixture-built registry is never mistaken for the machine.
347    ///
348    /// # Errors
349    /// The JSON does not parse as a registry document.
350    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(&reg.entries, reg.reserve_bytes);
355        Ok(reg)
356    }
357
358    /// Re-apply a reserve (an `APR_RESERVE_BYTES` override on a fixture) and reselect.
359    #[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    /// The Ready entries.
369    pub fn ready(&self) -> impl Iterator<Item = &BackendEntry> {
370        self.entries.iter().filter(|e| e.is_ready())
371    }
372
373    /// REG-8: first Ready non-cpu entry (deduplicated by `device_uid`), else cpu.
374    #[must_use]
375    pub fn select_default(&self) -> Selection {
376        select(&self.entries, self.reserve_bytes)
377    }
378
379    /// Physical devices among the Ready non-cpu entries (two entries with one
380    /// uid count once — lane 2's rule).
381    #[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    /// The JSON document `apr devices --json` prints.
394    ///
395    /// # Errors
396    /// Serialisation failed (cannot happen for these types; surfaced anyway).
397    pub fn to_json(&self) -> Result<String, String> {
398        serde_json::to_string_pretty(self).map_err(|e| e.to_string())
399    }
400
401    /// The printed block (spec §5 R-0, normative shape).
402    #[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    // Pass 1: entries that KNOW their free memory and cannot fit the reserve.
465    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    // Pass 2 (lane 2, device_uid): the same physical device seen through another
478    // API that reports NO free memory (wgpu) is the same full card — refuse its
479    // twins too, with the figure the sibling measured, so the selection cannot
480    // slide onto the very device that was just refused.
481    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
540/// Two DIFFERENT cards with the same name through one API (intel's two AMD
541/// W5700X both enumerate as "AMD Unknown (RADV NAVI10)") would share a uid and
542/// collapse into one device for REG-9. The k-th same-named entry within an API
543/// gets `#k` appended, so twins across APIs still match by ordinal while
544/// distinct cards stay distinct (found by the four-host dogfood, 2026-09-06).
545fn 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/// The factories this binary was built with. A kind whose feature is off is
641/// still a line (`NotCompiled`), produced by the registry itself.
642#[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/// Stable identity for a physical device seen through any API: vendor prefix +
654/// normalised name, so the cuda-driver and wgpu twins of one card agree.
655#[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}