Skip to content

perf(images): size the image cache by RAM and free GPU memory after clearing it - #10308

Merged
hm21 merged 11 commits into
mainfrom
perf/image-cache-ram-budget
Oct 19, 2026
Merged

hm21 merged 11 commits into
mainfrom
perf/image-cache-ram-budget

Conversation

@hm21

@hm21 hm21 commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Description

Follow-ups from the image cache budget study in #10301. Two changes, plus one investigated idea that turned out not to be needed.

1. The decoded-image cache budget is sized by RAM. It was a fixed 256 MB on every device. Under Impeller every MB of full cache costs about 2 MB of graphics memory (S26 and iPhone 12 Pro), so the fixed budget cost ~500 MB on a 6 GB phone just as on a 12 GB one. The budget is now RAM / 48, clamped to 96–256 MB, which keeps its real cost near 4 % of RAM:

RAM Budget
12 GB and more 256 MB
8 GB 170 MB
6 GB 128 MB
4 GB or less 96 MB
unknown (web, desktop, channel failure) 128 MB

The OS reports a bit less than the installed RAM (the S26 reports 11.4 GiB), so the reading is rounded up to whole GiB before dividing. device_info_plus 10.1.2 has no RAM getter, so a new divine/device_memory method channel returns ActivityManager.MemoryInfo.totalMem on Android and ProcessInfo.physicalMemory on iOS. I looked for an existing channel first: the app's own channels are install source, ProofMode, Zendesk, navigation and the app badge, and divine_video_player's diagnostics channel is Apple-only and lives in the player package, so none of them fits without mixing concerns. The budget is read once in the bootstrap, after the plugin calls, before anything decodes.

2. Every explicit cache clear schedules a frame. ImageCache.clear() only drops references: Impeller gives the textures back when it next draws a frame, and a static screen draws none. clearDecodedImageCache() clears and calls scheduleFrame(). The memory-pressure handler and clearOpenVineImageCache() use it. It uses scheduleFrame() rather than scheduleForcedFrame(), so a clear in the background draws nothing until the app returns.

3. Investigated, not shipped: clearing on Android before the surface goes away. On main the backgrounded S26 kept ~630 MB of GL memory because nothing drew a frame after the trim-memory clear. With change 2 that clear already returns it: on the S26 the trim-memory signal arrives while the app is still inactive, so the frame it schedules is drawn before the surface goes. Clearing again on inactive was worse, presumably because the visible thumbnails are decoded again during the transition (10–12 extra frames), and inactive also fires when the notification shade is pulled down, which would empty the cache. Clearing on hidden with a forced frame added nothing. Numbers below.

Related Issue: Closes #none · Relates to #10301

Device measurements

Profile builds of this branch with an uncommitted in-app driver: home feed, Explore → New grid scrolled until the cache reached its budget, then the steps below. S26 GL mtrack from dumpsys meminfo (two runs), iPhone physical footprint read in-app (one run).

Galaxy S26 (12 GB, Android 16): budget 256 MB.

Full cache on a static grid Run 1 Run 2
clear() without a frame (what main does): GL for 24 s, 0 frames drawn 673 MB 682 MB
… after forcing frames 353 MB 384 MB
clearDecodedImageCache(): before → within 1 s 742 → 404 MB 751 → 413 MB
Backgrounded with a full cache (HOME, 45 s) GL in the background
main (study runs, before → background) 757 → 625–632 MB
This branch 746 → 254 MB, 758 → 270 MB
Variant: also clear on inactive + frame (not shipped) 335 / 353 MB
Variant: also clear on hidden + forced frame (not shipped) 266 / 311 MB

iPhone 12 Pro (6 GB, iOS 18.5): budget 128 MB (was 256 MB). From the study, 128 MB saves ~210 MB of footprint on this phone at a full cache and keeps ~15 grid screens of instant scroll-back instead of ~30.

Full cache on a static grid Footprint
clear() without a frame: for 24 s, 0 frames drawn 606 MB
… after forcing frames 545 MB
clearDecodedImageCache(): before → within 2 s 642 → 535 MB
Backgrounded: memory warning empties the cache as before; footprint on return 661 → 457 MB

