Skip to main content

dynamis_gpu/
context.rs

1use crate::{BindingKind, BindingSpec, ComputePipeline};
2use std::collections::HashMap;
3use std::fmt::{self, Display, Formatter};
4use std::sync::{Arc, Mutex};
5use wgpu::{
6    Adapter, AdapterInfo, Backend, Backends, Device, DeviceLostReason, DeviceType, Features,
7    Instance, InstanceDescriptor, Limits, PowerPreference, Queue,
8};
9
10/// How much adapter capability the created device requests.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum LimitsPolicy {
13    /// Exactly [`GpuContext::MINIMUM_LIMITS`].
14    Minimum,
15    /// Everything the adapter reports.
16    Adapter,
17}
18
19/// Device acquisition options.
20pub struct GpuRequest {
21    pub backends: Backends,
22    pub power_preference: PowerPreference,
23    /// Case-insensitive substring of [`AdapterInfo::name`].
24    pub device_name: Option<String>,
25    /// Features the device must offer.
26    pub required_features: Features,
27    /// Features used only when the adapter offers them.
28    pub optional_features: Features,
29    pub limits: LimitsPolicy,
30}
31
32impl Default for GpuRequest {
33    fn default() -> Self {
34        Self {
35            backends: GpuRequest::NATIVE_BACKENDS,
36            power_preference: PowerPreference::HighPerformance,
37            device_name: None,
38            required_features: Features::empty(),
39            optional_features: GpuRequest::PROFILING_FEATURES,
40            limits: LimitsPolicy::Adapter,
41        }
42    }
43}
44
45impl GpuRequest {
46    /// Timestamp queries, requested from the device only when the `profile`
47    /// feature is built in.
48    #[cfg(feature = "profile")]
49    pub const PROFILING_FEATURES: Features =
50        Features::TIMESTAMP_QUERY.union(Features::TIMESTAMP_QUERY_INSIDE_ENCODERS);
51    #[cfg(not(feature = "profile"))]
52    pub const PROFILING_FEATURES: Features = Features::empty();
53
54    /// The native APIs this engine runs on.
55    pub const NATIVE_BACKENDS: Backends = Backends::DX12
56        .union(Backends::METAL)
57        .union(Backends::VULKAN);
58
59    pub fn adapter_named(name: impl Into<String>) -> Self {
60        Self {
61            device_name: Some(name.into()),
62            ..Self::default()
63        }
64    }
65
66    /// Request [`GpuContext::MINIMUM_LIMITS`] rather than the hardware maximum.
67    pub fn minimum_limits() -> Self {
68        Self {
69            limits: LimitsPolicy::Minimum,
70            ..Self::default()
71        }
72    }
73}
74
75/// Why no device could be created.
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub enum GpuUnavailable {
78    NoAdapter {
79        backends: Backends,
80    },
81    DeviceNotFound {
82        requested: String,
83        available: Vec<String>,
84    },
85    MissingFeatures {
86        missing: Features,
87        available: Features,
88    },
89    DeviceRejected {
90        message: String,
91    },
92    InsufficientLimits {
93        adapter: String,
94    },
95}
96
97impl Display for GpuUnavailable {
98    fn fmt(&self, out: &mut Formatter<'_>) -> fmt::Result {
99        match self {
100            Self::NoAdapter { backends } => {
101                write!(
102                    out,
103                    "no adapter exposed by the enabled backends {backends:?}"
104                )
105            }
106            Self::DeviceNotFound {
107                requested,
108                available,
109            } => write!(
110                out,
111                "no adapter name contains {requested:?}; available: {available:?}"
112            ),
113            Self::MissingFeatures { missing, available } => write!(
114                out,
115                "adapter lacks required features {missing:?} (offers {available:?})"
116            ),
117            Self::DeviceRejected { message } => write!(out, "device request rejected: {message}"),
118            Self::InsufficientLimits { adapter } => write!(
119                out,
120                "adapter {adapter:?} cannot satisfy the storage-buffer limits dynamis requires"
121            ),
122        }
123    }
124}
125
126impl std::error::Error for GpuUnavailable {}
127
128/// Why a device stopped working.
129#[derive(Clone, Debug, PartialEq, Eq)]
130pub struct DeviceLost {
131    pub reason: DeviceLostReason,
132    pub message: String,
133}
134
135impl Display for DeviceLost {
136    fn fmt(&self, out: &mut Formatter<'_>) -> fmt::Result {
137        write!(out, "{:?}: {}", self.reason, self.message)
138    }
139}
140
141#[derive(Default)]
142struct Health {
143    lost: Mutex<Option<DeviceLost>>,
144}
145
146pub struct GpuContext {
147    adapter: Adapter,
148    device: Device,
149    queue: Queue,
150    features: Features,
151    limits: Limits,
152    timestamp_period_ns: f32,
153    health: Arc<Health>,
154    pipelines: Arc<Mutex<HashMap<PipelineKey, Arc<ComputePipeline>>>>,
155}
156
157#[derive(PartialEq, Eq, Hash, Clone)]
158struct PipelineKey {
159    label: String,
160    shader: String,
161    entry: String,
162    workgroup_size: u32,
163    bindings: Vec<(u32, BindingKind)>,
164}
165
166impl PipelineKey {
167    fn of(
168        label: &str,
169        shader: &str,
170        entry: &str,
171        groups: &[&[BindingSpec]],
172        workgroup_size: u32,
173    ) -> Self {
174        let bindings = groups
175            .iter()
176            .flat_map(|group| group.iter())
177            .map(|spec| (spec.binding, spec.kind))
178            .collect();
179        Self {
180            label: label.to_owned(),
181            shader: shader.to_owned(),
182            entry: entry.to_owned(),
183            workgroup_size,
184            bindings,
185        }
186    }
187}
188
189impl GpuContext {
190    /// The adapter capability this engine requires.
191    pub const MINIMUM_LIMITS: Limits = Limits {
192        max_storage_buffers_per_shader_stage: 16,
193        max_storage_textures_per_shader_stage: 0,
194        max_storage_buffer_binding_size: 16 * 1024 * 1024,
195        max_buffer_size: 64 * 1024 * 1024,
196        max_compute_workgroup_size_x: 64,
197        max_compute_workgroup_size_y: 64,
198        max_compute_workgroup_size_z: 64,
199        max_compute_workgroups_per_dimension: 65_535,
200        ..Limits::defaults()
201    };
202
203    /// Acquire a device for `request`.
204    pub async fn open(request: &GpuRequest) -> Result<Self, GpuUnavailable> {
205        let adapter = select_adapter(request).await?;
206        let available = adapter.features();
207        let missing = request.required_features.difference(available);
208        if !missing.is_empty() {
209            return Err(GpuUnavailable::MissingFeatures { missing, available });
210        }
211        let features = request.required_features | (request.optional_features & available);
212        let limits = match request.limits {
213            LimitsPolicy::Adapter => adapter.limits(),
214            LimitsPolicy::Minimum => Self::MINIMUM_LIMITS,
215        };
216        if !Self::MINIMUM_LIMITS.check_limits(&adapter.limits()) {
217            return Err(GpuUnavailable::InsufficientLimits {
218                adapter: adapter.get_info().name,
219            });
220        }
221        let (device, queue) = adapter
222            .request_device(&wgpu::DeviceDescriptor {
223                label: Some("dynamis device"),
224                required_features: features,
225                required_limits: limits.clone(),
226                memory_hints: wgpu::MemoryHints::Performance,
227                ..Default::default()
228            })
229            .await
230            .map_err(|error| GpuUnavailable::DeviceRejected {
231                message: error.to_string(),
232            })?;
233        let health = Arc::new(Health::default());
234        let callback = health.clone();
235        device.set_device_lost_callback(move |reason, message| {
236            *callback.lost.lock().unwrap() = Some(DeviceLost { reason, message });
237        });
238        let timestamp_period_ns = queue.get_timestamp_period();
239        Ok(Self {
240            adapter,
241            device,
242            queue,
243            features,
244            limits,
245            timestamp_period_ns,
246            health,
247            pipelines: Arc::new(Mutex::new(HashMap::new())),
248        })
249    }
250
251    /// Acquire a device for the default request.
252    pub async fn new() -> Self {
253        match Self::open(&GpuRequest::default()).await {
254            Ok(context) => context,
255            Err(error) => panic!("{error}"),
256        }
257    }
258
259    /// Every adapter reachable through `backends`.
260    pub async fn available_adapters(backends: Backends) -> Vec<AdapterInfo> {
261        let instance = create_instance(backends);
262        instance
263            .enumerate_adapters(backends)
264            .await
265            .iter()
266            .map(|adapter| adapter.get_info())
267            .collect()
268    }
269
270    pub fn device(&self) -> &Device {
271        &self.device
272    }
273
274    pub fn queue(&self) -> &Queue {
275        &self.queue
276    }
277
278    pub fn adapter_info(&self) -> AdapterInfo {
279        self.adapter.get_info()
280    }
281
282    pub fn features(&self) -> Features {
283        self.features
284    }
285
286    pub fn limits(&self) -> &Limits {
287        &self.limits
288    }
289
290    pub fn supports(&self, features: Features) -> bool {
291        self.features.contains(features)
292    }
293
294    /// Nanoseconds per timestamp tick.
295    pub fn timestamp_period_ns(&self) -> f32 {
296        self.timestamp_period_ns
297    }
298
299    /// Whether per-pass GPU timing is available.
300    #[cfg(feature = "profile")]
301    pub fn supports_pass_timing(&self) -> bool {
302        self.supports(GpuRequest::PROFILING_FEATURES)
303    }
304
305    pub fn device_lost(&self) -> Option<DeviceLost> {
306        self.health.lost.lock().unwrap().clone()
307    }
308
309    pub fn assert_alive(&self) {
310        if let Some(lost) = self.device_lost() {
311            panic!("gpu device lost: {lost}");
312        }
313    }
314
315    pub fn compute_pipeline(
316        &self,
317        label: &str,
318        shader: &str,
319        entry: &str,
320        groups: &[&[BindingSpec]],
321        workgroup_size: u32,
322    ) -> ComputePipeline {
323        let key = PipelineKey::of(label, shader, entry, groups, workgroup_size);
324        let mut cache = self.pipelines.lock().unwrap();
325        if let Some(pipeline) = cache.get(&key) {
326            return (**pipeline).clone();
327        }
328        let pipeline = Arc::new(ComputePipeline::new(
329            &self.device,
330            label,
331            shader,
332            entry,
333            groups,
334        ));
335        cache.insert(key, pipeline.clone());
336        (*pipeline).clone()
337    }
338}
339
340impl Clone for GpuContext {
341    fn clone(&self) -> Self {
342        Self {
343            adapter: self.adapter.clone(),
344            device: self.device.clone(),
345            queue: self.queue.clone(),
346            features: self.features,
347            limits: self.limits.clone(),
348            timestamp_period_ns: self.timestamp_period_ns,
349            health: self.health.clone(),
350            pipelines: self.pipelines.clone(),
351        }
352    }
353}
354
355fn create_instance(backends: Backends) -> Instance {
356    Instance::new(InstanceDescriptor {
357        backends,
358        ..InstanceDescriptor::new_without_display_handle()
359    })
360}
361
362async fn select_adapter(request: &GpuRequest) -> Result<Adapter, GpuUnavailable> {
363    let instance = create_instance(request.backends);
364    let adapters = instance.enumerate_adapters(request.backends).await;
365    if adapters.is_empty() {
366        return Err(GpuUnavailable::NoAdapter {
367            backends: request.backends,
368        });
369    }
370    let candidates = match &request.device_name {
371        Some(needle) => {
372            let wanted = needle.to_lowercase();
373            let matched = adapters
374                .iter()
375                .filter(|adapter| adapter.get_info().name.to_lowercase().contains(&wanted))
376                .cloned()
377                .collect::<Vec<_>>();
378            if matched.is_empty() {
379                return Err(GpuUnavailable::DeviceNotFound {
380                    requested: needle.clone(),
381                    available: adapters
382                        .iter()
383                        .map(|adapter| adapter.get_info().name)
384                        .collect(),
385                });
386            }
387            matched
388        }
389        None => adapters,
390    };
391    Ok(prefer(candidates, request.power_preference))
392}
393
394fn prefer(candidates: Vec<Adapter>, power: PowerPreference) -> Adapter {
395    let mut candidates = candidates;
396    let rank = |adapter: &Adapter| {
397        let info = adapter.get_info();
398        let device = match power {
399            PowerPreference::HighPerformance => match info.device_type {
400                DeviceType::DiscreteGpu => 0u8,
401                DeviceType::VirtualGpu => 1,
402                DeviceType::IntegratedGpu => 2,
403                _ => 3,
404            },
405            PowerPreference::LowPower => match info.device_type {
406                DeviceType::IntegratedGpu => 0u8,
407                DeviceType::VirtualGpu => 1,
408                DeviceType::DiscreteGpu => 2,
409                _ => 3,
410            },
411            PowerPreference::None => 0u8,
412        };
413        (native_backend_rank(info.backend), device)
414    };
415    candidates.sort_by_key(|adapter| rank(adapter));
416    candidates
417        .into_iter()
418        .next()
419        .unwrap_or_else(|| panic!("adapter candidates must be non-empty"))
420}
421
422fn native_backend_rank(backend: Backend) -> u8 {
423    #[cfg(target_os = "windows")]
424    const PRIMARY: Backend = Backend::Dx12;
425    #[cfg(any(target_os = "macos", target_os = "ios", target_os = "visionos"))]
426    const PRIMARY: Backend = Backend::Metal;
427    #[cfg(not(any(
428        target_os = "windows",
429        target_os = "macos",
430        target_os = "ios",
431        target_os = "visionos"
432    )))]
433    const PRIMARY: Backend = Backend::Vulkan;
434
435    u8::from(backend != PRIMARY)
436}