Skip to content

Feature caching for the interactive annotate/predict loop - #73

Open
hinderling wants to merge 9 commits into
guiwitz:mainfrom
hinderling:feature-caching
Open

Feature caching for the interactive annotate/predict loop#73
hinderling wants to merge 9 commits into
guiwitz:mainfrom
hinderling:feature-caching

Conversation

@hinderling

Copy link
Copy Markdown
Contributor

Summary

Opt-in feature caching for the interactive annotate → predict loop: when the same image (or z-slice / movie frame) is segmented repeatedly while refining scribbles, its extracted features are reused instead of recomputed. This is by far the biggest win for ViT-family extractors (DINOv2/v3, JAFAR), where feature extraction dominates the loop — a re-predict on a cached slice is near-instant.

Cached output is bit-identical to uncached extraction: the cache stores the native per-scale features (the expensive half of the pyramid), and the cheap rescale/concat half runs on every request. Entries are content-addressed (blake2b of the prepared image + a signature of every feature-relevant setting), so the cache is self-invalidating — a changed image or setting simply misses; nothing needs manual invalidation.

Design (one commit per layer, reviewable top to bottom)

  1. feature_cache.py — a generic, FE-agnostic LRU cache bounded by a RAM budget with live headroom (it degrades to recomputation rather than OOM), plus an optional disk tier: RAM-evicted or RAM-oversized payloads spill to disk (with a per-entry opt-out for payloads cheaper to recompute than to pickle).
  2. Feature extractorsextract_features_pyramid is split into _pyramid_native (cacheable, device-independent) and _pyramid_reconstruct (cheap), with protocol hooks on the base class. Per-FE specifics: CNN Hookmodels keep their huge per-pixel payloads out of the disk tier, ComboFeatures opts out, and JAFAR/gaussian report instance state (jafar_scalings, sigma) for the cache key so changing it can never serve stale features.
  3. ConvpaintModelenable_feature_cache() plus the key derivation and a cache_only "peek" mode threaded through the predict path.
  4. Widget — cache controls in the Advanced tab (enable + RAM/disk budgets + live size label), cache lifecycle tied to model changes, and cache-first stack prediction: a peek pass serves all already-cached slices before the compute pass runs, because a plain sequential scan over a stack larger than the cache evicts exactly the slices it is about to need (LRU thrash).
  5. Train/predict normalization consistency — training now skips the model's redundant re-normalization pass (the widget already normalizes the stack). Verified per norm mode that this second pass never changed values beyond float noise; skipping it saves a full-stack pass, silences a bogus warning for imagenet FEs, and makes train/predict arrays byte-identical — the prerequisite for train → predict cache sharing, since keys are content hashes.

Tests

test_feature_cache.py: 20 tests covering the cache module (LRU order, budgets, disk spillover, invalidation) and the model-level protocol (disk routing of oversized payloads, spill opt-out, FE-state keys, peek semantics, and bit-identical cached predictions). Full suite passes (the two cellpose tests fail on main too in my environment — broken local cellpose install, unrelated).

Extracted from the larger performance work in #72 to be reviewable on its own; a follow-up PR with widget layout polish builds on this one.

Generic, FE-agnostic cache for the interactive annotate→predict loop:
stores opaque per-image payloads keyed by content hash + FE signature,
bounded by a RAM budget with live headroom (never OOMs — degrades to
recomputation), LRU eviction, and an optional disk tier that RAM-evicted
or oversized payloads spill to (per-entry opt-out via spill_ok for
payloads cheaper to recompute than to pickle).
Split extract_features_pyramid into _pyramid_native (expensive per-scale
native features, device-independent CPU numpy — the cacheable payload)
and _pyramid_reconstruct (cheap rescale+concat), keeping the combined
output bit-identical. Add the cache protocol hooks on the base class
(supports_feature_cache / cacheable_repr / features_from_cacheable /
cacheable_nbytes / cache_spill_to_disk / cache_extra_state) and the FE
overrides: Hookmodel keeps its huge CNN payloads out of the disk tier,
ComboFeatures opts out (it bypasses the pyramid split), and JAFAR/gaussian
report their instance state (jafar_scalings, sigma) for the cache key.

Includes two micro-fixes the split exposed: features_per_layer now
defaults to None on the base class (fe_use_min_features degrades with a
warning instead of AttributeError), and supported_devices no longer
returns a nested list for the CPU entry.
enable_feature_cache() attaches a FeatureCache; _get_features then routes
per-image pyramid extraction through _extract_pyramid_cached, which keys
the FE's native payload by (content hash of the prepared image, FE
signature). The signature covers the train-reset param set plus
image_downsample, patch size, and FE instance state (cache_extra_state),
so any setting that changes feature values misses instead of serving
stale features. With the cache disabled (default) behaviour is unchanged;
enabled, outputs are bit-identical (the pyramid split is exact).

cache_only=True threads a peek through _predict/_predict_image/
_get_features: return the prediction only if all needed features are
already cached, else None — lets a stack prediction serve cached slices
first, before a sequential scan evicts them (used by the widget).
GUI wiring for the cache: enable checkbox + RAM/disk budget settings,
live size/hit-rate label, (re)creating the cache when the model changes,
and clearing it only when the last user image layer is removed (plugin-
owned probability/feature layers — including renamed backups — never wipe
it; content-addressing handles staleness otherwise).

Predict-all now runs cache-first: a cheap peek pass serves all slices
whose features are already cached before the compute pass runs — a plain
sequential scan over a stack larger than the cache would evict exactly
the slices it is about to need (LRU thrash). Extended the cache tests
with the model-level protocol (disk routing, spill opt-out, FE-state
keys, bit-identical cached predictions).
The widget already normalizes the full stack (image_stack_norm) before
training, and prediction passes skip_norm=True on the same pre-normalized
data — but training passed skip_norm=False, so the model ran a second
normalization pass. That pass never changed values meaningfully (verified
per mode: the imagenet guard returns out-of-[0,1] data unchanged with a
spurious warning; default z-scoring and the percentile stretch are both
idempotent to float32 noise), but it wasted a full-stack pass, emitted a
bogus warning for imagenet FEs, and left train/predict arrays differing
at the float-noise level — which breaks feature-cache sharing, since
cache keys are content hashes and need byte-identical inputs. Passing
skip_norm=True makes train and predict prepare exactly the same arrays.
The multi-file training path still passes skip_norm=False — it feeds raw,
not-yet-normalized images.
@hinderling
hinderling marked this pull request as ready for review July 13, 2026 15:56
@codecov-commenter

codecov-commenter commented Jul 13, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 42.64264% with 382 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.15%. Comparing base (b1e1ddd) to head (6aed050).

Files with missing lines Patch % Lines
src/napari_convpaint/_tests/test_feature_cache.py 0.00% 254 Missing ⚠️
src/napari_convpaint/feature_cache.py 56.65% 88 Missing ⚠️
src/napari_convpaint/convpaint_widget.py 70.58% 30 Missing ⚠️
src/napari_convpaint/convpaint_model.py 87.23% 6 Missing ⚠️
src/napari_convpaint/utils.py 33.33% 4 Missing ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #73      +/-   ##
==========================================
- Coverage   63.20%   61.15%   -2.06%     
==========================================
  Files          32       34       +2     
  Lines        6515     7162     +647     
==========================================
+ Hits         4118     4380     +262     
- Misses       2397     2782     +385     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

A worker thread can now extract/cache while the GUI thread clears the
cache or changes limits (needed for the threaded-cancellation branch,
and already correct for API users sharing a model across threads).
… tensor-aware pad_to_shape

