Files
lightcraft/docs/gpu-pipeline.md
T
61005f3dc1 Culling: the loupe uses built previews; photos prepared ahead and in the background (#3077)
* PERF-CULL-0: ui.inspect reports how the loupe showed each photo it was stepped to

perf.loupeSteps records, per step, the ms from the step to the first frame
with anything of the photo, with a full-size picture (camera JPEG, cached
or rendered) and with LightCraft's own render, measured on the frames the
app draws, so culling benchmarks exclude the control channel's round trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 95f0343848fc0a238f80f07ceb292eaf6779aeaa)

* PERF-CULL-1: browsing a folder shows its first photos within a second

The Local view read and hashed every file of the folder before showing any
of them (2,933 EOS R5 C CR3s: 30.6 s to the first photo). It now hands the
files the library lacks straight to the import, ordered the way the view
sorts them (file time for the date sorts), which adds them batch by batch:
first photo 0.9 s, twelve thumbnails 1.4 s, the whole folder in the same
~34 s as before, in the background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit b766558e7ef4f802919a144da53e444f70e0186b)

* PERF-CULL-2: the loupe makes the next photos' camera previews ahead of the step

Culling steps through photos, and each step waited for the next raw's
embedded JPEG to be read and decoded (p50 187 ms, p95 332 ms on EOS R5 C
CR3s) and then for a full raw develop. Now:

- the loupe's stand-ins for the next five photos in the direction of
  travel and the one behind are made ahead (Slot::QuickAhead, priority
  just below the loupe's own render) and taken over at the step, so a
  step shows the camera JPEG at once (p50 0 ms);
- a slot keeps its photo while the photo stays wanted, so photos that
  shift places as the user steps are not made twice;
- decoded camera JPEGs stay in the memory LRU (never on disk), so going
  back shows them again without a decode;
- while an arrow key is held (a photo left within 0.25 s), the raw
  develop waits until the photo has stayed 0.12 s, and a render queued
  for a photo already left is dropped, so the workers make the previews
  instead of renders nobody sees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 0430b95636d2b6adce8887af7b9464687c95549e)

* PERF-CULL-3: CR3 previews take the smallest embedded JPEG that covers the view; plain sRGB JPEGs decode straight to 8-bit

Upstream's embedded_preview_covering (14e0d128) picks the smallest covering
JPEG of a TIFF-based raw; this extends it to Canon CR3 and adds the 8-bit
decode:
- A CR3 keeps an 8192 px JPEG track, a 1620 px PRVW box and a 160 px THMB.
  embedded_preview_covering now picks among the three (cr3_boxes lists the
  boxes), so a grid thumbnail or a loupe up to 1620 px decodes the PRVW.
  Loader at 1280 px on 20 EOS R5 C CR3s: 168-322 ms -> 31 ms a photo.
- lightcraft_codecs::decode_jpeg_srgb8_fit decodes a plain 8-bit sRGB
  JPEG (DCT-scaled) and box-averages it in linear light straight into
  8-bit sRGB, without the float image the general path builds; anything
  with an ICC profile, an Adobe RGB declaration, CMYK or 12-bit takes the
  general path. 45 MP preview fitted to 2560 px: 182 -> 151 ms (min of
  15, loaded machine).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit a67bb398c2f438cbe16f4db1431ae0270f1c8b04)

* PERF-CULL-4: Camera preview label; thumbnails ahead of the step; 100 % shows the camera JPEG first

- While the loupe shows a raw's embedded JPEG (or its thumbnail), a small
  'Camera preview' label at the top right says so, with what that means
  on hover (widget loupe:cameraPreview; nine languages), so colour and
  tone are not judged on the camera's rendering.
- The look-ahead also asks for the next photos' thumbnails (a small
  embedded JPEG each), so a held arrow key has something to show in full
  screen too, where no filmstrip asks for them (2400x1500 window, 40 held
  steps: camera JPEG on 36, nothing on 2; before 1 and 4). Stepping
  through a whole wedding keeps at most 600 thumbnails, those around the
  open photo.
- Zoomed in on an unedited raw, its camera JPEG at full size (8192 px for
  an EOS R5 C) is drawn under the window render until that arrives:
  100 % in ~0.9 s instead of ~5.6 s; released when not zoomed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 9f212b1e16815c3c0ca33130319a7ef41fb05a26)

* PERF-CULL-5: lightcraft-cli bench-cull and tools/cull-bench.py time culling a real shoot

bench-cull FOLDER (headless) times reading the folder into the Local view,
the first screen of grid thumbnails on the app's render threads, camera
previews at 1280 / 2560 px and full size, a cold 2560 px loupe render,
rating 50 photos in a row and importing N files in place / copying M,
as 'name: X ms wall' lines (p50, p95, n) and JSON. Libraries are made
under --work; nothing is written next to the photos.