Out of Scope

  • ReleaseCachedImagesOnDispose from perf(video-editor): give back the memory that camera and editor sessions kept #10306 is not merged yet and calls imageCache.clear() directly. Once both are in, it should call clearDecodedImageCache() so the camera and editor clear also returns memory on a static screen.
  • Grid thumbnails on *.divine.video load through Image.network with no disk cache, so a memory miss is an HTTP re-fetch. Unchanged here.

Verification

  • flutter analyze lib test integration_test: no issues.
  • flutter test for the new tests (test/startup/image_cache_budget_test.dart, test/services/device_memory_service_test.dart, test/utils/decoded_image_cache_test.dart) plus test/startup, test/app, the memory-pressure handler and social_providers_cleanup_test.dart: 211 passed.
  • bash scripts/ci/run_guards.sh: 65 passed.
  • Verified on Galaxy S26 and iPhone 12 Pro with profile builds: the budget each device picked, the clear-then-frame release, and the background round trips above.

Type of Change

  • ✨ New feature (non-breaking change which adds functionality)
  • 🛠️ Bug fix (non-breaking change which fixes an issue)
  • ❌ Breaking change (fix or feature that would cause existing functionality to change)
  • 🧹 Code refactor
  • ✅ Build configuration change
  • 📝 Documentation
  • 🗑️ Chore

Review fixes were pushed to this branch by an agent run that @realmeylisdev started. The review on this PR lists each commit.

Agent-Model: anthropic/claude-opus-5-5
Agent-Effort: max
Agent-Harness: claude-code/2.1.303
Agent-Context: fresh
Initiated-By: @realmeylisdev

…ring it

