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):
IMAGO_GPU_BACKEND=dx12 | vulkan | metal | gl | auto | off(comma lists allowed, e.g.vulkan,dx12): the backends for both the window and GPU rendering;offturns 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). IMAGO_GPU=0: GPU rendering off for the process (the window is unaffected).- Both
IMAGO_GPU=0andIMAGO_GPU_BACKEND=offalso keep AI Denoise, the face models and the AI mask model (SAM 3 on Metal) on the CPU.
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
imago_engine::media::develop(called by everyRenderJob): loupe / before / compare views,render_now(CLI, MCP, control channel renders) and exports render on the GPU when one is available; grid/filmstrip thumbnails (many small jobs in parallel) stay on the CPU.- Anything the GPU path cannot do returns
Noneand the CPU renders instead: no adapter (CI machines, software-only adapters),IMAGO_GPU=0orIMAGO_GPU_BACKEND=off(whole process; see Backends), theapp.gpu {enabled}command (runtime preference;ui.inspect→perf.gpushows 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 ofRgb32f/Plane/Rgba8data. - 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. - Soft proofing against an output space
(
RenderRequest::proof, with its destination and display gamut warnings) runs infinish; a proof against an output-device profile (its 3D table) renders on the CPU. - Per-stage hybrid: defringe and spot removal (rare, CPU-only for now) download the resampled image, run on the CPU and upload; 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:
GpuStagesmirrorsStageCache(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 offinishplus 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.
IMAGO_PROFILE=1prints 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, 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:
- 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 (groups1folds 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 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)
- 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.
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.memoryreports 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→memoryadds the loupe's stage caches (CPU images, GPU buffers) and the textures.- Heap profile: build
imago-cliwith--features dhat-heap;library.memorythen also reports live/peak heap bytes and the run writesdhat-heap.json(IMAGO_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 imago-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 (
imago_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);IMAGO_MEMORY_MBorapp.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_reliefasmemory::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
- Keep the loupe texture on the GPU (render into an
egui-wgputexture on eframe's device instead of 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 (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.