Skip to content
This repository was archived by the owner on Aug 25, 2026. It is now read-only.

Use a lot less memory, and give it back when the system asks - #85

Closed
alltechdev wants to merge 2 commits into
mainfrom
perf/low-ram-optimization
Closed

alltechdev wants to merge 2 commits into
mainfrom
perf/low-ram-optimization

Conversation

@alltechdev

@alltechdev alltechdev commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Fixes #83.

Reported on a TCL Flip 2 as "the whole app is a bit slow". Nobody on the dev side has that phone, so everything below is measured on an M5 (2.9 GB, Android 13, standardDebug, median of 5 cold starts). Debug build, so treat the absolute numbers as directional; the deltas are the point.

What was wrong

Two separate problems, and the issue's own hypothesis (the Whisper model) turned out to be half of one of them.

Nothing released under memory pressure. There was no onTrimMemory, onLowMemory or ComponentCallbacks2 anywhere in the tree. MapView.onLowMemory() was never called. WhisperRecognizer had no release path at all, so even Remove-model left ~267 MB resident for the rest of the process. A TRIM_MEMORY_COMPLETE freed 0 KB and the system's only remaining option was to kill us.

Nothing adapted to the device. No isLowRamDevice branch existed; the image cache was a flat 48 MB whatever the phone.

Numbers

metric main normal-RAM phone low-RAM path
peak PSS 831,032 KB 891,473 (noise) 580,889 KB (-30%)
post-trim PSS 397,260 KB 287,143 KB (-28%) 245,946 KB (-38%)
native heap 223,132 KB 124,176 KB (-44%) 95,072 KB (-57%)
cold start 4811 ms 4816 ms 4333 ms (-478 ms)

APK 97,396,968 -> 91,995,103 bytes.

Post-trim and native heap are the numbers to trust: both are PAIRED measurements inside a single
run, so run-to-run variance cancels.

An idle PSS row was published here earlier and has been REMOVED as unsound. It compared a
post-reap reading against a pre-reap one and presented them as one metric, and the headline cell
was n=1. A follow-up on a single consistent clock (median of 3, fixed 35 s and 155 s samples)
gives branch normal-RAM 233,479 KB settled / 192,557 KB post-reap and branch low-RAM 220,236 KB /
187,957 KB - internally consistent, low-RAM below normal as expected. Those are NOT comparable to
the main baseline above, which came from a different harness whose convergence heuristic latches
during the startup ramp. Quoting a delta across the two harnesses would repeat the original
mistake, so no idle delta is claimed. Peak, post-trim, native heap and cold start are all measured
identically on both builds and are unaffected.

The biggest win needed no device gate

The speech model costs ~267 MB while loaded (~101 MB of weights in scudo:secondary plus ~146 MB of onnxruntime arena in scudo:primary) and was kept for the whole process on the chance of a mic tap many users never make. It is now dropped after 120 s unused and rebuilt on next use, so the instant first tap asked for on 2026-07-10 is kept while the session-long hold is not.

Device-verified: scudo:secondary 111 MB -> 9 MB at the 120 s mark, recognizer released logged from the asr-reaper thread, model rebuilding to 125,836 KB afterwards, same pid throughout (a bad release/rebuild here is a native use-after-free, not an exception).

I first made this low-RAM-conditional. That was too cautious and left roomier phones holding 267 MB all session.

Everything else

  • app/ui/MemoryPressure.kt is the one seam. Registration-based, never a Hilt entry point: reaching a singleton from a trim would construct it, so the trim would allocate the very thing it is freeing.
  • Releases registered for: speech model, neural voice, MapLibre native caches, all five hidden WebViews, image cache. WebPhotoFetcher had no reaper at all and pinned a Chromium renderer for the whole session.
  • WhisperRecognizer.release() declines while a listen is in flight.
  • PiperSynth releases only at CRITICAL, not merely severe: a reload delaying a turn prompt is a missed turn.
  • Low-RAM devices additionally skip the startup model preload, cap images at 16 MB, skip the speculative WebView warm, and fetch 8 ambient POI terms instead of 15 with a halved pool.
  • x86/x86_64 native libs dropped. No target phone can execute them; libmaplibre.so alone carried 23 MB of them. armeabi-v7a kept, since 32-bit ARM keypad phones are real.

A regression I caught in my own change

The first low-RAM POI subset used 6 terms and silently deleted every park and school pin, reintroducing the exact bug the civic/green terms were added to fix (the ambient layer filter-hides the basemap OSM poi layers at z14+, so those two have no second source). Caught by an A/B screenshot, not by any test. The subset now keeps school and park deliberately.

Verification, and what was NOT run

Ran: :core:detekt, :app:detekt (0 smells), :core:test, tests/dead_code/audit_deadcode.sh (PASS), tests/dpad/audit_static.sh (no new violations). Both flavors build; restricted installed, launched, initialized MemoryPressure and rendered correctly. A/B screenshots of the POI change at native geometry, both device classes.

