Skip to content

perf(cache): reuse decoded images for full-resolution previews and fix stale cache entries - #1959

Merged
CyberTimon merged 2 commits into
CyberTimon:mainfrom
elhigu:perf/decoded-cache
Oct 22, 2026
Merged

CyberTimon merged 2 commits into
CyberTimon:mainfrom
elhigu:perf/decoded-cache

Conversation

@elhigu

@elhigu elhigu commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Description

This is part 2 of the performance fixes listed in my fork's README; part 1, #1790, is merged. It is rebased on current main (211ef55) and works with the new compacted decoded-image cache.

The decoded-image cache had two problems:

  • It could return stale images. It was keyed by path alone. A file rewritten under the same name kept returning the old pixels, for example tethered captures from a camera that reuses names like capt0000.nef. So did images decoded before a raw pre-processing setting changed: changing linearRawMode never cleared the cache, and a decode that finished after a settings change could put an image with the old settings back.
  • Full-resolution previews didn't use it. generate_preview_for_path decoded the file from disk on every call, even when the photo was open in the editor.

Where it makes the app faster

generate_preview_for_path renders the full-resolution previews of:

  • The culling view. Coming back to the culling view after editing a photo, or culling photos you have recently opened, renders them without decoding the raw again. The cache holds the photo open in the editor and up to imageCacheSize recent ones (5 by default), within its memory limit.
  • The collage modal, for photos that are already decoded.
  • The tethering panel, for captures that are already decoded. Opening a new capture in the editor now shows the new file even when the camera reuses its name; before, the editor could show the previous capture.

When the culling view and the editor ask for the same photo at the same time, it is now decoded once instead of twice.

Benchmark

Measured with the pipeline benchmark from my fork (tools/pipeline-bench, rapidraw bench). It runs the app's own functions without a window, times each step and hashes its output. main (211ef55) and main with this PR, median of 5 runs each, on a 12 MP Sony A7S II raw and a 26 MP Sony a6700 raw with their edits:

Step 12 MP, main 12 MP, this PR 26 MP, main 26 MP, this PR
Full-resolution preview of a decoded photo (generate_preview_for_path) 556 ms 263 ms 1598 ms 886 ms

Full-resolution previews of a photo that is already decoded are 1.8-2.1x faster. Decoding, thumbnails, opening a photo, the first editor preview and export are unchanged within run-to-run noise, and every step's output is identical with and without this PR. Both builds ran with MIMALLOC_ALLOW_THP=0 so that allocator settings don't distort the comparison.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Performance improvement
  • Code refactoring
  • Documentation update
  • UI/UX improvement
  • Build/CI or Dependency update

Changes Made

  • fix(cache): reuse decoded images only while file and settings match. Each cache entry carries a fingerprint of the file's size and modification time and of the six settings that change decoded pixels (highlight compression, linear raw mode, colour noise reduction, sharpening, pre-processing of non-raws, Apple RAW). A lookup that no longer matches is a miss and drops the entry. Entries keep their fingerprint while the cache holds them compacted. Decoding moves into one helper, get_or_decode_pristine, which also makes concurrent requests for the same file share one decode. load_image uses it, after compacting the other entries as before, is_image_cached checks the fingerprint without expanding a compacted entry, and the background AI jobs check it before reusing a cached decode.
  • perf: reuse decoded images for full-resolution previews. generate_preview_for_path uses get_or_decode_pristine and prepares the cached image with prepare_base_image_cow, so AI denoise, negative conversion and AI patches are applied exactly as before, and the cached image is borrowed instead of cloned when none of them changes it. It doesn't compact anything, so the photo open in the editor stays in full precision. For a photo the cache holds compacted, the preview uses that copy, the same as reopening the photo in the editor does.

Trade-off: full-resolution previews now add their photos to the decoded-image cache, within its existing count and memory limits.

Screenshots/Videos

No UI changes.

Testing

  • These changes were tested locally by a human and confirmed to work.
  • I haven't added any automated tests to the code because the codebase currently lacks a test suite.

Test Configuration:

  • OS: NixOS 26.11, Linux 7.2, GNOME on Wayland
  • Hardware: AMD Ryzen AI 9 HX 375 with Radeon 890M (RADV, Mesa 26.2.4), 32 GB RAM