tools/cull-bench.py drives the running app over the control channel
(browse, first photo, steps at rest, a held arrow key, 100 % zoom,
rating) and reads the app's own frame timings (perf.loupeSteps).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit bd2eaac591b72f8258acae4389c67aa4d1da3b27)

* PERF-CULL-5: bench-cull --files K samples a slow folder; e2e test; cull-bench usage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit c32c9556924ccdee6f8c103864df51c3e170df98)

* PERF-CULL: docs for culling speed (parity rows, gap 26, preview reads, opening a photo)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit fb69b5975b51f882d82b2ca684e0f7ddc8373908)

* PERF-CULL: the whole-file preview decodes a plain sRGB camera JPEG the quick path's way

Upstream's quick-path test wants the bounded read and the whole file to give the same picture
byte for byte; with the branch's straight-to-8-bit decode on the quick path only, a 49 px
preview came out differently. Both paths now take it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 0c8acab4c2f3e85d1c51fab5faf62b6e997d7c73)

* PERF-THUMB-FIRST: CR3 thumbnails decode the 1620 px PRVW copy, and grid thumbnails from camera JPEGs are kept in the thumbnail cache

A CR3 grid thumbnail decoded the full-size JPEG track (45 MP, ~150 ms each)
whatever size was asked; it now takes the smallest embedded copy that covers
the cell (PRVW, same shape), and the box search stops at mdat (was a scan of
the whole file). The decoded camera JPEG of an unedited raw is written to
the library's thumbnail cache, so a folder seen before fills from small
files. Measured on 250 R5 C raws (loaded machine): first screen fills in
294 ms (was 553), on a second visit 53 ms (was 610).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 12162f966dd859b7e53ac65b5c4564a8e837b7eb)

* PERF-CULL: cargo fmt

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

* PERF-CULL: docs for culling speed (parity rows LR-IMP-LOCAL, LR-IMP-PREVIEW-READ, LR-VIEW-DETAIL; gap 26)

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

* PERF-CULL: review fixes (bench work folder, 8-bit APP1 cap, size sort)

- bench-cull makes its libraries in a new folder of the run's own under
  --work and never deletes what is already there (it removed lib-browse,
  lib-add and lib-copy under a folder the caller named).
- The 8-bit sRGB JPEG path refuses more than 128 APP1 segments before its
  decoders walk them again, as the general decode does.
- Browsing by file size keeps unreadable files last in both directions
  (they were sized 0, so first when ascending).
- --files K in the CLI synopsis and help.

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

* PERF-PREVIEW-PREP: the loupe uses built previews; photos are prepared ahead and in the background

Built standard previews were never what the loupe showed: it rendered
every photo again (~1.8 s on a raw), the rendered previews in memory were
capped at 128 MB (~84 photos) whatever the budget, the disk cache was a
fixed 2 GB (a wedding's previews pruned each other), the cache's writer
skipped disk writes when it fell behind, and Build Previews with one photo
open built only that photo though the menu said "Visible".

- The loupe's render job takes a cached view render of the same settings
  that is at least as big as asked for, fitted down (no render).
- Memory for rendered previews is a quarter of the cache share; the disk
  cache's automatic size makes room for every photo (about 1.5 MB each);
  Settings offers Automatic, 50 GB and 200 GB.
- A build skips photos whose previews are there, writes each to disk
  before counting it, builds at least the loupe's size, and with one photo
  selected builds the view.
- Prepare photos in the background (Settings > Performance), per mode
  (culling, library, editing), app-wide (kept in ui.json) or per library:
  photos ahead rendered for the loupe (a few at a time, 0 = off) and the
  whole view's previews built in the spare time once the user steps
  through photos: two lanes, waiting while the photo on screen renders,
  nearest first in the direction of travel, one cancellable row, replaced
  by a build that is asked for. Commands prefs.backgroundPrep /
  prefs.setBackgroundPrep; ui.inspect perf.previewCache.

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

* PERF-CULL: the bench work-folder test removes only a folder it made

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

* PERF-CULL: browsing keeps the whole listing for the folder bookkeeping; cull-bench stops on small folders

The browse worker queues only the files the library lacks, but the folder
bookkeeping (browse times, records of files gone) needs every file listed:
a subfolder of known photos only was not stamped as browsed. The known
files now come along marked as already in the library. tools/cull-bench.py
stops waiting at min(12, photos) thumbnails.

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

* PERF-PREVIEW-PREP: review fixes (smart previews honour id, editing builds by the Library row, lenient prep settings)

- library.smartPreviews with an explicit id works on that photo, not the
  whole view.
- While editing, the whole view's background build follows the Library
  row (its switch and which photos); the Editing row's scope is for the
  photos kept ready ahead.
- The app-wide and per-library preparation settings are read leniently:
  a value this build doesn't know is the defaults (app) or none (library),
  and the other settings are kept.

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

* PERF-CULL: cull-bench counts twelve thumbnails while the folder is still loading

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

* PERF-PREVIEW-PREP: a view change stops the background build for the old view

The running background build was only re-aimed at the photo on screen; after
another folder, filter or library it went on building photos no longer
shown. It is now stopped when the view's signature changes (not counted as
stopped by the user), and the view now shown is prepared.

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