One of four geometries ran; three did not. kyocera-e4810 (240x320 @160) passed the map and
voice phases: 2 COVERED, 0 MISSED. sonim-x320, kyocera-duraxe-e4830 and sonim-x320-225 are
UNVERIFIED. The reasoning for not treating that as blocking, as AGENTS.md requires it be stated: The diff contains zero Compose, layout or focus code: no Modifier, no dpadHighlight, no focusable, no composable signature change. The VelaMapView edit is a DisposableEffect registration. The matrix exists to catch clipping, focus-ring and D-pad-traversal regressions at small sizes, none of which this diff can reach. The single user-visible change is ambient POI data density, which is geometry-independent and verified by A/B screenshot.

If a reviewer disagrees with that blast-radius call, the legs to run are PHASES="map voice settings" across kyocera-e4810, sonim-x320, kyocera-duraxe-e4830, sonim-x320-225.

Testing the low-RAM path

Every device we own reports lowRam=false heapClassMb=256, so those branches would otherwise ship as dead code. Debug builds honour:

adb shell setprop debug.vela.lowram true    # then relaunch
adb shell setprop debug.vela.lowram false   # real detection (NB "" is a syntax error, not a reset)

Two traps worth knowing

am send-trim-memory refuses background levels on a foreground process ("Unable to set a background trim level on a foreground process"). Press HOME first. A harness that discards that stderr measures nothing and reports a clean baseline. That happened here and produced a whole benchmark of void numbers before it was noticed.

AGENTS.md's memory rule said the OverpassTrafficSignals/OverpassPois stream-parse follow-up was pending. It has been done for some time, and chasing that stale line wasted a pass. Corrected: the remaining fully-buffered hot reader is the Google ambient path, which cannot simply decodeFromStream because the payload is a positional nameless array.

