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:
commit
8532d4ea56
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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", |_| {}),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue