diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs index 3ba58d370..8059b1d62 100644 --- a/apps/lightcraft-web/src/workers.rs +++ b/apps/lightcraft-web/src/workers.rs @@ -299,6 +299,7 @@ impl Workers { level: b.level, key: b.key, rendered: Err(msg), + gpu_fallback: None, loaded: None, quick: None, display: None, @@ -386,6 +387,7 @@ impl Workers { level: b.level, key: b.key, rendered, + gpu_fallback: None, loaded: None, quick: None, display: None, @@ -439,6 +441,7 @@ impl RenderOffload for Workers { level: job.level, key: job.key, rendered: Ok(Rendered { image, histogram, deep: None }), + gpu_fallback: None, loaded: None, quick: None, display: None, diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index ad1d7e560..975946d03 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1435,6 +1435,9 @@ pub struct Exported { /// Files written next to it, named like it with another extension: `(extension, bytes)` (the /// XMP sidecar of an `Original` export). pub sidecars: Vec<(&'static str, Vec)>, + /// Set when the render was tried on the GPU and fell back to the CPU: why (see + /// `docs/gpu-pipeline.md`). The file holds the CPU render either way. + pub gpu_fallback: Option, } /// Output size of photo `p` under `o` (its cropped full size when `o.resize` is `None`). @@ -1572,7 +1575,9 @@ impl PreparedExport { match self.work { Work::Render(w) => { let RenderWork { job, hdr_job, meta, opts } = *w; - let r = job.run().rendered?; + let result = job.run(); + let r = result.rendered?; + let gpu_fallback = result.gpu_fallback; let bytes = match hdr_job { Some(hj) => { let hr = hj.run().rendered?; @@ -1581,7 +1586,7 @@ impl PreparedExport { } None => encode_rendered(&r, &opts, meta.as_ref())?, }; - Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new() }) + Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new(), gpu_fallback }) } Work::File { path, read, packet, dng, label, size, remove_location } => { let bytes = match &read { @@ -1595,7 +1600,14 @@ impl PreparedExport { "{label}: can't remove the location from an original file that embeds GPS; export as JPEG, TIFF or DNG instead" )); } - return Ok(Exported { file_name, bytes, width: size.0, height: size.1, sidecars: vec![("xmp", packet.into_bytes())] }); + return Ok(Exported { + file_name, + bytes, + width: size.0, + height: size.1, + sidecars: vec![("xmp", packet.into_bytes())], + gpu_fallback: None, + }); }; if lightcraft_raw::probe(&bytes).is_none() { return Err(format!("{label}: DNG export needs a raw photo")); @@ -1609,7 +1621,7 @@ impl PreparedExport { }; let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), compression, ..Default::default() }) .map_err(|e| e.to_string())?; - Ok(Exported { file_name, bytes: dng, width: raw.width, height: raw.height, sidecars: Vec::new() }) + Ok(Exported { file_name, bytes: dng, width: raw.width, height: raw.height, sidecars: Vec::new(), gpu_fallback: None }) } } } @@ -1958,7 +1970,11 @@ impl<'a> Placer<'a> { match written { Ok((path, files, sidecars)) => { self.taken.extend(files); - self.out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars})); + let mut entry = json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars}); + if let Some(why) = &e.gpu_fallback { + entry["gpuFallback"] = json!(why); + } + self.out.push(entry); } Err(err) if self.stop_on_error => return Err(err), Err(err) => self.out.push(json!({"photo": photo.0, "file": file, "error": err})), @@ -2791,6 +2807,29 @@ mod tests { files.iter().map(|f| json!({"path": f["path"], "width": f["width"], "height": f["height"], "error": f["error"]})).collect() } + /// Issue #675: a batch reports a render that fell back from the GPU to the CPU on that file's + /// entry (`gpuFallback`, the render's own reason) and says nothing on the others. + #[test] + fn a_batch_reports_a_gpu_fallback_on_the_file_it_hit() { + if !lightcraft_gpu::available() { + eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason()); + return; + } + let mut s = crate::Session::with_demo(); + let ids: Vec<_> = s.visible().iter().copied().take(2).collect(); + let o = ExportOptions::from_json(&json!({"format": "png", "width": 64})); + // one lane: both photos render on this thread, and the fault hits the first render here + lightcraft_gpu::inject_fault(lightcraft_gpu::Fault::Limit(1 << 10)); + let (files, _, _) = lanes_batch(&mut s, &ids, &o, 1, true, usize::MAX); + let files = files.unwrap(); + assert_eq!(files.len(), 2, "{files:?}"); + let why: Vec<_> = files.iter().filter_map(|f| f.get("gpuFallback").and_then(serde_json::Value::as_str)).collect(); + assert_eq!(why.len(), 1, "exactly one file fell back: {files:?}"); + assert!(why[0].contains("limit"), "{files:?}"); + assert!(files[1].get("gpuFallback").is_none(), "the second render was fine: {files:?}"); + assert!(lightcraft_gpu::available(), "a limit is no device failure"); + } + // Issue #496: photos rendered side by side give the same files, names, order and progress as // one at a time, and a cancel stops at the same photo #[test] diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index bbd38f05c..ff03c0b8e 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -707,6 +707,9 @@ pub struct RenderResult { pub level: SourceLevel, pub key: u64, pub rendered: Result, + /// Set when this job's render was tried on the GPU and fell back to the CPU: why + /// ([`lightcraft_gpu::Fallback::Failed`]). The result is the CPU render either way. + pub gpu_fallback: Option, /// A source that was loaded by this job (to be inserted into the cache). pub loaded: Option, /// Set for a [`QuickJob`]'s stand-in: where the image came from. @@ -789,6 +792,7 @@ impl RenderJob { level: self.level, key: self.key, rendered: Ok(Rendered { image, histogram, deep: None }), + gpu_fallback: None, loaded: None, quick: None, display: None, @@ -810,7 +814,7 @@ impl RenderJob { // Thumbnails (many small jobs side by side) stay on the CPU; views and exports use // the GPU when there is one. let gpu = self.cache.is_none(); - let mut rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu); + let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu); // for a display profile: the image goes to the display's values, the histogram // and the cached view preview stay sRGB let mut srgb = None; @@ -842,6 +846,7 @@ impl RenderJob { level: self.level, key: self.key, rendered: Ok(rendered), + gpu_fallback, loaded: (!was_loaded).then_some(source), quick: None, display, @@ -854,6 +859,7 @@ impl RenderJob { level: self.level, key: self.key, rendered: Err(e), + gpu_fallback: None, loaded: None, quick: None, display: None, @@ -866,19 +872,39 @@ impl RenderJob { /// the CPU, reusing `stages` either way. Both produce the same image within 1–3 LSB (see /// `docs/gpu-pipeline.md`); `LIGHTCRAFT_GPU=0` or [`lightcraft_gpu::set_enabled`] forces the CPU. pub fn develop(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>, gpu: bool) -> Rendered { + develop_reporting(src, info, s, req, stages, gpu).0 +} + +/// [`develop`], also saying why the render fell back from the GPU to the CPU when it did (`None`: +/// it rendered on the GPU, or the GPU was not tried). The reason is this render's own, not the +/// process-wide [`lightcraft_gpu::last_fallback`]. +pub fn develop_reporting( + src: &Arc, + info: &SourceInfo, + s: &DevelopSettings, + req: &RenderRequest, + stages: Option<&StageCache>, + gpu: bool, +) -> (Rendered, Option) { // LUT profiles have no GPU stage: they render on the CPU - if gpu + let fallback = if gpu && !lightcraft_pipeline::lut::is_lut_profile(&s.profile.id) && !s.masks.iter().any(|m| m.visible && m.refine > 0.0) && req.proof.is_none() - && let Some(r) = lightcraft_gpu::render(src, info, s, req, stages) { - return r; - } - match stages { + match lightcraft_gpu::try_render(src, info, s, req, stages) { + Ok(r) => return (r, None), + Err(lightcraft_gpu::Fallback::Failed(why)) => Some(why), + Err(lightcraft_gpu::Fallback::NotTried) => None, + } + } else { + None + }; + let rendered = match stages { Some(st) => lightcraft_pipeline::render_cached(src, info, s, req, st), None => lightcraft_pipeline::render(src, info, s, req), - } + }; + (rendered, fallback) } /// Where a [`QuickJob`]'s stand-in image came from. @@ -919,6 +945,7 @@ impl QuickJob { level: SourceLevel::Thumb, key, rendered, + gpu_fallback: None, loaded: None, quick, display: None, diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 654d5dcca..da4a249bd 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -177,7 +177,13 @@ fn prepared_exports_run_on_another_thread() { /// 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. +/// image — with the reason recorded on the export itself. +/// +/// Issue #675: this test runs beside every other engine test, many of them rendering on the GPU. +/// It asks only about its own export (`Exported::gpu_fallback`), never the process-wide +/// `lightcraft_gpu::last_fallback` another thread's render overwrites, and the simulated failure +/// switches the GPU off for this thread alone, so neither the fault nor `reset_failures` reaches +/// the other tests. #[test] fn export_falls_back_to_the_cpu_when_gpu_work_is_lost() { let mut s = Session::with_demo(); @@ -192,18 +198,26 @@ fn export_falls_back_to_the_cpu_when_gpu_work_is_lost() { 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; + let faulted = export_photo(&mut s, id, &o, 1).unwrap(); if gpu { - let why = lightcraft_gpu::last_fallback().unwrap_or_default(); + let why = faulted.gpu_fallback.clone().unwrap_or_default(); assert!(why.contains("incomplete"), "{why}"); - // the GPU is off for the process now: this export renders on the CPU + // the GPU is off for this thread now: the next export is not even tried on it assert!(!lightcraft_gpu::available()); + assert!(lightcraft_gpu::unavailable_reason().unwrap_or_default().contains("incomplete"), "{:?}", lightcraft_gpu::unavailable_reason()); + // the thread's own `inject_fault` stopped only its own GPU use: the others still have it + let elsewhere = std::thread::spawn(|| (lightcraft_gpu::available(), lightcraft_gpu::unavailable_reason())).join().unwrap(); + assert_eq!(elsewhere, (true, None), "a simulated failure must not switch the GPU off for the other threads"); + } else { + assert_eq!(faulted.gpu_fallback, None); } - let cpu = export_photo(&mut s, id, &o, 1).unwrap().bytes; + let cpu = export_photo(&mut s, id, &o, 1).unwrap(); + assert_eq!(cpu.gpu_fallback, None, "not tried on the GPU: no fallback to report"); lightcraft_gpu::reset_failures(); + assert_eq!(lightcraft_gpu::available(), gpu, "reset_failures gives this thread the GPU back"); 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!(px(&faulted.bytes) == px(&cpu.bytes), "the fallback is the CPU render"); + let m = mean(&faulted.bytes); 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/lib.rs b/crates/gpu/src/lib.rs index a07d3e74f..fb9785ba6 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -11,8 +11,10 @@ //! //! Use [`render`]: it returns `None` when the GPU is unavailable, disabled (`LIGHTCRAFT_GPU=0`, //! `LIGHTCRAFT_GPU_BACKEND=off` or [`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`). +//! of memory or returned an incomplete image — callers then render on the CPU. [`try_render`] is +//! the same call reporting why *this* render fell back ([`Fallback`]); [`unavailable_reason`] and +//! [`last_fallback`] (the latest fallback in the process) feed the status displays (`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. //! @@ -88,6 +90,9 @@ pub fn unavailable_reason() -> Option { 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}")); } + if let Some(r) = broken_here() { + return Some(format!("stopped after a GPU failure: {r}")); + } #[cfg(not(target_arch = "wasm32"))] { match GPU.get() { @@ -101,12 +106,35 @@ pub fn unavailable_reason() -> Option { } } -/// 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. +/// The latest render in the process that fell back from the GPU to the CPU and why (device limit, +/// out of memory, device error, incomplete or blank result), if any. A diagnostic for status +/// displays (`ui.inspect` → `perf.gpuFallback`): renders on other threads overwrite it, so a caller +/// that wants the reason for *its own* render uses [`try_render`], which returns it. pub fn last_fallback() -> Option { LAST_FALLBACK.lock().unwrap_or_else(|e| e.into_inner()).clone() } +/// Why [`try_render`] made no GPU image: the caller renders on the CPU. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Fallback { + /// The GPU was not tried: it is unavailable, disabled or closing ([`unavailable_reason`]), or + /// the request is one only the CPU renders (high bit depth, soft proof, HDR range overlay). + NotTried, + /// The render was tried on the GPU and failed, for this reason (the same text + /// [`last_fallback`] reports). The CPU renders it instead. + Failed(String), +} + +impl Fallback { + /// The failure's reason; `None` when the GPU was not tried. + pub fn reason(&self) -> Option<&str> { + match self { + Fallback::NotTried => None, + Fallback::Failed(r) => Some(r), + } + } +} + /// A simulated GPU failure for the next [`render`] on this thread (tests of the CPU fallback). #[doc(hidden)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -122,9 +150,15 @@ pub enum Fault { thread_local! { static FAULT: std::cell::Cell> = const { std::cell::Cell::new(None) }; + /// Why a simulated failure ([`inject_fault`]) stopped GPU use on this thread. A simulated + /// fatal failure behaves like a real one for the thread that asked for it — [`render`] returns + /// `None`, [`available`] is false, [`unavailable_reason`] says why — but leaves the rest of + /// the process on the GPU: tests of the fallback run beside other tests rendering on it. + static BROKEN_HERE: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; } -/// Make the next [`render`] on this thread fail with `f` (tests). +/// Make the next [`render`] on this thread fail with `f` (tests). A fatal `f` then stops GPU use +/// on this thread only, until [`reset_failures`]. #[doc(hidden)] pub fn inject_fault(f: Fault) { FAULT.with(|c| c.set(Some(f))); @@ -135,12 +169,27 @@ fn take_fault() -> Option { FAULT.with(|c| c.take()) } -/// Use the GPU again after a failure (tests that inject faults). +/// Stop using the GPU on this thread (a simulated fatal failure). +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +fn mark_broken_here(reason: &str) { + let _ = BROKEN_HERE.try_with(|b| { + let mut b = b.borrow_mut(); + if b.is_none() { + *b = Some(reason.to_string()); + } + }); +} + +/// Why a simulated failure stopped GPU use on this thread, if one did. +fn broken_here() -> Option { + BROKEN_HERE.try_with(|b| b.borrow().clone()).ok().flatten() +} + +/// Use the GPU again on this thread after a simulated failure ([`inject_fault`]; tests). Real +/// failures are not undone: the process stays on the CPU, and nothing another thread did changes. #[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; + let _ = BROKEN_HERE.try_with(|b| *b.borrow_mut() = None); } /// Allow or forbid GPU rendering at runtime (a preference). `LIGHTCRAFT_GPU=0` forbids it for the @@ -149,9 +198,10 @@ pub fn set_enabled(on: bool) { ENABLED.store(on, Ordering::Relaxed); } -/// Whether GPU rendering is allowed (environment, preference, no earlier failure). +/// Whether GPU rendering is allowed (environment, preference, no earlier failure — real, or +/// simulated on this thread). pub fn enabled() -> bool { - ENABLED.load(Ordering::Relaxed) && !BROKEN.load(Ordering::Relaxed) && !env_disabled() + ENABLED.load(Ordering::Relaxed) && !BROKEN.load(Ordering::Relaxed) && !env_disabled() && broken_here().is_none() } /// The process is about to end: no GPU work starts from now on, on any thread. [`render`] returns @@ -382,8 +432,22 @@ pub fn stage_bytes(stages: &StageCache) -> usize { } /// Render `src` with `s` on the GPU, reusing the device-resident stages kept with `stages` (the -/// view's CPU stage cache). `None`: render on the CPU instead. +/// view's CPU stage cache). `None`: render on the CPU instead. [`try_render`] also says why. pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>) -> Option { + try_render(src, info, s, req, stages).ok() +} + +/// [`render`], reporting why this render made no GPU image: [`Fallback::NotTried`] when the GPU +/// was not used (callers can ask [`unavailable_reason`]), [`Fallback::Failed`] with the reason +/// when the GPU render failed and the CPU must render it. The reason belongs to this call, unlike +/// [`last_fallback`], which the latest render on any thread overwrites. +pub fn try_render( + src: &Arc, + info: &SourceInfo, + s: &DevelopSettings, + req: &RenderRequest, + stages: Option<&StageCache>, +) -> Result { #[cfg(not(target_arch = "wasm32"))] { // sections switched off with their eye render as if at their defaults (issue #316) @@ -396,14 +460,16 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R || req.proof.is_some() || req.overlay == lightcraft_pipeline::Overlay::HdrRange { - return None; + return Err(Fallback::NotTried); } let s = &*lightcraft_pipeline::settings_for(s, req); - // in flight until this returns; `None` once the process is ending (issue #620) - let _work = exit::enter()?; - let gpu = device()?; + // in flight until this returns; not tried once the process is ending (issue #620) + let _work = exit::enter().ok_or(Fallback::NotTried)?; + let gpu = device().ok_or(Fallback::NotTried)?; let ext = stages.map(|c| c.extension::()); let fault = take_fault(); + // A simulated failure stops GPU use on this thread only (see `BROKEN_HERE`). + let stop = |reason: &str| if fault.is_some() { mark_broken_here(reason) } else { mark_broken(reason) }; let _ = ctx::take_failure(); // nothing left over from an earlier render on this thread let (r, scoped) = { let _scope = ctx::RenderScope::new(gpu); @@ -417,12 +483,11 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R (Some(f), _) if f.kind == ctx::FailKind::Limit => Some(f), (f, scoped) => scoped.or(f), }; - match (r, failure) { + let reason = match (r, failure) { (Err(_), _) => { log::error!("gpu: render failed; using the CPU pipeline from now on"); - mark_broken("a GPU render panicked"); - record_fallback("a GPU render panicked; the GPU is not used again".into()); - None + stop("a GPU render panicked"); + "a GPU render panicked; the GPU is not used again".to_string() } (Ok(r), Some(f)) => { // the view's cached device stages may hold results of the failed passes @@ -435,7 +500,7 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R }; let reason = match f.kind { ctx::FailKind::Fatal => { - mark_broken(&f.reason); + stop(&f.reason); format!("{what}: {}; the GPU is not used again", f.reason) } ctx::FailKind::OutOfMemory => { @@ -448,20 +513,23 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R if render::profiling() { eprintln!(" gpu fallback to the CPU: {reason}"); } - record_fallback(reason); - None + reason } // 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 + format!("device failure during the render: {}", unavailable_reason().unwrap_or_default()) } - (Ok(r), None) => r, - } + (Ok(Some(r)), None) => return Ok(r), + // every path of `render::render` that gives up records a failure first; should one + // not, the CPU renders it rather than nothing + (Ok(None), None) => "GPU render: no image came back".to_string(), + }; + record_fallback(reason.clone()); + Err(Fallback::Failed(reason)) } #[cfg(target_arch = "wasm32")] { let _ = (src, info, s, req, stages); - None + Err(Fallback::NotTried) } } diff --git a/crates/gpu/tests/fallback.rs b/crates/gpu/tests/fallback.rs index 1f7ceb2f0..fe2ecad5e 100644 --- a/crates/gpu/tests/fallback.rs +++ b/crates/gpu/tests/fallback.rs @@ -1,14 +1,15 @@ //! 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`. +//! CPU) with the reason recorded, never as an image of zeros. One test, sequential: the simulated +//! fatal faults stop GPU use on this thread until `reset_failures` (issue #675: on this thread +//! only, so a test of the fallback can run beside tests that render on the GPU). //! 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_gpu::{Fallback, Fault}; use lightcraft_pipeline::{RenderRequest, SourceInfo, render}; use lightcraft_raster::Rgba8; @@ -57,39 +58,51 @@ fn failed_gpu_renders_fall_back_with_a_reason() { assert!(d < 0.5, "{what}: GPU differs from the CPU by {d:.3} LSB"); }; ok("healthy"); + // the render that fell back says why itself; the process-wide diagnostic agrees + let failed = |what: &str| -> String { + match lightcraft_gpu::try_render(&src, &info, &s, &req, None) { + Ok(_) => panic!("{what}: the GPU render must fall back"), + Err(Fallback::NotTried) => panic!("{what}: the render must be tried on the GPU ({:?})", lightcraft_gpu::unavailable_reason()), + Err(Fallback::Failed(why)) => { + assert_eq!(lightcraft_gpu::last_fallback().as_deref(), Some(why.as_str()), "{what}"); + why + } + } + }; // 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(); + let why = failed("output over the limit"); 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(); + let why = failed("mask planes over the limit"); 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 + // on this thread — other threads keep it (a simulated failure, not a real one) 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(); + let why = failed("validation error"); 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()); + assert_eq!(lightcraft_gpu::render(&src, &info, &s, &req, None).map(|_| ()), None, "no GPU render on this thread after a fatal fault"); + let elsewhere = std::thread::spawn(|| (lightcraft_gpu::available(), lightcraft_gpu::unavailable_reason())).join(); + assert_eq!(elsewhere.ok(), Some((true, None)), "a simulated failure switches the GPU off for its own thread only"); lightcraft_gpu::reset_failures(); + assert_eq!(lightcraft_gpu::unavailable_reason(), None); 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}"); + let why = failed("dropped work"); + assert!(why.contains("incomplete"), "an unwritten image must not be returned: {why}"); assert!(!lightcraft_gpu::available()); lightcraft_gpu::reset_failures(); ok("after dropped work"); @@ -97,7 +110,14 @@ fn failed_gpu_renders_fall_back_with_a_reason() { // 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()); + match lightcraft_gpu::try_render(&black, &info, &s0, &RenderRequest::fit(256, 256), None) { + Err(Fallback::Failed(why)) => assert!(why.contains("black"), "{why}"), + other => panic!("a black result is redone on the CPU: {:?}", other.map(|_| ())), + } assert!(lightcraft_gpu::last_fallback().unwrap_or_default().contains("black")); assert!(lightcraft_gpu::available()); + + // a request the GPU never renders is not a fallback + let deep = RenderRequest { depth: lightcraft_pipeline::OutputDepth::U16, ..RenderRequest::fit(256, 256) }; + assert_eq!(lightcraft_gpu::try_render(&src, &info, &s, &deep, None).err(), Some(Fallback::NotTried)); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ef68a5a74..d6c0c3d17 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -73,7 +73,7 @@ filmstrip is visible. | `ui.resize` | `{width, height}` | Resize the window | | `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) | | `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically | -| `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background` | Writes the files and returns `{files}`; with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | +| `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | | `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) | | `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error | | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` | diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 2eeb43c58..883c3a0bf 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -204,10 +204,19 @@ redone on the CPU and why, e.g. `"6000×4000: the GPU returned an incomplete ima 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. +`perf.gpuFallback` is one value for the whole process, overwritten by the latest render on any +thread. A render's *own* reason comes from `lightcraft_gpu::try_render` (`Err(Fallback::Failed(why))`; +`Fallback::NotTried` when the GPU was not used at all), which `lightcraft_engine::media::develop_reporting` +passes on as `RenderResult::gpu_fallback`; an export carries it as `Exported::gpu_fallback`, and +each file of an `app.export` batch result has `gpuFallback` when its render fell back (issue #675). **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`). +`export_falls_back_to_the_cpu_when_gpu_work_is_lost`). The fault is thread-local, and so are its +consequences: a simulated fatal failure stops GPU use on the injecting thread only (`available()` +false, `unavailable_reason()` set there), until `reset_failures()`, which undoes nothing else — a +real failure still stops the whole process. Such a test asserts on its own render's reason, not on +`last_fallback()`, so it can run beside other tests rendering on the GPU (issue #675). ## Correctness: CPU oracle and equivalence tests `crates/gpu/tests/equivalence.rs` renders the same settings on both and compares 8-bit sRGB: diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 73ba5ccd2..eb4ef08bc 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -456,7 +456,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise and Find Missing Photos each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | -| 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; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); no tiling beyond the buffer limit (CPU) | +| 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`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); no tiling beyond the buffer limit (CPU) | | LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local `plan/` (never committed), compared per slider and preset with a perceptual metric. Tuning that changes how existing settings render ships as a new process version (LRC-DEV-PROCESS, `docs/process-versions.md`) | | 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`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) | | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | |