diff --git a/apps/desktop-gpui/README.md b/apps/desktop-gpui/README.md index 492f4a547d3..5b1dba0ed46 100644 --- a/apps/desktop-gpui/README.md +++ b/apps/desktop-gpui/README.md @@ -20,6 +20,14 @@ cargo run `RUST_LOG=cap_gpui=debug cargo run` for logs — note the filter is `cap_gpui` (the binary), not `cap_desktop_gpui` (the package). +On dual-GPU Macs the renderer and every `cap-rendering` wgpu device follow the +system's GPU choice by default; wgpu matches `MTLCreateSystemDefaultDevice()` +rather than taking its first Metal adapter. Windows and Linux keep each call +site's previous preference unless the variable below names a GPU. +`CAP_GPU_PREFERENCE=high` (or `performance`/`discrete`) forces the +high-performance GPU, and `low` (or `integrated`/`battery`) forces the integrated +GPU. + The first build takes about 11 minutes and 6.4 GB of `target/`: gpui pulls its own revisions of the wgpu and font stacks. After that, an edit rebuilds only the app crate, at `opt-level = 0` and incrementally, in about 22s with plain @@ -797,8 +805,10 @@ sampling UV, the legacy page flips the canvas with `scaleX(-1)`; neither app ever mirrors the recorded camera track). Background blur runs live on preview frames through `src/camera_blur.rs`: a -dedicated `camera-blur` thread owns its own wgpu device (requested the way the -Tauri preview requests one — LowPower, downlevel limits) and runs the exact +dedicated `camera-blur` thread owns its own wgpu device (requested through +`cap_rendering::gpu_power_preference` like every other renderer and encoder — +system GPU by default on macOS, `LowPower` elsewhere, downlevel limits) and runs +the exact `cap_camera_effects::BlurProcessor` the recording's project config points the editor at, with the Tauri preview's 640×360 input cap and 150ms inference interval. The converter downscales to the cap inside the same hardware diff --git a/apps/desktop-gpui/patches/zed-gpu-preference.patch b/apps/desktop-gpui/patches/zed-gpu-preference.patch new file mode 100644 index 00000000000..076875db1bf --- /dev/null +++ b/apps/desktop-gpui/patches/zed-gpu-preference.patch @@ -0,0 +1,46 @@ +diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs +--- a/crates/gpui_macos/src/metal_renderer.rs ++++ b/crates/gpui_macos/src/metal_renderer.rs +@@ -246,14 +246,34 @@ + } + + fn create_device() -> Result { +- // Prefer low‐power integrated GPUs on Intel Mac. On Apple +- // Silicon, there is only ever one GPU, so this is equivalent to +- // `metal::Device::system_default()`. +- if let Some(d) = metal::Device::all() +- .into_iter() +- .min_by_key(|d| (d.is_removable(), !d.is_low_power())) +- { +- Ok(d) ++ let preference = std::env::var("CAP_GPU_PREFERENCE") ++ .unwrap_or_default() ++ .to_ascii_lowercase(); ++ let prefer_low_power = matches!(preference.as_str(), "low" | "integrated" | "battery"); ++ let prefer_high_performance = ++ matches!(preference.as_str(), "high" | "performance" | "discrete"); ++ if !prefer_low_power && !prefer_high_performance { ++ if let Some(device) = metal::Device::system_default() { ++ log::info!("selected system Metal device: {}", device.name()); ++ return Ok(device); ++ } ++ } ++ if let Some(device) = metal::Device::all().into_iter().min_by_key(|device| { ++ ( ++ if prefer_low_power { ++ !device.is_low_power() ++ } else { ++ device.is_low_power() ++ }, ++ device.is_removable(), ++ ) ++ }) { ++ log::info!( ++ "selected Metal device: {} (low_power={})", ++ device.name(), ++ device.is_low_power() ++ ); ++ Ok(device) + } else { + // For some reason `all()` can return an empty list, see https://github.com/zed-industries/zed/issues/37689 + // In that case, we fall back to the system default device. diff --git a/apps/desktop-gpui/src/camera_blur.rs b/apps/desktop-gpui/src/camera_blur.rs index 4f72cdeb7c8..8afa680fd56 100644 --- a/apps/desktop-gpui/src/camera_blur.rs +++ b/apps/desktop-gpui/src/camera_blur.rs @@ -176,25 +176,24 @@ pub fn run(jobs: flume::Receiver, results: flume::Sender) { /// One device + queue for the app's blur lifetime, latched like Tauri's /// `blur_processor_init_attempted`: a failed bring-up is not retried on every /// toggle. The Tauri preview keeps its device for the window's lifetime -/// (`camera.rs:573-583`, `LowPower`, downlevel limits); only the ONNX/blur -/// resources are per-enable. +/// (`camera.rs:573-583`, downlevel limits); only the ONNX/blur resources are +/// per-enable. fn shared_device(runtime: &tokio::runtime::Runtime) -> Option<(wgpu::Device, wgpu::Queue)> { static DEVICE: OnceLock> = OnceLock::new(); DEVICE .get_or_init(|| { runtime.block_on(async { let instance = cap_rendering::create_wgpu_instance_sync(); - let adapter = instance - .request_adapter(&wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::LowPower, - force_fallback_adapter: cap_rendering::force_software_wgpu_adapter(), - compatible_surface: None, - }) - .await - .map_err(|error| { - tracing::warn!("camera blur adapter unavailable: {error}"); - }) - .ok()?; + let adapter = cap_rendering::request_wgpu_adapter( + &instance, + wgpu::PowerPreference::LowPower, + cap_rendering::force_software_wgpu_adapter(), + ) + .await + .map_err(|error| { + tracing::warn!("camera blur adapter unavailable: {error}"); + }) + .ok()?; let (device, queue) = adapter .request_device(&wgpu::DeviceDescriptor { label: Some("Camera Preview Blur"), diff --git a/apps/desktop-gpui/src/camera_blur_portable.rs b/apps/desktop-gpui/src/camera_blur_portable.rs index 0247ef09a1d..63ca50d7592 100644 --- a/apps/desktop-gpui/src/camera_blur_portable.rs +++ b/apps/desktop-gpui/src/camera_blur_portable.rs @@ -62,14 +62,13 @@ impl PortableCameraBlur { .context("camera blur runtime")?; let (device, queue) = runtime.block_on(async { let instance = cap_rendering::create_wgpu_instance_sync(); - let adapter = instance - .request_adapter(&wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::LowPower, - force_fallback_adapter: cap_rendering::force_software_wgpu_adapter(), - compatible_surface: None, - }) - .await - .map_err(|error| anyhow!("camera blur adapter: {error}"))?; + let adapter = cap_rendering::request_wgpu_adapter( + &instance, + wgpu::PowerPreference::LowPower, + cap_rendering::force_software_wgpu_adapter(), + ) + .await + .map_err(|error| anyhow!("camera blur adapter: {error}"))?; adapter .request_device(&wgpu::DeviceDescriptor { label: Some("Camera Preview Blur"), diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 6abde058f9f..571e49d19f6 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -218,6 +218,80 @@ pub fn force_software_wgpu_adapter() -> bool { }) } +fn parse_gpu_power_preference(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "low" | "integrated" | "battery" => Some(wgpu::PowerPreference::LowPower), + "high" | "performance" | "discrete" => Some(wgpu::PowerPreference::HighPerformance), + _ => None, + } +} + +fn configured_gpu_power_preference() -> Option { + let value = std::env::var("CAP_GPU_PREFERENCE").ok()?; + parse_gpu_power_preference(&value) +} + +fn resolve_gpu_power_preference( + configured: Option, + default: wgpu::PowerPreference, +) -> wgpu::PowerPreference { + match configured { + Some(preference) => preference, + None if cfg!(target_os = "macos") => wgpu::PowerPreference::None, + None => default, + } +} + +/// `CAP_GPU_PREFERENCE` selects the physical GPU when more than one is present. +/// `low` picks the integrated GPU and `high` picks the high-performance (usually +/// discrete) GPU. When it is unset (or `system`/`auto`), macOS follows the +/// system GPU and every other platform keeps the `default` its call site used +/// before this override existed. +pub fn gpu_power_preference(default: wgpu::PowerPreference) -> wgpu::PowerPreference { + resolve_gpu_power_preference(configured_gpu_power_preference(), default) +} + +#[cfg(target_os = "macos")] +fn system_metal_adapter(instance: &wgpu::Instance) -> Option { + let name = metal::Device::system_default()?.name().to_string(); + let adapter = instance + .enumerate_adapters(wgpu::Backends::METAL) + .into_iter() + .find(|adapter| adapter.get_info().name == name); + if adapter.is_some() { + tracing::info!(adapter_name = %name, "Following the system Metal device"); + } else { + tracing::warn!(adapter_name = %name, "System Metal device is not available through wgpu"); + } + adapter +} + +/// Requests a hardware adapter, or a fallback adapter when +/// `force_fallback_adapter` is set. On macOS with no override, the adapter +/// matching the system Metal device is used so Cap follows the GPU macOS is +/// currently driving. +pub async fn request_wgpu_adapter( + instance: &wgpu::Instance, + default: wgpu::PowerPreference, + force_fallback_adapter: bool, +) -> Result { + #[cfg(target_os = "macos")] + if !force_fallback_adapter + && configured_gpu_power_preference().is_none() + && let Some(adapter) = system_metal_adapter(instance) + { + return Ok(adapter); + } + + instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: gpu_power_preference(default), + force_fallback_adapter, + compatible_surface: None, + }) + .await +} + pub fn create_wgpu_instance_sync() -> wgpu::Instance { #[cfg(not(target_os = "windows"))] let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default()); @@ -285,12 +359,7 @@ pub async fn probe_software_adapter() -> Option<(bool, String)> { let hardware_adapter = if force_software_adapter { None } else { - instance - .request_adapter(&wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::HighPerformance, - force_fallback_adapter: false, - compatible_surface: None, - }) + request_wgpu_adapter(&instance, wgpu::PowerPreference::HighPerformance, false) .await .ok() }; @@ -2497,12 +2566,7 @@ pub async fn create_shared_device() -> Result let hardware_adapter = if force_software_adapter { None } else { - instance - .request_adapter(&wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::HighPerformance, - force_fallback_adapter: false, - compatible_surface: None, - }) + request_wgpu_adapter(&instance, wgpu::PowerPreference::HighPerformance, false) .await .ok() }; @@ -5207,6 +5271,65 @@ impl ProjectUniforms { mod tests { use super::*; + #[test] + fn gpu_power_preference_defaults_follow_the_platform() { + let high_performance = wgpu::PowerPreference::HighPerformance; + let low_power = wgpu::PowerPreference::LowPower; + let expected = |default| { + if cfg!(target_os = "macos") { + wgpu::PowerPreference::None + } else { + default + } + }; + for value in [None, Some(""), Some("system"), Some("auto"), Some("System")] { + let configured = value.and_then(parse_gpu_power_preference); + assert_eq!( + resolve_gpu_power_preference(configured, high_performance), + expected(high_performance), + "{value:?}" + ); + assert_eq!( + resolve_gpu_power_preference(configured, low_power), + expected(low_power), + "{value:?}" + ); + } + } + + #[cfg(target_os = "macos")] + #[test] + fn system_metal_adapter_matches_the_system_default_device() { + let instance = create_wgpu_instance_sync(); + let system = metal::Device::system_default().expect("system Metal device"); + let adapter = system_metal_adapter(&instance).expect("system Metal adapter"); + assert_eq!(adapter.get_info().name, system.name()); + } + + #[test] + fn gpu_power_preference_honors_overrides() { + for value in ["low", "integrated", "battery", "LOW"] { + assert_eq!( + resolve_gpu_power_preference( + parse_gpu_power_preference(value), + wgpu::PowerPreference::HighPerformance + ), + wgpu::PowerPreference::LowPower, + "{value}" + ); + } + for value in ["high", "performance", "discrete", "HIGH"] { + assert_eq!( + resolve_gpu_power_preference( + parse_gpu_power_preference(value), + wgpu::PowerPreference::LowPower + ), + wgpu::PowerPreference::HighPerformance, + "{value}" + ); + } + } + #[test] fn camera_blur_output_rejects_missing_pending_and_failed_masks() { use cap_camera_effects::{BlurFailure, BlurMaskStatus, BlurMode, BlurOutputStatus}; diff --git a/scripts/prepare-gpui-dependency.sh b/scripts/prepare-gpui-dependency.sh index bffbd90c10d..21d018cdc08 100755 --- a/scripts/prepare-gpui-dependency.sh +++ b/scripts/prepare-gpui-dependency.sh @@ -8,6 +8,7 @@ patch_paths=( "apps/desktop-gpui/patches/zed-windows.patch" "apps/desktop-gpui/patches/zed-linux.patch" "apps/desktop-gpui/patches/zed-idle-frames.patch" + "apps/desktop-gpui/patches/zed-gpu-preference.patch" ) patch_files=("${patch_paths[@]/#/$repo_root/}") base_revision="5d1f83d9f27a19bec1fb241dc33b42238af9cf8d" diff --git a/scripts/prepare-gpui-dependency.test.mjs b/scripts/prepare-gpui-dependency.test.mjs index e66507100a4..9170c8c4b0d 100644 --- a/scripts/prepare-gpui-dependency.test.mjs +++ b/scripts/prepare-gpui-dependency.test.mjs @@ -122,6 +122,7 @@ test("dependency verification handles overlapping patches without changing the c writeFileSync(path.join(upstream, "renderer.rs"), "base\n"); writeFileSync(path.join(upstream, "windows.rs"), "base\n"); writeFileSync(path.join(upstream, "frames.rs"), "base\n"); + writeFileSync(path.join(upstream, "gpu.rs"), "base\n"); commit(); const base = git(upstream, "rev-parse", "HEAD").trim(); const patch = (name) => { @@ -142,6 +143,8 @@ test("dependency verification handles overlapping patches without changing the c patch("zed-linux.patch"); writeFileSync(path.join(upstream, "frames.rs"), "paced frames\n"); patch("zed-idle-frames.patch"); + writeFileSync(path.join(upstream, "gpu.rs"), "high performance GPU\n"); + patch("zed-gpu-preference.patch"); const script = path.join(cap, "scripts/prepare-gpui-dependency.sh"); writeFileSync( script, @@ -165,6 +168,10 @@ test("dependency verification handles overlapping patches without changing the c readFileSync(path.join(zed, "frames.rs"), "utf8"), "paced frames\n", ); + assert.equal( + readFileSync(path.join(zed, "gpu.rs"), "utf8"), + "high performance GPU\n", + ); }); writeFileSync(path.join(zed, "unrelated.txt"), "preserve staged work\n");