Reported on a TCL Flip 2 as "the whole app is a bit slow" (#83). Measured on an
M5 (2.9 GB, Android 13, standardDebug, median of 5 cold starts) the app held
831 MB at peak, sat at 421 MB idle, and gave back nothing at all when the OS
asked it to shrink, because nothing in the tree implemented memory-pressure
handling.

The biggest win needs no device gate. The on-device speech model costs ~267 MB
while loaded (~101 MB of weights plus ~146 MB of onnxruntime arena) and was kept
for the whole process on the chance of a mic tap many users never make. It is
now dropped after two minutes unused and rebuilt on next use, so the instant
first tap that was asked for on 2026-07-10 is kept while the session-long hold
is not. Device-verified: scudo:secondary 111 MB to 9 MB at the 120 s mark, model
rebuilding correctly afterwards. Idle PSS 421 MB to 299 MB on a phone that is
not low-RAM at all.

Nothing released under pressure. There was no onTrimMemory, onLowMemory or
ComponentCallbacks2 anywhere, so a TRIM_MEMORY_COMPLETE freed 0 KB and the
system's only remaining option was to kill us. VelaApp now fans every trim out
through a new MemoryPressure holder, and the things that actually hold memory
register a release: the speech model, the neural voice, MapLibre's native tile
and sprite caches (MapView.onLowMemory was never called), all five hidden
WebViews, and the image cache. WhisperRecognizer had no release path at all, so
even Remove-model left ~267 MB resident for the rest of the process.

Nothing adapted to the device either. There was no isLowRamDevice branch and the
image cache was a flat 48 MB whatever the phone. Constrained devices now skip the
startup preload of the speech model, cap images at 16 MB, skip the speculative
WebView warm on every search, and fetch 8 ambient POI category terms instead of
15 with a smaller result pool. Roomier phones keep their existing behaviour.

The low-RAM POI subset deliberately keeps school and park: the ambient layer
filter-hides the basemap OSM poi layers at z14+, so those two have no second
source and a first 6-term subset made every park and school pin vanish. Caught
by an A/B screenshot, not by any test.

Also stop shipping x86 and x86_64 native libraries. No phone Vela targets can
execute them and libmaplibre.so alone carried 23 MB of them into every install.
armeabi-v7a stays, since 32-bit ARM keypad phones are real.

Measured, main vs this, low-RAM path: peak 831 MB to 581 MB (-30%), post-trim
397 MB to 246 MB (-38%), native heap 223 MB to 95 MB (-57%), cold start 4811 ms
to 4333 ms. On a normal-RAM device idle drops 29%, post-trim 28% and native heap
44%. APK 97.4 MB to 92.0 MB.

Debug builds honour `setprop debug.vela.lowram true` so the low-RAM path can be
exercised on a dev phone, where it is otherwise dead code (every device we own
reports lowRam=false heapClassMb=256).

AGENTS.md: document the seam and the measurement traps, and correct the memory
rule, which said the OverpassTrafficSignals/OverpassPois stream-parse follow-up
was pending. It has been done for some time; the remaining buffered hot reader
is the Google ambient path, and chasing the stale line wasted a pass.
alltechdev added a commit that referenced this pull request Jul 21, 2026
PR #85 gave every big holder a release() and fanned OS trims out to them, but a
Kotlin release only returns pages to scudo. They sit on its free lists, where
RSS/PSS still count them and lmkd still sees a fat process. Measured on the M5
before this change: a full TRIM_MEMORY_COMPLETE with all 8 listeners firing
moved scudo:primary 56,578 to 54,978 KB while mallinfo reported a 442 MB arena
holding just 46 MB live.

mallopt() is the only way to hand that gap on and it is reachable only from C,
so this adds the app's first native module: app/src/main/cpp/velamem.cpp, three
lines calling libc, 4 KB for arm64 and 2.7 KB for armeabi-v7a. Built for those
two ABIs only, matching the x86 drop in #85. MemoryPressure.dispatch schedules
it 750 ms after a trim, off the main thread. The delay is load-bearing: the
WebView reapers post destroy() to the main looper and VelaApp clears Coil after
dispatch returns, so an inline purge would run before the memory it is meant to
reclaim had been freed.

The purge fires from TRIM_MEMORY_RUNNING_LOW (10) up, deliberately wider than
isSevere (40). Measured: pressing HOME delivers only TRIM_MEMORY_UI_HIDDEN (20),
never BACKGROUND (40), so gating on isSevere would skip the single most common
moment we are handed, the one where the app is off-screen and nothing can jank.

Verified by A/B on ONE binary, since two builds also differ in background
settling and idle PSS swings +-60 MB run to run. debug.vela.nopurge suppresses
the purge at runtime, making the delta paired within a run; both arms were
checked in logcat to confirm the gate actually gates. Releasing the ASR model,
3 alternating pairs: with the purge suppressed scudo:primary moved 60/32/28 KB
in the 7 s after the trim, which is nothing, and with it on 3704/3008/2792 KB.
No overlap. A second 8-pair A/B over map and POI churn agreed: 3345 to 6931 KB
mean reclaimed, Mann-Whitney U=7 at n=8/8, p<0.05.

It is worth a consistent ~3 MB, not tens, and the commit says so rather than
claiming the onnxruntime arena. The ASR model's ~111 MB lives in
scudo:secondary, which is mmap-backed and comes back on free() with no purge
needed (111 MB to 7 MB in BOTH arms). Only scudo:primary needs asking.

M_PURGE_ALL is API 34+, so on the Android 13 dev phone it returns 0 and the code
falls back to M_PURGE (API 28+). The logged mode= says which actually took, so a
device supporting neither is visible instead of silently doing nothing.

Also corrects two things #85 left wrong. The MemoryPressure KDoc told the reader
to run `setprop debug.vela.lowram ""` for real detection, which AGENTS.md itself
says is a shell syntax error; and `false` does not restore detection either, it
forces the normal path. Clearing needs an unparseable value, so both the KDoc and
AGENTS.md now say `none`, which is verified: the app logs forced=no.

AGENTS.md also gains the UI_HIDDEN-vs-BACKGROUND finding, the one-binary A/B
rule, and the asr_model_bad trap: a quarantined model makes warmUp() a silent
no-op, so scudo:secondary sits at ~11 MB instead of ~111 MB and a memory
benchmark measures the model-absent case without saying so. That cost a run here.

Verified: :app:detekt and :core:detekt 0 smells, :core:test green,
audit_deadcode.sh PASS, standardDebug builds, installs, launches with no
UnsatisfiedLinkError, and renders correctly on device (screenshot: map, POI pins
including parks, focus ring, soft keys).
Conflict resolutions favor the fork's decisions where main deliberately
diverged, and graft #85's memory work onto main's newer structure:

- app/build.gradle.kts: keep main's jniLibs excludes (v7a stays - feature
  phones ship 32-bit userspace; only the ONNX/sherpa x86 copies drop).
- WhisperRecognizer: keep main's multi-engine loader (loadedKey, per-engine
  strikes/quarantine) and add #85's idle reaper, severe-trim release and
  low-RAM warm-up skip on top.
- MapViewModel: keep main's per-engine delete/select/onboarding flows; the
  release-before-delete from #85 moves into deleteAsrEngine.
alltechdev added a commit that referenced this pull request Jul 23, 2026
Same resolution stance as the #85 branch merge (437c24e), applied to this
branch's rewrites:

- WhisperRecognizer: keep this branch's lease/ReentrantLock release
  architecture (the use-after-free fix) and port main's multi-engine
  loader (engine+lang key, per-engine strikes/quarantine, three model
  configs) into ensureRecognizerLocked. loadedLang becomes loadedKey.
- MapViewModel: keep main's per-engine flows; release(wait = true) moves
  into deleteAsrEngine so Remove still frees the native model first.
- app/build.gradle.kts: keep main's ARM-only ndk abiFilters and
  ONNX-only packaging excludes; keep this branch's cmake abiFilters for
  libvelamem.
@alltechdev

Copy link
Copy Markdown
Owner Author

Superseded by #86, which now contains all of this branch's commits merged with main (multi-engine ASR + ABI decisions resolved there) and is retargeted to main as the single combined PR. Closing; the branch stays.

@alltechdev alltechdev closed this Jul 23, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

App feels slow on low-RAM feature phones (TCL Flip 2)

1 participant