From 2218ca5a4c571eb3a76634ced202030d7bcfb312 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 5 Oct 2026 03:58:57 -0400 Subject: [PATCH] Issue #78: GPU export: respect device limits, fall back to CPU on GPU errors A GPU render no longer hands back an image it cannot vouch for: - set the wgpu device-lost callback (wgpu never reports a lost device as an error, so a driver reset went unnoticed); readback waits with a timeout and checks the poll result and map callback instead of blocking forever - each render runs in OutOfMemory/Validation/Internal error scopes; errors fail that render (OOM and limit failures for that render only, device errors for the process) and it is redone on the CPU - every buffer is checked against the device limit when created (no validation error, no process-wide disable); LIGHTCRAFT_GPU_LIMITS=webgpu| creates the device with lower limits to reproduce small adapters - the output buffer is cleared and the per-pixel kernel writes alpha 255: any unwritten pixel (work silently dropped, the issue's all-black export) fails the render; an all-black result is re-rendered on the CPU - renders submit per stage and every 16 M invocations, the per-pixel stage runs in ~4 MP bands: no long submission for a slow iGPU's watchdog to reset - ui.inspect perf.gpuReason / perf.gpuFallback, app.gpu reason/lastFallback, System Info and Settings say why the GPU is off (e.g. llvmpipe skipped) and why the last render fell back Tests: crates/gpu/tests/fallback.rs (limit, mid-render limit, validation error, dropped work, black photo), engine export falls back to the CPU image, banded 3000x2000 GPU/CPU equivalence. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/query.rs | 10 +- crates/engine/src/tests_export.rs | 33 ++++ crates/gpu/src/ctx.rs | 246 +++++++++++++++++++++++--- crates/gpu/src/lib.rs | 156 ++++++++++++++-- crates/gpu/src/params.rs | 4 +- crates/gpu/src/render.rs | 122 ++++++++++--- crates/gpu/src/wgsl/finish.wgsl | 6 +- crates/gpu/tests/equivalence.rs | 26 +++ crates/gpu/tests/fallback.rs | 103 +++++++++++ crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/menus.rs | 11 +- crates/ui-egui/src/panels/settings.rs | 5 +- docs/control-protocol.md | 2 +- docs/gpu-pipeline.md | 66 ++++++- docs/parity.md | 2 +- docs/whats-new.md | 8 + 16 files changed, 723 insertions(+), 79 deletions(-) create mode 100644 crates/gpu/tests/fallback.rs diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 0f5ada82..09fbbbdb 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -186,11 +186,17 @@ pub fn specs() -> Vec { "versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::>(), })) }), - cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process)", always, |_, p| { + cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process); returns {enabled, available, adapter, reason (why the GPU is off), lastFallback (latest render redone on the CPU, and why)}", always, |_, p| { if let Some(on) = p.get("enabled").and_then(Value::as_bool) { lightcraft_gpu::set_enabled(on); } - Ok(json!({"enabled": lightcraft_gpu::enabled(), "available": lightcraft_gpu::available(), "adapter": lightcraft_gpu::adapter_name()})) + Ok(json!({ + "enabled": lightcraft_gpu::enabled(), + "available": lightcraft_gpu::available(), + "adapter": lightcraft_gpu::adapter_name(), + "reason": lightcraft_gpu::unavailable_reason(), + "lastFallback": lightcraft_gpu::last_fallback(), + })) }), cmd!(query "library.memory", "Memory Usage", [], None, "{} — bytes held by each cache (decoded sources, rendered previews, GPU buffers; heap when instrumented)", always, |s, _| { Ok(serde_json::to_value(s.memory_report()).unwrap_or_default()) diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 33100575..13c28213 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -174,3 +174,36 @@ fn prepared_exports_run_on_another_thread() { assert_eq!(files[0]["width"], 48); assert_eq!(seen.lock().unwrap().len(), 2); } + +/// Issue #78: a GPU render whose work never ran (a driver that drops a submission without an +/// error) gave an all-black export. The export must come from the CPU instead — the same +/// image — with the reason recorded. +#[test] +fn export_falls_back_to_the_cpu_when_gpu_work_is_lost() { + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.4})).unwrap(); + s.execute("develop.set", &json!({"control": "effects.clarity", "value": 20})).unwrap(); + let o = ExportOptions::from_json(&json!({"format": "png"})); + let mean = |b: &[u8]| { + let d = lightcraft_codecs::decode(b, Default::default()).unwrap().image; + d.data.iter().map(|p| (p[0] + p[1] + p[2]) as f64).sum::() / (3 * d.data.len()) as f64 + }; + let gpu = lightcraft_gpu::available(); + let healthy = mean(&export_photo(&mut s, id, &o, 1).unwrap().bytes); + lightcraft_gpu::inject_fault(lightcraft_gpu::Fault::DropWork); + let faulted = export_photo(&mut s, id, &o, 1).unwrap().bytes; + if gpu { + let why = lightcraft_gpu::last_fallback().unwrap_or_default(); + assert!(why.contains("incomplete"), "{why}"); + // the GPU is off for the process now: this export renders on the CPU + assert!(!lightcraft_gpu::available()); + } + let cpu = export_photo(&mut s, id, &o, 1).unwrap().bytes; + lightcraft_gpu::reset_failures(); + let px = |b: &[u8]| lightcraft_codecs::decode(b, Default::default()).unwrap().image.data; + assert!(px(&faulted) == px(&cpu), "the fallback is the CPU render"); + let m = mean(&faulted); + assert!(m > 0.02, "not black: mean {m}"); + assert!((m - healthy).abs() < 0.01, "GPU {healthy} vs CPU {m}"); +} diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 1424d6a7..0659bddf 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -275,37 +275,47 @@ fn module_source(m: &Module, consts: &str) -> String { } impl Gpu { - /// Create a device on the best available adapter (no software fallback), or `None`. - pub fn new() -> Option { + /// Create a device on the best available adapter (no software fallback), or why there is none. + pub fn new() -> Result { let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); desc.backends = wgpu::Backends::PRIMARY; let instance = wgpu::Instance::new(desc); let adapter = pollster::block_on( instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }), ) - .ok()?; + .map_err(|e| format!("no GPU adapter ({e})"))?; let info = adapter.get_info(); if info.device_type == wgpu::DeviceType::Cpu { log::info!("gpu: only a software adapter ({}), using the CPU pipeline", info.name); - return None; + return Err(format!("software adapter ({}) skipped: the CPU pipeline is faster", info.name)); } - let limits = adapter.limits(); + 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); - return None; + return Err(format!("{} has too few storage buffers per shader stage ({} < 10)", info.name, limits.max_storage_buffers_per_shader_stage)); + } + if let Some((binding, buffer)) = limits_override() { + limits.max_storage_buffer_binding_size = limits.max_storage_buffer_binding_size.min(binding); + limits.max_buffer_size = limits.max_buffer_size.min(buffer); + log::info!("gpu: LIGHTCRAFT_GPU_LIMITS: storage bindings ≤ {} MiB, buffers ≤ {} MiB", binding >> 20, buffer >> 20); } let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("lightcraft"), required_limits: limits.clone(), ..Default::default() })) - .ok()?; - // Errors outside an error scope (e.g. out of memory during a render): log, and stop using - // the GPU (the render in flight is redone on the CPU). + .map_err(|e| format!("{}: device creation failed ({e})", info.name))?; + // Errors outside a render's error scopes (see `ErrorScopes`): log, and stop using the GPU. device.on_uncaptured_error(std::sync::Arc::new(|e| { log::error!("gpu: {e}"); - crate::mark_broken(); + crate::mark_broken(&format!("device error: {e}")); })); + // wgpu reports a lost device (driver reset, GPU hang, watchdog) only here — never as an + // error — so without this callback a lost device went unnoticed. + device.set_device_lost_callback(|reason, msg| { + log::error!("gpu: device lost ({reason:?}): {msg}"); + crate::mark_broken(&format!("device lost ({reason:?}): {msg}")); + }); let scope = device.push_error_scope(wgpu::ErrorFilter::Validation); let consts = constants(); let mut kernels = HashMap::new(); @@ -334,7 +344,7 @@ impl Gpu { } if let Some(e) = pollster::block_on(scope.pop()) { log::error!("gpu: kernels failed to build on {}: {e}", info.name); - return None; + return Err(format!("{}: kernels failed to build ({e})", info.name)); } let dummy = device.create_buffer(&wgpu::BufferDescriptor { label: Some("dummy"), @@ -343,13 +353,20 @@ impl Gpu { mapped_at_creation: false, }); let max_buffer = limits.max_storage_buffer_binding_size.min(limits.max_buffer_size); - log::info!("gpu: {} ({:?})", info.name, info.backend); - Some(Gpu { device, queue, info, kernels, dummy, free: Default::default(), max_buffer }) + log::info!("gpu: {} ({:?}), buffers ≤ {} MiB", info.name, info.backend, max_buffer >> 20); + Ok(Gpu { device, queue, info, kernels, dummy, free: Default::default(), max_buffer }) } /// A buffer of `len` 32-bit values with undefined contents (a recycled one when possible). + /// + /// A buffer over the device limit fails the render ([`FailKind::Limit`]) instead of raising a + /// validation error: the caller gets a placeholder and the render is redone on the CPU. pub fn buffer(&self, len: usize) -> Buf { let size = (len.max(1) * 4) as u64; + if size > self.limit() { + self.over_limit(size); + return Buf { buf: Some(Tracked::new(self.placeholder())), len }; + } let recycled = { let mut f = self.free.lock().unwrap_or_else(|e| e.into_inner()); // the smallest pooled buffer that holds `size` with at most 25 % to spare: photos of @@ -426,6 +443,10 @@ impl Gpu { if bytes.is_empty() { return self.buffer(1); } + if bytes.len() as u64 > self.limit() { + self.over_limit(bytes.len() as u64); + return Buf { buf: Some(Tracked::new(self.placeholder())), len }; + } let buf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: bytes, @@ -434,9 +455,32 @@ impl Gpu { Buf { buf: Some(Tracked::new(buf)), len } } + fn over_limit(&self, size: u64) { + fail( + FailKind::Limit, + format!("a {} MiB buffer exceeds the device's {} MiB storage-buffer limit", size.div_ceil(1 << 20), self.limit() >> 20), + ); + } + + /// Stands in for a buffer that could not be created (the render has failed already). + fn placeholder(&self) -> wgpu::Buffer { + self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("placeholder"), + size: 16, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) + } + /// Whether a buffer of `len` 32-bit values fits the device limits. pub fn fits(&self, len: usize) -> bool { - (len as u64) * 4 <= self.max_buffer + (len as u64) * 4 <= self.limit() + } + + /// Largest buffer a render may use (bytes): the device limit, or a lower one set for the + /// render on this thread ([`LimitOverride`]). + pub fn limit(&self) -> u64 { + LIMIT.try_with(|l| l.get()).ok().flatten().map_or(self.max_buffer, |l| l.min(self.max_buffer)) } pub fn encoder(&self) -> wgpu::CommandEncoder { @@ -445,10 +489,12 @@ impl Gpu { /// Record kernel `name` with parameters `p` over buffers `bufs` (None = unused binding). pub fn run(&self, enc: &mut wgpu::CommandEncoder, name: &str, p: &[u32], bufs: &[Option<&Buf>], groups: [u32; 3]) { + if failed() { + return; // the render is redone on the CPU: don't spend the GPU on it + } let Some(k) = self.kernels.get(name) else { // a kernel name typo: fail this render over to the CPU instead of crashing - log::error!("gpu: unknown kernel {name}"); - crate::mark_broken(); + fail(FailKind::Fatal, format!("unknown kernel {name}")); return; }; assert_eq!(bufs.len(), k.nbuf, "{name}: binding count"); @@ -470,8 +516,16 @@ impl Gpu { } /// Submit `enc`, then read back the first `len` 32-bit values of `src`. + /// + /// A failed readback (device lost, map error, timeout) fails the render ([`FailKind::Fatal`]) + /// and returns zeros, which the caller discards; after an earlier failure in the same render + /// nothing more is submitted. pub fn finish_and_read(&self, mut enc: wgpu::CommandEncoder, src: &Buf, len: usize) -> Vec { assert!(len <= src.len, "readback of {len} values from a buffer of {}", src.len); + let zeros = || vec![::zeroed(); len]; + if failed() { + return zeros(); + } let size = (len * 4) as u64; let staging = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("readback"), @@ -486,17 +540,20 @@ impl Gpu { slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); - let _ = self.device.poll(wgpu::PollType::wait_indefinitely()); - // A failed readback (device lost, out of memory) marks the GPU broken: the caller - // (`crate::render`) then discards this render's zeroed result and renders on the CPU. let failed = |why: String| { - log::error!("gpu readback failed: {why}"); - crate::mark_broken(); - vec![::zeroed(); len] + fail(FailKind::Fatal, format!("readback failed: {why}")); + zeros() }; - match rx.recv() { + // A lost device makes the wait fail and the map callback never run: checking both keeps + // this from blocking forever (or handing back an unwritten buffer as the image). + if let Err(e) = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(READBACK_TIMEOUT) }) { + return failed(format!("{e}")); + } + // another thread's poll may be the one that runs the callback: allow it a moment + match rx.recv_timeout(std::time::Duration::from_secs(10)) { Ok(Ok(())) => {} - other => return failed(format!("{other:?}")), + Ok(Err(e)) => return failed(format!("{e}")), + Err(e) => return failed(format!("map callback: {e}")), } let data = match slice.get_mapped_range() { Ok(d) => d, @@ -509,6 +566,127 @@ impl Gpu { } } +/// Longest wait for a render's GPU work: beyond it the render fails over to the CPU (drivers +/// normally reset a hung GPU much sooner). +const READBACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + +/// `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). +fn limits_override() -> Option<(u64, u64)> { + parse_limits(&std::env::var("LIGHTCRAFT_GPU_LIMITS").ok()?) +} + +fn parse_limits(v: &str) -> Option<(u64, u64)> { + let v = v.trim().to_ascii_lowercase(); + match v.as_str() { + "" => None, + "webgpu" | "downlevel" | "default" => { + let d = wgpu::Limits::default(); + Some((d.max_storage_buffer_binding_size, d.max_buffer_size)) + } + n => { + let mb: u64 = n.trim_end_matches("mib").trim_end_matches("mb").trim().parse().ok()?; + let b = mb.max(1) << 20; + Some((b, 2 * b)) + } + } +} + +/// How a GPU render failed (it is then redone on the CPU). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FailKind { + /// A buffer over the device limits: this render only. + Limit, + /// The device ran out of memory: this render only (the buffer pool is emptied). + OutOfMemory, + /// The image came back entirely black: redone on the CPU in case the GPU dropped work. + Blank, + /// Device error or loss, readback failure, incomplete image: the GPU is not used again. + Fatal, +} + +#[derive(Clone, Debug)] +pub(crate) struct Failure { + pub kind: FailKind, + pub reason: String, +} + +thread_local! { + /// A lower buffer limit for the render on this thread (fault injection in tests). + static LIMIT: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// Lowers [`Gpu::limit`] on this thread while it lives. +pub(crate) struct LimitOverride; + +impl LimitOverride { + pub fn new(bytes: u64) -> LimitOverride { + LIMIT.with(|l| l.set(Some(bytes))); + LimitOverride + } +} + +impl Drop for LimitOverride { + fn drop(&mut self) { + let _ = LIMIT.try_with(|l| l.set(None)); + } +} + +thread_local! { + /// The first failure of the render running on this thread. + static FAILURE: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// Record a failure of the render running on this thread (the first one is kept). +pub(crate) fn fail(kind: FailKind, reason: String) { + let _ = FAILURE.try_with(|f| { + let mut f = f.borrow_mut(); + if f.is_none() { + *f = Some(Failure { kind, reason }); + } + }); +} + +/// Has the render on this thread failed? +pub(crate) fn failed() -> bool { + FAILURE.try_with(|f| f.borrow().is_some()).unwrap_or(false) +} + +/// The render's failure, clearing it. +pub(crate) fn take_failure() -> Option { + FAILURE.try_with(|f| f.borrow_mut().take()).ok().flatten() +} + +/// wgpu error scopes around a render: out-of-memory, validation and internal errors raised by +/// this thread's calls are captured (instead of reaching the uncaptured-error handler) and fail +/// the render. +pub(crate) struct ErrorScopes([wgpu::ErrorScopeGuard; 3]); + +impl ErrorScopes { + pub fn push(g: &Gpu) -> ErrorScopes { + ErrorScopes([ + g.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory), + g.device.push_error_scope(wgpu::ErrorFilter::Validation), + g.device.push_error_scope(wgpu::ErrorFilter::Internal), + ]) + } + + /// Pop the scopes: the most telling error captured (out of memory first — an allocation + /// failure makes later commands invalid too — then internal, then validation). + pub fn pop(self) -> Option { + let [oom, validation, internal] = self.0; + // scopes pop in reverse order of their creation + let internal = pollster::block_on(internal.pop()); + let validation = pollster::block_on(validation.pop()); + let oom = pollster::block_on(oom.pop()); + if let Some(e) = oom { + return Some(Failure { kind: FailKind::OutOfMemory, reason: format!("out of device memory: {e}") }); + } + internal.or(validation).map(|e| Failure { kind: FailKind::Fatal, reason: format!("device error: {e}") }) + } +} + fn storage_entry(binding: u32, read_only: bool) -> wgpu::BindGroupLayoutEntry { wgpu::BindGroupLayoutEntry { binding, @@ -529,3 +707,23 @@ pub fn groups1(n: usize) -> [u32; 3] { let x = g.min(32768); [x as u32, g.div_ceil(x) as u32, 1] } + +#[cfg(test)] +mod tests { + #[test] + fn limits_override_parses() { + assert_eq!(super::parse_limits("webgpu"), Some((128 << 20, 256 << 20))); + assert_eq!(super::parse_limits(" Downlevel "), Some((128 << 20, 256 << 20))); + assert_eq!(super::parse_limits("64"), Some((64 << 20, 128 << 20))); + assert_eq!(super::parse_limits("512MiB"), Some((512 << 20, 1024 << 20))); + assert_eq!(super::parse_limits(""), None); + assert_eq!(super::parse_limits("lots"), None); + } + + #[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 + assert!(super::groups1(100_000_000 * 3).iter().all(|g| *g <= 65535)); + assert!(super::groups2(16384, 16384, [16, 16]).iter().all(|g| *g <= 65535)); + } +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index fb08a913..449b1e5a 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -7,14 +7,16 @@ //! CPU inside the same render (per-stage hybrid); see `docs/gpu-pipeline.md`. //! //! Use [`render`]: it returns `None` when the GPU is unavailable, disabled (`LIGHTCRAFT_GPU=0` or -//! [`set_enabled`]), or the render does not fit the device — callers then render on the CPU. +//! [`set_enabled`]), the render does not fit the device, or the device reported an error, ran out +//! of memory or returned an incomplete image — callers then render on the CPU. [`unavailable_reason`] +//! and [`last_fallback`] say why (`ui.inspect` → `perf.gpuReason` / `perf.gpuFallback`). //! The browser build has no GPU path yet (WebGPU device creation is asynchronous): everything here //! compiles to the CPU fallback on wasm32. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use lightcraft_develop::DevelopSettings; use lightcraft_pipeline::{RenderRequest, Rendered, SourceInfo, StageCache}; @@ -34,12 +36,95 @@ static ENABLED: AtomicBool = AtomicBool::new(true); /// Set when a GPU render failed: the process stays on the CPU from then on. static BROKEN: AtomicBool = AtomicBool::new(false); +/// Why the GPU stopped being used (the first fatal failure). +static BROKEN_REASON: Mutex> = Mutex::new(None); +/// The latest render that fell back to the CPU, and why. +static LAST_FALLBACK: Mutex> = Mutex::new(None); + /// Stop using the GPU for the rest of the process (after a device error). #[cfg_attr(target_arch = "wasm32", allow(dead_code))] -fn mark_broken() { +fn mark_broken(reason: &str) { + let mut r = BROKEN_REASON.lock().unwrap_or_else(|e| e.into_inner()); + if r.is_none() { + *r = Some(reason.to_string()); + } BROKEN.store(true, Ordering::Relaxed); } +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +fn record_fallback(reason: String) { + *LAST_FALLBACK.lock().unwrap_or_else(|e| e.into_inner()) = Some(reason); +} + +/// Why renders do not use the GPU, or `None` when they do (or will, once the device is created). +/// E.g. "disabled by LIGHTCRAFT_GPU=0", "software adapter (llvmpipe …) skipped", "device lost …". +/// Never blocks. +pub fn unavailable_reason() -> Option { + if env_disabled() { + return Some("disabled by LIGHTCRAFT_GPU=0".into()); + } + if !ENABLED.load(Ordering::Relaxed) { + return Some("disabled by the GPU rendering preference (app.gpu)".into()); + } + if BROKEN.load(Ordering::Relaxed) { + let r = BROKEN_REASON.lock().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_else(|| "device error".into()); + return Some(format!("stopped after a GPU failure: {r}")); + } + #[cfg(not(target_arch = "wasm32"))] + { + match GPU.get() { + Some(Err(e)) => Some(e.clone()), + _ => None, + } + } + #[cfg(target_arch = "wasm32")] + { + Some("the browser build has no GPU path yet".into()) + } +} + +/// The latest render that fell back from the GPU to the CPU and why (device limit, out of memory, +/// device error, incomplete or blank result), if any. +pub fn last_fallback() -> Option { + LAST_FALLBACK.lock().unwrap_or_else(|e| e.into_inner()).clone() +} + +/// A simulated GPU failure for the next [`render`] on this thread (tests of the CPU fallback). +#[doc(hidden)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Fault { + /// A command the device rejects (validation error). + Validation, + /// The per-pixel stage's work never runs (what a driver that silently drops or resets a + /// submission looks like: the readback holds no image). + DropWork, + /// A storage-buffer limit of this many bytes for this render. + Limit(u64), +} + +thread_local! { + static FAULT: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// Make the next [`render`] on this thread fail with `f` (tests). +#[doc(hidden)] +pub fn inject_fault(f: Fault) { + FAULT.with(|c| c.set(Some(f))); +} + +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +fn take_fault() -> Option { + FAULT.with(|c| c.take()) +} + +/// Use the GPU again after a failure (tests that inject faults). +#[doc(hidden)] +pub fn reset_failures() { + BROKEN.store(false, Ordering::Relaxed); + *BROKEN_REASON.lock().unwrap_or_else(|e| e.into_inner()) = None; + *LAST_FALLBACK.lock().unwrap_or_else(|e| e.into_inner()) = None; +} + /// Allow or forbid GPU rendering at runtime (a preference). `LIGHTCRAFT_GPU=0` forbids it for the /// whole process regardless. pub fn set_enabled(on: bool) { @@ -57,20 +142,20 @@ fn env_disabled() -> bool { } #[cfg(not(target_arch = "wasm32"))] -static GPU: std::sync::OnceLock> = std::sync::OnceLock::new(); +static GPU: std::sync::OnceLock> = std::sync::OnceLock::new(); #[cfg(not(target_arch = "wasm32"))] pub(crate) fn device() -> Option<&'static ctx::Gpu> { if env_disabled() { return None; } - GPU.get_or_init(|| std::panic::catch_unwind(ctx::Gpu::new).ok().flatten()).as_ref() + GPU.get_or_init(|| std::panic::catch_unwind(ctx::Gpu::new).unwrap_or_else(|_| Err("device creation panicked".into()))).as_ref().ok() } /// The device if it has been created (never creates it). #[cfg(not(target_arch = "wasm32"))] pub(crate) fn existing_device() -> Option<&'static ctx::Gpu> { - GPU.get().and_then(|g| g.as_ref()) + GPU.get().and_then(|g| g.as_ref().ok()) } /// Create the device and compile the kernels on a background thread now (app start), so the @@ -160,7 +245,7 @@ pub fn set_pool_limit(bytes: u64) { /// Free recycled buffers until at most `keep` bytes stay pooled (e.g. when the app goes idle). pub fn trim_pool(keep: u64) { #[cfg(not(target_arch = "wasm32"))] - if let Some(g) = GPU.get().and_then(|g| g.as_ref()) { + if let Some(g) = existing_device() { g.trim(keep); } #[cfg(target_arch = "wasm32")] @@ -191,19 +276,60 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R } let gpu = device()?; let ext = stages.map(|c| c.extension::()); - let r = { + let fault = take_fault(); + let _ = ctx::take_failure(); // nothing left over from an earlier render on this thread + let (r, scoped) = { let _scope = ctx::RenderScope::new(gpu); - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| render::render(gpu, src, info, s, req, ext.as_deref()))) + let errors = ctx::ErrorScopes::push(gpu); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| render::render(gpu, src, info, s, req, ext.as_deref(), fault))); + (r, errors.pop()) }; - match r { - // a device error during the render: its result is not trustworthy - Ok(_) if BROKEN.load(Ordering::Relaxed) => None, - Ok(r) => r, - Err(_) => { + // An error the device raised explains the readback failures that follow it; a buffer + // over the limit explains the errors its placeholder raises. + let failure = match (ctx::take_failure(), scoped) { + (Some(f), _) if f.kind == ctx::FailKind::Limit => Some(f), + (f, scoped) => scoped.or(f), + }; + match (r, failure) { + (Err(_), _) => { log::error!("gpu: render failed; using the CPU pipeline from now on"); - BROKEN.store(true, Ordering::Relaxed); + mark_broken("a GPU render panicked"); + record_fallback("a GPU render panicked; the GPU is not used again".into()); None } + (Ok(r), Some(f)) => { + // the view's cached device stages may hold results of the failed passes + if let Some(e) = &ext { + e.clear(); + } + let what = match &r { + Some(r) => format!("{}×{}", r.image.width, r.image.height), + None => "GPU render".into(), + }; + let reason = match f.kind { + ctx::FailKind::Fatal => { + mark_broken(&f.reason); + format!("{what}: {}; the GPU is not used again", f.reason) + } + ctx::FailKind::OutOfMemory => { + gpu.trim(0); + format!("{what}: {}", f.reason) + } + ctx::FailKind::Limit | ctx::FailKind::Blank => format!("{what}: {}", f.reason), + }; + log::warn!("gpu: rendering on the CPU instead: {reason}"); + if render::profiling() { + eprintln!(" gpu fallback to the CPU: {reason}"); + } + record_fallback(reason); + None + } + // a device error on another thread or a lost device during the render + (Ok(Some(_)), None) if BROKEN.load(Ordering::Relaxed) => { + record_fallback(format!("device failure during the render: {}", unavailable_reason().unwrap_or_default())); + None + } + (Ok(r), None) => r, } } #[cfg(target_arch = "wasm32")] diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index d592ca9b..6ed7b498 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -71,6 +71,8 @@ const FIELDS: &[(&str, usize)] = &[ ("OUT_Y", 3), ("OUT_TRC", 1), ("OUT_GAMMA", 1), + // first row of a band dispatch (the kernel runs over rows Y0.., see `render`) + ("Y0", 1), ]; /// `(name, index)` of every field (for the WGSL constants). @@ -87,7 +89,7 @@ pub fn finish_fields() -> Vec<(&'static str, usize)> { } /// Offset of field `name`; `None` (and a debug assertion) for a name not in [`FIELDS`]. -fn index(name: &str) -> Option { +pub fn index(name: &str) -> Option { let mut i = 0; for (n, len) in FIELDS { if *n == name { diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 1a80af13..0e18785c 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -12,7 +12,7 @@ use lightcraft_pipeline::{Plan, RenderRequest, Rendered, SourceInfo, local}; use lightcraft_raster::resample::Filter; use lightcraft_raster::{Histogram, Plane, Rgb32f, Rgba8}; -use crate::ctx::{Buf, Gpu, groups1, groups2}; +use crate::ctx::{Buf, FailKind, Gpu, fail, groups1, groups2}; use crate::params::{Present, finish_block}; /// Device-resident stages of one view, kept next to the CPU [`lightcraft_pipeline::StageCache`] @@ -128,27 +128,54 @@ impl GpuStages { pub(crate) struct Cx<'a> { pub gpu: &'a Gpu, enc: Option, + /// Kernel invocations recorded since the last submit. + pending: u64, } +/// Kernel invocations recorded before a render submits them (under one full-size pass over a +/// 24 MP export, several passes over a preview): no single submission runs long. +const FLUSH_INVOCATIONS: u64 = 16 << 20; + +/// Pixels per dispatch of the per-pixel stage (the heaviest kernel). +const BAND_PIXELS: usize = 4 << 20; + impl<'a> Cx<'a> { pub fn new(gpu: &'a Gpu) -> Cx<'a> { - Cx { gpu, enc: Some(gpu.encoder()) } + Cx { gpu, enc: Some(gpu.encoder()), pending: 0 } } /// Record kernel `name` (see [`Gpu::run`]). pub fn run(&mut self, name: &str, p: &[u32], bufs: &[Option<&Buf>], groups: [u32; 3]) { let enc = self.enc.get_or_insert_with(|| self.gpu.encoder()); self.gpu.run(enc, name, p, bufs, groups); + // every kernel runs 256 invocations per workgroup + self.pending += groups.iter().map(|g| *g as u64).product::() * 256; + if self.pending >= FLUSH_INVOCATIONS { + self.flush(); + } } /// Submit what is recorded and read back `len` values of `b`. pub fn read(&mut self, b: &Buf, len: usize) -> Vec { + self.pending = 0; let enc = self.enc.take().unwrap_or_else(|| self.gpu.encoder()); self.gpu.finish_and_read(enc, b, len) } + /// Submit what is recorded so far without waiting. Renders submit stage by stage and every + /// [`FLUSH_INVOCATIONS`]: long submissions are what GPU watchdogs reset (Windows TDR, the + /// i915 hang check; issue #78's iGPU spent seconds on an export recorded as one submission), + /// and buffers dropped by earlier work become reusable by later work (lower peak memory). + pub fn flush(&mut self) { + self.pending = 0; + if let Some(enc) = self.enc.take() { + self.gpu.submit([enc.finish()]); + } + } + /// Submit what is recorded and wait for it (profiling only: stage timings). pub fn sync(&mut self) { + self.pending = 0; if let Some(enc) = self.enc.take() { self.gpu.submit([enc.finish()]); } @@ -434,12 +461,13 @@ struct Host { log_l: Option>, } -fn profiling() -> bool { +pub(crate) fn profiling() -> bool { static ON: std::sync::OnceLock = std::sync::OnceLock::new(); *ON.get_or_init(|| std::env::var_os("LIGHTCRAFT_PROFILE").is_some()) } -/// Render on `gpu`, reusing `stages` (if given). `None` when the render does not fit the device. +/// Render on `gpu`, reusing `stages` (if given). `None` (or a result the caller discards) when the +/// render failed: the reason is recorded with [`crate::ctx::fail`]. pub fn render( gpu: &Gpu, src: &Arc, @@ -447,20 +475,31 @@ pub fn render( s: &DevelopSettings, req: &RenderRequest, stages: Option<&GpuStages>, + fault: Option, ) -> Option { let mut t = profiling().then(std::time::Instant::now); - // Under `LIGHTCRAFT_PROFILE` each stage is submitted and waited for, so the timings are real. - let lap = |what: &str, t: &mut Option, cx: &mut Cx<'_>| { - if let Some(t) = t { + // Each stage is submitted on its own; under `LIGHTCRAFT_PROFILE` also waited for, so the + // timings are real. + let lap = |what: &str, t: &mut Option, cx: &mut Cx<'_>| match t { + Some(t) => { cx.sync(); eprintln!(" gpu {what}: {:.1} ms", t.elapsed().as_secs_f64() * 1e3); *t = std::time::Instant::now(); } + None => cx.flush(), }; let plan = lightcraft_pipeline::plan(src, info, s, req); let (w, h) = (plan.w, plan.h); let n = w * h; + let _limit = match fault { + Some(crate::Fault::Limit(bytes)) => Some(crate::ctx::LimitOverride::new(bytes)), + _ => None, + }; if !gpu.fits(n * 3) { + fail( + FailKind::Limit, + format!("{w}×{h} needs {} MiB buffers, over the device's {} MiB storage-buffer limit", (n * 12).div_ceil(1 << 20), gpu.limit() >> 20), + ); return None; } let s = &*plan.settings; @@ -521,23 +560,35 @@ pub fn render( }; let (p, aux) = finish_block(&fp, &terms, &present); let aux = gpu.upload(&aux); - let out = gpu.buffer(n); - cx.run( - "main", - &p, - &[ - Some(&lin), - Some(&prep.log_l), - Some(&prep.base), - prep.clarity.as_deref(), - prep.texture.as_deref(), - prep.dark.as_deref(), - masks.as_ref(), - Some(&aux), - Some(&out), - ], - groups2(w, h, [16, 16]), - ); + // cleared, so that work which never ran cannot pass for an image (see the check below) + let out = cx.zeroed(n); + if fault == Some(crate::Fault::Validation) { + // a dispatch the device rejects (too many workgroups) + cx.run("box_h", &[0; 5], &[Some(&aux), Some(&out)], [gpu.device.limits().max_compute_workgroups_per_dimension + 1, 1, 1]); + } + // in bands of rows: each dispatch (and submission) stays short on a slow GPU + let rows = (BAND_PIXELS / w.max(1) / 16 * 16).max(16); // whole workgroups: bands never overlap + let y0_at = crate::params::index("Y0").unwrap_or(0); + let mut p = p; + for y0 in (0..h).step_by(rows).filter(|_| fault != Some(crate::Fault::DropWork)) { + p[y0_at] = y0 as u32; + cx.run( + "main", + &p, + &[ + Some(&lin), + Some(&prep.log_l), + Some(&prep.base), + prep.clarity.as_deref(), + prep.texture.as_deref(), + prep.dark.as_deref(), + masks.as_ref(), + Some(&aux), + Some(&out), + ], + groups2(w, rows.min(h - y0), [16, 16]), + ); + } // the alpha a mask overlay shows: copied out before the readback below submits let overlay_mask = req.overlay.mask(s).map(|m| { let list = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()); @@ -549,8 +600,25 @@ pub fn render( let data: Vec<[u8; 4]> = cx.read(&out, n); let image = Rgba8 { width: w, height: h, data }; lap("finish + readback", &mut t, &mut cx); + if crate::ctx::failed() { + return None; + } + // The per-pixel kernel writes alpha 255 everywhere and `out` was cleared: a pixel with any + // other alpha means work silently did not run (a driver that reset or dropped a submission + // without reporting it — issue #78's all-black exports). + let unwritten = unwritten(&image.data); + if unwritten > 0 { + fail(FailKind::Fatal, format!("the GPU returned an incomplete image ({unwritten} of {n} pixels unwritten)")); + return None; + } let histogram = Histogram::of_srgb8(&image); lap("histogram", &mut t, &mut cx); + // An entirely black result (upstream work that did not run would give that too): redo it on + // the CPU, which costs time only for the rare photo that really is black. + if n >= 4096 && [&histogram.r, &histogram.g, &histogram.b].iter().all(|c| c.first() == Some(&histogram.total)) { + fail(FailKind::Blank, "the GPU result is entirely black; rendering it on the CPU to be sure".into()); + return None; + } let mut image = image; let overlay_mask = overlay_mask.map(|r| match r { Ok(b) => cx.read_plane(&b, w, h), @@ -565,6 +633,11 @@ pub fn render( Some(Rendered { image, histogram, deep: None }) } +/// Pixels whose alpha is not 255 (cheap: no early exit, so it vectorizes). +fn unwritten(data: &[[u8; 4]]) -> usize { + data.chunks(4096).filter(|c| c.iter().fold(255u8, |a, p| a & p[3]) != 255).map(|c| c.iter().filter(|p| p[3] != 255).count()).sum() +} + /// The white-balanced, retouched, denoised image. fn linear(cx: &mut Cx<'_>, sampled: &Buf, info: &SourceInfo, plan: &Plan<'_>, host: &mut Host) -> Buf { let (w, h) = (plan.w, plan.h); @@ -679,6 +752,7 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla let sub = cx.gpu.buffer(m); map(cx, "subsample", m, &[local::AIRLIGHT_STEP as u32], [Some(&d), None, None], &sub); let air = local::airlight_of(cx.read(&sub, m)); + cx.flush(); let b = Arc::new(d); planes.dark = Some((sg.to_bits(), b.clone(), air)); (Some(b), air) diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index 30c2d25c..14473d29 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -243,11 +243,11 @@ fn out_encode(v: f32) -> f32 { fn main(@builtin(global_invocation_id) gid: vec3) { let w = pu(F_W); let h = pu(F_H); - if (gid.x >= w || gid.y >= h) { + let x = gid.x; + let y = gid.y + pu(F_Y0); + if (x >= w || y >= h) { return; } - let x = gid.x; - let y = gid.y; let i = y * w + x; let n = w * h; let gain = pf(F_GAIN); diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index cda8774f..a83c7beb 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -65,6 +65,32 @@ fn typical(s: &mut DevelopSettings) { s.detail.sharpen_amount = 40.0; } +/// A render big enough for the per-pixel stage to run in several bands of rows (and the render +/// in many submissions), with grain (position-dependent) and masks. +#[test] +fn banded_full_size_render_matches() { + if !gpu() { + return; + } + let (w, h) = (3000, 2000); + let src = scene(3, w, h); + let mut s = DevelopSettings::default(); + typical(&mut s); + s.grain.amount = 30.0; + s.vignette.amount = -30.0; + s.masks = vec![Mask { + components: vec![MaskComponent { + name: None, + op: MaskOp::Add, + invert: false, + shape: MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.9) }, + }], + adjust: lightcraft_develop::LocalAdjustments { exposure: -0.6, ..Default::default() }, + ..Default::default() + }]; + check("banded 3000×2000", &src, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, h)); +} + fn cases() -> Vec<(&'static str, Edit)> { vec![ ("default", |_| {}), diff --git a/crates/gpu/tests/fallback.rs b/crates/gpu/tests/fallback.rs new file mode 100644 index 00000000..1f7ceb2f --- /dev/null +++ b/crates/gpu/tests/fallback.rs @@ -0,0 +1,103 @@ +//! Issue #78: a GPU render that cannot be trusted — over the device's buffer limits, a device +//! error, work that silently did not run — must come back as `None` (the caller renders on the +//! CPU) with the reason recorded, never as an image of zeros. One test, sequential: the fatal +//! faults stop GPU use for the whole process until `reset_failures`. +//! Skips (passes with a note) when no GPU adapter exists. + +use std::sync::Arc; + +use lightcraft_develop::{DevelopSettings, Mask, MaskComponent, MaskOp, MaskShape}; +use lightcraft_geom::Point; +use lightcraft_gpu::Fault; +use lightcraft_pipeline::{RenderRequest, SourceInfo, render}; +use lightcraft_raster::Rgba8; + +fn mean_abs_diff(a: &Rgba8, b: &Rgba8) -> f64 { + let sum: u64 = a.data.iter().zip(&b.data).map(|(p, q)| (0..3).map(|c| p[c].abs_diff(q[c]) as u64).sum::()).sum(); + sum as f64 / (a.data.len() * 3) as f64 +} + +fn masked(s: &mut DevelopSettings, k: usize) { + for i in 0..k { + let c = Point::new(0.2 + 0.2 * i as f64, 0.5); + s.masks.push(Mask { + id: i as u32 + 1, + components: vec![MaskComponent { + name: None, + op: MaskOp::Add, + invert: false, + shape: MaskShape::Radial { center: c, rx: 0.2, ry: 0.15, angle: 0.0, feather: 50.0, invert: false }, + }], + ..Default::default() + }); + s.masks[i].adjust.exposure = 0.5; + } +} + +#[test] +fn failed_gpu_renders_fall_back_with_a_reason() { + if !lightcraft_gpu::available() { + eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason()); + return; + } + assert_eq!(lightcraft_gpu::unavailable_reason(), None); + let (w, h) = (1200, 800); + let src = Arc::new(lightcraft_scenes::demo_library()[0].render(w, h)); + let info = SourceInfo { raw: true, ..Default::default() }; + let mut s = DevelopSettings::default(); + s.light.exposure = 0.3; + s.effects.clarity = 20.0; + masked(&mut s, 4); + let req = RenderRequest::fit(w, h); + let cpu = render(&src, &info, &s, &req).image; + let ok = |what: &str| { + let g = lightcraft_gpu::render(&src, &info, &s, &req, None) + .unwrap_or_else(|| panic!("{what}: gpu render ({:?})", lightcraft_gpu::last_fallback())); + let d = mean_abs_diff(&cpu, &g.image); + assert!(d < 0.5, "{what}: GPU differs from the CPU by {d:.3} LSB"); + }; + ok("healthy"); + + // the output itself is over the limit: refused up front, the GPU stays in use + lightcraft_gpu::inject_fault(Fault::Limit(1 << 20)); + assert!(lightcraft_gpu::render(&src, &info, &s, &req, None).is_none()); + let why = lightcraft_gpu::last_fallback().unwrap_or_default(); + assert!(why.contains("limit"), "{why}"); + assert!(lightcraft_gpu::available(), "a limit is no device failure"); + + // the output fits but the mask planes (4 masks: 4 planes in one buffer) don't: caught when the + // buffer is created, without a device error, and the GPU stays in use + lightcraft_gpu::inject_fault(Fault::Limit((w * h * 3 * 4 + 1024) as u64)); + assert!(lightcraft_gpu::render(&src, &info, &s, &req, None).is_none()); + let why = lightcraft_gpu::last_fallback().unwrap_or_default(); + assert!(why.contains("exceeds"), "{why}"); + assert!(lightcraft_gpu::available(), "{:?}", lightcraft_gpu::unavailable_reason()); + ok("after the limit faults"); + + // a device (validation) error: captured by the render's error scope; the GPU is not used again + lightcraft_gpu::inject_fault(Fault::Validation); + assert!(lightcraft_gpu::render(&src, &info, &s, &req, None).is_none()); + let why = lightcraft_gpu::last_fallback().unwrap_or_default(); + assert!(why.contains("device error"), "{why}"); + assert!(!lightcraft_gpu::available()); + assert!(lightcraft_gpu::unavailable_reason().is_some_and(|r| r.contains("device error")), "{:?}", lightcraft_gpu::unavailable_reason()); + lightcraft_gpu::reset_failures(); + ok("after a device error"); + + // the per-pixel work never runs (a driver dropping a submission without an error): before + // the fix this came back as a fully black image + lightcraft_gpu::inject_fault(Fault::DropWork); + assert!(lightcraft_gpu::render(&src, &info, &s, &req, None).is_none(), "an unwritten image must not be returned"); + let why = lightcraft_gpu::last_fallback().unwrap_or_default(); + assert!(why.contains("incomplete"), "{why}"); + assert!(!lightcraft_gpu::available()); + lightcraft_gpu::reset_failures(); + ok("after dropped work"); + + // a black photo is still rendered (on the CPU, to be sure) + let black = Arc::new(lightcraft_raster::Rgb32f::from_fn(256, 256, |_, _| [0.0; 3])); + let s0 = DevelopSettings::default(); + assert!(lightcraft_gpu::render(&black, &info, &s0, &RenderRequest::fit(256, 256), None).is_none()); + assert!(lightcraft_gpu::last_fallback().unwrap_or_default().contains("black")); + assert!(lightcraft_gpu::available()); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 02e14aa8..e71124de 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -78,7 +78,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "selection": app.session.selection.ids.iter().map(|p| p.0).collect::>(), "activeMask": app.session.active_mask, "widgetCount": app.widgets.len(), - "perf": {"frameMs": app.perf.frame_ms, "logicMs": app.perf.logic_ms, "updateMs": app.perf.update_ms, "maxUpdateMs": app.perf.max_update_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "pendingSlots": app.renderer.pending_slots(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten()}, + "perf": {"frameMs": app.perf.frame_ms, "logicMs": app.perf.logic_ms, "updateMs": app.perf.update_ms, "maxUpdateMs": app.perf.max_update_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "pendingSlots": app.renderer.pending_slots(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(), "gpuReason": lightcraft_engine::gpu::unavailable_reason(), "gpuFallback": lightcraft_engine::gpu::last_fallback()}, "loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})), "hoverPreview": app.hover_preview.as_ref().map(|h| h.label.clone()), "status": app.ui.status, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4cdb85e3..5f2ed2e0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -856,7 +856,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option format!("none (CPU rendering): {why}"), + None => "none (CPU rendering)".into(), + }), + ), ("GPU rendering".to_string(), if app.ui.settings.gpu { "on".into() } else { "off".into() }), ("Memory budget".to_string(), mb(lightcraft_engine::memory::default_budget() as u64)), ("Preview size".to_string(), format!("{} px", app.ui.settings.preview_edge)), @@ -872,6 +878,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option>()); if p.get("open").and_then(Value::as_bool).unwrap_or(true) { app.ui.dialog = Some(Dialog::SystemInfo { rows }); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 6c89cb1b..ee917017 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -342,7 +342,10 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { heading(ui, t, "Rendering"); check(ui, "settings.gpu", &mut app.ui.settings.gpu, "Use the GPU for rendering"); let status = if !gpu::available() { - "No usable GPU found: rendering on the CPU".to_string() + match gpu::unavailable_reason() { + Some(why) => format!("Rendering on the CPU: {why}"), + None => "No usable GPU found: rendering on the CPU".to_string(), + } } else { format!("GPU: {}", gpu::adapter_name().unwrap_or_else(|| "starting…".into())) }; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9623a757..cade7ecc 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -21,7 +21,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | | `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) | | `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus | -| `ui.inspect` | — | UI state, window, canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …), status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}` | +| `ui.inspect` | — | UI state, window, canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}` | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | | `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) | | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points | diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index b8db4585..e82ff01c 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -10,9 +10,9 @@ pure Rust (wgpu, naga); the drivers are the system's. No GL backend is compiled available; grid/filmstrip thumbnails (many small jobs in parallel) stay on the CPU. - Anything the GPU path cannot do returns `None` and the CPU renders instead: no adapter (CI machines, software-only adapters), `LIGHTCRAFT_GPU=0` (whole process), the `app.gpu {enabled}` - command (runtime preference; `ui.inspect` → `perf.gpu` shows the adapter), an output larger than - the device's storage-buffer limit, or a device error / panic during a render (the process then - stays on the CPU). + command (runtime preference; `ui.inspect` → `perf.gpu` shows the adapter), a buffer larger than + the device's storage-buffer limit, or a render the device did not complete correctly (see + [Limits, failures and the CPU fallback](#limits-failures-and-the-cpu-fallback)). - wasm32: the crate compiles to the CPU fallback (WebGPU needs asynchronous device creation and a device shared with the canvas — open item below). @@ -44,6 +44,59 @@ pure Rust (wgpu, naga); the drivers are the system's. No GL backend is compiled per pass cost as much as the passes. - `LIGHTCRAFT_PROFILE=1` prints GPU stage timings (each stage is then submitted and waited for). +## Limits, failures and the CPU fallback +Issue #78: on an Intel UHD (ICL GT1) iGPU with Mesa/Vulkan, exports from the desktop app came out +as valid JPEGs that were entirely black, while previews, `lightcraft-cli render` and +`LIGHTCRAFT_GPU=0` were fine — and the GPU export took ~15 s against ~3 s on the CPU. The readback +returned a buffer the per-pixel stage had never written, with no error. The likeliest cause: the +whole export was recorded as one command submission lasting seconds on that GPU, and the driver +reset it (GPU hang check / preemption timeout — the app's own window keeps the GPU busy, the CLI +doesn't) without the error reaching wgpu. wgpu also reports a lost device only through the +device-lost callback, which was not set. So a GPU render now refuses to hand back anything it +cannot vouch for: + +- **Short submissions.** A render submits after every stage and every 16 M kernel invocations + (`FLUSH_INVOCATIONS`); the per-pixel stage runs in bands of ~4 MP. Every submission on a slow + iGPU stays short enough to be preempted and never trips a watchdog (Windows TDR ~2 s, i915). + Buffers released by earlier stages are reused by later ones, lowering peak device memory. +- **Error scopes.** Each render runs inside out-of-memory, validation and internal error scopes + (thread-local in wgpu): errors fail that render instead of disappearing into the uncaptured + handler. The device-lost callback and the uncaptured handler (other threads) stop GPU use. +- **Readback.** The wait has a timeout (60 s); a failed wait, map error or missing map callback + fails the render (before, a lost device left the readback waiting forever). +- **Limits.** The device is created with the adapter's limits; every buffer is checked against + `min(max_storage_buffer_binding_size, max_buffer_size)` when it is created. A render with a + buffer over the limit (24 MP RGB f32 = 275 MiB; a 128 MiB WebGPU-default device; many masks) + falls back for that render, without a device error. Dispatches stay under 65535 workgroups per + dimension (`groups1` folds 1-D kernels onto two axes). Exports over the limit are not tiled: + most stages need neighbourhoods tens to hundreds of pixels wide (guided filters at clarity / + base radius, dehaze dark channel) and some need global values (airlight percentile), so tiles + would need wide overlaps and a two-pass plan — the CPU renders those exports instead. +- **Sanity checks on the result.** The output buffer is cleared before the per-pixel stage, which + writes alpha 255 everywhere: any pixel without it means work did not run, and the GPU is not used + again (`incomplete image`). An entirely black result (n ≥ 4096 pixels) is re-rendered on the CPU + for that render only — a really black photo costs one extra CPU render, nothing else; there are + no false positives because the CPU result is what is returned. + +| failure | this render | later renders | +|---|---|---| +| buffer over the device limit | CPU | GPU | +| out of device memory | CPU (buffer pool emptied) | GPU | +| entirely black result | CPU | GPU | +| validation / internal error, device lost, readback failure or timeout, incomplete image, panic | CPU | CPU (whole process) | + +**Diagnosing.** `ui.inspect` → `perf.gpu` (adapter in use), `perf.gpuReason` (why renders don't use +the GPU, e.g. `"disabled by LIGHTCRAFT_GPU=0"`, `"software adapter (llvmpipe (LLVM 21.1.8, 256 bits)) +skipped: …"`, `"stopped after a GPU failure: device lost …"`), `perf.gpuFallback` (the latest render +redone on the CPU and why, e.g. `"6000×4000: the GPU returned an incomplete image (24000000 of +24000000 pixels unwritten); the GPU is not used again"`). The same in `app.gpu` (`reason`, +`lastFallback`), Help ▸ System Info and Settings ▸ Performance. `LIGHTCRAFT_PROFILE=1` prints the +fallback with the stage timings; `RUST_LOG=warn` logs it. +**Reproducing a smaller GPU:** `LIGHTCRAFT_GPU_LIMITS=webgpu` (or `downlevel`) creates the device +with 128 MiB storage bindings / 256 MiB buffers, `LIGHTCRAFT_GPU_LIMITS=` with n MiB / 2n MiB. +Tests inject failures with `lightcraft_gpu::inject_fault` (`crates/gpu/tests/fallback.rs`, +`export_falls_back_to_the_cpu_when_gpu_work_is_lost`). + ## Correctness: CPU oracle and equivalence tests `crates/gpu/tests/equivalence.rs` renders the same settings on both and compares 8-bit sRGB: bounds **mean |Δ| < 0.5 LSB and max |Δ| ≤ 3 LSB** per channel. Measured (Apple M4 Pro, Metal): @@ -120,8 +173,11 @@ the device on their first GPU render. `lightcraft_gpu::ready()` asks without blo reading back); share the device with eframe. - WebGPU in the browser build (async init, single device per canvas). - Defringe and spot removal kernels; Sky/Subject heuristics (replaced by the segmenter in M12). -- Tiling for sources / outputs beyond the buffer limit (60 MP+ on smaller adapters): tile with - overlap equal to the largest filter radius. +- Tiling for sources / outputs beyond the buffer limit (24 MP+ on WebGPU-default limits, 60 MP+ + on most desktop adapters; these render on the CPU now): tile with overlap equal to the largest + filter radius, with global values (airlight) from a first pass. +- Integrated GPUs much slower than the CPU (issue #78's GT1): a measured per-device choice of the + faster path for exports. - Large-radius blurs at full size (dehaze dark channel at 24 MP) dominate the export: a summed-area table or a downsampled dark channel would cut them further. - GPU histogram (atomics) to skip the CPU pass over the readback. diff --git a/docs/parity.md b/docs/parity.md index df4140e3..0e63ca1f 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -480,7 +480,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | | | LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `crates/preview/src/lib.rs` (`JobPool`), `crates/ui-egui/src/export_task.rs`, `crates/ui-egui/src/import.rs` | renders off the UI thread; exports started from the UI run on a worker thread with a progress panel and Cancel (`ui.inspect` → `export`); imports show a progress window. No combined activity centre | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | -| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback | +| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`); short submissions for slow iGPUs; no tiling beyond the buffer limit (CPU) | | LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging) | | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | | LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests_panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1ebec435..a6327a73 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -9,6 +9,14 @@ with a counterpart here come along, the rest is listed. - 23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners. +### Reliability +- Exports are never black because of the GPU (issue #78): a GPU render that runs out of device + memory, exceeds the GPU's buffer limits, hits a driver error or reset, or comes back + incomplete is redone on the CPU — the file is the same image either way. Work is sent to the GPU + in short pieces so slow integrated GPUs aren't reset by their watchdog. `ui.inspect` → `perf` + (`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't + used (e.g. a skipped software adapter such as llvmpipe) and why the last render fell back. + ### Library - Smart albums with a rule editor: match all / any / none, nested groups, 26 fields. - Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.