From fc99d212f6216d4126846c26753ecb9b65c5bef6 Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Sun, 11 Oct 2026 12:42:29 -0500 Subject: [PATCH 1/4] 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. From 21a7d64d467d0dfd39e0344b02f07458201f3508 Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Fri, 30 Oct 2026 01:01:58 -0500 Subject: [PATCH 2/4] docs(gpu): #1050 run the GPU tests locally on a software adapter docs/gpu-pipeline.md says how to run lightcraft-gpu's tests on a machine without a GPU: LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1 admits Mesa's lavapipe on Linux and WARP on Windows (Metal on macOS needs nothing), and LIGHTCRAFT_SKIP_LOG shows whether anything still skipped for want of an adapter. The comments on the variable and in equivalence.rs no longer say CI runs them: the platform CI legs of #1148 are not landing. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/ctx.rs | 5 ++-- crates/gpu/tests/it/equivalence.rs | 6 ++--- docs/gpu-pipeline.md | 37 +++++++++++++++++++++--------- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 73d7dc366..9897faf5f 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -758,9 +758,8 @@ fn refuse_adapter(device_type: wgpu::DeviceType, allow_software: bool) -> bool { } /// `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). +/// GPU (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")) } diff --git a/crates/gpu/tests/it/equivalence.rs b/crates/gpu/tests/it/equivalence.rs index b043152fc..6a1a8464c 100644 --- a/crates/gpu/tests/it/equivalence.rs +++ b/crates/gpu/tests/it/equivalence.rs @@ -2,8 +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; CI runs them on -//! software adapters with `LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1` (lavapipe, WARP) and on Metal. +//! channel for every case. Skips (passes with a note) when no GPU adapter exists; +//! `LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1` runs them on a software adapter (lavapipe, WARP) instead. use std::sync::Arc; @@ -899,7 +899,7 @@ 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 +/// the limit of Mesa's lavapipe), 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; diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index de246f432..736c20871 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -50,8 +50,8 @@ Overrides (read once at launch): - 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. + otherwise refused because the CPU pipeline is faster. For running the GPU tests on a machine + without a GPU (below); 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 @@ -275,15 +275,30 @@ 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. +**Running them without a GPU (issue #1050).** `Gpu::new` turns down software adapters, so on a +machine without a hardware GPU (a VM, a container) every GPU test skips. +`LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1` lets them through for one run (tests only: the app never sets it): + +```sh +rm -f /tmp/gpu-skips.jsonl # the log is appended to; an absolute path, as tests run in crates/gpu +LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1 LIGHTCRAFT_SKIP_LOG=/tmp/gpu-skips.jsonl cargo test -p lightcraft-gpu --tests +grep '"reason":"no GPU adapter' /tmp/gpu-skips.jsonl # no output: nothing skipped for want of an adapter +``` + +- **Linux:** Mesa's lavapipe (Vulkan; `mesa-vulkan-drivers` on Debian and Ubuntu). Its 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. +- **Windows:** WARP, the DX12 software rasterizer that ships with Windows (shaders through FXC, as + above). +- **macOS:** Metal is a hardware adapter, so the tests run without the variable. + +A machine with a hardware GPU picks it over the software adapter, variable or not. Each test that +skips prints `skipped (gpu): …` and appends a line to the `LIGHTCRAFT_SKIP_LOG` file; with a software +adapter the only one left should be the device-loss test (`no GPU device, or one that can't be lost on +purpose here`). Measured 2026-10-11 on lavapipe and WARP, and again 2026-10-30 on lavapipe (Mesa's +llvmpipe on LLVM 20, 143 compared renders), every compared render stayed within **max 1 LSB, mean ≤ +0.0003 LSB**, as on the M4 Pro. The equivalence binary takes about 20 s on lavapipe and several +minutes on WARP. 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. From 15bacc76602ef7e8ffbe3a6f7227e942ebfe43c6 Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Fri, 30 Oct 2026 12:45:13 -0500 Subject: [PATCH 3/4] docs(gpu): #1050 the skip-log check catches every GPU skip and logs to a private directory The check grepped for "no GPU adapter" only, so the ctx unit tests' "no GPU device" skips (and nn's "no GPU runner here") went unseen. It now lists every "need":"gpu" line except the expected device-loss one. On the 2026-10-30 lavapipe logs it shows all 24 GPU skips without the opt-in (the old grep showed 21) and nothing with it. The log goes in a new mktemp -d directory instead of a fixed /tmp name another local user could plant a symlink at. Co-Authored-By: Claude Opus 5.5 --- docs/gpu-pipeline.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 736c20871..cd7fd03e3 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -280,9 +280,9 @@ machine without a hardware GPU (a VM, a container) every GPU test skips. `LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1` lets them through for one run (tests only: the app never sets it): ```sh -rm -f /tmp/gpu-skips.jsonl # the log is appended to; an absolute path, as tests run in crates/gpu -LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1 LIGHTCRAFT_SKIP_LOG=/tmp/gpu-skips.jsonl cargo test -p lightcraft-gpu --tests -grep '"reason":"no GPU adapter' /tmp/gpu-skips.jsonl # no output: nothing skipped for want of an adapter +log="$(mktemp -d)/gpu-skips.jsonl" # a new private directory; an absolute path, as tests run in crates/gpu +LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1 LIGHTCRAFT_SKIP_LOG="$log" cargo test -p lightcraft-gpu --tests +grep '"need":"gpu"' "$log" | grep -v 'lost on purpose' # no output: no GPU test skipped but the device-loss one ``` - **Linux:** Mesa's lavapipe (Vulkan; `mesa-vulkan-drivers` on Debian and Ubuntu). Its storage From fe3b1813b11c8be0d928407267de89eec4d2e671 Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Sat, 31 Oct 2026 23:25:02 -0500 Subject: [PATCH 4/4] docs(gpu): #1050 the skip check also expects the device-memory test, which a software adapter can't run out of #3419 added a test that holds device memory until an allocation fails; on lavapipe the allocations never fail, so it skips with the opt-in too. Co-Authored-By: Claude Opus 5.5 --- docs/gpu-pipeline.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index cd7fd03e3..a35218c46 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -282,7 +282,7 @@ machine without a hardware GPU (a VM, a container) every GPU test skips. ```sh log="$(mktemp -d)/gpu-skips.jsonl" # a new private directory; an absolute path, as tests run in crates/gpu LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1 LIGHTCRAFT_SKIP_LOG="$log" cargo test -p lightcraft-gpu --tests -grep '"need":"gpu"' "$log" | grep -v 'lost on purpose' # no output: no GPU test skipped but the device-loss one +grep '"need":"gpu"' "$log" | grep -v -e 'lost on purpose' -e 'memory never ran out' # no output: only the two below skipped ``` - **Linux:** Mesa's lavapipe (Vulkan; `mesa-vulkan-drivers` on Debian and Ubuntu). Its storage @@ -294,8 +294,9 @@ grep '"need":"gpu"' "$log" | grep -v 'lost on purpose' # no output: no GPU tes A machine with a hardware GPU picks it over the software adapter, variable or not. Each test that skips prints `skipped (gpu): …` and appends a line to the `LIGHTCRAFT_SKIP_LOG` file; with a software -adapter the only one left should be the device-loss test (`no GPU device, or one that can't be lost on -purpose here`). Measured 2026-10-11 on lavapipe and WARP, and again 2026-10-30 on lavapipe (Mesa's +adapter the only ones left should be the device-loss test (`no GPU device, or one that can't be lost on +purpose here`) and the device-memory test (`no GPU device, or its memory never ran out`: on lavapipe its +allocations never fail, seen 2026-10-31). Measured 2026-10-11 on lavapipe and WARP, and again 2026-10-30 on lavapipe (Mesa's llvmpipe on LLVM 20, 143 compared renders), every compared render stayed within **max 1 LSB, mean ≤ 0.0003 LSB**, as on the M4 Pro. The equivalence binary takes about 20 s on lavapipe and several minutes on WARP.