Skip to main content

vyre_driver_wgpu/runtime/device/
selector.rs

1//! Adapter selection + enumeration (C5 refactor).
2//!
3//! The legacy [`super::device::cached_device`] singleton picks the
4//! first adapter `wgpu::Instance::request_adapter` returns  -  fine for
5//! a single-GPU dev box, useless for multi-GPU servers that need to
6//! choose a specific device by vendor, index, or power preference.
7//!
8//! This module ships the explicit selection API:
9//!
10//! * [`enumerate_adapters`]  -  list every adapter wgpu reports.
11//! * [`AdapterCriteria`]  -  match by device type, vendor, name
12//!   substring, or power preference.
13//! * [`select_adapter`]  -  pick one matching the criteria (returns
14//!   the first match; callers wanting all matches iterate
15//!   [`enumerate_adapters`] themselves).
16//! * [`init_device_for_adapter`]  -  build a device+queue bound to the
17//!   chosen adapter.
18//! * `VYRE_ADAPTER_INDEX`  -  env override used by the backend
19//!   auto-picker to route programs to a specific device without
20//!   patching code.
21//!
22//! The legacy `cached_device()` still serves the default case: one
23//! singleton device, first compatible adapter. Callers that want
24//! multi-GPU now select an adapter by index before constructing a
25//! device/queue pair.
26
27use vyre_driver::error::{Error, Result};
28
29use crate::staging_reserve::reserve_backend_vec;
30
31/// Stable adapter identity used for deterministic recovery.
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub(crate) struct AdapterIdentity {
34    name: String,
35    vendor: u32,
36    device: u32,
37    device_type: wgpu::DeviceType,
38    driver: String,
39    driver_info: String,
40    backend: wgpu::Backend,
41}
42
43impl AdapterIdentity {
44    pub(crate) fn from_info(info: &wgpu::AdapterInfo) -> Self {
45        Self {
46            name: info.name.clone(),
47            vendor: info.vendor,
48            device: info.device,
49            device_type: info.device_type,
50            driver: info.driver.clone(),
51            driver_info: info.driver_info.clone(),
52            backend: info.backend,
53        }
54    }
55
56    fn matches(&self, info: &wgpu::AdapterInfo) -> bool {
57        self.name == info.name
58            && self.vendor == info.vendor
59            && self.device == info.device
60            && self.device_type == info.device_type
61            && self.driver == info.driver
62            && self.driver_info == info.driver_info
63            && self.backend == info.backend
64    }
65}
66
67/// Criteria used by [`select_adapter`].
68#[derive(Debug, Default, Clone)]
69pub struct AdapterCriteria {
70    /// Prefer an adapter whose `device_type` matches.
71    pub device_type: Option<wgpu::DeviceType>,
72    /// Prefer an adapter whose vendor id matches.
73    pub vendor: Option<u32>,
74    /// Prefer an adapter whose name contains this substring
75    /// (case-insensitive).
76    pub name_contains: Option<String>,
77    /// Prefer an adapter with this power policy.
78    pub power: Option<wgpu::PowerPreference>,
79}
80
81/// Human-readable adapter probe details for GPU acquisition failures.
82#[derive(Clone, Debug, Default, Eq, PartialEq)]
83pub struct AdapterProbeReport {
84    /// Adapters visible to wgpu during the centralized probe.
85    pub probed: Vec<String>,
86    /// Feature, limit, or device-request reasons that prevented use.
87    pub missing: Vec<String>,
88}
89
90impl AdapterCriteria {
91    /// Build criteria for a high-performance discrete GPU.
92    #[must_use]
93    pub fn high_performance() -> Self {
94        Self {
95            device_type: Some(wgpu::DeviceType::DiscreteGpu),
96            power: Some(wgpu::PowerPreference::HighPerformance),
97            ..Self::default()
98        }
99    }
100
101    /// Build criteria for a low-power integrated GPU (laptop
102    /// battery savings).
103    #[must_use]
104    pub fn low_power() -> Self {
105        Self {
106            device_type: Some(wgpu::DeviceType::IntegratedGpu),
107            power: Some(wgpu::PowerPreference::LowPower),
108            ..Self::default()
109        }
110    }
111}
112
113/// List every adapter the wgpu instance reports.
114#[must_use]
115pub fn enumerate_adapters() -> Vec<wgpu::AdapterInfo> {
116    match try_enumerate_adapters() {
117        Ok(adapters) => adapters,
118        Err(error) => {
119            // Law 10: a probe failure is NOT "no GPUs present". Surface it
120            // loudly so callers do not read an empty vec as a device-free host.
121            tracing::error!(
122                %error,
123                "adapter enumeration probe failed; reporting zero adapters is a probe error, not an absence of GPUs"
124            );
125            Vec::new()
126        }
127    }
128}
129
130/// List every adapter the wgpu instance reports with fallible metadata staging.
131///
132/// # Errors
133///
134/// Returns `Error::Gpu` when probe-result metadata cannot be reserved.
135pub(crate) fn try_enumerate_adapters() -> Result<Vec<wgpu::AdapterInfo>> {
136    let instance = wgpu::Instance::default();
137    let adapters = instance.enumerate_adapters(wgpu::Backends::all());
138    let mut infos = Vec::new();
139    reserve_probe_vec(&mut infos, adapters.len(), "adapter enumeration metadata")?;
140    infos.extend(adapters.iter().map(wgpu::Adapter::get_info));
141    Ok(infos)
142}
143
144/// Report whether the centralized adapter probe can see at least one real GPU.
145#[must_use]
146pub fn has_real_gpu_adapter() -> bool {
147    let instance = wgpu::Instance::default();
148    instance
149        .enumerate_adapters(wgpu::Backends::all())
150        .iter()
151        .any(|adapter| crate::capabilities::is_real_gpu(&adapter.get_info()))
152}
153
154/// Re-open the live wgpu adapter matching a previously selected adapter info.
155///
156/// Tests and capability probes use this instead of directly constructing their
157/// own `wgpu::Instance` so adapter identity and failure diagnostics stay in the
158/// runtime device contract.
159///
160/// # Errors
161///
162/// Returns `Error::Gpu` when the adapter is no longer visible.
163pub fn adapter_for_info(expected: &wgpu::AdapterInfo) -> Result<wgpu::Adapter> {
164    let instance = wgpu::Instance::default();
165    let adapters = instance.enumerate_adapters(wgpu::Backends::all());
166    let mut probed = Vec::new();
167    reserve_probe_vec(&mut probed, adapters.len(), "adapter recovery probe report")?;
168    for adapter in adapters {
169        let candidate = adapter.get_info();
170        if adapter_info_matches(&candidate, expected) {
171            return Ok(adapter);
172        }
173        probed.push(format!(
174            "{} ({:?}, backend={:?}, vendor={:08x}, device={:08x})",
175            candidate.name,
176            candidate.device_type,
177            candidate.backend,
178            candidate.vendor,
179            candidate.device
180        ));
181    }
182
183    Err(Error::Gpu {
184        message: format!(
185            "selected adapter `{}` ({:?}, backend={:?}, vendor={:08x}, device={:08x}) is no longer enumerable. Probed adapters: [{}]. Fix: repair GPU visibility or reacquire the WGPU backend.",
186            expected.name,
187            expected.device_type,
188            expected.backend,
189            expected.vendor,
190            expected.device,
191            probed.join(", ")
192        ),
193    })
194}
195
196fn adapter_info_matches(candidate: &wgpu::AdapterInfo, expected: &wgpu::AdapterInfo) -> bool {
197    candidate.name == expected.name
198        && candidate.vendor == expected.vendor
199        && candidate.device == expected.device
200        && candidate.device_type == expected.device_type
201        && candidate.driver == expected.driver
202        && candidate.driver_info == expected.driver_info
203        && candidate.backend == expected.backend
204}
205
206/// Build the centralized adapter diagnostic report used by acquisition errors.
207#[must_use]
208pub fn adapter_probe_report() -> AdapterProbeReport {
209    let instance = wgpu::Instance::default();
210    let adapters = instance.enumerate_adapters(wgpu::Backends::all());
211    let mut report = AdapterProbeReport {
212        probed: Vec::new(),
213        missing: Vec::new(),
214    };
215
216    for adapter in adapters {
217        let info = adapter.get_info();
218        report.probed.push(format!(
219            "{} ({:?}, backend={:?})",
220            info.name, info.device_type, info.backend
221        ));
222        if matches!(
223            info.device_type,
224            wgpu::DeviceType::Cpu | wgpu::DeviceType::Other
225        ) {
226            continue;
227        }
228        if !adapter.features().contains(wgpu::Features::TIMESTAMP_QUERY) {
229            report.missing.push("TIMESTAMP_QUERY".to_string());
230        }
231        if !adapter
232            .features()
233            .contains(wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS)
234        {
235            report
236                .missing
237                .push("TIMESTAMP_QUERY_INSIDE_ENCODERS".to_string());
238        }
239        let adapter_limits = adapter.limits();
240        if let Err(error) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
241            label: Some("vyre probe"),
242            required_features: wgpu::Features::empty(),
243            required_limits: wgpu::Limits {
244                max_storage_buffers_per_shader_stage:
245                    adapter_limits.max_storage_buffers_per_shader_stage,
246                ..wgpu::Limits::default()
247            },
248            memory_hints: wgpu::MemoryHints::default(),
249            trace: wgpu::Trace::Off,
250        })) {
251            report
252                .missing
253                .push(format!("device request failed on {}: {error}", info.name));
254        }
255    }
256
257    report
258}
259
260/// Select the first adapter matching `criteria`. Returns its index
261/// into [`enumerate_adapters`] plus its info.
262///
263/// # Errors
264///
265/// Returns `Error::Gpu` when no adapter matches.
266pub fn select_adapter(criteria: &AdapterCriteria) -> Result<(usize, wgpu::AdapterInfo)> {
267    let instance = wgpu::Instance::default();
268    let adapters = instance.enumerate_adapters(wgpu::Backends::all());
269    for (idx, adapter) in adapters.iter().enumerate() {
270        let info = adapter.get_info();
271        if adapter_is_selectable(&info, criteria) {
272            return Ok((idx, info));
273        }
274    }
275    Err(Error::Gpu {
276        message: format!(
277            "no real GPU adapter matches criteria {criteria:?}. Fix: loosen the criteria or install drivers exposing the requested GPU class."
278        ),
279    })
280}
281
282fn adapter_is_selectable(info: &wgpu::AdapterInfo, criteria: &AdapterCriteria) -> bool {
283    crate::capabilities::is_real_gpu(info) && adapter_matches(info, criteria)
284}
285
286fn adapter_matches(info: &wgpu::AdapterInfo, criteria: &AdapterCriteria) -> bool {
287    if let Some(ty) = criteria.device_type {
288        if info.device_type != ty {
289            return false;
290        }
291    }
292    if let Some(vendor) = criteria.vendor {
293        if info.vendor != vendor {
294            return false;
295        }
296    }
297    if let Some(needle) = &criteria.name_contains {
298        if !adapter_name_contains(&info.name, needle) {
299            return false;
300        }
301    }
302    true
303}
304
305fn adapter_name_contains(name: &str, needle: &str) -> bool {
306    if needle.is_empty() {
307        return true;
308    }
309    if name.is_ascii() && needle.is_ascii() {
310        return name
311            .as_bytes()
312            .windows(needle.len())
313            .any(|window| window.eq_ignore_ascii_case(needle.as_bytes()));
314    }
315    name.to_lowercase().contains(&needle.to_lowercase())
316}
317
318/// Initialize a device + queue bound to the adapter at `index`.
319///
320/// Pairs with [`enumerate_adapters`] / [`select_adapter`] to give
321/// callers full control over which GPU the backend binds to.
322///
323/// # Errors
324///
325/// Returns `Error::Gpu` when `index` is out of range or device
326/// creation fails.
327pub fn init_device_for_adapter(
328    index: usize,
329) -> Result<(
330    (wgpu::Device, wgpu::Queue),
331    wgpu::AdapterInfo,
332    crate::runtime::device::EnabledFeatures,
333)> {
334    super::device::wait_for_gpu(acquire_gpu_for_adapter(index))
335}
336
337/// Recreate a device on the same adapter identity used by an existing backend.
338///
339/// # Errors
340///
341/// Returns `Error::Gpu` when the adapter disappeared, no longer reports as a
342/// real GPU, or rejects device creation.
343pub(crate) fn init_device_for_adapter_identity(
344    identity: &AdapterIdentity,
345) -> Result<(
346    (wgpu::Device, wgpu::Queue),
347    wgpu::AdapterInfo,
348    crate::runtime::device::EnabledFeatures,
349)> {
350    super::device::wait_for_gpu(acquire_gpu_for_adapter_identity(identity))
351}
352
353async fn acquire_gpu_for_adapter_identity(
354    identity: &AdapterIdentity,
355) -> Result<(
356    (wgpu::Device, wgpu::Queue),
357    wgpu::AdapterInfo,
358    crate::runtime::device::EnabledFeatures,
359)> {
360    let instance = wgpu::Instance::default();
361    let adapters = instance.enumerate_adapters(wgpu::Backends::all());
362    for adapter in &adapters {
363        let info = adapter.get_info();
364        if identity.matches(&info) {
365            if !crate::capabilities::is_real_gpu(&info) {
366                return Err(Error::Gpu {
367                    message: format!(
368                        "recovery target `{}` now reports device type {:?}, which is not a real GPU execution target. Fix: restore the original GPU adapter or construct a new backend for the changed adapter.",
369                        info.name, info.device_type
370                    ),
371                });
372            }
373            return super::device::request_device_for_adapter(adapter, "vyre device (recovered)")
374                .await;
375        }
376    }
377
378    let mut probed = Vec::new();
379    reserve_probe_vec(
380        &mut probed,
381        adapters.len(),
382        "adapter identity recovery probe report",
383    )?;
384    probed.extend(adapters.iter().map(|adapter| {
385        let info = adapter.get_info();
386        format!(
387            "{} ({:?}, backend={:?}, vendor={:08x}, device={:08x})",
388            info.name, info.device_type, info.backend, info.vendor, info.device
389        )
390    }));
391    Err(Error::Gpu {
392        message: format!(
393            "original recovery adapter was not found. Target: {:?}. Probed adapters: [{}]. Fix: restore the original GPU or create a new WgpuBackend for the available adapter.",
394            identity,
395            probed.join(", ")
396        ),
397    })
398}
399
400/// Async variant of [`init_device_for_adapter`].
401///
402/// # Errors
403///
404/// Returns `Error::Gpu` when `index` is out of range or device
405/// creation fails.
406pub async fn acquire_gpu_for_adapter(
407    index: usize,
408) -> Result<(
409    (wgpu::Device, wgpu::Queue),
410    wgpu::AdapterInfo,
411    crate::runtime::device::EnabledFeatures,
412)> {
413    let instance = wgpu::Instance::default();
414    let adapters = instance.enumerate_adapters(wgpu::Backends::all());
415    let adapter = adapters.get(index).ok_or_else(|| Error::Gpu {
416        message: format!(
417            "adapter index {index} out of range (saw {} adapters). Fix: call enumerate_adapters() first to see valid indices.",
418            adapters.len()
419        ),
420    })?;
421    let info = adapter.get_info();
422    if !crate::capabilities::is_real_gpu(&info) {
423        return Err(Error::Gpu {
424            message: format!(
425                "adapter index {index} resolved to `{}` with device type {:?}, which is not a real GPU execution target. Fix: choose a discrete, integrated, or virtual GPU adapter.",
426                info.name, info.device_type
427            ),
428        });
429    }
430    super::device::request_device_for_adapter(adapter, "vyre device (selected)").await
431}
432
433/// Read the `VYRE_ADAPTER_INDEX` env override. `None` when unset.
434///
435/// # Errors
436///
437/// Returns an actionable GPU configuration error when the env var is
438/// set but cannot be parsed. A typoed adapter override must not
439/// silently fall back to automatic GPU selection.
440#[must_use]
441pub fn adapter_index_from_env() -> Result<Option<usize>> {
442    adapter_index_from_raw(std::env::var("VYRE_ADAPTER_INDEX").ok().as_deref())
443}
444
445fn adapter_index_from_raw(raw: Option<&str>) -> Result<Option<usize>> {
446    let Some(raw) = raw else {
447        return Ok(None);
448    };
449    raw.parse::<usize>().map(Some).map_err(|error| Error::Gpu {
450        message: format!(
451            "VYRE_ADAPTER_INDEX={raw:?} is not a valid adapter index: {error}. Fix: set VYRE_ADAPTER_INDEX to a non-negative integer from enumerate_adapters(), or unset it for automatic GPU selection."
452        ),
453    })
454}
455
456fn reserve_probe_vec<T>(vec: &mut Vec<T>, additional: usize, context: &'static str) -> Result<()> {
457    reserve_backend_vec(vec, additional, context).map_err(|error| Error::Gpu {
458        message: error.to_string(),
459    })
460}
461
462#[cfg(test)]
463
464mod tests {
465    use super::*;
466
467    #[test]
468    fn enumerate_adapters_finds_required_gpu() {
469        let adapters = enumerate_adapters();
470        assert_ne!(adapters.len(), 0,
471            "Fix: WGPU adapter enumeration returned no adapters on a GPU-required release host; repair driver/runtime configuration instead of accepting a CPU-only environment."
472        );
473    }
474
475    #[test]
476    fn criteria_high_perf_has_discrete_preset() {
477        let c = AdapterCriteria::high_performance();
478        assert_eq!(c.device_type, Some(wgpu::DeviceType::DiscreteGpu));
479        assert_eq!(c.power, Some(wgpu::PowerPreference::HighPerformance));
480    }
481
482    #[test]
483    fn criteria_low_power_has_integrated_preset() {
484        let c = AdapterCriteria::low_power();
485        assert_eq!(c.device_type, Some(wgpu::DeviceType::IntegratedGpu));
486    }
487
488    #[test]
489    fn env_override_parses_valid_index() {
490        assert_eq!(adapter_index_from_raw(Some("3")).unwrap(), Some(3));
491    }
492
493    #[test]
494    fn env_override_rejects_garbage() {
495        let error = adapter_index_from_raw(Some("not-a-number"))
496            .expect_err("invalid VYRE_ADAPTER_INDEX must error");
497        assert!(
498            error.to_string().contains("VYRE_ADAPTER_INDEX"),
499            "Fix: invalid adapter-index errors must name the misconfigured env var"
500        );
501    }
502
503    #[test]
504    fn selection_rejects_cpu_adapters_before_device_acquisition() {
505        let cpu_info = wgpu::AdapterInfo {
506            name: "llvmpipe".to_string(),
507            vendor: 0,
508            device: 0,
509            device_type: wgpu::DeviceType::Cpu,
510            driver: "software".to_string(),
511            driver_info: "cpu".to_string(),
512            backend: wgpu::Backend::Vulkan,
513        };
514        let gpu_info = wgpu::AdapterInfo {
515            name: "RTX 5090".to_string(),
516            vendor: 0x10de,
517            device: 0x2c02,
518            device_type: wgpu::DeviceType::DiscreteGpu,
519            driver: "nvidia".to_string(),
520            driver_info: "gpu".to_string(),
521            backend: wgpu::Backend::Vulkan,
522        };
523        let criteria = AdapterCriteria::default();
524
525        assert!(
526            !adapter_is_selectable(&cpu_info, &criteria),
527            "Fix: adapter selection must never return CPU/Other devices for later fallback handling."
528        );
529        assert!(adapter_is_selectable(&gpu_info, &criteria));
530    }
531
532    #[test]
533    fn adapter_identity_matches_every_recovery_field() {
534        let info = wgpu::AdapterInfo {
535            name: "gpu-a".to_string(),
536            vendor: 0x10de,
537            device: 0x2684,
538            device_type: wgpu::DeviceType::DiscreteGpu,
539            driver: "nvidia".to_string(),
540            driver_info: "driver-a".to_string(),
541            backend: wgpu::Backend::Vulkan,
542        };
543        let identity = AdapterIdentity::from_info(&info);
544        assert!(identity.matches(&info));
545
546        let mut changed = info.clone();
547        changed.device = 0x2685;
548        assert!(
549            !identity.matches(&changed),
550            "Fix: recovery must not silently bind to a different physical adapter."
551        );
552    }
553
554    #[test]
555    fn adapter_name_contains_matches_ascii_without_lowercase_in_hot_path() {
556        assert!(adapter_name_contains("NVIDIA GeForce RTX 5090", "rtx"));
557        assert!(adapter_name_contains("NVIDIA GeForce RTX 5090", "RTX"));
558        assert!(!adapter_name_contains("NVIDIA GeForce RTX 5090", "radeon"));
559        assert!(adapter_name_contains("Mötley GPU", "mötley"));
560    }
561
562    #[test]
563    fn production_selector_uses_fallible_probe_reservations() {
564        let production = include_str!("selector.rs")
565            .split("#[cfg(test)]")
566            .next()
567            .expect("Fix: selector production section should precede tests");
568
569        assert!(
570            !production.contains("Vec::with_capacity"),
571            "Fix: GPU probe paths must not use infallible capacity constructors."
572        );
573        assert!(
574            production.contains("reserve_probe_vec"),
575            "Fix: GPU probe metadata should reserve through the shared WGPU staging helper."
576        );
577        assert!(
578            !production.contains("info.name.to_lowercase()"),
579            "Fix: adapter matching must not allocate lowercase strings per adapter."
580        );
581        assert!(production.contains("adapter_name_contains"));
582    }
583}