skimage.transform.resize computes a full resize even when the target shape
equals the input shape — for the common patch_size=1 / image_downsample=1
prediction path that was ~17 ms/slice of pure waste (rescale_features
already had this guard). The class-labels guard keeps the uint8 contract.

pad_to_shape gains a torch branch (same symmetric convention) so it works
for on-device feature tensors.
The pyramid split cast NN features to CPU numpy inside _pyramid_native,
which silently moved the whole multi-scale rescale from torch-on-device
onto CPU skimage (rescale_features dispatches on array type): ~2-4x
slower stack prediction than main on GPU, cache on or off.

- _pyramid_native keeps features in their native form (torch tensors on
  the extraction device for NN FEs); _pyramid_reconstruct rescales and
  concatenates on-device with a single host transfer at the end.
- The cache payload is device-independent CPU numpy (_native_to_payload)
  and records was_torch; features_from_cacheable lifts such payloads back
  onto the device, so cache HITS use the same torch backend as fresh
  extractions: a hit is now faster than a recompute (it saves the forward
  pass) instead of ~5x slower, and hit/miss results stay identical.
- On a miss, _extract_pyramid_cached reconstructs from the on-device
  native form via cacheable_repr_and_features (one extraction pass) and
  stores only the numpy payload, so the cache-enabled path is as fast as
  the cache-off path.
- Numpy-native FEs (gaussian etc.) are untouched: their payloads stay
  numpy and reconstruct via skimage on hit and miss alike.
- Caching surface trimmed while at it: cacheable_repr and
  cacheable_nbytes dropped (cacheable_repr_and_features is the single
  extension point; the cache sizes payloads itself), invalidate(predicate)
  folded into clear() (content-addressed entries never go stale),
  disk_dir ctor param dropped, cache_extra_state takes no Param, the
  widget's three cache-settings handlers collapse into
  _apply_feature_cache, and _predict_all's two loops merge into one
  cache-first pair.

Benchmarks (VGG16, scalings [1,2,4], MPS): main 34 ms/slice; before this
fix 125 (cache on or off); after: 18.3 off / 19.8 miss / 15.4 hit —
bit-identical across all paths and devices.

Tests: hit == miss == cache-off equality for an NN FE (vgg16) and a
numpy FE, payload-form assertions, plus a threaded cache hammer.
@hinderling

Copy link
Copy Markdown
Contributor Author

Pushed three commits addressing a performance regression found while benchmarking this branch on GPU:

  • FeatureCache thread-safety (RLock on all public methods) — a worker thread can extract/cache while the GUI thread clears the cache or changes limits.
  • Utils: identity-resize early-returns in rescale_outputs/rescale_class_labels (~17 ms/slice of wasted skimage work on the default prediction path; applies to main too), and a tensor-aware pad_to_shape.
  • On-device feature pyramid, end-to-end: the pyramid split had cast NN features to CPU numpy before the multi-scale rescale, silently moving it from torch-on-device onto CPU skimage (2–4× slower stack prediction than main on GPU, cache on or off). Features now stay on-device through rescale/concat with a single host transfer; the cache payload is still device-independent numpy but records its torch origin, so cache hits are lifted back onto the device and use the same backend as fresh extraction — a hit is now faster than recomputing, and hit/miss/cache-off results are bit-identical (asserted by new tests for both an NN FE and a numpy FE). The caching API surface was also trimmed while unmerged (cacheable_repr_and_features is the single extension point).

Benchmarks (VGG16, scalings [1,2,4], MPS): main 34 ms/slice → 18.3 cache-off / 19.8 miss / 15.4 hit (full table in the last commit message). Verified against the #69 tiling-consistency suite (17/17) plus cache-on tiled-vs-untiled and non-patch-multiple DINOv2 probes — all pixel-identical.

…t site

The docstring already documents the device contract; add an inline marker at
the actual fallback (hoisted into lift_device) so the caveat is visible where
the hit is reconstructed. No behavior change.
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