qualia_core_db/gpu_context/device_registry.rs
1//! Device-per-circuit registry — obtain a `wgpu::Device` for a SPECIFIC adapter/circuit
2//! (STELLAR §A H3 foundation for heterogeneous GPU routing).
3//!
4//! [`super::shared_gpu`] / [`super::try_shared_gpu`] give you the single process-wide *primary*
5//! device (a `PowerPreference::HighPerformance` pick — the discrete GPU on a discrete+integrated
6//! box). This module lets code obtain a device for a **specific** enumerated circuit — e.g. the
7//! integrated GPU — so audio/vision work can run off the LLM's silicon.
8//!
9//! **Role policy:** keep the primary circuit free for the LLM; audio/vision use the *auxiliary*
10//! circuit. [`try_auxiliary_gpu`] gives callers the fallback chain **auxiliary → primary → None**
11//! (the caller then degrades to CPU) so they always get *a* device or `None`, never a panic.
12//!
13//! Mirrors [`super::try_shared_gpu`]'s discipline: no `unwrap()` outside tests, and it NEVER panics
14//! on a missing or failed device — every failure path returns `None`. Native only.
15#![cfg(not(target_arch = "wasm32"))]
16
17use std::collections::hash_map::Entry;
18use std::sync::OnceLock;
19
20use super::{init_shared_gpu_for_adapter, try_shared_gpu, GpuAdapterCaps, SharedGpuContext};
21
22/// One enumerated compute circuit: the live `wgpu::Adapter`, its capability snapshot, and a stable
23/// identity (vendor / device / backend) matching `device_benchmark` / `host_topology`.
24pub struct CircuitAdapter {
25 /// The live adapter (cheap `Arc`-backed handle) this circuit routes to.
26 pub adapter: wgpu::Adapter,
27 /// Immutable capability snapshot (carries vendor / device / backend / device_type).
28 pub caps: GpuAdapterCaps,
29}
30
31impl CircuitAdapter {
32 /// wgpu device class for role heuristics.
33 #[inline]
34 pub fn device_type(&self) -> wgpu::DeviceType {
35 self.caps.device_type
36 }
37
38 /// Stable physical identity — matches `device_benchmark` / `host_topology` conventions.
39 #[inline]
40 pub fn identity(&self) -> (u32, u32, wgpu::Backend) {
41 (self.caps.vendor, self.caps.device, self.caps.backend)
42 }
43}
44
45/// The wgpu instance the registry enumerated adapters from. Cloned into each per-circuit device so
46/// the device and any surface it creates share one instance.
47static REGISTRY_INSTANCE: OnceLock<wgpu::Instance> = OnceLock::new();
48/// Enumerate-once cache of routable circuits (deduped to one entry per physical GPU).
49static ADAPTERS: OnceLock<Vec<CircuitAdapter>> = OnceLock::new();
50/// Per-circuit device cache — one `OnceLock<Option<..>>` slot per enumerated adapter (by index).
51static DEVICES: OnceLock<Vec<OnceLock<Option<SharedGpuContext>>>> = OnceLock::new();
52
53/// Prefer discrete GPUs, then integrated, then virtual/other, with software CPU adapters last.
54fn device_type_rank(t: wgpu::DeviceType) -> u8 {
55 match t {
56 wgpu::DeviceType::DiscreteGpu => 0,
57 wgpu::DeviceType::IntegratedGpu => 1,
58 wgpu::DeviceType::VirtualGpu => 2,
59 wgpu::DeviceType::Other => 3,
60 wgpu::DeviceType::Cpu => 4,
61 }
62}
63
64/// Backend preference when the same physical GPU is enumerated on several backends
65/// (mirrors `host_topology` / `device_benchmark`).
66fn backend_rank(b: wgpu::Backend) -> u8 {
67 match b {
68 wgpu::Backend::Metal => 0,
69 wgpu::Backend::Dx12 => 1,
70 wgpu::Backend::Vulkan => 2,
71 wgpu::Backend::Gl => 3,
72 _ => 4,
73 }
74}
75
76#[inline]
77fn registry_instance() -> &'static wgpu::Instance {
78 REGISTRY_INSTANCE.get_or_init(wgpu::Instance::default)
79}
80
81/// Enumerate every adapter once (cached), deduped to **one entry per physical circuit**
82/// (vendor, device) keeping the preferred backend, and sorted discrete → integrated → other → cpu
83/// for a deterministic, index-stable list. Returns an empty slice on a headless / GPU-less box.
84///
85/// The returned indices are the stable handles used by [`try_device_for_adapter`],
86/// [`primary_circuit_index`], and [`auxiliary_circuit_index`].
87pub fn enumerate_circuits() -> &'static [CircuitAdapter] {
88 ADAPTERS.get_or_init(|| {
89 let instance = registry_instance();
90 let raw = pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all()));
91 // Dedup by physical (vendor, device); keep the top-ranked backend for each.
92 let mut best: std::collections::HashMap<(u32, u32), (u8, wgpu::Adapter, GpuAdapterCaps)> =
93 std::collections::HashMap::new();
94 for adapter in raw {
95 let caps = GpuAdapterCaps::from_adapter(&adapter);
96 let rank = backend_rank(caps.backend);
97 match best.entry((caps.vendor, caps.device)) {
98 Entry::Occupied(mut e) => {
99 if rank < e.get().0 {
100 e.insert((rank, adapter, caps));
101 }
102 }
103 Entry::Vacant(e) => {
104 e.insert((rank, adapter, caps));
105 }
106 }
107 }
108 let mut circuits: Vec<CircuitAdapter> = best
109 .into_values()
110 .map(|(_, adapter, caps)| CircuitAdapter { adapter, caps })
111 .collect();
112 // Drop device==0 phantom rows for a vendor that also enumerated a real device id
113 // (some backends, notably GL, report device 0 for a card already seen with a real id).
114 let real_vendors: std::collections::HashSet<u32> = circuits
115 .iter()
116 .filter(|c| c.caps.device != 0)
117 .map(|c| c.caps.vendor)
118 .collect();
119 circuits.retain(|c| c.caps.device != 0 || !real_vendors.contains(&c.caps.vendor));
120 // Deterministic, stable ordering: discrete first, then integrated, then the rest.
121 circuits.sort_by_key(|c| {
122 (
123 device_type_rank(c.caps.device_type),
124 c.caps.vendor,
125 c.caps.device,
126 )
127 });
128 circuits
129 })
130}
131
132/// Per-circuit device slots, lazily sized to the number of enumerated circuits.
133fn device_slots() -> &'static Vec<OnceLock<Option<SharedGpuContext>>> {
134 DEVICES.get_or_init(|| {
135 let n = enumerate_circuits().len();
136 let mut v = Vec::with_capacity(n);
137 for _ in 0..n {
138 v.push(OnceLock::new());
139 }
140 v
141 })
142}
143
144/// Lazily build (and cache) a [`SharedGpuContext`] for the enumerated circuit at `index`.
145///
146/// Returns `None` for an out-of-range index, when no tokio runtime can be started, or on any
147/// device-creation failure — it **NEVER panics** (mirrors [`super::try_shared_gpu`]). Idempotent:
148/// repeated calls return the same cached device, or the same cached `None` (each circuit probes at
149/// most once).
150pub fn try_device_for_adapter(index: usize) -> Option<&'static SharedGpuContext> {
151 let circuits = enumerate_circuits();
152 if index >= circuits.len() {
153 return None;
154 }
155 device_slots()[index]
156 .get_or_init(|| {
157 let handle = match tokio::runtime::Handle::try_current() {
158 Ok(h) => h,
159 Err(_) => match tokio::runtime::Runtime::new() {
160 Ok(rt) => Box::leak(Box::new(rt)).handle().clone(),
161 Err(_) => return None,
162 },
163 };
164 let instance = registry_instance().clone();
165 let adapter = circuits[index].adapter.clone();
166 tokio::task::block_in_place(|| {
167 handle
168 .block_on(init_shared_gpu_for_adapter(instance, adapter))
169 .ok()
170 })
171 })
172 .as_ref()
173}
174
175/// Index of the **primary** circuit — the discrete / HighPerformance adapter that
176/// [`super::shared_gpu`] uses. If the process-wide shared device is already initialized, matches its
177/// exact identity (that is definitionally the primary). Otherwise picks the first discrete GPU
178/// (what `HighPerformance` selects), else the top-ranked adapter. `None` only when no circuit
179/// exists. Never forces the shared device to initialize.
180///
181/// Policy: keep the primary circuit free for the LLM; audio/vision use the auxiliary circuit.
182pub fn primary_circuit_index() -> Option<usize> {
183 let circuits = enumerate_circuits();
184 if circuits.is_empty() {
185 return None;
186 }
187 // If the process-wide shared (primary) device already exists, match it exactly — but do NOT
188 // force initialization here (peek only).
189 if let Some(Some(shared)) = super::SHARED_GPU.get() {
190 let want = (shared.adapter_caps.vendor, shared.adapter_caps.device);
191 if let Some(i) = circuits
192 .iter()
193 .position(|c| (c.caps.vendor, c.caps.device) == want)
194 {
195 return Some(i);
196 }
197 }
198 circuits
199 .iter()
200 .position(|c| c.caps.device_type == wgpu::DeviceType::DiscreteGpu)
201 .or(Some(0))
202}
203
204/// Index of the best **auxiliary** circuit — the best NON-primary circuit, so the primary stays
205/// free for the LLM. Prefers a (non-primary) `DeviceType::IntegratedGpu`; otherwise the next-best
206/// non-primary circuit (the list is already ranked discrete → integrated → …). Returns `None` when
207/// only one circuit exists.
208///
209/// A measured `device_benchmark::CapabilityMatrix` ranking would be more precise, but benchmarking
210/// spawns worker processes and is not "cheaply available", so this uses the DeviceType heuristic.
211///
212/// Policy: keep the primary circuit free for the LLM; audio/vision use the auxiliary circuit.
213pub fn auxiliary_circuit_index() -> Option<usize> {
214 let circuits = enumerate_circuits();
215 if circuits.len() < 2 {
216 return None;
217 }
218 let primary = primary_circuit_index();
219 let is_primary = |i: usize| Some(i) == primary;
220 // Prefer a non-primary integrated GPU.
221 if let Some(i) = (0..circuits.len()).find(|&i| {
222 !is_primary(i) && circuits[i].caps.device_type == wgpu::DeviceType::IntegratedGpu
223 }) {
224 return Some(i);
225 }
226 // Else the first non-primary circuit.
227 (0..circuits.len()).find(|&i| !is_primary(i))
228}
229
230/// A device for the **auxiliary** circuit if one exists, else the **primary** shared device, else
231/// `None`. Fallback chain: **auxiliary → primary → None** (the caller then degrades to CPU). Never
232/// panics — every failure resolves to `None`.
233///
234/// Policy: keep the primary circuit free for the LLM; audio/vision call this to use the auxiliary
235/// GPU, transparently falling back to the primary (or CPU) when there is only one circuit.
236pub fn try_auxiliary_gpu() -> Option<&'static SharedGpuContext> {
237 auxiliary_circuit_index()
238 .and_then(try_device_for_adapter)
239 .or_else(try_shared_gpu)
240}
241
242#[cfg(all(test, not(target_arch = "wasm32")))]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn enumerate_is_nonempty_or_headless() {
248 // Enumeration returns a cached list; its length + identities are stable across calls
249 // (may be 0 on a headless box — we assert idempotence, not a nonzero count).
250 let a = enumerate_circuits();
251 let b = enumerate_circuits();
252 assert_eq!(
253 a.len(),
254 b.len(),
255 "enumeration must be cached (stable length)"
256 );
257 for (x, y) in a.iter().zip(b.iter()) {
258 assert_eq!(
259 x.identity(),
260 y.identity(),
261 "cached identities must be stable"
262 );
263 }
264 }
265
266 #[test]
267 #[serial_test::serial(gpu)]
268 fn try_device_for_adapter_never_panics_and_caches() {
269 let n = enumerate_circuits().len();
270 if n == 0 {
271 // GPU-less / headless: index 0 must return None without panic.
272 assert!(try_device_for_adapter(0).is_none());
273 return;
274 }
275 let first = try_device_for_adapter(0).is_some();
276 let second = try_device_for_adapter(0).is_some();
277 assert_eq!(
278 first, second,
279 "try_device_for_adapter must be cached + consistent"
280 );
281 // Out-of-range never panics.
282 assert!(try_device_for_adapter(n + 1000).is_none());
283 }
284
285 #[test]
286 #[serial_test::serial(gpu)]
287 fn try_auxiliary_gpu_never_panics() {
288 // Consistent across calls, never panics.
289 let a = try_auxiliary_gpu().is_some();
290 let b = try_auxiliary_gpu().is_some();
291 assert_eq!(a, b, "try_auxiliary_gpu must be consistent across calls");
292 // When a working primary device exists, the fallback chain (aux → primary) must yield one.
293 if try_shared_gpu().is_some() {
294 assert!(a, "aux must fall back to the working primary device");
295 }
296 }
297
298 #[test]
299 #[serial_test::serial(gpu)]
300 fn primary_and_auxiliary_indices_differ_when_multi_adapter() {
301 let n = enumerate_circuits().len();
302 let p = primary_circuit_index();
303 let aux = auxiliary_circuit_index();
304 if n >= 2 {
305 assert!(p.is_some(), "multi-adapter → a primary exists");
306 assert!(aux.is_some(), "multi-adapter → an auxiliary exists");
307 assert_ne!(p, aux, "primary and auxiliary must differ");
308 } else if n == 1 {
309 assert_eq!(p, Some(0), "single adapter → primary is index 0");
310 assert_eq!(
311 aux, None,
312 "single adapter → no auxiliary (fallback covers callers)"
313 );
314 } else {
315 assert_eq!(p, None);
316 assert_eq!(aux, None);
317 }
318 }
319}