* PERF-CULL: cull-bench picks the thumbnail threshold from the final photo count

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

* PERF-PREVIEW-PREP: the view signature covers every photo; the view-change test holds the build explicitly

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

* PERF-CULL: cull-bench stops waiting for twelve thumbnails 30 s after the folder loaded

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

* PERF-PREVIEW-PREP: in the marked scope, marking a photo changes what the view's build prepares

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

* PERF-CULL: cull-bench times only the thumbnails the browse adds

Thumbnails already cached before library.browse no longer count towards
the first-thumbnail and twelve-thumbnail times.

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

* PERF-CULL: count every thumbnail made, for the browse benchmark

thumbTextures counts resident thumbnails, and evicting old ones can hide
new ones. The renderer now also counts each grid thumbnail it stores
(perf.thumbsMade, never lowered by eviction), and cull-bench times the
browse from that.

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

* PERF-PREVIEW-PREP: the marked-scope test waits for its builds before cleaning up

Both preview builds finish before the test library is removed, and the
removal is checked, so no worker writes into a folder being deleted.

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

* PERF-CULL: stand-ins made ahead keep their order, size and photo

Review fixes for the loupe's stand-ins and the browse benchmark:
- a stand-in still queued ahead moves to its new place in the order
  when the user steps;
- one made ahead still shows when the loupe's own request failed;
- one made ahead for a smaller loupe isn't taken over by a larger one;
- quick and full-size camera-preview keys include the photo, so a
  virtual copy gets its own;
- perf.thumbsMade counts photos given a thumbnail, not a render
  replacing a stand-in.
Each has a test that fails without it.

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

* PERF-CULL: a late stand-in never takes another photo's place in the loupe

A stand-in made ahead takes over after the loupe's own request failed
only while the loupe still wants that photo and has no other request
pending; the test covers stepping on before it arrives.

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

* PERF-CULL: a stand-in made ahead and no longer wanted is dropped when it arrives

Freeing a look-ahead slot whose job is already running now also forgets
its request, so the late result isn't kept as a hidden texture, unless
the loupe is waiting for exactly that stand-in.

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

* PERF-CULL: a running stand-in the loupe waits for keeps its look-ahead slot

When the next look-ahead no longer wants the photo the loupe has just
stepped to, the slot whose job is still making that photo's stand-in stays
reserved instead of going to a new photo, so the result still reaches
the loupe.

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

* PERF-CULL: CodeRabbit round 10 (browse order ties, library skip, CR3 boxes after mdat, badge once, look-ahead crop key, cli test binary)

- browse_order: import- and edit-date sorts queue new files by name (they
  tie on those keys); ties go by name in the view's direction, as the
  catalog breaks them.
- Local browsing never lists the library's own folder (browse_files, as
  scan_with skips it).
- CR3 PRVW/THMB search skips only the mdat payload: boxes after it (a moov
  holding THMB) are still found.
- The camera-preview badge and the loupe timing are settled once, after
  the window renders (no double badge with the full-size camera JPEG).
- The loupe look-ahead key includes the crop.
- bench-cull CLI tests use binary() after upstream's test split.

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

* PERF-CULL: CodeRabbit round 11 (browse listing errors and library root, rating ties, every CR3 PRVW, checked CR3 box math, look-ahead view key, windows wait while settling)

- browse_files: a folder that can't be listed is an error (the scan ends
  as failed, nothing is forgotten as gone), and the library's folder or
  one inside it lists nothing.
- New photos tie on rating: queued by name in the view's direction.
- Every valid CR3 PRVW/THMB is a candidate (the smallest covering one is
  picked); the box offsets use checked arithmetic.
- The loupe look-ahead is keyed to the visible list's generation (a new
  sort, filter or source changes the neighbours).
- While stepping fast, zoomed-in window renders wait like the frame, and a
  window drawn before the frame is timed as the render.

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

* PERF-CULL: CodeRabbit round 12 (loupe timing: the After window, over any stand-in)