Automated checks:

  • The benchmark above: every step's output hashes identically on main and with this PR.
  • Every commit builds and passes cargo fmt --check and cargo clippy --all-targets --all-features -- -D warnings.
  • Six unit tests of the cache pass: a rewritten file and a changed setting change the fingerprint, a stale entry is dropped instead of returned, the least recently used entry is evicted first, concurrent decodes of one file run once, and an entry compacted to f16 is still found by its fingerprint and dropped when it goes stale. They are not part of this PR; they're on my fork (test/decoded-cache-unit-tests) if you want them.

Checklist

  • My code follows the project's code style
  • I haven't added unnecessary AI-generated code comments
  • My changes generate no new warnings or errors

Additional Notes

Suggested release-notes bullet:

Faster full-resolution previews: the culling view, collage and tethering previews reuse already decoded photos, and the cache no longer shows outdated images after a file is overwritten or a raw setting changes.

AI Disclaimer:

Please state the involvement of AI in this PR:

  • This PR is created by an AI agent
  • This PR is mostly AI-generated but edited/merged together by a human
  • This PR was handwritten with AI assistance (spell check, logic suggestions, error resolving)
  • This PR contains only blood, sweat, and coffee (AI-free)

The code, measurements and this description are 100% AI-generated with Claude Code.

🤖 Generated with Claude Code

@elhigu
elhigu requested a review from CyberTimon as a code owner October 18, 2026 23:58
@elhigu
elhigu force-pushed the perf/decoded-cache branch from e67a191 to 7385039 Compare October 20, 2026 07:46
@elhigu

elhigu commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Rebasedand fixed conflicts

elhigu and others added 2 commits October 22, 2026 17:52
The decoded-image cache was keyed by path alone. A file rewritten under
the same name, for example a tethered capture from a camera that reuses
names like capt0000.nef, kept returning the old pixels. So did files
decoded before a raw pre-processing setting changed: changing
linearRawMode never cleared the cache, and a decode that finished after
a settings change could put an image with the old settings back.

Each entry now carries a fingerprint of the file's size and modification
time and of the settings that change decoded pixels, also while the cache
keeps it compacted. A lookup that no longer matches is a miss and drops
the entry.

Decoding moves into one helper, get_or_decode_pristine, which also makes
concurrent requests for the same file share one decode instead of
decoding it twice. load_image uses it, is_image_cached checks the fingerprint
without expanding a compacted entry, and the background AI jobs check it
before reusing a cached decode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
generate_preview_for_path renders the full-resolution previews of the
culling view, the collage modal and the tethering panel. It decoded the
file from disk on every call. It now uses get_or_decode_pristine, so a
file that is already decoded, for example because it is open in the
editor, isn't decoded again, and a request that races the editor's own
load shares its decode. The cached image is prepared with
prepare_base_image_cow, which applies AI denoise, negative conversion and
AI patches as before and borrows the image when none of them changes it.
Nothing is compacted, so the photo open in the editor keeps full
precision.

A repeated full-resolution render took 556 ms -> 263 ms for a 12 MP raw
and 1598 ms -> 886 ms for a 26 MP raw, with identical output.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@elhigu
elhigu force-pushed the perf/decoded-cache branch from 7385039 to 9be0104 Compare October 22, 2026 14:53
@elhigu

elhigu commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

rebased, fixed conflicts, updated benchmark numbers

@CyberTimon

Copy link
Copy Markdown
Owner

Thanks a lot for the PR, looks good to me. Also sorry for all the merge conflicts I've caused ;)

@CyberTimon
CyberTimon merged commit 77e255b into CyberTimon:main Oct 22, 2026
15 of 16 checks passed
@elhigu
elhigu deleted the perf/decoded-cache branch October 27, 2026 16:07
elhigu added a commit to elhigu/RapidRAW that referenced this pull request Oct 27, 2026
CyberTimon#1790 and CyberTimon#1959 are merged, and RapidRAW 1.6.7 does several of this
fork's earlier fixes itself, so list only the fixes the fork still adds.
The speedups are now measured against 1.6.7 with rapidraw bench on a
12 MP and a 26 MP raw, with identical output in every step. The
recordings are marked as made on 1.6.5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants