flutter_edge_ai_diagnostics
Renamed from
flutter_gemma_diagnostics. Same package, new name: swap the dependency and thepackage:flutter_gemma_diagnostics/imports; nothing on the device changes.FlutterGemmaDiagnosticsis nowFlutterEdgeAiDiagnostics(0.2.0 drops the old name);dart fix --applyrenames it. See the migration guide.
Opt-in memory diagnostics for flutter_edge_ai apps, read from the OS on Android, iOS and macOS.
Why not the memory number your profiler shows?
The first "memory used" number people reach for is usually RSS, the resident set size — the RES column in top, ps -o rss: every page of the app that is sitting in physical RAM right now. For a model it answers the wrong question, because it adds together two kinds of memory that the OS treats in opposite ways. (Xcode's memory gauge is the exception: on iOS it already shows the footprint this package reports.)
File-backed pages the OS can take back. LiteRT-LM maps a .litertlm file into memory (mmap) instead of reading it into a buffer. Pages of the weights the model has touched count in RSS, but they are still just a view of the file on disk: when memory gets tight the OS drops them and reads them again later. They are reclaimable, but not free: a model reads weights on every token, so dropped pages must be read back from storage. Android lmkd can kill under refault thrashing; the kernel does not necessarily discard every file page before reclaiming anonymous memory.
Anonymous memory without a reloadable model file. Everything the app built itself and that exists nowhere else: the heap, a copy of the weights in a buffer or on the GPU, the KV cache, activations. There is no model file to reload these contents from. The OS can compress or swap anonymous pages, but it cannot simply discard live contents.
In RSS the two look the same. Mapped weights and weights copied into the heap can show similar RSS while producing different footprint readings. A mapped model still needs a working set that fits the device to avoid repeated storage reads. On iOS, jetsam enforces its per-app limit on the footprint rather than RSS.
This package reports anonymousBytes (the iOS and macOS footprint, or Private_Dirty + SwapPss on Android), anonymousPeakBytes on iOS and macOS (the footprint's lifetime peak), fileBackedBytes on Android (resident clean pages: mapped files on a CPU backend, plus GPU driver buffers on some GPU backends), and availableBytes (headroom on iOS, a device-wide estimate on Android, null on macOS). Take readings before loading, after loading, and during generation. Their changes help explain the model's memory cost, with the limits below.
Usage
dependencies:
flutter_edge_ai_diagnostics: ^0.4.0
If you only use it from integration_test/ or test/, put it under dev_dependencies instead. Note that neither section strips anything from a release build: what keeps it out is not importing it from lib/.
import 'package:flutter_edge_ai_diagnostics/flutter_edge_ai_diagnostics.dart';
if (FlutterEdgeAiDiagnostics.isSupported) {
final snapshot = await FlutterEdgeAiDiagnostics.memorySnapshot();
print(snapshot.anonymousBytes);
print(snapshot.availableBytes);
}
Each Android snapshot walks the process's page tables (about 150 ms on a low-end device with a 4.9 kernel, and before Linux 5.10 it stalls the process's mmap calls), so do not poll faster than about once a second. The read is asynchronous, which moves the wait off your isolate but not the kernel's cost.
Take a snapshot before loading a model, after loading it, and during generation. On Android, neither process counter alone measures the model's entire memory cost.
What each field means
| Field | iOS and macOS | Android |
|---|---|---|
anonymousBytes |
phys_footprint from task_info(TASK_VM_INFO): on iOS the value jetsam enforces, including IOKit/GPU (Metal) allocations and compressed memory; on macOS the figure Activity Monitor calls "Memory", with no kill limit behind it |
Private_Dirty + SwapPss from /proc/self/smaps_rollup. GPU memory (KGSL, Mali, dmabuf) is mostly outside it; on Adreno most of it is in fileBackedBytes instead |
fileBackedBytes |
null: not read on iOS or macOS yet | Private_Clean + Shared_Clean from /proc/self/smaps_rollup: resident clean pages (mapped files on a CPU backend, plus GPU driver buffers on some GPU backends, see below). Counts shared pages in full |
anonymousPeakBytes |
ledger_phys_footprint_peak from the same task_info reply: the highest phys_footprint since the process started (a lifetime mark, never reset) |
null: the kernel keeps no anonymous-only peak |
availableBytes |
iOS: os_proc_available_memory(), headroom before this app hits its limit; null on macOS, which has no per-app limit |
MemAvailable from /proc/meminfo: available on the whole device, an optimistic upper bound |
On Android, clean does not mean exclusively file-backed. Private_Clean can include anonymous pages read back from zram and not written since, and MADV_FREE pages. On zram's skip-swapcache path, a page can lose its swap slot and leave SwapPss. Loading a model under memory pressure can therefore move cold heap memory from anonymousBytes into fileBackedBytes; the delta is not an exact count of mapped weights.
GPU backends. On a GPU backend on Adreno, the driver's buffers are mapped through /dev/kgsl-3d0 and are mostly clean pages, so they are counted here too. In one run on the vivo 1933 (Adreno 616, kernel 4.9) a Gemma 4 E2B GPU load raised that mapping from 6 to 981 MiB (988 after one prompt), matching dumpsys meminfo's Gfx dev (989 MiB); about 115 MiB of it was dirty, which is in anonymousBytes, and all file mappings together (model, libraries, others) were about 260 MiB (Pss). With a GPU backend fileBackedBytes is therefore not a model-file figure: compare with a run of the same app on CPU, or subtract GpuMemoryReading.inFileBackedBytes from FlutterEdgeAiDiagnostics.gpuMemory() (experimental, see below). One device and one run each; other GPU drivers (Mali) were not measured.
The baseline includes every clean mapped file the process has touched. Take a reading before loading and subtract, while interpreting both counters together. Singly mapped dirty file pages count in Private_Dirty (inside anonymousBytes); Shared_Dirty is in neither field. Clean pages and dirty pages do not form a complete split of anonymous and file-backed memory.
fileBackedBytes is null on iOS and macOS because this package does not read a corresponding value there yet. TASK_VM_INFO has an external field; evaluating it is a follow-up.
The platforms enforce memory differently, and the numbers reflect it:
- iOS gives each app a hard limit.
anonymousBytesis what that limit is measured against, andavailableBytesis how far away it is. - Android has no per-app limit. lmkd kills based on device-wide pressure and process priority, and starts well before
MemAvailablereaches zero. There,anonymousBytesis what the app holds, not a kill threshold.
Null versus an exception
- A null field means the value is not reported on this platform or OS version:
anonymousBytesandfileBackedByteson an Android kernel without/proc/self/smaps_rollup. Mainline Linux added it in 4.14, but vendor kernels may backport it;fileBackedByteson iOS and macOS: the package does not read a corresponding value yet;anonymousPeakByteson Android, which keeps no anonymous-only peak, and on an iOS or macOS version whosetask_inforeply predates the field;gpuMemory()returns null when the device has no reader: always on iOS and macOS, and on an Android phone with no/dev/kgsl-3d0(a Mali GPU, for one; not measured; see GPU memory). On an Adreno phone it returns zeros before the process has used the GPU;availableByteson macOS, which has no per-app limit;availableByteson iOS when the call returns 0. Apple returns 0 both when no limit applies (the simulator) and when the limit is already exceeded, and the two cannot be told apart.
MemoryReadExceptionmeans the value should exist and the read failed: a kernel error, a permission or I/O error, or a file that lacks a field it always carries.UnsupportedErroris thrown bymemorySnapshot()andgpuMemory()off Android, iOS and macOS, rather than returning empty values.
Platforms
Android, iOS and macOS. Everything is read through dart:io and dart:ffi, so the package has no Kotlin, Swift, Gradle or podspec, and no dependency on flutter_edge_ai itself: it measures the process, whichever engine runs in it.
The Linux host test writes and flushes a 64 MiB file on the checkout's filesystem, then maps and reads it: fileBackedBytes rises without a comparable rise in anonymousBytes. The example app's integration test asserts the field is positive on Android and null on iOS and macOS.
With version 0.2.2 on vivo 1933 (Android 11, kernel 4.9), the opt-in Gemma 4 E2B test measured fileBackedBytes in MiB:
| Active backend | Before load | After load | After one prompt | After close |
|---|---|---|---|---|
| CPU | 103.6 | 800.5 | 1043.2 | 102.3 |
| GPU | 102.3 | 1390.3 | 1413.2 | 116.1 |
At load and after the prompt, Private_Clean + Shared_Clean − (Rss − Anonymous) was negative on both backends. This gives no positive lower bound for clean anonymous pages in this run; it does not establish their absence. Values depend on the device and what pages were already resident.
Earlier 256 MiB allocation checks for anonymousBytes ran on:
- Android: vivo 1933 (Android 11), Pixel 8a (Android 15) and Galaxy A34 (Android 16);
- iOS: iPhone 17 Pro simulator (iOS 26.5).
GPU memory (experimental)
FlutterEdgeAiDiagnostics.gpuMemory() returns a GpuMemoryReading, or null when there is no reader for this device. It is a separate call, not a MemorySnapshot field, because it costs more and may change.
| Field | Meaning |
|---|---|
inAnonymousBytes |
Private_Dirty of the process's kgsl-3d0 mappings: already in anonymousBytes |
inFileBackedBytes |
Private_Clean + Shared_Clean of the same mappings: already in fileBackedBytes; subtract it to get the non-GPU file-backed memory |
outsideSnapshotBytes |
GPU memory held for the process without a mapping in it. Always null in this version: the per-process counters under /sys/class/kgsl are closed to an app on the two devices tried (a vivo 1933 and a Galaxy S26+), and page_alloc there counts the whole device |
- What it covers. Only the Adreno memory that is mapped into the process. On a vivo 1933 with FunctionGemma 270M on the GPU,
dumpsys meminfolisted Gfx dev 363,080 kB, GL mtrack 365,960 kB and Graphics 749,592 kB, and the reading was 363,080 kB (340,736 dirty + 22,344 clean): about half of Graphics. A leak in GPU buffers the process never maps would not show here. Whether GL mtrack counts the same pages again was not determined. - When it is null. The device has no reader: iOS and macOS always (not read yet:
task_vm_info.ledger_tag_graphics_footprintandMTLDevice.currentAllocatedSizeexist, and Apple's GPU memory is already insidephys_footprint), and an Android phone with no/dev/kgsl-3d0(a Mali GPU, for one;File('/dev/mali0').existsSync()is false on the Adreno phones tried, a Mali phone was not tried). On an Adreno phone it is never null: a process that has not used the GPU yet (a headless engine, a background isolate, a read before the first frame) gets zeros, becauseFile('/dev/kgsl-3d0').existsSync()is true from an app on a vivo 1933 and a Galaxy S26+ (typeSyncandstatSyncsay not found, so the code usesexistsSync). Off Android, iOS and macOS it throwsUnsupportedError. - How it is read.
/proc/self/smaps, matchingkgsl-3d0anywhere in a mapping's name, as AOSP's libmeminfo does, so the/kgsl-3d0name that some kernels may use is covered (AOSP added it for kernel 6.6); a 6.12 Galaxy S26+ still uses/dev/kgsl-3d0, and no device with the shorter name was tried. The file is read as Latin-1, and malformed input, or a missingsmaps(it exists on every Android kernel), throwsMemoryReadExceptioninstead of giving a number. - Cost. The kernel builds per-mapping
smapsmapping by mapping while holding the process'smmap_lock, so it can stall the process's othermmapcalls, on every kernel; do not call it per frame or per token. On the vivo it took about 0.2 s before a model was loaded and 0.3 to 0.4 s with one on the GPU (3,300 and 4,200 mappings, screen on). The file is read asynchronously, but decoding and scanning it run on the calling isolate. - A reading, not a delta. It includes what the app's own rendering maps (about 6 MiB in a Flutter app). Take one before loading a model and subtract.
- Subtracting it from a snapshot is approximate.
fileBackedBytesandinFileBackedBytescome from two reads of/proca fraction of a second apart, and a GPU mapping grows by hundreds of MiB while a model loads (6 to 981 MiB for Gemma 4 E2B), so the difference can come out negative. Take both back to back while the app is idle, and treat the difference as approximate; the same holds forinAnonymousBytesandanonymousBytes.Shared_Dirtypages of the GPU mappings are in neither.
Teach your AI assistant this package
dart run skills@ get --all
Installs the agent skills flutter_edge_ai bundles — this package does not depend on flutter_edge_ai, so they come with it only when your app depends on flutter_edge_ai too. One of them, flutter-edge-ai-diagnostics, covers what each field means per platform, null versus MemoryReadException, and how to measure what a model costs.
Roadmap
gpuMemory() is experimental and Adreno-only; a Mali reading, GPU memory on iOS and macOS, and fileBackedBytes on iOS from TASK_VM_INFO.external, are not done.
Libraries
- flutter_edge_ai_diagnostics
- Opt-in memory diagnostics for flutter_edge_ai apps, read from the OS on Android, iOS and macOS.