In a split view only the After side's window says the raw's render is on
screen; and a window drawn over the camera JPEG counts as the render too
(the source shown is a render only when it is "render" or "cached").

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

* PERF-CULL: CodeRabbit round 13 (loupe timing: the Before window when the Before is shown alone)

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

* PERF-CULL: CodeRabbit round 14 (loupe timing: a Before window side by side with \ held is not the shown view)

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

* PERF-CULL: CodeRabbit round 15 (the split-view timing test asserts its precondition)

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

* PERF-CULL: CodeRabbit round 16 (side by side with \ held, each window stays in its pane)

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

* PERF-CULL: a cancelled browse stops ordering its files; browsing hands back earlier probes

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

* PERF-CULL: the web build runs the browse job in place as FnMut (it hands the probes back)

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

* PERF-CULL: a scan hands the probes it took back however it ends (CodeRabbit, #3077 / #3080)

- The browse worker sends a result on every path: the listing, or `Stopped` (the folder couldn't be listed, or
  another folder was chosen) with the probes `ScanInput::new` took from the session. A listing failure is never
  sent as an empty listing.
- A scan stopped without waiting (its cross, or a browse replaced by another) keeps its channel until the worker
  ends, so the probes still come back; a finished scan's probes join the session's instead of replacing them.
- A cancelled scan whose worker ended without a result is not reported as "Scan failed".

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

* PERF-PREVIEW-PREP: built previews are the ones on disk; stand-ins ahead follow the preparation settings (CodeRabbit, #3077)

- A build counts a photo as built only when its previews are in the disk cache (when it has one): a loupe
  render kept only in memory, its deferred write skipped, is built again and written (`stored_dims`).
- `DiskCache::put` and `PreviewCache::put_at` say whether the file was written; a view preview the cache
  folder can't take fails the photo instead of counting it as built.
- The loupe's stand-ins ahead honour Settings > Performance for the mode: none for "Only the photo on screen"
  or no photos ahead (the queued ones are dropped), only marked photos in the marked scope. The settings are
  part of what the look-ahead was made for.

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

* PERF-PREVIEW-PREP: a preview for a discarded cache fails the photo; cache file sizes are read from 4 KiB first (CodeRabbit, #3077 / #3080)

- `PreviewCache::put_at` returns false for a job made before the cache was cleared or retired (nothing is
  kept), so a build that overlaps Discard Preview Cache counts that photo as failed, not built. The warning
  says which of the two happened.
- `DiskCache::dims` reads a file's first 4 KiB, and up to 64 KiB only when the size marker isn't there.

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

* PERF-PREVIEW-PREP: a photo another build renders counts once it is built; a write pruned away is not kept (CodeRabbit, #3077 / #3080)

- A build that meets a photo another build is rendering waits for that render, then counts the photo as
  built only when its previews are there, else renders it itself (it was counted as shared at once).
- `DiskCache::put` returns false when the pruning its own write starts removes that file (a picture bigger
  than the room pruning leaves).

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

* PERF-CULL: put_memory holds the cache generation through its insertion (CodeRabbit, #3080)

A clear can no longer finish between put_memory's look at the generation and its insertion.

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

* PERF-CULL: browsing skips the library's folder by any name it has (a symlink, a path through ..) (CodeRabbit, #3077)

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

* PERF-CULL: rustfmt the library-folder check

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

* PERF-PREVIEW-PREP: cancelled builds stop before their test library is removed (CodeRabbit, #3077)

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

* PERF-CULL: the library folder is skipped by its resolved path only; lib/../outside is browsed, an unresolvable folder reports its error (CodeRabbit, #3077)

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

* PERF-PREVIEW-PREP: the replaced background build is held by TestHold, not a 250 ms deadline (CodeRabbit, #3077)

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

* PERF-CULL: a browsed folder has one spelling (. and .. taken out by name) for the view, its listing and the catalog's records; an unresolvable root inside the library is still skipped (CodeRabbit, #3055 / #3077 / #3080)

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

* PERF-PREVIEW-PREP: TestHold counts its holders: one test's release doesn't free a build another test holds (CodeRabbit, #3077 / #3080)

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

* PERF-PREVIEW-PREP: TestHold's release without the deprecated fetch_update

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

* PERF-CULL: browsing resolves `..` the way the system does, and `.` is the current folder

`lexical_path` took `..` out by name, which is wrong on POSIX when the folder before it is a
symlink (`link/..` is the folder above where the link points). `browse_path` makes the root
absolute first (so `.` and `a/..` name the current folder, not an empty path), resolves a link
just before a `..` on POSIX, and keeps every other spelling as written (a mapped drive stays a
drive letter, matching the catalog's records). Windows takes `..` out by name, as before.
Tests: a folder link followed by `..` lists what the system lists there; `.` is the current folder.

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

* PERF-CULL: browsing resolves the folder on its worker, never on the window's thread

`browse()` called `browse_path` (which may canonicalize a link) and `is_dir()` before starting the
worker, so a link into a stalled network mount could freeze the window. The UI now spells the
folder with `browse_spelling` (`.`/`..` by name, no disk access) and shows it as given; the worker
resolves it with `browse_path`, checks it is a folder (else the scan ends as failed, with the
usual toast), and sends the resolved spelling, which the view takes before the listing arrives.
Tests: a browse whose resolution is held returns at once and waits, then reads the folder; a
missing folder fails on the worker; `browse_spelling` matches `browse_path` without links.

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

* PERF-CULL: the browse worker resolves the path as given, not the view's spelling (CodeRabbit, #3055 / #3077 / #3080)

On Unix `photos/link/../select` with `link` pointing at `real/day` is `real/select`; the spelling with `..`
taken out by name read `photos/select`. The view keeps the spelling until the worker answers.

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

* PERF-PREVIEW-PREP: bench-cull measures small folders and never writes its results among the photos (CodeRabbit, #3077)

A folder with no more photos than the grid sample was reported with empty camera-preview and loupe timings: the samples now fall back to the photos it has. A --json path in the source folder is refused before the run.

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

* PERF-PREVIEW-PREP: editing renders the loupe from its source; the loupe holds background work back only while its render is on the way (CodeRabbit, #3077)

In Develop a cached view render left the source undecoded until the first slider draft. foreground() was renewed every loupe frame even with the render shown, so background builds mostly waited.

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

* PERF-PREVIEW-PREP: loupeSteps.total, a step cursor that survives the 256-step list (CodeRabbit, #3077)

tools/cull-bench.py sliced the list by its length, which stops growing at 256 steps, and reported no steps on repeated runs. Also documents perf.thumbsMade and perf.previewCache.

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

* PERF-PREVIEW-PREP: the camera preview is located once when the fast decode declines (CodeRabbit, #3077)

The general decode takes the stand-in JPEG the fast path already found instead of parsing the container again.

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

* PERF-PREVIEW-PREP: the uncapped preview cache warns once per session when its drive runs low

The automatic preview cache budget grows with the library and has no ceiling. When the library
opens, and at most every five minutes while previews are built, the engine reads the free space of
the drive holding the cache (fs4, MIT OR Apache-2.0, pure Rust over rustix / windows-sys); under
10 % or under 20 GB free it raises one alert per session. The desktop app shows it as a notice with
the drive's free space and the cache's size and a button to Settings > Performance, where a size
limit can be set. A drive that can't say its free space gives no alert (logged). library.info
reports lowDiskSpace; the notice is in ui.inspect's notices like the others.

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

* PERF-PREVIEW-PREP: the free-space read runs off the UI thread; a queued low-disk notice goes with its library; --work outside the source folder (CodeRabbit, #3077)

- Session::check_disk_space reads the drive on a worker thread and takes the answer on a later
  call (take_disk_space_alert, library.info, the next check): a slow network share no longer
  holds up a library open or a UI frame. Test: a_slow_drive_does_not_hold_up_the_check.
- The UI keeps the low-disk notice with the library generation it was raised for and drops it
  when another library opens. Test: a_queued_notice_goes_when_another_library_opens.
- The real-drive test runs on native targets only (read is None on wasm32).
- bench-cull refuses a --work folder inside the measured folder, also one not made yet, before
  anything is created. Test: the_work_folder_never_goes_in_the_source_folder.

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

* PERF-PREVIEW-PREP: one free-space read at a time across library switches, the cache counted on its worker; a --json link into the source folder is refused (CodeRabbit, #3077/#3080)

- A library opened while the drive's free-space read still runs keeps that read (marked stale)
  instead of starting a second worker beside it; its answer is dropped and the current library's
  drive is read once it has come. Switching libraries while a share hangs no longer piles up
  workers. Test: switching_libraries_while_a_drive_hangs_starts_no_second_read.
- When the drive is low, the preview cache is counted on the read's worker and the count travels
  with the answer: taking it on the UI thread no longer scans a big cache on a slow share.
  DiskCache::known_size reads the count without touching the disk.
  Test: the_preview_cache_is_counted_off_the_ui_thread.
- bench-cull: a --json path that exists is resolved itself (a link to a photo in the source folder
  is that photo), a link that points nowhere yet by the file writing through it would create, a
  loop of links is refused. Test: a_json_link_into_the_source_folder_is_refused.

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

* PERF-PREVIEW-PREP: bench-cull --json replaces its file instead of writing into it, so a hard link to a photo can't overwrite the photo (CodeRabbit, #3077)

The results go to a new file next to the destination, renamed over it; the
temporary file is removed on error. Test:
a_json_hard_link_to_a_photo_leaves_the_photo_alone (fails with the old write).

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

* PERF-PREVIEW-PREP: bench-cull refuses a --json entry in the source folder, also a link there to a file outside (CodeRabbit, #3080)

Writing the results replaces the --json entry itself, so a link sitting in the
source folder that points outside it would be replaced, removing that entry.
check_outputs now checks where the entry sits (its folder resolved, the final
link not followed) as well as where it resolves to.

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

* PERF-CULL: a frame without the loupe ends its step timing, so time away from Detail never counts (CodeRabbit, #3055)

LoupeTiming::left was never called: a photo's record stayed open while the grid, Compare, Survey,
Reference, People or the slideshow was up, so dwell / any / full / sharp ms counted time outside
Detail. observe() marks the frame; finish_ui_pass ends the step when the loupe drew nothing.

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

* PERF-PREVIEW-PREP: Build Standard-Sized Previews keeps 2560 px before a loupe was drawn (CodeRabbit, #3077)

On Automatic with no loupe size known yet (a fresh session) the command left `edge` out, so the
engine built its 2048 px minimum and a typical fit view rendered again. The loupe-aware engine
size still applies once a loupe was drawn; a chosen limit is used as is.

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

* PERF-CULL: main's merge keeps one Russian "★ Favorites" and its browse test uses the scan's end channel

Both sides had added the same key; main's new Auto Import browse test sends through the scan
channel, which this branch carries as `ScanEnd`.

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

* PERF-CULL: stepping fast drops the queued Before render too; the thumbnail count is per photo across eviction (CodeRabbit, #3055)

While an arrow is held the Before view's raw render waits like the loupe's own, and one still
queued for a photo left behind is dropped. `thumbsMade` counts each photo once per library, so a
thumbnail evicted and made again, or a library left and opened again, doesn't count twice.

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

* PERF-PREVIEW-PREP: the free space is read every few minutes, not only while previews are built (CodeRabbit, #3080)

Thumbnails and the loupe's previews fill the cache too, so a drive could run low after the library
opened without the notice: the low-disk check now runs whatever fills the cache (still at most
every five minutes). The engine gains two hidden test hooks (a reader and forgetting the last
read).

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

* PERF-PREVIEW-PREP: a slow free-space read shows in the activity stack and an idle window wakes for the disk check (CodeRabbit, #3077)

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

* PERF-PREVIEW-PREP: background preparation advances on every logic pass, hidden ones too (CodeRabbit, #3077)

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

* PERF-CULL: a photo the loupe opens on is never a fast step; only a step inside the loupe has a predecessor (CodeRabbit, #3077)

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

* PERF-CULL: the cull benchmark rates one named photo and puts its rating back (CodeRabbit, #3077)

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

* PERF-CULL: the cull benchmark refuses a full step history, says when thumbnails were in memory, fails on a lost key and ends a zoom that needs no region (CodeRabbit, #3055)

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

* PERF-PREVIEW-PREP: a forced build renders a photo another build was rendering itself (CodeRabbit, #3080)

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

* PERF-CULL: the cull benchmark stops on a folder with no photos and times a zoom by the region that ended it (CodeRabbit, #3055/#3080)

- browse: a folder that stays loaded with no supported photo ends the browse loop after 3 s
  instead of waiting 30 minutes, and the run stops with a clear message before the loupe
  section instead of failing on an empty selection;
- zoom to 100 %: the time reported is the moment the loop ended on (the region's arrival, or
  the settled whole frame when no region is asked for), so a whole frame that settled before
  a region was requested no longer gives a too-short zoom time.

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

* PERF-CULL: the cull benchmark says no photos are visible, keeps an earlier result, and records an unfinished zoom as missing (CodeRabbit, #3055/#3077/#3080)

- an empty selection after browsing means no photo is visible (a filter can hide them), not that
  the folder has none: say so and point at the filters;
- LABEL.json is no longer overwritten: an existing one stops the run before it starts unless
  --overwrite is given (the final write opens with "x" then);
- a zoom that did not finish within 60 s is written as null, not as a 60 s zoom time.

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

* PERF-CULL: a raw's preview pointer is read with checked bounds on its offset and size (FredApps, #3080)

The JPEGInterchangeFormat pointer and the undefined-tag JPEGs were sliced with `as usize` casts of
the file's own u64 numbers: safe on 64-bit (the slice is `get`, its end cut at the file's end), but
an offset past 4 GiB would wrap to a small, wrong one on a 32-bit build (wasm32). `file_span` now
converts with `try_from` and saturates the end.

Test: `a_preview_pointer_past_the_end_of_the_file_finds_no_preview` (pointers past the end, lengths
past it, u32::MAX for both; a real strip preview beside the lying pointer is still found; every
truncation of the file is safe).

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

* PERF-CULL: bench-cull refuses a --work or --json inside the photo folder (FredApps, #3055)

Nothing is ever written next to the photos: a --work folder or a --json file inside FOLDER (the
folder itself, a subfolder, or a path that goes there through `..`) is refused before anything is
made. Paths are compared resolved (links followed as far as they exist). The cleanup already
removes only what the run made (its own `bench-cull-run-…` folder's `lib-copy`), never --work.

Test: `work_and_json_inside_the_photo_folder_are_refused`.

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

* PERF-PREVIEW-PREP: a test that building previews writes nothing next to the photos (FredApps, #3077)

`building_previews_writes_nothing_next_to_the_photos`: Build Previews and the background
preparation while culling leave the shoot folder with the same files, byte for byte; previews go
only to the library's own cache.

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

* PERF-CULL: cull-bench.py prints its usage on missing arguments and refuses a dangling LABEL.json link up front (CodeRabbit, #3055/#3077)

- PORT / FOLDER missing (or PORT not a number), or an option with no value or an unreadable one,
  stop with the usage line instead of a traceback;
- the early "LABEL.json exists" check uses `os.path.lexists`, so a dangling link there is refused
  before the run, not at the final exclusive write.

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

* PERF-CULL: a preview cut short by the end of the file is not taken (CodeRabbit, #3077)

`file_span` keeps a span the file's end cuts short only while it still holds a JPEG end marker
(an overstated length with the whole JPEG inside). A truncated JPEG passed the frame-header check,
won as the largest preview and then failed to decode, so no preview was shown.

Test: `a_preview_cut_short_by_the_end_of_the_file_is_not_taken`.

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

* PERF-PREVIEW-PREP: the shoot-folder test waits for the background build to finish (CodeRabbit, #3077)

`building_previews_writes_nothing_next_to_the_photos` used a 5 s capped poll and then went on; it
now asserts `wait_finished` (30 s), so a late write by a slow build can't slip past the check.

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

* PERF-CULL: a cut-short preview must reach its own JPEG end marker; cull-bench refuses bad options up front (CodeRabbit, #3055/#3077/#3080)

- raw: file_span keeps a span the file's end cuts short only when walking the JPEG's marker
  segments reaches its end marker inside it, and cuts it there. An FF D9 inside an APP payload no
  longer counts (it is skipped by the segment length), and bytes that don't start with FF D8 are
  refused without scanning the rest of the file. Tests: the cut-short test gains an APP1 payload
  holding FF D9 (taken before this fix) and a whole JPEG followed by other bytes;
  the_end_of_a_jpeg_is_its_own_end_marker covers stuffed bytes, restart markers, bad segment
  lengths and every truncation.
- tools/cull-bench.py: an unknown option, an option where a value belongs (--label --overwrite),
  --steps/--hold-ms below 1, or a port outside 1-65535 stop with the usage line before the run.

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

* PERF-CULL: cull-bench refuses a run whose steps wouldn't fit the app's loupe history (CodeRabbit, #3055)

The app keeps its last 256 loupe steps. A --steps that can't fit a run (1 + 11 + STEPS + 1) stops
before the run, and each phase checks the room left before it starts, so a hold whose first steps
were dropped is never reported as the whole hold.

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

* PERF-CULL: bench-cull refuses ".." in --json and --work (CodeRabbit, #3544)

A ".." after a folder the run hasn't made yet can't be resolved by the source-folder check, and
the run then makes that folder: --json NEW/../FOLDER/r.json would land in the source folder.
Test: a_dot_dot_in_the_outputs_is_refused.

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

* PERF-CULL: a cut-short preview needs a scan before its end and the walks share a budget; cull-bench catches dropped steps and odd arguments (CodeRabbit, #3055/#3077/#3080)

- raw: jpeg_end accepts an end marker only after a start of scan (an end with no image data
  before it is refused), and the walks of a file's pointers past its end share scan_budget, as
  scan_for_jpeg's do. Tests: a whole-looking JPEG with no scan is not taken;
  the_walks_to_a_jpegs_end_share_a_budget.
- tools/cull-bench.py: the port must be a decimal number int() takes (isdecimal, guarded); a
  FOLDER that looks like an option stops with usage; --steps goes up to 254 (only the hold's
  STEPS + 1 must fit, with one to spare); each phase starts with room to spare, so a full history
  means other steps came in and the phase stops instead of reporting a shifted window.

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

* PERF-CULL: the camera JPEG goes only under the window render, and a stand-in decoded across a cache clear isn't kept (CodeRabbit, #3544)

- ui-egui: zoomed in, the full-size camera JPEG is painted only where the window render will go
  (window_cover), so a window capped smaller than the view leaves the developed whole-frame render
  in the strips beside it instead of an unlabelled camera JPEG. Test:
  the_camera_jpeg_covers_only_where_the_window_goes.
- engine/preview: QuickJob's camera-JPEG cache carries the cache generation it was made in and
  writes with put_deferred_at / the new put_memory_at, so a job decoding when Discard Preview
  Cache clears the cache can't put its picture back (as render jobs already do). Test:
  a_camera_jpeg_decoded_across_a_cache_clear_is_not_kept.

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

* PERF-CULL: a bad build request leaves the background build alone, and preparation settings that can't be saved aren't kept (CodeRabbit, #3544)

- library.buildPreviews stops a running background build only after its own ids are known to be
  good, and a build whose thread can't start is marked finished instead of left "running".
- prep_tick clears started_for when the background build fails to start, so the view retries.
- set_prep_settings / clear_library_prep put the previous settings back when prefs.json can't be
  written. Tests: a_bad_build_request_leaves_the_background_build_running,
  a_prep_setting_that_cant_be_saved_is_not_kept.

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

* PERF-CULL: a background build that can't start waits 30 s before the view tries again

Clearing `started_for` on a failed start made every tick call library.buildPreviews again while
the cause lasted (CodeRabbit on #3080). The view now waits RETRY_AFTER first. Test
`a_build_that_cant_start_waits_before_trying_again`.

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

* PERF-CULL: a build waiting to retry asks for a frame at its time; a build whose worker can't start leaves the background one running

CodeRabbit on #3077/#3080: a background build that couldn't start retried only on
the next input. The session now says how long until the retry (prep_retry_in) and the
app asks for a frame then.

CodeRabbit on #3544: a build asked for whose worker thread couldn't start cancelled the
background preparation and left nothing running. The background build is now stopped
only once the new one has started; otherwise it stays the session's build.

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

* PERF-CULL: a retry time that has passed asks for no more frames

CodeRabbit on #3077 / #3080: once the retry deadline passed, prep_retry_in returned
zero, so a view that couldn't be tried (grid, preparation off) asked for a frame at
once on every pass and kept an idle window drawing. It returns None then.

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

* PERF-CULL: CodeRabbit items from #3577 that live in #3077 and #3055

- disk space: a read whose thread can't start (or whose worker ends without
  answering) is tried again after 10 s, not after the 5-minute check interval;
  a hung read is polled every 5 s after its first 5 s instead of every 250 ms
- the low-disk notice of the library before is not drawn in the frame after a
  library switch
- library.smartPreviews reads its ids before stopping the background build
- a forced build renders the grid thumbnail again instead of reading its cache
- the loupe's look-ahead asks for its stand-ins again after a renderer reset
- browse: a broken link before `..` is an error, not the link's parent folder
- the probes of two stopped scans: the later scan's wins whichever ends first
- camera JPEG fast path: a JPEG with an incomplete ICC sequence goes the
  general way; a CR3 without a JPEG track picks its box preview by size too
- tests: bench-cull compares the source photos' bytes, the disk notice test
  waits for the open's read

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

* PERF-CULL: CodeRabbit round 4 on #3077 and the #3583 items in its code

- A background smart-preview request cancels the running build only once its worker started;
  a worker that can't start leaves the earlier build running and the session's (test hook).
- The notice logic looks again at the next disk check (10 s after a read that couldn't start).
- CR3 boxes: largest by pixels; every top-level mdat left out; whole-file fallback takes the
  first box of a kind; a copy without an end marker isn't chosen to cover a size; a test
  decodes the chosen copy.
- The fitted JPEG decode reads a SEF file as the image before its trailer; its doc says a
  header that can't be read is Ok(None).
- Settings: a saved cache size the list doesn't offer shows as its own option.
- bench-cull rejects a second FOLDER; a browse through `..` reads again.
- cull-bench.py: --size checked, baseline waits for renders, --skip-browse claims no folder,
  a short rest phase warns.

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

* Format the quick-full draw call

* Loupe stand-in choice moves to its own function

* Render results: the newer-draft and display checks move to their own functions

* Render results: a frame left on the GPU is shown by its own function

* Format the frame's texture entry

* Docs: one row each for the import and performance entries

* Format the quick view's key

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 08:32:26 +01:00

477 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](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](light-v3.md) 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 `Interval`s 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.
**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](#backends-environment-variables-and-troubleshooting-issue-136)), 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](#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):
```sh
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.