Files
lightcraft/docs/gpu-pipeline.md
T
a220d3eb68 LR-EDIT-DETAIL-DENOISE: built-in BM3D denoising with GPU acceleration (#2982)
* LR-EDIT-DETAIL-DENOISE: AI Denoise works out of the box with a built-in denoiser

Turning AI Denoise on no longer needs an ONNX model and a denoise-model.json. Without a model of the user's own, the engine runs a built-in pure-Rust denoiser (noise measured per tile, non-local means on the Bayer planes, edge-directed demosaic) through the same tiling, cache, queue, preview and export path as a model. Installing a model stays optional; Stop using returns to the built-in one, and the id `builtin` is reserved.

The set-up prompt and its resume-after-install flow (model_setup.rs, PhotoState::NoModel) are removed. Tests updated for the new behaviour, with new tests for the built-in path; docs, trackers and locale catalogs updated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: built-in denoiser cleans the colour planes together and adapts to the noise

The built-in denoiser now measures the photo's noise once from the whole mosaic (the model prepares the photo before its tiles run) instead of per tile, stabilises the variance (generalised Anscombe), rotates the four Bayer planes with a Hadamard transform and runs a non-local-means filter whose patch distance spans all planes, so colour blotches are cleaned with the brightness. It cleans gently at low noise to keep fine detail, and harder, with a second pass that matches on the first pass's cleaner picture, as the noise grows. Cached pictures made by the first version are made again (builtin version 2).

Adds an opt-in benchmark (tests/denoise_builtin_quality.rs, public CC0 raw plus noise of known level, scored against the original) and tests for the whole-photo noise estimate, strong noise, bad noise values and the strength ramp. On the Sony A7 III sample the result is 3.6, 7.0, 9.1 and 9.7 dB above the noisy picture at light, medium, heavy and extreme noise, against 2.9, 6.0, 6.5 and 5.8 dB for the first version.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* contributors: add esli1283

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: docs say how to run the built-in denoiser's quality benchmark

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: built-in denoiser cleans colour further, keeps more texture and leaves no isolated specks

Colour-difference planes get a wider pass guided by the cleaned brightness plane, the first-pass threshold is lower (more texture kept), and cells with no matching patch lean on a looser average in heavy noise. Skips negligible weights for speed; bumps builtin::VERSION to 3. Benchmark adds a texture figure; on the public A7 III raw the gain over the noisy picture is +3.7 / +7.2 / +9.7 / +10.4 dB (was +3.6 / +7.0 / +9.1 / +9.7).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Windows: tests build paths one component at a time and doc_path has no unused variable

On Windows `cargo xtask ci` failed before it reached any of the work under test:
- doc_path.rs bound the gvfs path outside the cfg(not(windows)) block that
  uses it, an unused variable under -D warnings.
- tests_import and the MCP headless discovery test joined "trip/day2" onto a
  temp folder, giving mixed separators, and compared them with the normalised
  paths the folder walk returns. They now join each component.

Test and lint changes only.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: built-in denoiser filters with block-matching collaborative filtering, on the GPU

The built-in denoiser's filter is now block matching with collaborative
filtering (BM3D / CBM3D, written from the paper) instead of non-local means.
On the public Sony A7 III benchmark with added noise it scores 0.25 to 0.6 dB
higher at every noise level and keeps more texture at medium to extreme noise
(light noise keeps slightly less: 0.58 against 0.62), as fast as before on the
processor. docs/denoise.md records the comparison with non-local means and
weighted nuclear norm minimisation (tried, not kept: about +0.03 texture for ten
times the time) and what the tuning showed.

- denoise-core: bm3d.rs (matching on the brightness plane, 2-D DCT plus Haar
  across the group, hard threshold then Wiener, hard threshold rising with the
  noise). Builtin::run_with takes the filter stage, so every other step is shared
  between devices. Tuning.bm3d selects it; non-local means stays as the fallback.
  builtin::VERSION is 4, so cached pictures are made again.
- gpu: bm3d.rs and wgsl/bm3d_*.wgsl run the grouping, the filtering and the
  putting back on the graphics card, in the same order as the reference (the
  results agree to rounding: at most 0.1 noise deviation at a few tie blocks,
  4e-6 on average, identical every run). Noise measurement, stabilising and the
  demosaic stay on the processor. FXC (DX12) needs the working arrays at module
  scope and no dynamically indexed vector: documented in docs/gpu-pipeline.md.
- engine: the card is set up once per process for the first photo that may use
  it, checked against the processor on a check tile (in the filter's own domain:
  the demosaic's direction choice can flip a pixel on a tiny difference), timed,
  and used where it is faster. A tile it gets wrong runs on the processor and
  three stop it. Settings' Run on choice, device line and "Try the graphics card
  again" now work for the built-in denoiser. Its set-up marker lives in the
  models folder.
- The benchmark takes LC_GPU=1, LC_NLM=1 and LC_B_* for sweeps.

On an RTX 4080 SUPER a 256-cell tile takes 22 ms on the card against 408 ms on
one processor thread, and the 1536 x 1024 benchmark crop 0.13 to 0.24 s against
1.0 to 2.1 s on four threads, with identical scores.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: the built-in denoiser measures the noise per colour plane and sets its strength from it

The first noise estimator read a picture's texture as noise: it never reported
less than about 0.003 at mid-grey whatever the real noise, so the denoiser
always believed a photo was about as noisy as ISO 12800. The threshold curve was
tuned on that over-estimate, and its levels (0.024 to 0.14) are far noisier than
real Canon bodies produce (about 0.0025 at ISO 400 up to 0.038 above ISO 25600,
measured on a private library; only aggregates are recorded in the docs).

- estimate_noise: each colour plane is read on its own (they sit at different
  levels in a raw), a second-difference operator cancels flat and sloping signal,
  12 x 12 blocks, the quietest 5 % at each brightness divided by the bias of that
  quantile of pure noise (0.67, measured), a constrained line fit. The first
  version stays as estimate_noise_v1 to compare with. On 30 public pictures with
  known added noise the 10th to 90th percentile of estimated / true deviation at
  mid-grey is 0.97 to 1.29 at the ISO 6400 level against 0.94 to 1.96.
- bm3d::Params::lambda is a curve of the noise (1.2 at 0.0028 up to 3.5 at 0.14,
  straight against its logarithm) instead of two end values and a strength, and
  plan / filter take the noise level; the GPU path shares plan, so CPU and GPU
  agree as before. Fitted on clean low-ISO photos of a Canon EOS R and R6 Mark III
  with noise of their measured levels added: the lowest threshold within about a
  quarter of a decibel of the best at each level.
- Result (PSNR, EOS R / R6 Mark III, 10 + 5 photos): ISO 25600 level 38.36 /
  38.86 from 36.18 / 36.21, ISO 51200 level 33.83 / 34.45 from 28.42 / 28.78, ISO
  6400 39.93 / 40.69 from 39.30 / 39.74, quiet photos no longer over-smoothed;
  the noise left in the result falls from 1.3 to 3.1 down to 0.73 to 0.99.
- Tests: threshold curve, flat versus textured pictures, no-flat-area pictures,
  clipped / NaN mosaics; an opt-in accuracy harness on the public corpus
  (denoise_noise_estimate) with a calibration of the quantile bias; the benchmark
  takes LC_REAL, LC_BIN, LC_FULL, LC_EST and LC_B_LOFF.
- builtin::VERSION is 5, so cached pictures are made again. docs/denoise.md has
  "How the noise is measured" and "Fitted on real cameras".

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: GPU workspace pool cannot miss a wake-up; benchmark path and parity date fixed

Review findings (CodeRabbit) on the built-in denoiser pull request:

- bm3d::Bm3dGpu::forget and nn::NetRunner::forget changed the workspace count
  without the free-list lock. A take() that had found no free workspace and read
  made == WORKSPACES, but had not gone to sleep yet, could miss the wake-up and wait
  for good on a slot that was already free. forget now changes the count under the
  free lock, in the same order as take(), then notifies.
- A test for each runner holds the free lock and checks that forget waits for it;
  both fail on the old code and pass now.
- docs/denoise.md: the benchmark command resolves the raw file from the repository
  root, since the test runs from the crate's folder.
- docs/target-app-parity.md: Last updated matches its newest revision-history entry.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: doc comments for the functions this branch adds

CodeRabbit's pre-merge docstring check counted 49.72 % of the functions this pull request touches as documented (threshold 80 %). Every function the branch adds, the built-in denoiser's block-matching and non-local-means filters, its noise estimator, the GPU runner and their tests, now says what it does or what the test proves. Older functions the branch only touched are left as they were. Comments only: no code line changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: make the GPU set-up marker's folder; fix the benchmark's default path

The crash guard was lost when the models folder did not exist yet. The opt-in
quality benchmark resolved its default raw file against crates/engine.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: enforce the cache limit without a models folder

The built-in denoiser makes products when there is no models folder, but
eviction returned early in that case, so the cache could outgrow its limit.
It now applies the default limit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: the cache-limit test needs no 20 GiB file off Unix

The no-models-folder test now checks the limit itself on every platform and
keeps the oversized sparse file for Unix only.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* S0002: review fixes for the built-in denoiser

Tell the runner a photo's noise only when it could be measured, run prepare()
after the work gate with a cancel check, reword the Settings hint for builds
without a models folder, drop untranslated placeholder locale entries (they
fall back to English) and restore the cs.json spacing; rustfmt.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* S0002: fix review findings: command descriptions, stale gpu test, self-test noise

The denoise.models.select and denoise.toggle descriptions now describe the
built-in fallback. Drop our workspace-forget test, which read fields that
upstream's Workspaces pool (#2631, same fix) replaced and so did not compile;
upstream's own tests cover it. The built-in self-test is told a noise instead
of measuring a 16-cell tile too small to measure.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: esli1283 <esli.aaron@outlook.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-05 09:36:22 +01:00

38 KiB
Raw Blame History

GPU develop pipeline (M5.2)

The CPU pipeline (crates/pipeline) is the reference ("oracle"). lightcraft-gpu (crates/gpu, L3) evaluates the same stages with wgpu compute shaders (WGSL) on Metal / Vulkan / DX12. Everything is pure Rust (wgpu, naga); the drivers are the system's. No GL backend is compiled in.

CPU fast guided-filter allocation measurements and reproduction commands are documented in guided-filter-memory.md. That optimization retains the full-size base and does not change the GPU's coefficient buffers.

Process V4's camera-look Contrast and Blacks share the CPU's generated lookup tables and ProPhoto matrices. finish.wgsl::light_ops applies them after V2's Whites tone curve and before the colour stage. Full frames and render windows use the same pointwise operators; see calibrated Light operators for HDR rules and limits.

Warped geometry uses a CPU-computed coverage bit mask for the source boundary. The CPU reference evaluates that boundary in double precision; GPU float rounding otherwise can turn a blank edge pixel into a photo pixel. The mask uses one bit per output pixel (rows padded to 32 bits). Sampling and color corrections remain on the GPU, and existing geometry stage caching reuses the result. The mask is built per 32 × 16 block (Warp::block_coverage): the warp formulas are written once, generic over lightcraft_geom::Real, and evaluated with outward-rounded Intervals over the block. Those bounds enclose the f64 result of every pixel in the block, so a block whose bounds lie inside the image edges (the same f64 comparisons, no margin), or beyond one of them, is filled at once; only blocks across the edge, or whose bounds are undecided (a perspective denominator that may vanish), evaluate each pixel (Warp::covers, the framing chain Warp::frame the CPU resample uses too). The bits equal the per-pixel decision. At 6000 × 4000 with lens warp + perspective the mask takes ~2.7 ms (32 threads; ~24.5 ms on one) instead of ~30 ms (~580 ms), and only when warped geometry rebuilds.

Backends, environment variables and troubleshooting (issue #136)

wgpu loads the driver of every backend in an instance's set while it enumerates adapters — even when it then picks another one. A Vulkan driver that crashes there (issue #136: an access violation in Intel's igvk64.dll on a UHD 630 under Windows 11) takes the process down before any window appears, and a native crash cannot be caught. So LightCraft only lets wgpu touch the backends it means to use (lightcraft_gpu::backend), for its compute device and for the desktop window (eframe/egui-wgpu, window_wgpu_options in apps/lightcraft/src/main.rs):

platform default (window and compute)
Windows DX12 only — the Vulkan driver is never loaded unless asked for
macOS Metal
Linux / BSD Vulkan (the window also lists GL, eframe's default; no GL backend is compiled in)

Overrides (read once at launch):

  • LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | gl | auto | off (comma lists allowed, e.g. vulkan,dx12): the backends for both the window and GPU rendering; off turns GPU rendering off (CPU pipeline) — the window still needs a backend and keeps the platform default. A backend this build doesn't contain (gl) is ignored with a warning.
  • else WGPU_BACKEND (wgpu's own variable, same names) — before #136 only the window honoured it, the compute device always used Vulkan + DX12 (+ Metal).
  • LIGHTCRAFT_GPU=0: GPU rendering off for the process (the window is unaffected).
  • Both LIGHTCRAFT_GPU=0 and LIGHTCRAFT_GPU_BACKEND=off also keep AI Denoise, the face models and the AI mask model (SAM 3 on Metal) on the CPU.
  • LIGHTCRAFT_GPU_ADAPTER=<part of a card's name> (any case, e.g. 4070, radeon, "rtx 4070 super"): the graphics card for GPU rendering, AI Denoise and the face models, on a machine with more than one (backend::pick_adapter). Of the hardware cards whose name contains it, the one whose whole name it is wins, then dedicated before built-in before virtual cards, then wgpu's order (backend::choose_named_adapter); the log says which when several match (… matches 2 graphics cards (…): using …). Software renderers (WARP, llvmpipe) never: a name that matches only one is logged as such. A name that matches no card is logged (gpu: LIGHTCRAFT_GPU_ADAPTER=… names none of the graphics cards (…); using the default choice) and the default high-performance card is used. Each line is logged once, though the render device, AI Denoise and the face models each ask. Each of those makes its own wgpu instance and device, so devices on different cards share no buffer, pipeline or queue. The window keeps the card wgpu picks for it by power preference, and rendering runs on a device of its own on the chosen card (the photo is copied to the window's card). Rendering shares the window's device when the variable is unset or names the window's card (backend::may_adopt_window_device). The log's gpu: LIGHTCRAFT_GPU_ADAPTER=…: using … line names the card used. Handy to keep test runs or a long export off the card the screens hang on.
  • LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1: accept a software adapter (llvmpipe / lavapipe, WARP), which is otherwise refused because the CPU pipeline is faster. For running the GPU tests on a machine without a GPU (below); the app never sets it.

DX12 shader compiler (issue #471). Every instance (window and compute) compiles DX12 shaders with FXC (d3dcompiler_47.dll, part of Windows; lightcraft_gpu::backend::backend_options). wgpu's default, Auto, loads whichever dxcompiler.dll the DLL search path finds first. LightCraft ships none, so that copy belongs to another program (a Windows SDK, a folder on PATH). An older copy without dxil.dll beside it (e.g. DXC 1.7) warns that the DXIL is unsigned, wgpu treats the warning as a compile error, its indirect-validation pipelines fail, the device is lost, and the window never opened (WGPU error: Parent device is lost). WGPU_DX12_COMPILER=dxc | auto | fxc (wgpu's variable) still chooses; with dxc, put dxcompiler.dll and dxil.dll (DXC ≥ 1.8.2502) next to lightcraft.exe. FXC builds the compute kernels somewhat slower than DXC (≈ 9 s vs 7.5 s for the GPU test suite on an RTX 4080), off the startup path.

FXC merges stores to neighbouring elements of a storage array into one wider store. NVIDIA's DX12 driver ran the 16-byte store it made of a denoise convolution's output wrong on a GTX 1070 (every second pixel's channels shifted; DXC and Vulkan were right), so nn_conv.wgsl steps those stores by 1 + zero from its uniform and FXC leaves them apart. The transposed convolution's merged store ran right on that card, but it steps the same way. nn::tests::a_layer_stores_every_pixels_channels_where_the_next_reads_them catches the fault on such a card; a_transposed_convolution_stores_every_pixels_channels_where_the_next_reads_them checks the other store exactly.

FXC also cannot address two things the WGSL looks harmless with, and answers with X3550 … forcing loop to unroll and then X3511 unable to unroll loop (the shader module fails, so the pipeline is never made): an array handed to a function through a pointer (ptr<function, array<…>>) that the function then indexes with a loop variable, and a vector indexed with a loop variable on the left of an assignment (acc[ch] = … with acc: vec4<f32>). The block-matching kernels (wgsl/bm3d_*.wgsl) keep their working arrays at module scope (var<private>, one copy of the transforms generated per array, bm3d_transform.wgsl) and accumulate in array<f32, 4> instead. A GPU test that only records a skip when the device cannot build the kernels hides this, so read the skip note (cargo test … -- --nocapture).

Off the startup path. The compute device is created on a background thread once the window is up (gpu::warm_up from the first frame's settings), and never while GPU rendering is off: with Settings ▸ Performance ▸ Use the GPU for rendering unchecked (applied before the window opens), LIGHTCRAFT_GPU=0 or LIGHTCRAFT_GPU_BACKEND=off, no GPU driver is loaded for rendering at all.

Quitting. The devices are never dropped, and renders run on worker threads: a render still inside the driver while the process exits crashes there (a SIGSEGV in the NVIDIA Vulkan driver on Linux, issue #620), which no panic hook catches. So every entry into a device counts as work in flight (crates/gpu/src/exit.rs), and the desktop app ends with LightcraftApp::shutdown (from on_exit, after settings and library are saved): gpu::begin_shutdown() — from then on gpu::render returns None, no device is created, denoise tiles fail over and a render job gives up instead of rendering on the CPU — then the render pool stops (queued jobs dropped, running ones waited for) and gpu::wait_idle waits for GPU work on other threads (export, denoise, the device warm-up). One deadline of 2 s covers it all; past it the app quits anyway. A job pool that is dropped (headless hosts, tests) waits for its running jobs the same way (JobPool::shutdown, crates/preview/src/pool.rs).

Crash sentinel. The desktop app writes gpu-init.marker into its settings folder (next to ui.json: %APPDATA%\LightCraft, ~/Library/Application Support/LightCraft, ~/.config/lightcraft) just before the compute device is created and its kernels are compiled (and, on the first AI denoise, before that network's kernels are) and removes it as soon as that returns, successfully or not (issue #250: Apple's Metal compiler, MTLCompilerService, aborting on a kernel on an older Intel Mac). A kernel the driver refuses without crashing is caught by wgpu error scopes: the log says why (gpu: rendering on the CPU: …) and renders stay on the CPU. LIGHTCRAFT_GPU=0 (or Settings ▸ uncheck Use the GPU for rendering) forces the CPU path from the start. If the marker is still there at the next launch, the process died inside the driver: LightCraft starts with GPU rendering off (the preference is saved unchecked), removes the marker and says so in a notice. Checking Use the GPU for rendering again tries the GPU once more (and re-arms the sentinel). Not with LIGHTCRAFT_NO_PREFS (tests, scripts), and not in a --memory session, which writes nothing: it neither arms the sentinel nor removes a marker it finds (it still starts with GPU rendering off when one is there, and the next ordinary launch reports and clears it). Killing the app during the ~0.3 s of device creation, or two instances starting at the same moment, can trip it falsely — harmless: rendering is then on the CPU until the box is checked again. The sentinel covers the compute device and every kernel build; a crash while the window's renderer starts is avoided by the backend defaults above or worked around with LIGHTCRAFT_GPU_BACKEND.

The window's renderer and its fallback (Windows). Before the window opens, each renderer is probed in order: DX12, Vulkan, GL, then eframe's OpenGL renderer (glow). A probe creates a device and builds egui's pipeline inside wgpu error scopes, so a driver that cannot compile egui's shaders ("The parameter is incorrect. (0x80070057)" in egui_pipeline) is logged and skipped instead of killing the app; no pop-up unless that renderer was forced (#1624). The renderer that worked is remembered in the settings folder (window-renderer); a start that dies before the window is up leaves window-init.marker, and the next start (relaunched automatically, at most 4 times) tries that renderer last. LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | gl | glow (or WGPU_BACKEND) forces one; DX12 stays first because a broken Vulkan driver can crash the process uncatchably (#136). Compute keeps its own device and falls back to the CPU as before.

The window's surface: one frame in flight. The window's wgpu options start from eframe's defaults (NativeOptions::default().wgpu_options, window_gpu::base_options), whose surface is egui-wgpu's SurfaceConfig::LOW_LATENCY: vsync, at most one frame queued ahead of the display (two drawables on Metal). egui-wgpu's bare WgpuConfiguration::default() is HIGH_THROUGHPUT (two frames queued, three drawables), and the window used it from #136 until this change, so a drag reached the screen one frame (8.3 ms at 120 Hz, 16.7 ms at 60 Hz) later. The window's own GPU work is small (egui's meshes and texture uploads; renders run on the compute device), which is what LOW_LATENCY is meant for. During a live window resize egui-wgpu still uses three drawables on macOS.

Troubleshooting a crash at startup (Windows). Start LightCraft from a .cmd file or a terminal with set LIGHTCRAFT_GPU_BACKEND=dx12 (the default since #136), or =off to keep the GPU out of rendering; set VK_LOADER_DRIVERS_DISABLE=*igvk64* (Vulkan loader) hides a specific Vulkan driver from every program started with it. Help ▸ System Info and app.gpu show the adapter and backend in use (e.g. Intel(R) UHD Graphics 630 (Dx12)).

LightCraft stops during an export or a render; the log says "device lost" (Windows, issue #1337). When the graphics driver resets the GPU (the Windows watchdog, a driver hang: DXGI 0x887A0005, "GPU device instance has been suspended"), renders fall back to the CPU, but the window's renderer (egui-wgpu) panics on the lost device and LightCraft stops. It says so ("LightCraft stopped unexpectedly", not "could not open its window") and records it in gpu-lost.marker in the settings folder: the next start turns Use the GPU for rendering off and says why once. Turn it back on in Settings ▸ Performance after a driver update.

Window never appears, "not responding" (Windows, NVIDIA; issue #374). With Shader Cache Size set to Disabled in NVIDIA Control Panel (Manage 3D settings → Global Settings), NVIDIA's driver walks the whole system drive while the window's GPU device is created (D3D12CreateDevice → nvwgf2umx.dll → NvMemMapStoragex.dll!TotalDiskUsage), before the first frame: a black, unresponsive window for minutes. The Vulkan driver shares that code, so another backend doesn't help. Set Shader Cache Size back to Driver Default (or any size); the setting is global, there is no per-program override. Other programs that create a DX12 or Vulkan device at startup hang the same way (PhotoCraft does). It is a driver issue: nothing in LightCraft itself walks the drive.

Where it is used

  • lightcraft_engine::media::develop (called by every RenderJob): loupe / before / compare views, render_now (CLI, MCP, control channel renders) and exports render on the GPU when one is available. Thumbnails (grid, filmstrip, preset and profile previews, faces) render on the GPU once the device is ready (lightcraft_gpu::ready()) and on the CPU until then, so they never wait for device creation at startup. Six workers rendering 256 px thumbnails: 3.6 ms each on an RTX 5070 vs 4.3–5.2 ms on 32 CPU threads (crates/gpu/tests/it/thumbs_bench.rs), and the cores stay free for raw decoding.
  • 8-bit renders take the GPU path with LUT profiles (the .cube table rides in the kernel's aux buffer, trilinear like Lut3d::apply, after grain), masks with Refine Edges (the cross guided filter of Auto Mask, blended by √refine before the mask's invert and amount) and soft proofs into a colour space (proof-space gamut mapping and both gamut warnings, into an output space or a display). These render on the CPU: high-bit-depth exports and Visualize HDR (both need float output), soft proofs to an output device's profile (a printer or paper profile), and photos with a content-aware Fill spot (no GPU kernel for it).
  • Anything the GPU path cannot do returns None and the CPU renders instead: no adapter (CI machines, software-only adapters), LIGHTCRAFT_GPU=0 or LIGHTCRAFT_GPU_BACKEND=off (whole process; see Backends), the app.gpu {enabled} 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).
  • wasm32: the crate compiles to the CPU fallback (WebGPU needs asynchronous device creation and a device shared with the canvas — open item below).

Structure

  • Shared parameters, so the two implementations cannot drift apart: pipeline::plan (effective settings, frame, output size, stage-cache keys), Frame::sample_plan, local::{wb_matrix_for, nr_params, plane_sigmas, guided_fast_step, airlight_of}, finish::{FinishParams, mask_terms}, masks::brush_dabs; exact tables (tone LUT, sRGB LUT, curve LUTs, resample taps) and the OkLab matrices are uploaded / generated into the WGSL prelude from the CPU values.
  • Buffers, not textures: images are array<f32> with the CPU's interleaved layout (RGB, 1–3 channels), so upload / readback are plain copies of Rgb32f / Plane / Rgba8 data.
  • Kernels (crates/gpu/src/wgsl/): geom (orientation pixel map, bilinear through the crop/straighten/flip affine or the full lens + perspective warp), resize (separable resample with the CPU's taps; Mitchell prefilter, box/bilinear for the fast guided filter), blur (box passes, three each way = the CPU's Gaussian: horizontal ones sum each pixel's window, so adjacent threads read adjacent pixels; vertical ones slide running sums down chunks of a column), map (log luminance, dark channel, guided-filter steps, white balance, luminance / colour NR, airlight sampling), mask (linear / radial / luminance / colour range / brush shapes, combine, finalize), finish (the whole per-pixel stage) — each mirrors a named CPU function.
  • Defringe (fringe_lab, a separable lightness minmax filter, fringe_apply; parameters from optics::defringe_params) and spot removal (spot module, spot by spot in place: spot_prep, the Heal tone correction as two device Gaussians, spot_apply; Remove's 48 candidate sources are scored by spot_score and only the scores are read back; layout and candidates from spots::{layout, candidates, ring_dirs, score_radii, rank}) run on the device in lin_cpu's order. The histogram is counted on the device (hist, atomics, the same sampling grid as Histogram::of_srgb8, so it is exact).
  • Soft proofing against an output space (RenderRequest::proof, with its destination and display gamut warnings) runs in finish; a proof against an output-device profile (its 3D table) renders on the CPU.
  • Per-stage hybrid: the camera's local tone mapping (ProRAW gain tables, off by default) runs the scene-linear stage on the CPU and uploads; heuristic mask shapes (Sky, Subject, Background, …) and brushes of more than 4096 dabs are evaluated on the CPU and uploaded; sources over the buffer limit are resampled on the CPU.
  • Stage cache: GpuStages mirrors StageCache (same keys: source identity + geo, lin_key, per-plane radii) and lives in it as an extension, so the UI needs no change. The uploaded source is kept per view. A tone / colour / exposure drag re-runs one dispatch of finish plus the readback.
  • One command encoder per render; the only mid-render readback is the airlight samples (dehaze, when the dark channel is recomputed). Dropped buffers are recycled (exact size, ≤ 2 GB pool) once the thread that dropped them has submitted — allocating and zero-filling fresh 100–300 MB buffers 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. perf.gpuFallback is one value for the whole process, overwritten by the latest render on any thread. A render's own reason comes from lightcraft_gpu::try_render (Err(Fallback::Failed(why)); Fallback::NotTried when the GPU was not used at all), which lightcraft_engine::media::develop_reporting passes on as RenderResult::gpu_fallback; an export carries it as Exported::gpu_fallback, and each file of an app.export batch result has gpuFallback when its render fell back (issue #675). Reproducing a smaller GPU: LIGHTCRAFT_GPU_LIMITS=webgpu (or downlevel) creates the device with 128 MiB storage bindings / 256 MiB buffers, LIGHTCRAFT_GPU_LIMITS=<n> with n MiB / 2n MiB; the window's device, when adopted, is capped the same way. Tests inject failures with lightcraft_gpu::inject_fault (crates/gpu/tests/it/fallback.rs, export_falls_back_to_the_cpu_when_gpu_work_is_lost). The fault is thread-local, and so are its consequences: a simulated fatal failure stops GPU use on the injecting thread only (available() false, unavailable_reason() set there), until reset_failures(), which undoes nothing else — a real failure still stops the whole process. Such a test asserts on its own render's reason, not on last_fallback(), so it can run beside other tests rendering on the GPU (issue #675).

Correctness: CPU oracle and equivalence tests

crates/gpu/tests/it/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): max 1 LSB, mean ≤ 0.0003 LSB for every case — 26 settings cases (all tone/colour/effects tools, 3 vignette styles, grain, curves, grading, B&W, WB, NR, crop/straighten/flip, lens + perspective, three mask sets incl. CPU-evaluated shapes, spots + defringe), all 8 orientations ± crop, embedded DNG lens data, display-referred sources, draft quality, full-size renders; the 24 MP export in render_bench also differs by max 1 LSB. Cached (slider-drag) GPU renders are bit-identical to fresh ones. windows_match runs the settings cases again as a window of a 3000 × 2000 frame (RenderRequest::window, the zoomed loupe, issue #323) plus a vignette in a corner and a spot reading from outside the window: the vignette geometry arrives as the VIG_VIEW parameter, the airlight and noise-reduction size come from the whole frame, and a window that spots or Auto Mask strokes grew is cut back after the readback. The tests skip (pass with a note) when no adapter exists.

AMD Radeon 8060S (RADV STRIX_HALO, Vulkan 1.4, Mesa 26.2.4, Linux 7.2), 2026-10-12, the first non-Apple GPU verified: all 16 tests pass, 135 compared cases. 124 differ by max 1 LSB, 10 are bit-identical, and one (the point colour range overlay) has max 2 LSB on 0.0002% of pixels. Worst mean 0.0003 LSB. That is the same picture as on Metal, inside the bounds (cargo test -p lightcraft-gpu --release --test it equivalence:: -- --nocapture).

Running them without a GPU (issue #1050). Gpu::new turns down software adapters, so on a machine without a hardware GPU (a VM, a container) every GPU test skips. LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1 lets them through for one run (tests only: the app never sets it):

log="$(mktemp -d)/gpu-skips.jsonl"   # a new private directory; an absolute path, as tests run in crates/gpu
LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1 LIGHTCRAFT_SKIP_LOG="$log" cargo test -p lightcraft-gpu --tests
grep '"need":"gpu"' "$log" | grep -v -e 'lost on purpose' -e 'memory never ran out'   # no output: only the two below skipped
  • Linux: Mesa's lavapipe (Vulkan; mesa-vulkan-drivers on Debian and Ubuntu). Its storage bindings stop at 128 MiB (the WebGPU default), so the two big-source tests use a 117 MiB source: above what a view keeps with its stages (96 MiB) and small enough to be uploaded there.
  • Windows: WARP, the DX12 software rasterizer that ships with Windows (shaders through FXC, as above).
  • macOS: Metal is a hardware adapter, so the tests run without the variable.

A machine with a hardware GPU picks it over the software adapter, variable or not. Each test that skips prints skipped (gpu): … and appends a line to the LIGHTCRAFT_SKIP_LOG file; with a software adapter the only ones left should be the device-loss test (no GPU device, or one that can't be lost on purpose here) and the device-memory test (no GPU device, or its memory never ran out: on lavapipe its allocations never fail, seen 2026-10-31). Measured 2026-10-11 on lavapipe and WARP, and again 2026-10-30 on lavapipe (Mesa's llvmpipe on LLVM 20, 143 compared renders), every compared render stayed within max 1 LSB, mean ≤ 0.0003 LSB, as on the M4 Pro. The equivalence binary takes about 20 s on lavapipe and several minutes on WARP.

Remaining differences come from f32 vs f64 coordinate math, fast-math transcendental functions on Metal and summation order in the box filters — all far below one 8-bit step.

Measurements

In the app: Help ▸ Debug ▸ Frame Rate (view.frameRate, {show?}; LIGHTCRAFT_FPS=1 starts with it on, never saved) draws frames per second, each frame's UI CPU time against its time spent waiting (for vsync, or for the GPU), the latest whole-frame and zoom-window render times, the render queue, renders thrown away as superseded, the GPU adapter and its memory, and a graph of the last frames with a tick where a render arrived. Once a second it logs the same numbers (fps-hud:), so a scripted drag (ui.dragWidget) can be measured from the log.

render_bench on corpus/raw/arw-sony-a7m3-compressed.arw (24 MP; loupe = 2560 px preview → 1920×1280), min of 7 runs, wall clock, on a heavily shared machine (load average 60–110 on 14 cores, so CPU numbers are pessimistic; the GPU numbers are less affected):

scenario CPU GPU
loupe 1920×1280 cold (new photo, incl. upload) 415 ms 32 ms
loupe draft 1152×768 cold 169 ms 21 ms
exposure drag 1920×1280 (warm) 22–42 ms 3.9 ms
highlights drag, draft (warm) 14 ms 3.2 ms
clarity drag, draft (warm) 16–23 ms 3.0 ms
NR drag, draft / 1920×1280 (warm) 186 / 301–471 ms 10 / 18 ms
per-pixel stage 1920×1280, + vibrance/saturation 53–65 ms 5 ms
export render 6000×4000 1037–1414 ms 291–330 ms

On Windows (Radeon 780M iGPU, DX12), a full-size JPEG export of the same raw with heavy edits (NR 30 / 25, clarity, texture, dehaze, sharpening, highlights / shadows) took 3.6–4.0 s on the GPU against 1.9–2.0 s on the CPU: the horizontal box pass had each thread slide a running sum over 16 pixels, so the threads of a workgroup read pixels 16 apart, and it took 9–15× as long as the vertical pass. Summing each pixel's window instead (adjacent threads, adjacent pixels): 1.9–2.0 s, with the GPU's NR stage 1.5 → 0.4 s and spatial planes 0.89 → 0.29 s (LIGHTCRAFT_PROFILE). The ignored test bench_kernels (crates/gpu/src/render.rs) times the passes on a 24 MP plane.

GPU timings include the readback of the 8-bit result and the histogram. Device creation + kernel compilation: ~0.4 s once per process — the desktop app starts it on a background thread at launch (lightcraft_gpu::warm_up), so the first loupe render doesn't wait for it; other processes create the device on their first GPU render. lightcraft_gpu::ready() asks without blocking.

On Windows / DX12 the kernels cost more: FXC compiles each pipeline single-threaded inside create_compute_pipeline (issue #471). Built one after another, the 27 took ~2.2 s on an Intel Core Ultra 7 165U iGPU (app.gpu 2.4 s median, up to 7 s on the first run after an install), so ctx::build_kernels shares them out to worker threads (issue #2936): ~1.05 s there (app.gpu 1.2 s), most likely the largest kernel (finish.wgsl) alone (not timed per kernel). wgpu's error scopes are per thread, so every worker opens its own; a refused kernel still means no GPU. The gpu: info log line gives the kernel count and build time.

Drawing renders straight from the GPU

The desktop app creates the window's wgpu device with the adapter's limits and hands it to the GPU renderer (lightcraft_gpu::adopt_window_device, on a background thread at startup: the kernels build there; the crash sentinel covers it), so rendering and drawing share one device. A slider drag's drafts of the views (Detail, Before, the zoomed window, hover, Compare, the second window) are then marked RenderJob::on_screen: the render ends in an Rgba8Unorm texture (lightcraft_gpu::render_display → Output::Display) that egui-wgpu draws as a native texture (ScreenTextures in ui-egui, implemented by the app) - no readback, no conversion to egui's pixels, no re-upload. The per-render checks run on the device instead: every pixel written (an atomic count), not entirely black (the device histogram). Rows are re-laid at a 256-byte stride for the texture copy when the width needs it.

Renders that need pixels keep the readback: the settled full-quality render after a drag (the cached view preview, overlays, screenshots), thumbnails, exports, overlays (Visualize Spots, mask overlays), windows that spots grew (cut on the CPU), a monitor profile (corrected on the CPU), and anything the GPU path declines. A device that doesn't suit (software adapter, too few storage buffers, kernels that don't build there) makes the renderer create a device of its own, as before. A headless ui.screenshot (the control channel keeps CPU copies of what is drawn) taken while a view shows a GPU-resident draft reads that texture back for the picture (ScreenTextures::read, lightcraft_gpu::read_texture); drawing never does.

  • LIGHTCRAFT_GPU_WINDOW=0: never adopt the window's device (a device of its own, renders read back) - to compare, or for a driver that misbehaves with compute and drawing on one device.
  • ui.inspect → perf.gpuWindowDevice (renders run on the window's device), perf.screenTextures (the UI can draw GPU-resident renders), perf.mainOnGpu (the loupe shows one now).
  • Measured (RTX 5070, DX12, a 45 MP CR3, 90 exposure steps of a drag at 30 Hz): the loupe's draft render 1.69 ms median from the texture vs 2.05 ms with the readback; the UI thread unchanged within noise (~1 ms). The difference grows with the loupe's size (4K/5K displays, zoom).
  • With the renderer on the window's device, errors the device raises outside a render (the window's own included) are logged and stop GPU rendering (CPU from then on) instead of wgpu's default panic.

Opening a photo (M5.4)

  • The loupe shows a stand-in at once (media::QuickJob): the photo's cached view render for its current settings, else (raws with their import look) the embedded camera JPEG, else a thumbnail-level render; the full render replaces it in place.
  • Raw sources for previews (2560 px) and thumbnails are binned straight from the mosaic (RawImage::develop_binned, 2× for a 24 MP preview); only exports / 1:1 demosaic at full size.
  • The next and previous photos in filmstrip order are prepared in the background once the current one is rendered (decoded source kept, view render cached): stepping through photos shows the developed image in ~50 ms.
  • Culling (PERF-CULL): the stand-ins of the next five photos in the direction of travel and the one behind are made ahead (render::Slot::QuickAhead) and taken over at the step, so a step shows the camera JPEG at once; while an arrow key is held (a photo left within 0.25 s) the raw render of a photo waits until it has stayed 0.12 s, and a render queued for a photo already left is dropped. ui.inspect → perf.loupeSteps times each step on the frames drawn.

Memory (M5.6)

A view's stages keep an uploaded source only up to 96 MB (a Preview-level source); a bigger one (the original a zoom window is cut from) is kept once, outside the views' stages, for all views of the photo (the Before and After windows and every pan share it; the Before window of a Before/After waits at first open until the After's job has handed the original over, or each would decode and upload its own copy) and given back when the app idles with no zoom window on screen (a zoomed view keeps it for its next pan: a 64 MP original is 800 MB to upload again; issue #323: kept per view it counted against the stage budget on every tick of a drag, and not kept every new window uploaded 288 MB again).

  • library.memory reports what the engine's caches hold (decoded thumbnail / preview / full-size sources, rendered previews) and the GPU renderer's device buffers (allocated, of which pooled and retired); ui.inspect → memory adds the loupe's stage caches (CPU images, GPU buffers) and the textures.
  • Heap profile: build lightcraft-cli with --features dhat-heap; library.memory then also reports live/peak heap bytes and the run writes dhat-heap.json (LIGHTCRAFT_DHAT_FILE), whose allocation sites at the peak (t-gmax) show who holds the memory.
  • Measuring the scenario (import 14 raws from corpus/raw, open the loupe, step 12 times): /usr/bin/time -l lightcraft-cli snapshot <files> --script steps.jsonl -o out.png → "maximum resident set size" and "peak memory footprint". Run it several times: the high-water mark is noisy (allocator caching, scheduling). On Apple silicon GPU buffers count in the footprint.
  • Imports read raw headers only (lightcraft_raw::probe_info): no pixel data is decompressed, except the first rows a few vendor formats need for facts their headers lack (Nikon NEF: the first 128 rows for the masked trailing columns, issue #708).
  • One budget (memory::budget, default min(25 % of RAM, 1.5 GiB); LIGHTCRAFT_MEMORY_MB or app.memoryBudget {mb}): half for the engine caches (decoded thumbnail / preview / full sources and rendered previews, evicted least recently used across them; the photo on screen stays), a quarter for decodes in flight (memory::work_gate: grid thumbnails, neighbour prefetch and import probes wait for room, loupe and export loads never wait), an eighth for the GPU buffer pool (reuse by best fit within 25 %; freed after 3 s idle).
  • Decodes free the file's bytes and the raw samples as soon as they are consumed and colour / crop / orient in place. macOS keeps freed large blocks resident ("reusable") until the system is short of memory, so the resident size climbed with every photo decoded; the apps install malloc_zone_pressure_relief as memory::set_release_hook, called after each decode, import and idle trim.
  • Result (M4 Pro, 14 raws, loupe, 12 steps): peak RSS 1.9–2.3 GB → 0.83–0.99 GB, peak footprint 2.5–2.6 GB → 0.91–0.96 GB, heap peak 930 → ~600 MB; stepping median ~13 ms (unchanged).

Open items

  • WebGPU in the browser build (async init, single device per canvas).
  • Sky/Subject heuristics (replaced by the segmenter in M12); the ProRAW local tone map.
  • 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.