NC Imago · Docs

GPU develop pipeline (M5.2)

The CPU pipeline (crates/pipeline) is the reference ("oracle"). imago-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.

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 imago_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 Imago only lets wgpu touch the backends it means to use (imago_gpu::backend), for its compute device and for the desktop window (eframe/egui-wgpu, window_wgpu_options in apps/imago/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):

DX12 shader compiler (issue #471). Every instance (window and compute) compiles DX12 shaders with FXC (d3dcompiler_47.dll, part of Windows; imago_gpu::backend::backend_options). wgpu's default, Auto, loads whichever dxcompiler.dll the DLL search path finds first. Imago 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 imago.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.

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), IMAGO_GPU=0 or IMAGO_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 ImagoApp::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%\Imago, ~/Library/Application Support/Imago, ~/.config/imago) 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. IMAGO_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: Imago 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 IMAGO_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 IMAGO_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. IMAGO_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.

Troubleshooting a crash at startup (Windows). Start Imago from a .cmd file or a terminal with set IMAGO_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)).

Imago 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 Imago stops. It says so ("Imago 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 (Picto does). It is a driver issue: nothing in Imago itself walks the drive.

Where it is used

Structure

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, imago-cli render and IMAGO_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:

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 IMAGO_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. IMAGO_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 imago_gpu::try_render (Err(Fallback::Failed(why)); Fallback::NotTried when the GPU was not used at all), which imago_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: IMAGO_GPU_LIMITS=webgpu (or downlevel) creates the device with 128 MiB storage bindings / 256 MiB buffers, IMAGO_GPU_LIMITS=<n> with n MiB / 2n MiB. Tests inject failures with imago_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 imago-gpu --release --test it equivalence:: -- --nocapture).

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?}; IMAGO_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 (IMAGO_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 (imago_gpu::warm_up), so the first loupe render doesn't wait for it; other processes create the device on their first GPU render. imago_gpu::ready() asks without blocking.

Opening a photo (M5.4)

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

Open items