Skip to content

task(perf): find out why the app keeps ~0.5 GB of graphics memory after camera and editor sessions #10301

Description

@hm21

Description

After long camera and video-editor sessions, the app keeps several hundred MB more graphics memory than it started with, and that memory is not freed after leaving the camera or editor. Find out what holds it, and either release it or document why it has to stay.

Context

Both stress passes found this and left it as an open question: #10108 (video editor) and #10109 (camera). The Dart side is clean: after a full GC, no editor objects are alive (84 MB Dart heap on the iPhone after the editor pass). On Android, the native and Java heaps return to baseline about 20 s after an export. What stays is graphics memory on Android and the process footprint on iOS.

The amount stops rising after a while: further sessions do not add to it. So there is no unbounded leak, but the level after a session is not "close to baseline" either. On a phone with less RAM than the test devices (Galaxy S26 with 12 GB, iPhone 12 Pro with 6 GB), the extra 0.5 GB makes the app more likely to be killed in the background or during the next export.

What was measured

Profile builds 1.0.25 (820) from main, driven by a throwaway in-app stress driver.

Device Session Before After, stays there
Galaxy S26, Android 16 Editor stress pass (#10108), dumpsys meminfo GL mtrack on the home screen ~300 MB ~820 MB
Galaxy S26, Android 16 210 camera takes (#10109), dumpsys meminfo graphics 437 MB 710 MB
Galaxy S26, Android 16 50 camera → editor → camera round trips (#10109), dumpsys meminfo graphics 400 MB 827 MB
iPhone 12 Pro, iOS 18.5 Editor stress pass (#10108), footprint 523 MB ~1.1 GB

These numbers predate the editor memory fixes from the two passes (#10171, #10177, #10180, #10231, #10237), so they may have changed.

Candidates (none verified)

  • Flutter's image cache: its budget is raised to 256 MB (AppConstants.imageCacheMaxBytes), and stop-motion stills alone filled it during task(camera): check that very long camera sessions don't crash or break the app on Android and iOS #10109. This would explain part of the iOS growth, but not memory that dumpsys counts as graphics on Android.
  • GPU textures and render targets that Impeller keeps pooled after the editor's large offscreen passes.
  • Video player or camera preview surfaces and textures that are not released when the camera or editor closes.
  • Caches inside pro_image_editor / pro_video_editor that outlive the editor.

Steps

  1. Re-measure on current main with the same profile-build setup: baseline on the home screen, then 50 camera → editor → camera round trips, then 5 minutes idle on the home screen.
  2. Check whether PaintingBinding.instance.imageCache.clear() plus clearLiveImages() gives the memory back. If it does, the image cache is the cause.
  3. If not, take a GPU memory breakdown: Android with dumpsys gpu --gpumem and dumpsys gfxinfo <package>, iOS with Instruments (VM Tracker, IOSurface and Metal allocations).
  4. Fix the holder, or write down why the memory has to stay and what bounds it.

Acceptance Criteria

  • The retained memory is attributed to concrete owners on Android and iOS
  • After leaving the camera or editor, memory returns close to baseline, or the issue states why it cannot and what bounds it
  • Before and after numbers on a real Android device and a real iPhone are recorded here

Activity

  1. self-assigned this
    on Oct 19, 2026
  2. added theissue type on Oct 19, 2026
  3. moved this from Triage to In progress in Divine programon Oct 19, 2026
  4. hm21 commented on Oct 19, 2026

    @hm21
    MemberAuthor

    Findings

    Re-measured on main (9badb02) with profile builds and a throwaway in-app driver: home screen baseline, 50 camera → editor → camera round trips (one 0.8 s take each, editor open 4 s, take deleted afterwards), back to the home screen.

    The holder is Flutter's image cache, on both platforms. Every round trip left ~6 MB of decoded images of clips that no longer exist: the clip thumbnail decoded at full size (640×1138, 2.8 MB) by the timeline tile fallback, the same file decoded again at 1080 px for the editor poster (2.5 MB), ~6 timeline strip thumbnails and a few small entries. After ~35 round trips the cache hit its 256 MB budget (476 entries), and that is exactly where memory stopped growing.

    • Android: imageCache.clear() brought GL mtrack straight back to the baseline, so nothing else is retained. A full cache costs about twice its byte count in graphics memory (+225 MB of cache → +~490 MB GL mtrack): Impeller keeps every decoded image as a texture with a full mip chain.
    • iOS: the footprint grew in lockstep with the same cache and stopped at the same point. Clearing the cache gives the footprint back once Flutter draws its next frame (824 → 552 MB in a follow-up run); the ~13 MB I first read here came from a run where no new frame followed the clear.
    • Backgrounding is already covered on Android: the engine sends a memory-pressure warning on UI_HIDDEN, and our handler empties the cache (204 → 6 entries, GL 559 → 335 MB in the background). Not measured on iOS.
    • Ruled out: Impeller's render-target cache (evicted a few frames after last use), camera and player surfaces (GL returned to baseline once the image cache was empty), static caches in pro_image_editor / pro_video_editor (none hold images).

    Numbers

    Galaxy S26, Android 16, dumpsys meminfo GL mtrack. Home-screen samples swing by ~115 MB with the feed players, so ranges are given.

    Before the fix With the fix
    Home screen before 236–372 MB 240–362 MB
    During 50 round trips 252 → 738 MB flat at ~257 MB
    Home screen after 743–859 MB 212–331 MB
    After imageCache.clear() 243–359 MB 213–331 MB
    Image cache after 476 entries, 254 MB empty

    iPhone 12 Pro, iOS 18.5, physical footprint. The two runs started from different feed states, so compare within a column.

    Before the fix With the fix
    Home screen before 725–747 MB 476–556 MB
    Camera open 569 MB 527 MB
    During 50 round trips 492 → 835 MB 424–446 MB, flat
    Home screen after 844–853 MB 393–429 MB
    After imageCache.clear(), before a new frame was drawn 840 MB 429–433 MB

    Fix (#10306)

    • The camera and editor routes empty the image cache when they close. Images still on screen stay; anything else is decoded again from disk the next time it is shown.
    • The timeline tile fallback decodes the clip thumbnail at tile height instead of full size.

    Trade-off: grid thumbnails that were decoded but are off screen when the user leaves the camera or editor are decoded again from the disk cache when scrolled back to.

    Not covered here: the 256 MB budget itself still costs ~500 MB of graphics memory on Android once feed and grid browsing fill it.

    Edited: corrected the iOS clear result; the first version said the memory stayed with the process.

  5. hm21 commented on Oct 19, 2026

    @hm21
    MemberAuthor

    What the 256 MB image cache budget costs on device

    Follow-up to the open point at the end of the findings above: what AppConstants.imageCacheMaxBytes (256 MB) costs once ordinary browsing fills it, and whether it should shrink or scale with device RAM.

    Measured on main (9badb02) with profile builds and a throwaway in-app driver (not committed): home feed, 15 feed swipes, Explore → New grid scrolled until the cache reached its budget, idle, imageCache.clear(), 2 s of forced frames, refill, background and foreground, then a scroll-back test at 256 MB and at 128 MB (twice each). Galaxy S26 (Android 16, 12 GB RAM): dumpsys meminfo GL mtrack, three runs. iPhone 12 Pro (iOS 18.5, 6 GB RAM): physical footprint read in-app, one run.

    Every MB of decoded cache costs about 2 MB of graphics memory

    Normal browsing fills the budget in about a minute: the home feed holds 7 images (33 MB), 15 swipes bring it to ~33 (70 MB), opening Explore to ~50 (100 MB), and about 20 screens of grid scrolling to the full 250 MB (~150 thumbnails).

    Galaxy S26, GL mtrack iPhone 12 Pro, footprint
    Home feed, 7 images / 33 MB 221–337 MB ~505 MB
    After 15 feed swipes, ~33 images / 70 MB 310–420 MB ~583 MB
    Explore grid, cache full (~150 images / 250 MB) 685–690 MB 824–880 MB
    Graphics memory per MB of cache (linear fit while filling) 1.9–2.0 (runs: 2.03, 2.04, 1.88) 1.95
    Average decoded grid thumbnail 1.6 MB 2.2 MB

    So a full 256 MB budget is roughly 500 MB of GPU memory on both devices, about twice what the cache reports. Impeller uploads every decoded image as a private texture with a full mip chain (image_decoder_impeller.cc), which explains 1.33×; I did not break down the rest.

    clear() gives the memory back, but only on the next drawn frame

    Galaxy S26, GL mtrack iPhone 12 Pro, footprint
    Cache full, idle on the grid 690 MB 824 MB
    30 s after imageCache.clear(), no frame drawn 690 MB 824 MB
    After 2 s of forced frames that decode nothing 386 MB 552 MB

    A full Dart GC forced over the VM service, with no frame, changed nothing on the S26 (685 → 685 MB; Dart's external usage was 0.2 MB, so Dart no longer held the images). So iOS does return the memory; the "only ~13 MB back" in the findings above (and the iOS note in #10306) was most likely the same effect, since a playing feed video redraws without a new Flutter frame.

    Backgrounding: iOS frees it, Android keeps it

    • iPhone: entering the background empties the cache (engine memory warning → our handler), and the footprint fell from 877 to 483 MB within 2 s.
    • S26 (two runs): the cache was emptied too (0 entries), but GL mtrack stayed at 625–632 MB for the whole ~45 s in the background; only the window surface (~125 MB) went. It fell with the first frame after returning. Once the app is in the background no frame follows the clear, so the textures stay, and a backgrounded app is exactly where the low-memory killer looks.

    What a smaller budget costs: the scroll-back window

    Grid thumbnails on *.divine.video go through Image.network (PassiveAuthThumbnailImage._shouldBypassCacheManager), so they have no disk cache: a memory miss is a new HTTP request (65–90 ms on average here), and the tile shows its blurhash and fades in over 500 ms.

    Scroll ~340–360 tiles down the New grid (45,000 pt), then back to the top at 3,000 pt/s. "Instant" = served from memory on the way back; one grid screen ≈ 4.3 tiles.

    Budget Device GPU memory at the bottom Tiles that come back instantly Re-fetched on the way up On screen while loading or fading in
    256 MB S26 784–818 MB GL 125–149 (29–34 grid screens) 209–224 30–57
    128 MB S26 550–560 MB GL 74 (17 grid screens) 284 41–53
    256 MB iPhone 884–899 MB footprint 116–128 (27–30 grid screens) 248–258 87–124
    128 MB iPhone 676–681 MB footprint 65 (15 grid screens) 311 92–110

    The budget only decides how far back the instant window reaches. Past it, both budgets re-fetch and flash the same way, which is why the last column barely moves. Halving the budget halves the window, from about 30 to about 16 grid screens, and saves ~210 MB of footprint on the iPhone and ~250 MB of GL on the S26.

    Recommendation

    Size the budget by RAM instead of a fixed 256 MB. Its real cost is about 2× the budget, so keeping that cost near 4 % of RAM gives budget ≈ RAM / 48, clamped to 96–256 MB:

    RAM Budget Real cost (~2×) Instant scroll-back (grid screens)
    ≥ 12 GB (S26) 256 MB ~500 MB ~30
    8 GB 170 MB ~340 MB ~20 (est.)
    6 GB (iPhone 12 Pro) 128 MB ~250 MB ~15
    ≤ 4 GB 96 MB ~190 MB ~11 (est.)

    device_info_plus 10.1.2 has no RAM getter, so this needs ActivityManager.MemoryInfo.totalMem / ProcessInfo.processInfo.physicalMemory through an existing channel or a dependency bump. If that is too much for now, 128 MB everywhere is the safe interim: it still keeps 15–17 grid screens of instant scroll-back on both devices.

    Independent of the budget, two follow-ups come out of the frame finding:

    1. Schedule a frame after every explicit clear (WidgetsBinding.instance.scheduleForcedFrame() in the memory-pressure handler, and after ReleaseCachedImagesOnDispose from perf(video-editor): give back the memory that camera and editor sessions kept #10306 empties the cache), so a static screen actually returns the memory.
    2. On Android, empty the cache while the app still has a surface (on inactive/hidden, followed by a frame) rather than only in the trim-memory callback. Not tried yet; it needs a device check.
  6. moved this from In progress to Testing in Divine programon Oct 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions