Merge pull request #84 from storytold/fix/issue-78-gpu-black-export

Issue #78: GPU export: respect device limits, fall back to CPU on GPU errors
This commit is contained in:
Brandon Thomas 2026-10-05 05:09:57 -04:00 committed by GitHub
commit 8532d4ea56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 723 additions and 79 deletions

View File

@ -186,11 +186,17 @@ pub fn specs() -> Vec<CommandSpec> {
"versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::<Vec<_>>(),
}))
}),
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())

View File

@ -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::<f64>() / (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}");
}

View File

@ -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<Gpu> {
/// Create a device on the best available adapter (no software fallback), or why there is none.
pub fn new() -> Result<Gpu, String> {
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<T: bytemuck::Pod>(&self, mut enc: wgpu::CommandEncoder, src: &Buf, len: usize) -> Vec<T> {
assert!(len <= src.len, "readback of {len} values from a buffer of {}", src.len);
let zeros = || vec![<T as bytemuck::Zeroable>::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![<T as bytemuck::Zeroable>::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>` = 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<Option<u64>> = 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<Option<Failure>> = 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> {
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<Failure> {
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));
}
}

View File

@ -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<Option<String>> = Mutex::new(None);
/// The latest render that fell back to the CPU, and why.
static LAST_FALLBACK: Mutex<Option<String>> = 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<String> {
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<String> {
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<Option<Fault>> = 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> {
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<Option<ctx::Gpu>> = std::sync::OnceLock::new();
static GPU: std::sync::OnceLock<Result<ctx::Gpu, String>> = 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<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &R
}
let gpu = device()?;
let ext = stages.map(|c| c.extension::<GpuStages>());
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")]

View File

@ -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<usize> {
pub fn index(name: &str) -> Option<usize> {
let mut i = 0;
for (n, len) in FIELDS {
if *n == name {

View File

@ -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<wgpu::CommandEncoder>,
/// 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::<u64>() * 256;
if self.pending >= FLUSH_INVOCATIONS {
self.flush();
}
}
/// Submit what is recorded and read back `len` values of `b`.
pub fn read<T: bytemuck::Pod>(&mut self, b: &Buf, len: usize) -> Vec<T> {
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<Arc<Plane>>,
}
fn profiling() -> bool {
pub(crate) fn profiling() -> bool {
static ON: std::sync::OnceLock<bool> = 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<Rgb32f>,
@ -447,20 +475,31 @@ pub fn render(
s: &DevelopSettings,
req: &RenderRequest,
stages: Option<&GpuStages>,
fault: Option<crate::Fault>,
) -> Option<Rendered> {
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<std::time::Instant>, 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<std::time::Instant>, 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)

View File

@ -243,11 +243,11 @@ fn out_encode(v: f32) -> f32 {
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
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);

View File

@ -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", |_| {}),

View File

@ -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::<u64>()).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());
}

View File

@ -78,7 +78,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
"selection": app.session.selection.ids.iter().map(|p| p.0).collect::<Vec<_>>(),
"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,

View File

@ -856,7 +856,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
("Version".to_string(), env!("CARGO_PKG_VERSION").to_string()),
("System".to_string(), format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH)),
("CPU threads".to_string(), std::thread::available_parallelism().map(|n| n.get().to_string()).unwrap_or_else(|_| "?".into())),
("GPU".to_string(), gpu.unwrap_or_else(|| "none (CPU rendering)".into())),
(
"GPU".to_string(),
gpu.unwrap_or_else(|| match lightcraft_engine::gpu::unavailable_reason() {
Some(why) => 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<Re
format!("{:.1} ms (logic {:.1} ms; slowest {:.0} ms)", app.perf.update_ms, app.perf.logic_ms, app.perf.max_update_ms),
));
rows.push(("Last loupe render".into(), format!("{:.0} ms", app.renderer.last_main_ms)));
if let Some(f) = lightcraft_engine::gpu::last_fallback() {
rows.push(("Last GPU fallback".into(), f));
}
let r = json!(rows.iter().map(|(k, v)| json!({"label": k, "value": v})).collect::<Vec<_>>());
if p.get("open").and_then(Value::as_bool).unwrap_or(true) {
app.ui.dialog = Some(Dialog::SystemInfo { rows });

View File

@ -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()))
};

View File

@ -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 |

View File

@ -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=<n>` 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.

View File

@ -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 |

View File

@ -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.