A full 256 MB decoded-image cache costs about 500 MB of graphics memory
under Impeller (#10301), on every device. The budget is now RAM / 48,
clamped to 96-256 MB (12 GB: 256, 8 GB: 170, 6 GB: 128), read once at
startup through a new divine/device_memory channel, with 128 MB when RAM
is unknown.

Clearing the cache only drops references: Impeller frees the textures
when it next draws a frame, and a static screen draws none. Every
explicit clear now schedules a frame. On Android this also makes the
trim-memory clear on backgrounding return the memory, because it lands
while the app is still inactive.
@hm21 hm21 self-assigned this Oct 19, 2026
@hm21
hm21 marked this pull request as ready for review October 19, 2026 10:15
@hm21
hm21 requested a review from a team as a code owner October 19, 2026 10:15
@hm21
hm21 requested review from rabble and realmeylisdev and removed request for a team October 19, 2026 10:15
@divine-roborev

divine-roborev Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

roborev: Combined Review (1e0ca09)

Verdict: No findings at or above high severity.


Reviewers: 2 done | Synthesis: opencode | Total: 7m19s

@github-actions

This comment has been minimized.

…nored

The test fed a 5.6 GiB reading, whose budget (6 GiB / 48 = 128 MiB) is
exactly the unknown-RAM fallback, so a configureImageCacheBudget that
ignored the reading still passed. Use the 8 GB reading instead: its
budget is neither the fallback nor a clamp bound.
The first clearDecodedImageCache() call already scheduled a frame and
nothing drew it, so the hasScheduledFrame check after the
includeLiveImages: true call passed whether or not that call scheduled
one. Draw the first frame before the second clear. That call is the
only form production uses.
clearDecodedImageCache() uses scheduleFrame() rather than
scheduleForcedFrame() so a clear while the app is paused draws nothing
until it returns, as its dartdoc and the PR description say. No test
pinned that: with scheduleForcedFrame() every existing test stayed
green. Clear while paused and expect no scheduled frame.
Every caller in the suite fakes it (openVineImageCacheClearProvider is
overridden with a no-op, the age-verification test injects a counter),
so dropping includeLiveImages: true, the frame, or the disk clear kept
every test green. includeLiveImages is what stops revoked adult media
from being served again from a live image.

The function reads the global disk cache, and the tests keep that cache
out of the shared test isolate (see debugImageCacheOverride): its JSON
index debounces writes on a 3 s timer that would outlive the test. So it
takes an optional @VisibleForTesting disk cache. Production calls,
including the Future<void> Function() tear-off in social_providers, are
unchanged.
The bootstrap call is now the only place the RAM-sized budget takes
effect, and nothing ran it: configureImageCacheBudget's tests build their
own ImageCache, and no test runs startOpenVineApp. Removing the call,
pointing it at another cache or passing a reader that returns null kept
every test green while every device fell back to a fixed budget. Follows
error_widget_builder_wiring_test.dart, which pins a startup wiring line
the same way for the same reason (#8647).
The PR's measured background release (625-632 MB of graphics memory on
main against 254-270 MB) comes from this one callback, and no test ran
it: memory_pressure_handler_test injects its own clearImageCache, and no
test mounts DivineApp. Reverting it to the old clear()..clearLiveImages()
kept every test green.
…t a frame

The dartdoc said ImageCache.clear() only drops references and Impeller
frees the textures on the next frame. In the pinned SDK, clear() and
clearLiveImages() dispose each entry, and the entry releases its image
handle in a SchedulerBinding post-frame callback
(painting/image_cache.dart:601-611). So no frame means nothing is freed
on any renderer, not only Impeller. That matters for anyone deciding
whether a bare clear() is safe on Skia, desktop or the web.
…easured on

The dartdoc said that on Android the memory-pressure clear on
backgrounding arrives while the app is still inactive, so its frame is
drawn. That ordering was measured on one Galaxy S26. Android sends
TRIM_MEMORY_UI_HIDDEN when the process stops being important, so it can
land after the app is paused, for example while a publish holds a
foreground service. scheduleFrame() then returns without a frame, and
nothing is freed until the app returns.
The rule agents load said Flutter's default ImageCache holds 100 images
(it holds 1000 entries and 100 MiB) and told them to fix low-end OOMs by
assigning maximumSizeBytes directly. After this PR that assignment would
silently override the RAM-sized budget on every device. Name the one
place the budget is set, and the helper to clear through.
@github-actions

This comment has been minimized.

@realmeylisdev realmeylisdev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hm21 I reviewed this at 955f4e0 and pushed nine small commits to the branch, one per finding, so you can revert any of them on its own. This approval covers the new head da7e954.

The change holds up. The budget math matches the table, and both native channels return sensible values. The startup read also can't race the channel registration. On iOS the implicit-engine callback that registers the channel runs before Dart starts (Flutter 3.47.2 engine: FlutterViewController.mm:307-313, with the launch at :744). On Android, configureFlutterEngine runs in onAttach, before the Dart entrypoint starts in onStart. That ordering is what shows the iOS channel works. The iPhone 12 Pro run can't show it alone: 6 GB maps to 128 MB, which is also the fallback. Most of what I found was tests that couldn't fail.

Pushed

Commit What and why
c425b57 The configure test fed 5.6 GiB. That reading's budget is exactly the 128 MB fallback, so the test passed even if the reading was ignored. It now feeds 7.4 GiB, whose budget is the 8 GB one.
4294292 In the live-images test, the second frame check couldn't fail, because the first clear had already scheduled a frame. The test now draws that frame first.
f026513 Nothing checked that the helper uses scheduleFrame() rather than scheduleForcedFrame(). A new test clears while the app is paused and expects no frame.
c669088 No test ran clearOpenVineImageCache(); every caller in the suite is faked. Two tests now cover includeLiveImages: true (what keeps revoked adult media out of memory), the frame and the disk clear. This touches production code: it adds an optional @visibleForTesting diskCache parameter. The real disk cache can't run in the shared test isolate, because its index writes on a 3-second timer that outlives the test. Production calls are unchanged.
de7c7c7 No test ran the startup call, which is now the only place the budget takes effect. Added a wiring test, like error_widget_builder_wiring_test.dart.
e27a7a1 Added the same wiring test for the memory-pressure clear in DivineApp. That clear is where the measured background release comes from.
d58b889 The dartdoc said Impeller holds the memory until the next frame. In fact ImageCache itself releases each dropped image in a post-frame callback (Flutter 3.47.2 painting/image_cache.dart:601-611). So without a frame nothing is freed, on any renderer. The PR description has the same explanation and is worth fixing too.
880ea2b The doc presented the S26's event order as Android behaviour. Android doesn't guarantee that order (see 3 below).
da7e954 .claude/rules/performance.md told agents to set maximumSizeBytes directly, which would now override the RAM-based budget on every device. It also said Flutter's default holds 100 images; it holds 1000. The rule now points at image_cache_budget.dart and the clear helper.

I checked each test fix by mutation. With the behaviour deliberately broken, the old test still passed and the new one fails.

Yours to decide (nothing pushed)

  1. Stop-motion playback on 4-8 GB phones. StopMotionPlayer precaches every still once and shows only the current one. So a loop plays from memory only while all of its stills fit in the cache. The preview screens decode stills at screen height, which is about 14 MB per 9:16 still on an iPhone 12 Pro. About 18 stills fit in 256 MB and about 9 fit in 128 MB. A sequence between those sizes used to loop from memory. Now every tick misses the cache and decodes the next still from disk again. That's the stutter cacheHeight was added to prevent (stop_motion_player.dart:54-58). Longer sequences already did this on main. One playback check on a 6 GB phone would settle it. If it stutters, the player could hold its stills with keepAlive() handles while it's mounted, or decode them at the player's own size.
  2. Web and desktop drop from 256 MB to 128 MB. They report no RAM, so they get the fallback. The table says this is intended. I'm only flagging that the 2× GPU-cost reasoning was measured on two phones. macOS could read ProcessInfo.physicalMemory the same way iOS does.
  3. A background clear can still free nothing. The background release depends on Android's trim signal arriving while the app is still inactive. Android sends it later while the process stays important, for example while a publish holds its foreground service. Then scheduleFrame() does nothing, and the memory isn't freed until the app returns. This is no worse than main.
  4. Nothing makes callers use the helper. In #10306, ReleaseCachedImagesOnDispose still calls imageCache.clear() directly, and its doc names imageCacheMaxBytes as the budget. Both need updating once these two PRs meet. A zero-baseline guard against direct imageCache.clear() calls would make the convention stick.

Declined

  • Running the memory read alongside the two native calls before it: it saves about a millisecond.
  • Rounding to the nearest standard RAM size: a phone that reports more than 1 GiB below its installed RAM drops one budget step, which errs toward less GPU memory.
  • Removing DeviceMemoryService's unused channel parameter: it mirrors InstallSourceService exactly.

Validation: flutter analyze lib test integration_test is clean. 218 tests pass across test/startup, test/app, both cache-helper tests, the device-memory and memory-pressure tests, social_providers_cleanup_test and moderation_providers_test. scripts/ci/run_guards.sh: all 65 guards pass. CI on da7e954: 29 checks pass, including iOS Native Tests, and the daily service-test publish is skipped as usual.

Independent review: the description doesn't record which model wrote the original change. This review and the pushed fixes are by Claude, so independent review of this head needs a reviewer from another provider. I added my identity block to the description.

@rabble, the head has moved since you were requested.

Agent-Model: anthropic/claude-opus-5-5
Agent-Effort: max
Agent-Harness: claude-code/2.1.303
Agent-Context: fresh
Initiated-By: @realmeylisdev

Both tests read a Dart file as text and match it with a regex, so they
check how the call is written rather than what the app does: a harmless
refactor (extracting the argument, renaming the service) turns them red,
while a reviewer sees a deleted call in the diff anyway. The behaviour
stays covered by the image_cache_budget, decoded_image_cache and
open_vine_image_cache tests.
@hm21

hm21 commented Oct 19, 2026

Copy link
Copy Markdown
Member Author

Thanks @realmeylisdev, the review commits helped: the post-frame-callback explanation is the real mechanism (_CachedImageBase.dispose() defers the handle dispose), the 7.4 GiB reading makes the configure test able to fail, and clearOpenVineImageCache finally has coverage.

I removed two of them in 1e0ca09: test/app/divine_app_memory_pressure_wiring_test.dart and test/startup/image_cache_budget_wiring_test.dart. Both read a Dart file as text and match it with a regex, so they pin how the call is written rather than what the app does. A harmless refactor (extracting the argument, renaming the service) turns them red, and a deleted call shows up in the diff for any reviewer anyway. The behaviour stays covered by the image_cache_budget, decoded_image_cache and open_vine_image_cache tests.

@hm21
hm21 enabled auto-merge October 19, 2026 13:35
@github-actions

Copy link
Copy Markdown

Mobile PR Preview

Preview refreshed for 1e0ca09

Last refresh: 1e0ca09 at 2026-10-19 13:38:02 UTC (preview run)

Property Value
Preview URL https://ce2c9a68.openvine-app.pages.dev
Pages project openvine-app
Preview branch pr-10308
PR branch perf/image-cache-ram-budget
Commit 1e0ca09

@hm21
hm21 added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit 0465732 Oct 19, 2026
29 checks passed
@hm21
hm21 deleted the perf/image-cache-ram-budget branch October 19, 2026 14:27
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