diff --git a/README.md b/README.md index 6fac25cd..daff93b3 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,13 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux and web backend defaults are unchanged. +A PC whose GPUs were installed from different driver packages of one vendor (a laptop with an +integrated and a discrete AMD GPU, for example) can list one GPU twice, once through each installed +version of the Vulkan driver, and a window drawn through the stale one crashes inside the driver +(`amdvlk64.dll`, exit code `0xc0000005`). FilmCraft draws with the newer one and says so in +`%APPDATA%\FilmCraft\Logs\filmcraft.log`; installing one driver package for every GPU removes the +duplicate. With one driver per GPU the adapter is the one wgpu picks, as before. + ### macOS | Build | File | Notes | diff --git a/apps/filmcraft/src/graphics.rs b/apps/filmcraft/src/graphics.rs index e79ace08..a372d5ac 100644 --- a/apps/filmcraft/src/graphics.rs +++ b/apps/filmcraft/src/graphics.rs @@ -5,6 +5,13 @@ //! `igvk64.dll`, #512 #413; AMD's OpenGL driver `atio6axx.dll`, #477), where no Rust error //! handling runs, so every launch died the same way until the user set `WGPU_BACKEND` by hand. //! +//! # The adapter +//! In the default mode on Windows the adapter is wgpu's own choice, except for a GPU that Vulkan +//! lists through two installed versions of one driver: there the newer one draws, because a +//! window drawn through the stale one dies inside the driver (`amdvlk64.dll`; see +//! `filmcraft_gpu::adapter`). That case is avoided up front instead of left to the fallback +//! below, which would end in DirectX 12 only. +//! //! # When a start counts as failed //! The data folder keeps the mode to start with and the failed starts in a row in it //! ([`STATE_FILE`]). Each start also leaves its own attempt file (`graphics-start/-.json`, @@ -435,7 +442,16 @@ fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY), + Mode::Default if windows => { + create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY); + // wgpu's own choice of adapter, except for a GPU that Windows lists through two + // versions of one driver: the first entry can be a stale one that crashes inside the + // driver (`filmcraft_gpu::adapter`). A selector replaces `power_preference` + // (`WGPU_POWER_PREF`), so it takes that along. + let power = create.power_preference; + create.native_adapter_selector = + Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| filmcraft_gpu::adapter::pick(adapters, surface, power))); + } Mode::Default => {} // Without Vulkan, whose driver is what crashes; GPU effects may fall back to the CPU. Mode::Dx12 => create.instance_descriptor.backends = Backends::DX12, @@ -495,7 +511,19 @@ mod tests { assert_eq!(backends, Backends::PRIMARY); assert!(!backends.contains(Backends::GL)); assert!(backends.contains(Backends::DX12) && backends.contains(Backends::VULKAN)); - assert!(!selector); + // and chooses the adapter itself: a GPU listed through two versions of one driver + assert!(selector); + } + + #[test] + fn only_the_windows_default_and_software_modes_choose_the_adapter() { + for backend_override in [None, Some(Backends::VULKAN), Some(Backends::DX12)] { + assert!(configured(Mode::Default, backend_override, true).1); + // elsewhere, and in DirectX 12 only mode, the adapter is wgpu's own choice + assert!(!configured(Mode::Default, backend_override, false).1); + assert!(!configured(Mode::Dx12, backend_override, true).1); + } + assert!(configured(Mode::Software, None, true).1 && configured(Mode::Software, None, false).1); } #[test] diff --git a/crates/gpu/src/adapter.rs b/crates/gpu/src/adapter.rs new file mode 100644 index 00000000..d87a949a --- /dev/null +++ b/crates/gpu/src/adapter.rs @@ -0,0 +1,365 @@ +//! Which adapter to draw with when one GPU is listed through two versions of one driver. +//! +//! On Windows the Vulkan loader takes one driver from each display adapter's driver package. A PC +//! whose GPUs were installed from different packages of one vendor (a laptop with an integrated +//! and a discrete AMD GPU, the first on the manufacturer's old package and the second on a current +//! one) loads two versions of that driver, and the newer GPU is listed once through each. wgpu +//! takes the first entry of the device type it prefers, which is whichever version the loader +//! listed first; a window drawn through the stale one kills the process inside the driver +//! (`amdvlk64.dll`, 0xc0000005), where no Rust error handling runs. +//! +//! [`choose`] is wgpu's own rule (`Instance::request_adapter`: the adapters that can draw to the +//! surface, by device type for the power preference, then as enumerated) with one addition: when +//! the adapter that rule picks is listed again by the same driver, the entry exposing the most +//! features is used, i.e. the newer version. Without such a duplicate the choice is wgpu's own. +//! Entries of one GPU from different drivers (RADV and AMDVLK, a vendor's driver and Dozen) are +//! left alone: those are installed side by side on purpose, and wgpu's order between them stays. + +use std::cmp::Reverse; +use std::fmt; + +use wgpu::{Adapter, Backend, DeviceType, PowerPreference, Surface}; + +/// One enumerated adapter as plain data, so that [`choose`] is a pure function tests run without +/// a GPU. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Candidate { + pub name: String, + pub backend: Backend, + pub device_type: DeviceType, + /// PCI vendor and device ids; both zero when the backend reports none (OpenGL). + pub vendor: u32, + pub device: u32, + /// PCI bus id such as `0000:03:00.0`; empty when the driver reports none. + pub pci_bus_id: String, + /// The driver's name (Vulkan) or version (DirectX 12). + pub driver: String, + /// More about the driver, for the log. + pub driver_info: String, + /// How many wgpu features the driver exposes; a newer version of one driver exposes more. + pub features: u32, + /// Whether the surface can be drawn through this adapter (true without a surface). + pub usable: bool, +} + +impl Candidate { + pub fn of(adapter: &Adapter, surface: Option<&Surface<'_>>) -> Self { + let info = adapter.get_info(); + Candidate { + name: info.name, + backend: info.backend, + device_type: info.device_type, + vendor: info.vendor, + device: info.device, + pci_bus_id: info.device_pci_bus_id, + driver: info.driver, + driver_info: info.driver_info, + features: adapter.features().bits().0.iter().map(|word| word.count_ones()).sum(), + usable: surface.is_none_or(|s| adapter.is_surface_supported(s)), + } + } +} + +impl fmt::Display for Candidate { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let kind = match self.device_type { + DeviceType::DiscreteGpu => "discrete GPU", + DeviceType::IntegratedGpu => "integrated GPU", + DeviceType::VirtualGpu => "virtual GPU", + DeviceType::Cpu => "software", + DeviceType::Other => "unknown type", + }; + let driver = format!("{} {}", self.driver, self.driver_info); + write!(f, "\"{}\" ({:?}, {kind}, driver {}, {} features", self.name, self.backend, driver.trim(), self.features)?; + if !self.pci_bus_id.is_empty() { + write!(f, ", bus {}", self.pci_bus_id)?; + } + if !self.usable { + write!(f, ", cannot draw to the window")?; + } + write!(f, ")") + } +} + +/// Whether `a` and `b` are one GPU listed twice by one driver, i.e. through two installed +/// versions of it: the same backend, device type, PCI vendor and device and driver name, and the +/// same bus id when both report one. Adapters without PCI ids (OpenGL reports zeros) are never +/// the same: two of them cannot be told apart. +fn same_gpu_and_driver(a: &Candidate, b: &Candidate) -> bool { + let identified = a.vendor != 0 || a.device != 0; + let same_bus = a.pci_bus_id.is_empty() || b.pci_bus_id.is_empty() || a.pci_bus_id == b.pci_bus_id; + identified && a.backend == b.backend && a.device_type == b.device_type && a.vendor == b.vendor && a.device == b.device && a.driver == b.driver && same_bus +} + +/// wgpu's device type order for `power` (wgpu-core's `Instance::request_adapter`): lower first. +/// Without a preference the adapters stay as enumerated. +fn type_order(device_type: DeviceType, power: PowerPreference) -> u8 { + let prefer_integrated = match power { + PowerPreference::LowPower => true, + PowerPreference::HighPerformance => false, + PowerPreference::None => return 0, + }; + match device_type { + DeviceType::DiscreteGpu if prefer_integrated => 2, + DeviceType::IntegratedGpu if prefer_integrated => 1, + DeviceType::DiscreteGpu => 1, + DeviceType::IntegratedGpu => 2, + DeviceType::Other => 3, + DeviceType::VirtualGpu => 4, + DeviceType::Cpu => 5, + } +} + +/// The index of the adapter wgpu's `request_adapter` picks: the first usable one of the best +/// device type for `power`. `None` without a usable adapter. +fn wgpu_choice(candidates: &[Candidate], power: PowerPreference) -> Option { + let usable = candidates.iter().enumerate().filter(|(_, c)| c.usable); + usable.min_by_key(|(index, c)| (type_order(c.device_type, power), *index)).map(|(index, _)| index) +} + +/// The index of the adapter to draw with: [`wgpu_choice`], or the entry exposing the most +/// features among those that are the same GPU listed by the same driver (the first of them when +/// several expose as many). `None` without a usable adapter. +pub fn choose(candidates: &[Candidate], power: PowerPreference) -> Option { + let first = wgpu_choice(candidates, power)?; + let Some(preferred) = candidates.get(first) else { + return Some(first); + }; + let versions = candidates.iter().enumerate().filter(|(_, c)| c.usable && same_gpu_and_driver(preferred, c)); + versions.max_by_key(|(index, c)| (c.features, Reverse(*index))).map(|(index, _)| index).or(Some(first)) +} + +/// [`choose`] over enumerated adapters, as egui-wgpu's `native_adapter_selector` for the window +/// (`surface`). The adapters and the choice go to the log. +pub fn pick(adapters: &[Adapter], surface: Option<&Surface<'_>>, power: PowerPreference) -> Result { + let candidates: Vec = adapters.iter().map(|a| Candidate::of(a, surface)).collect(); + for candidate in &candidates { + log::info!("graphics adapter available: {candidate}"); + } + let listed = || candidates.iter().map(ToString::to_string).collect::>().join(", "); + let index = choose(&candidates, power).ok_or_else(|| format!("no usable graphics adapter among {}: {}", candidates.len(), listed()))?; + let (Some(chosen), Some(adapter)) = (candidates.get(index), adapters.get(index)) else { + return Err(format!("graphics adapter {index} of {} is missing", adapters.len())); + }; + let others = candidates.iter().enumerate().filter(|(i, c)| *i != index && c.usable && same_gpu_and_driver(chosen, c)).count(); + if others > 0 { + log::warn!( + "{:?} lists \"{}\" {} times: several versions of its driver are installed, and a window drawn through a stale one can crash. Using the one with the most features; installing one driver package for every GPU removes the others.", + chosen.backend, + chosen.name, + others.saturating_add(1) + ); + } + log::info!("graphics adapter chosen: {chosen}"); + Ok(adapter.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + use wgpu::Backend::{Dx12, Gl, Vulkan}; + use wgpu::DeviceType::{Cpu, DiscreteGpu, IntegratedGpu, Other}; + use wgpu::PowerPreference::{HighPerformance, LowPower}; + + const AMD: u32 = 0x1002; + const RX_6800M: u32 = 0x73df; + const RADEON_IGPU: u32 = 0x1638; + const POWERS: [PowerPreference; 3] = [PowerPreference::None, LowPower, HighPerformance]; + + #[expect(clippy::too_many_arguments, reason = "one line per adapter in the listings below")] + fn gpu( + name: &str, + backend: Backend, + device_type: DeviceType, + vendor: u32, + device: u32, + bus: &str, + driver: &str, + driver_info: &str, + features: u32, + ) -> Candidate { + Candidate { + name: name.into(), + backend, + device_type, + vendor, + device, + pci_bus_id: bus.into(), + driver: driver.into(), + driver_info: driver_info.into(), + features, + usable: true, + } + } + + /// An ASUS G513QY as wgpu 30 lists it: the integrated Radeon is on the manufacturer's 2021 + /// driver package and the RX 6800M on a 2026 one, so the Vulkan loader has both versions of + /// AMD's driver, and the 2021 one lists the RX 6800M first. A window drawn through that entry + /// died inside `amdvlk64.dll` (0xc0000005) on every launch. + fn g513qy() -> Vec { + let amd = "AMD proprietary driver"; + vec![ + gpu("AMD Radeon RX 6800M", Vulkan, DiscreteGpu, AMD, RX_6800M, "", amd, "26.9.2", 60), + gpu("AMD Radeon RX 6800M", Vulkan, DiscreteGpu, AMD, RX_6800M, "", amd, "26.9.2 (AMD proprietary shader compiler)", 68), + gpu("AMD Radeon(TM) Graphics", Vulkan, IntegratedGpu, AMD, RADEON_IGPU, "", amd, "21.30.02.13", 56), + gpu("AMD Radeon RX 6800M", Dx12, DiscreteGpu, AMD, RX_6800M, "0000:03:00.0", "32.0.21045.11001", "", 36), + gpu("AMD Radeon(TM) Graphics", Dx12, IntegratedGpu, AMD, RADEON_IGPU, "0000:08:00.0", "30.0.13002.13003", "", 36), + gpu("Microsoft Basic Render Driver", Dx12, Cpu, 0x1414, 0x8c, "", "10.0.26100.9549", "", 36), + ] + } + + #[test] + fn a_gpu_listed_through_two_versions_of_its_driver_draws_with_the_newer_one() { + let adapters = g513qy(); + // the window asks for high performance; `WGPU_POWER_PREF=none` for no preference + for power in [HighPerformance, PowerPreference::None] { + assert_eq!(wgpu_choice(&adapters, power), Some(0), "wgpu takes the stale entry, which crashed"); + assert_eq!(choose(&adapters, power), Some(1)); + } + // whichever version the loader lists first + let mut newer_first = adapters.clone(); + newer_first.swap(0, 1); + assert_eq!(choose(&newer_first, HighPerformance), Some(0)); + // two entries exposing as much: wgpu's, the first + let mut equal = adapters.clone(); + if let Some(c) = equal.get_mut(1) { + c.features = 60; + } + assert_eq!(choose(&equal, HighPerformance), Some(0)); + // three versions: the one exposing the most + let mut three = adapters.clone(); + three.insert(1, gpu("AMD Radeon RX 6800M", Vulkan, DiscreteGpu, AMD, RX_6800M, "", "AMD proprietary driver", "24.1.1", 64)); + assert_eq!(choose(&three, HighPerformance), Some(2)); + } + + #[test] + fn a_newer_version_never_overtakes_another_gpu_or_backend() { + // low power: the integrated GPU, as wgpu picks, whatever the discrete one is listed as + assert_eq!(choose(&g513qy(), LowPower), Some(2)); + // DirectX 12 enumerated first: its entry of the GPU stays the choice + let mut dx12_first = g513qy(); + dx12_first.rotate_left(3); + for power in [HighPerformance, PowerPreference::None] { + assert_eq!(choose(&dx12_first, power), Some(0)); + } + assert_eq!(choose(&dx12_first, LowPower), wgpu_choice(&dx12_first, LowPower)); + } + + #[test] + fn without_two_versions_of_one_driver_the_choice_is_wgpus() { + let nvidia = |backend, bus, driver, features| gpu("NVIDIA GeForce RTX 4070", backend, DiscreteGpu, 0x10de, 0x2786, bus, driver, "", features); + let intel = |backend, bus, driver, features| gpu("Intel(R) UHD Graphics 770", backend, IntegratedGpu, 0x8086, 0x4680, bus, driver, "", features); + let radeon = |bus, driver, features| gpu("AMD Radeon RX 7900 XT", Vulkan, DiscreteGpu, AMD, 0x744c, bus, driver, "", features); + let gl = |name: &str, features| gpu(name, Gl, Other, 0, 0, "", "", "", features); + let without = |skip: usize| g513qy().into_iter().enumerate().filter(|(i, _)| *i != skip).map(|(_, c)| c).collect::>(); + let listings: Vec> = vec![ + vec![], + vec![nvidia(Vulkan, "0000:01:00.0", "NVIDIA", 70)], + // a laptop with two GPUs, through Vulkan and DirectX 12 + vec![ + intel(Vulkan, "0000:00:02.0", "Intel Corporation", 60), + nvidia(Vulkan, "0000:01:00.0", "NVIDIA", 70), + nvidia(Dx12, "0000:01:00.0", "32.0.15.6094", 36), + intel(Dx12, "0000:00:02.0", "31.0.101.5186", 36), + gpu("Microsoft Basic Render Driver", Dx12, Cpu, 0x1414, 0x8c, "", "10.0.26100.1", "", 36), + ], + // one GPU through two different drivers, the second exposing more: Linux with AMDVLK and RADV + vec![radeon("0000:03:00.0", "AMD open-source driver", 58), radeon("0000:03:00.0", "radv", 71)], + // a vendor's driver and Dozen (Vulkan on DirectX 12) + vec![nvidia(Vulkan, "", "Dozen", 75), nvidia(Vulkan, "0000:01:00.0", "NVIDIA", 70)], + // two cards of one model, on their own buses + vec![radeon("0000:03:00.0", "AMD proprietary driver", 60), radeon("0000:04:00.0", "AMD proprietary driver", 68)], + // OpenGL reports no ids: two adapters cannot be told apart + vec![gl("ANGLE", 20), gl("ANGLE", 30)], + vec![gpu("llvmpipe (LLVM 19.1.0, 256 bits)", Vulkan, Cpu, 0x10005, 0, "", "llvmpipe", "", 50)], + // the laptop above with either version of the driver alone + without(0), + without(1), + ]; + for adapters in &listings { + for power in POWERS { + assert_eq!(choose(adapters, power), wgpu_choice(adapters, power), "{power:?} over {adapters:#?}"); + } + } + assert_eq!(choose(&[], HighPerformance), None); + } + + #[test] + fn two_entries_are_one_gpu_only_with_the_same_ids_type_driver_and_bus() { + let stale = g513qy().swap_remove(0); + let newer = g513qy().swap_remove(1); + assert!(same_gpu_and_driver(&stale, &newer) && same_gpu_and_driver(&stale, &stale)); + // a bus id only one version reports does not tell them apart; two different ones do + let on_bus = |bus: &str| Candidate { pci_bus_id: bus.into(), ..newer.clone() }; + assert!(same_gpu_and_driver(&stale, &on_bus("0000:03:00.0"))); + assert!(same_gpu_and_driver(&on_bus("0000:03:00.0"), &on_bus("0000:03:00.0"))); + assert!(!same_gpu_and_driver(&on_bus("0000:03:00.0"), &on_bus("0000:04:00.0"))); + for other in [ + Candidate { backend: Dx12, ..newer.clone() }, + Candidate { device_type: IntegratedGpu, ..newer.clone() }, + Candidate { vendor: 0x10de, ..newer.clone() }, + Candidate { device: RADEON_IGPU, ..newer.clone() }, + Candidate { driver: "radv".into(), ..newer.clone() }, + ] { + assert!(!same_gpu_and_driver(&stale, &other), "{other}"); + } + let unidentified = Candidate { vendor: 0, device: 0, ..newer }; + assert!(!same_gpu_and_driver(&unidentified, &unidentified)); + } + + #[test] + fn adapters_that_cannot_draw_to_the_window_are_left_out() { + let unusable = |which: &dyn Fn(usize, &Candidate) -> bool| { + let mut adapters = g513qy(); + for (i, c) in adapters.iter_mut().enumerate() { + c.usable = !which(i, c); + } + adapters + }; + // the newer version cannot: the other entry is all there is of that GPU through Vulkan + assert_eq!(choose(&unusable(&|i, _| i == 1), HighPerformance), Some(0)); + assert_eq!(choose(&unusable(&|i, _| i == 0), HighPerformance), Some(1)); + // Vulkan cannot at all: DirectX 12 + assert_eq!(choose(&unusable(&|_, c| c.backend == Vulkan), HighPerformance), Some(3)); + assert_eq!(choose(&unusable(&|_, _| true), HighPerformance), None); + } + + #[test] + fn candidates_describe_themselves_for_the_log() { + let adapters = g513qy(); + let text = adapters.get(1).map(ToString::to_string).unwrap_or_default(); + assert_eq!(text, "\"AMD Radeon RX 6800M\" (Vulkan, discrete GPU, driver AMD proprietary driver 26.9.2 (AMD proprietary shader compiler), 68 features)"); + let offscreen = adapters.get(4).map(|c| Candidate { usable: false, ..c.clone() }.to_string()).unwrap_or_default(); + assert_eq!( + offscreen, + "\"AMD Radeon(TM) Graphics\" (Dx12, integrated GPU, driver 30.0.13002.13003, 36 features, bus 0000:08:00.0, cannot draw to the window)" + ); + } + + /// Against the adapters of the machine running the tests: [`wgpu_choice`] is what wgpu's + /// `request_adapter` returns, and [`pick`] returns that adapter too, or a version of it + /// exposing more features. + #[test] + fn on_this_host_the_choice_is_wgpus_unless_its_driver_is_listed_twice() { + if wgpu::Instance::enabled_backend_features().is_empty() { + return; + } + // no OpenGL: the app leaves it out on Windows, where creating it can crash a driver + let backends = wgpu::Backends::PRIMARY; + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends, ..wgpu::InstanceDescriptor::new_without_display_handle() }); + let adapters = pollster::block_on(instance.enumerate_adapters(backends)); + let candidates: Vec = adapters.iter().map(|a| Candidate::of(a, None)).collect(); + for power in POWERS { + let options = wgpu::RequestAdapterOptions { power_preference: power, ..Default::default() }; + let wgpu_own = pollster::block_on(instance.request_adapter(&options)).ok().map(|a| Candidate::of(&a, None)); + assert_eq!(wgpu_choice(&candidates, power).and_then(|i| candidates.get(i)), wgpu_own.as_ref(), "{power:?} over {candidates:#?}"); + let picked = pick(&adapters, None, power).ok().map(|a| Candidate::of(&a, None)); + match (&wgpu_own, &picked) { + (Some(wgpu_own), Some(picked)) if wgpu_own != picked => { + assert!(same_gpu_and_driver(wgpu_own, picked) && picked.features > wgpu_own.features, "{power:?}: {picked} instead of {wgpu_own}"); + } + _ => assert_eq!(picked, wgpu_own, "{power:?}"), + } + } + } +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index b21f25a9..4f15697d 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -38,6 +38,7 @@ use filmcraft_frame::{Chroma, PixelData, VideoFrame}; use filmcraft_render::Blend; use filmcraft_render::plan::{FramePlan, PlanLayer, PlanStep}; +pub mod adapter; pub mod export_renderer; pub mod fx; pub mod lut; diff --git a/docs/gaps.md b/docs/gaps.md index 9b2a2796..b1699057 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -85,7 +85,9 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** (Windows), then a software adapter (WARP, lavapipe, llvmpipe) with CPU compositing, and keeps the mode that works; a start-up error wgpu reports moves on in the same process. A fallback mode shows a notice with Use Default Graphics; `--graphics default|dx12|software` picks a mode by - hand. An explicit `WGPU_BACKEND` stays in charge. + hand. An explicit `WGPU_BACKEND` stays in charge. On Windows, a GPU that Vulkan lists through two + installed versions of one driver (GPUs on different driver packages) draws with the newer one + instead of dying inside the stale one (`filmcraft_gpu::adapter`). - **Missing:** a launch that never fails, and CI that runs the gates. - **Evidence:** open issues: macOS 12 Monterey aborts on a missing VideoToolbox symbol (#468, #661, #655), Intel UHD graphics (`igvk64.dll`, #512), Windows 11 does not run or fails after setup