From fc99d212f6216d4126846c26753ecb9b65c5bef6 Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Sun, 11 Oct 2026 12:42:29 -0500 Subject: [PATCH] test(gpu): #1050 opt-in software adapter for the equivalence tests `Gpu::new` refuses software adapters (llvmpipe / lavapipe, WARP): the CPU pipeline is faster. CI runners have nothing else, so the CPU <-> GPU equivalence tests have always skipped there. `LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1` admits them. The app never sets it and the default is unchanged. On lavapipe all 134 compared renders stay within max 1 LSB, mean <= 0.0003 LSB, the same as Metal on an M4 Pro. The two big-source window tests used a 24 MP (288 MB) source. That is over lavapipe's 128 MiB storage binding (the WebGPU default), so geometry ran on the CPU and nothing was uploaded: one test failed and the other passed without checking anything. They now use 3200 x 3200 (117 MiB): above RETAIN_SOURCE_BYTES, so the source is still shared, and uploaded on every adapter. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/ctx.rs | 33 ++++++++++++++++++++++++++++-- crates/gpu/tests/it/equivalence.rs | 30 +++++++++++++++++---------- docs/gpu-pipeline.md | 13 ++++++++++++ 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index d3e56f749..73d7dc366 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -386,7 +386,8 @@ fn module_source(m: &Module, consts: &str) -> String { } impl Gpu { - /// Create a device on the best available adapter (no software fallback), or why there is none. + /// Create a device on the best available adapter (no software adapter unless + /// `LIGHTCRAFT_GPU_ALLOW_SOFTWARE`), or why there is none. pub fn new(backends: wgpu::Backends) -> Result { let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); // only these drivers are loaded (`crate::backend`: never Vulkan on Windows unless asked) @@ -401,10 +402,13 @@ impl Gpu { ) .map_err(|e| format!("no GPU adapter among {backends:?} ({e})"))?; let info = adapter.get_info(); - if info.device_type == wgpu::DeviceType::Cpu { + if refuse_adapter(info.device_type, software_allowed()) { log::info!("gpu: only a software adapter ({}), using the CPU pipeline", info.name); return Err(format!("software adapter ({}) skipped: the CPU pipeline is faster", info.name)); } + if info.device_type == wgpu::DeviceType::Cpu { + log::info!("gpu: LIGHTCRAFT_GPU_ALLOW_SOFTWARE: using the software adapter {}", info.name); + } let mut limits = adapter.limits(); if limits.max_storage_buffers_per_shader_stage < 10 { log::info!("gpu: {} has too few storage buffers per stage", info.name); @@ -747,6 +751,20 @@ impl Gpu { /// normally reset a hung GPU much sooner). const READBACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); +/// Whether to turn down an adapter of `device_type`: a software rasterizer (llvmpipe / lavapipe, +/// WARP) is slower than the CPU pipeline, so it is refused unless `allow_software`. +fn refuse_adapter(device_type: wgpu::DeviceType, allow_software: bool) -> bool { + device_type == wgpu::DeviceType::Cpu && !allow_software +} + +/// `LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1`: accept a software adapter. For tests on machines without a +/// GPU (CI runners: Mesa lavapipe on Linux, WARP on Windows), so the CPU ↔ GPU equivalence tests +/// run there instead of skipping; the app never sets it (software rendering is slower than the CPU +/// pipeline). +fn software_allowed() -> bool { + std::env::var("LIGHTCRAFT_GPU_ALLOW_SOFTWARE").is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) +} + /// `LIGHTCRAFT_GPU_LIMITS`: lower the device limits, e.g. to reproduce a smaller adapter. /// `webgpu` / `downlevel` = 128 MiB storage bindings and 256 MiB buffers (the WebGPU defaults), /// `` = n MiB storage bindings and 2n MiB buffers. Returns (binding bytes, buffer bytes). @@ -1151,6 +1169,17 @@ mod tests { println!("CHILD: survived"); } + #[test] + fn software_adapters_need_the_opt_in() { + use wgpu::DeviceType::{Cpu, DiscreteGpu, IntegratedGpu, Other, VirtualGpu}; + assert!(super::refuse_adapter(Cpu, false), "llvmpipe / WARP are refused by default"); + assert!(!super::refuse_adapter(Cpu, true), "LIGHTCRAFT_GPU_ALLOW_SOFTWARE accepts them"); + for t in [DiscreteGpu, IntegratedGpu, VirtualGpu, Other] { + assert!(!super::refuse_adapter(t, false), "{t:?}"); + assert!(!super::refuse_adapter(t, true), "{t:?}"); + } + } + #[test] fn dispatches_stay_within_the_workgroup_limit() { // 1-D kernels over a 100 MP image and 2-D ones over 16k × 16k stay under 65535 per axis diff --git a/crates/gpu/tests/it/equivalence.rs b/crates/gpu/tests/it/equivalence.rs index 7432ebb7c..b043152fc 100644 --- a/crates/gpu/tests/it/equivalence.rs +++ b/crates/gpu/tests/it/equivalence.rs @@ -2,7 +2,8 @@ //! `lightcraft_gpu::render`, compared in 8-bit sRGB. //! //! Bounds (documented in `docs/gpu-pipeline.md`): mean |Δ| < 0.5 LSB and max |Δ| ≤ 3 LSB per -//! channel for every case. Skips (passes with a note) when no GPU adapter exists, e.g. on CI. +//! channel for every case. Skips (passes with a note) when no GPU adapter exists; CI runs them on +//! software adapters with `LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1` (lavapipe, WARP) and on Metal. use std::sync::Arc; @@ -889,13 +890,20 @@ fn windows_match() { check("window: vignette corner", &src, &info, &s, &corner); } -/// Issue #323: a window of a 24 MP photo is cut from a source of 288 MB on the device. Keeping that -/// upload with the view's stages counted it against the stage budget on every tick of a drag and -/// got the stages cleared, and the upload and resampling redone: the window's stages are its own -/// pixels, not the photo's. +/// Issue #323: a window of a big photo is cut from the whole source on the device (288 MB for 24 MP). +/// Keeping that upload with the view's stages counted it against the stage budget on every tick of +/// a drag and got the stages cleared, and the upload and resampling redone: the window's stages are +/// its own pixels, not the photo's. // (these two share the process's one big source: a test lock keeps them from replacing each other's) static BIG_SOURCE_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(()); +/// The big photo of these tests: 10.24 MP, a 117 MiB source. That is over what a view keeps with its +/// stages (96 MiB, so it is shared) and within a 128 MiB storage binding (the WebGPU default, and +/// the limit of Mesa's lavapipe in CI), so the source goes to the device on every adapter. A source +/// over the device's limit is resampled on the CPU instead and never uploaded, which would leave +/// these tests nothing to check. +const BIG: usize = 3200; + #[test] fn a_window_does_not_keep_the_whole_source_on_the_device() { let _lock = BIG_SOURCE_TESTS.lock().unwrap_or_else(|e| e.into_inner()); @@ -903,11 +911,11 @@ fn a_window_does_not_keep_the_whole_source_on_the_device() { return; } use lightcraft_pipeline::PixelWindow; - let src = scene(1, 6000, 4000); + let src = scene(1, BIG, BIG); let info = SourceInfo { raw: true, ..Default::default() }; let stages = StageCache::default(); let s = DevelopSettings::default(); - let req = RenderRequest { window: Some(PixelWindow { x: 2000, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(6000, 4000) }; + let req = RenderRequest { window: Some(PixelWindow { x: 2000, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(BIG, BIG) }; let _ = lightcraft_gpu::render(&src, &info, &s, &req, Some(&stages)).expect("gpu render"); let held = lightcraft_gpu::stage_bytes(&stages); assert!(held < 100 << 20, "the view's GPU stages hold {} MiB for a 640×480 window", held >> 20); @@ -919,7 +927,7 @@ fn a_window_does_not_keep_the_whole_source_on_the_device() { assert!(lightcraft_gpu::stage_bytes(&stages) < 100 << 20); } -/// …but the 288 MB is uploaded once for every window of every view of the photo, not once per +/// …but the source is uploaded once for every window of every view of the photo, not once per /// window: panning at 1:1, and the Before and After windows, share one device copy of it, which /// goes when the app idles (`trim_pool(0)`). #[test] @@ -929,17 +937,17 @@ fn windows_of_a_big_photo_share_one_uploaded_source() { return; } use lightcraft_pipeline::PixelWindow; - let src = scene(1, 6000, 4000); + let src = scene(1, BIG, BIG); let info = SourceInfo { raw: true, ..Default::default() }; let s = DevelopSettings::default(); let (after, before) = (StageCache::default(), StageCache::default()); - let at = |x: usize| RenderRequest { window: Some(PixelWindow { x, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(6000, 4000) }; + let at = |x: usize| RenderRequest { window: Some(PixelWindow { x, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(BIG, BIG) }; let start = lightcraft_gpu::source_uploads(); for (cache, x) in [(&after, 1000), (&after, 1256), (&after, 1512), (&before, 1512), (&before, 1768)] { lightcraft_gpu::render(&src, &info, &s, &at(x), Some(cache)).expect("gpu render"); } assert_eq!(lightcraft_gpu::source_uploads() - start, 1, "one upload for five windows of two views"); - assert!(lightcraft_gpu::shared_source_bytes() >= 280_000_000, "{}", lightcraft_gpu::shared_source_bytes()); + assert!(lightcraft_gpu::shared_source_bytes() >= BIG * BIG * 12, "{}", lightcraft_gpu::shared_source_bytes()); assert!(lightcraft_gpu::stage_bytes(&after) < 100 << 20, "and it is not in the views' stages"); lightcraft_gpu::trim_pool(0); assert_eq!(lightcraft_gpu::shared_source_bytes(), 0, "an idle app gives it back"); diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 8dceb96ca..de246f432 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -49,6 +49,9 @@ Overrides (read once at launch): - `LIGHTCRAFT_GPU=0`: GPU rendering off for the process (the window is unaffected). - Both `LIGHTCRAFT_GPU=0` and `LIGHTCRAFT_GPU_BACKEND=off` also keep AI Denoise, the face models and the AI mask model (SAM 3 on Metal) on the CPU. +- `LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1`: accept a software adapter (llvmpipe / lavapipe, WARP), which is + otherwise refused because the CPU pipeline is faster. For tests on machines without a GPU (CI); + the app never sets it. **DX12 shader compiler (issue #471).** Every instance (window and compute) compiles DX12 shaders with FXC (`d3dcompiler_47.dll`, part of Windows; `lightcraft_gpu::backend::backend_options`). wgpu's @@ -272,6 +275,16 @@ bit-identical, and one (the point colour range overlay) has max 2 LSB on 0.0002% mean 0.0003 LSB. That is the same picture as on Metal, inside the bounds (`cargo test -p lightcraft-gpu --release --test it equivalence:: -- --nocapture`). +**In CI** (`.github/workflows/ci.yml`, issue #1050) they run on every platform: on Metal on the macOS +runners, and on software adapters on Linux (Mesa lavapipe) and Windows (WARP, DX12 with FXC), which +`LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1` admits (tests only: the app never sets it). The step fails if they +skip. Measured on lavapipe (CI and locally) and WARP: all 134 compared renders within **max 1 LSB, +mean ≤ 0.0003 LSB**, as on the M4 Pro; on the macOS runner's (paravirtual) Metal device one case reaches +2 LSB (point colour range overlay, 0.0002% of pixels over 1), the rest max 1. The suite takes about +50 s on lavapipe, 30 s on the macOS runner and 2–7.5 min on WARP. lavapipe's +storage bindings stop at 128 MiB (the WebGPU default), so the two big-source tests use a 117 MiB +source: above what a view keeps with its stages (96 MiB) and small enough to be uploaded there. + Remaining differences come from f32 vs f64 coordinate math, fast-math transcendental functions on Metal and summation order in the box filters — all far below one 8-bit step.