Skip to content

build: AGP 9.3 with R8 optimized resource shrinking, and lower on-device scoring memory - #16

Merged
IgnacioLD merged 3 commits into
mainfrom
build/agp9-r8-memory
Nov 10, 2026
Merged

IgnacioLD merged 3 commits into
mainfrom
build/agp9-r8-memory

Conversation

@IgnacioLD

Copy link
Copy Markdown
Owner

Play Console (2.5.4, Android vitals) warns that the R8 setup may use more memory because optimized resource shrinking is off, and says to move to AGP 9. Google starts enforcing these memory and code optimization thresholds in February 2027. This PR upgrades the build, removes keep rules that are no longer needed, and lowers the memory that on-device scoring holds. There are three commits, one per concern.

1. AGP 8.9.1 -> 9.3.3, Gradle 8.11.1 -> 9.7.1

  • Why 9.3.3 and not 9.5.1: 9.3.3 is the newest AGP inside Kotlin 2.4.20's tested range (AGP 8.5.2-9.3.1, Gradle 7.6.3-9.7.0). 9.4.x and 9.5.x are outside that range, and 9.5.x was released less than two weeks ago. AGP 9.3 needs at least Gradle 9.5. I picked Gradle 9.7.1, the version deploy/Dockerfile already builds the server with. Both picks are patch releases of tested versions. The wrapper jar matches the official sha256.
  • KMP modules: :engine and :shared now use com.android.kotlin.multiplatform.library, because AGP 9 rejects com.android.library next to the KMP plugin. These stay as they were: the opt-in Android target (-Pglosso.android), onnxMain, kotlin.mpp.applyDefaultHierarchyTemplate=false and JVM target 17.
  • :androidApp: uses AGP's built-in Kotlin, so the kotlin-android plugin is gone and the JVM target follows compileOptions. resourceConfigurations is replaced by androidResources.localeFilters. Both flavors, the licensing gate per flavor and the release -P inputs are unchanged.
  • Test moves:
    • shared/src/androidUnitTest is now androidHostTest, so the task is :shared:testAndroidHostTest.
    • BottomActionBarTest, GlossoArtworkTest and PlayerPointerTest stopped compiling in :androidApp once the UI moved to :shared: they use internal API, and one still imported Coil 2. That broke connectedAndroidTest and tools/score-parity/run-parity-test.sh on main. They now live in shared/src/androidDeviceTest (:shared:connectedAndroidDeviceTest).
  • Gradle 9.7 deprecations fixed: delegated by getting/creating/registering in engine, shared and androidApp, and Task.project read at execution time by the licensing tasks.
  • Licensing: the locks and licenses.json are regenerated with tools/licensing/collect.py. kotlin-stdlib-common:2.4.20 now resolves.
  • Docs: README and CONTRIBUTING list :shared:testAndroidHostTest.

2. R8

  • Optimized resource shrinking is AGP 9's default. isMinifyEnabled and isShrinkResources were already on for release.
  • Rules removed from proguard-rules.pro:
    • the kotlinx.serialization block, which the library already ships;
    • -keep class androidx.credentials.playservices.** { *; }, because the AAR ships a narrower rule;
    • the Coil 2 rules, which named classes that no longer exist;
    • every blanket -dontwarn, because R8 reports no missing class without them;
    • the duplicate native-method rule;
    • -renamesourcefileattribute, so R8 9 writes its mapping id as the source file.
  • Rule kept: ai.onnxruntime.**. The JNI layer looks up classes, fields and constructors by name, and the AAR ships rules only for its telemetry classes.
Release, unsigned before (main d70a496) after
playstore APK size 138,753,248 B 138,421,983 B
playstore referenced methods 30,511 26,178 (-14%)
playstore dex 5,119,168 B 4,621,976 B
playstore res/ files / resources.arsc 277 / 113,228 B 13 / 44,704 B
fdroid APK size 138,232,808 B 138,227,046 B
fdroid referenced methods 25,514 23,963
fdroid dex 4,463,520 B 4,291,760 B
playstore AAB 60,310,866 B 59,993,510 B

Methods were counted with apkanalyzer dex references. Most of the APK is the ONNX Runtime .so for 4 ABIs (about 135 MB), so the total size barely moves.

3. On-device scoring memory

  • Model load: the model was already loaded from the file path, not through a ByteArray.
  • Session options: the session is still created once and kept. It now runs with setCPUArenaAllocator(false) and session.use_device_allocator_for_initializers=1. These change only where tensors are allocated, not the math.
  • Background release: OnDeviceScorer releases the model on TRIM_MEMORY_BACKGROUND and reloads it on the next take. A take holds the load lock while it runs, so the session cannot be closed under it.
  • Not changed: the model stays loaded when the learner leaves practice but stays in the app, to avoid reloading it on every screen.
  • Bit-exact check: a SHA-256 over the recognition (IPA, per-token confidences, timings) of score-parity/fixtures/good.wav was 1f3c5e77... before and after. tools/engine-bench/run.sh output is identical line for line between main and this branch.

Memory on emulator-5558 (arm64, 1080x2340 at 480 dpi), debug build. A scratch instrumented probe (not committed) walks through the phases while adb shell dumpsys meminfo me.shirobyte42.glosso is sampled every 0.5 s. Values are MB at the end of each phase:

Phase PSS before PSS after Native heap before Native heap after Java heap before -> after
idle 183 187 4 6 9 -> 10
model loaded 589 592 400 400 9 -> 10
after 3 scored takes 610 574 416 378 13 -> 13
second recognizer loaded (peak) 1018 918 826 724 11 -> 10
second recognizer closed 639 588 447 394 10 -> 10
onTrimMemory(TRIM_MEMORY_BACKGROUND) 638 230 447 36 10 -> 10

Scoring latency for the fixture was unchanged within noise (321/291/268 ms before, 337/310/265 ms after).

Verification (rebased on main d70a496)

  • Unit tests:
    • ./gradlew :engine:jvmTest: 463 passed
    • LC_ALL=en_US.UTF-8 ./gradlew :server:test: 347 passed
    • :androidApp:testFdroidDebugUnitTest / testPlaystoreDebugUnitTest: 296 / 295 passed
    • :shared:testAndroidHostTest: 146 passed. The same test files ran as testDebugUnitTest before the move: 142 on the old base, plus the tests that came with feat(app): let the learner point the app at a self-hosted server #15.
  • Lint: :androidApp:lintFdroidDebug and lintPlaystoreDebug report 0 errors. Warnings went from 73 to 11 per flavor. The one new warning is OldTargetApi: the newer lint knows API 37, and raising targetSdk is a separate product change.
  • iOS: :engine:compileKotlinIosSimulatorArm64 and :shared:compileKotlinIosSimulatorArm64 pass.
  • Server image path: ./gradlew :server:installDist -Pglosso.android=false -Pglosso.ios=false passes without the Android SDK in the environment. Docker is not available on this machine, so docker build was not run. The Dockerfile already uses Gradle 9.7.1.
  • Release builds: assemblePlaystoreRelease, assembleFdroidRelease and bundlePlaystoreRelease pass.
  • Release on device: I used a temporary local setup, not committed: testBuildType = "release", debug signing, and keep rules for the test harness only. ScoreParityTest passed on the R8-minified playstore release on emulator-5558: good.wav scored 100 / PERFECT with delta 0, and silence, noise and low were not evaluable. ONNX Runtime loads and scores under the new rules. OfflineReferenceParityTest skipped because thirty.wav is not staged anywhere.
  • Debug on device: tools/score-parity/run-parity-test.sh --serial emulator-5558 passed. :shared:connectedAndroidDeviceTest ran 6 tests and all passed.
  • Engine bench: GLOSSO_MODEL=<abs>/wav2vec2_phoneme_int8_dyn_pertensor.onnx tools/engine-bench/run.sh gives native >= 98 for 30/45 and REFSCORE 22/28 (>= 98), identical on main and this branch. The bench's own 0.9 rate gate already fails on main; this PR does not change that.
  • gitleaks: gitleaks dir . --redact reports only the 2 known false positives.

Notes

  • AGP prints an informational warning that :engine's commonTest has no Android host test. Turning one on fails to compile, because ParityPlatform has no Android actual. The old androidUnitTest variant never compiled either.
  • No NDK r28 is installed locally, so "Unable to strip libonnxruntime.so" is printed, as it was before. The libraries are already stripped.

https://claude.ai/code/session_01FcVpZoyr8VdRLp1QecX1Ng

AGP 9 makes R8's optimized resource shrinking the default, which Play
Console asks for. 9.3.3 is the newest AGP inside Kotlin 2.4.20's tested
range (AGP up to 9.3.1, Gradle up to 9.7.0; both picks are patch releases).

- :engine and :shared use com.android.kotlin.multiplatform.library, since
  AGP 9 rejects com.android.library next to the Kotlin Multiplatform plugin.
  The opt-in Android target (glosso.android), onnxMain and the hand-wired
  hierarchy are unchanged.
- :androidApp uses AGP's built-in Kotlin (no kotlin-android plugin; the JVM
  target follows compileOptions) and androidResources.localeFilters.
- shared/src/androidUnitTest is now androidHostTest (:shared:testAndroidHostTest).
- The three Compose device tests of shared components no longer compiled
  in :androidApp (they use internal :shared API) and move to
  shared/src/androidDeviceTest (:shared:connectedAndroidDeviceTest).
- Gradle 9.7 deprecations fixed: no delegated `by getting/creating/registering`,
  and the licensing tasks no longer read Task.project at execution time.
- Licensing locks and catalogue refreshed (kotlin-stdlib-common now resolves).

Claude-Session: https://claude.ai/code/session_01FcVpZoyr8VdRLp1QecX1Ng
kotlinx.serialization and Credential Manager ship their own consumer rules;
ours duplicated them (and kept the whole androidx.credentials.playservices
package). The Coil rules named Coil 2 classes that no longer exist, and the
blanket -dontwarn lines hid nothing: R8 reports no missing class without
them. The native-method rule is already in proguard-android-optimize.txt.
Only the ONNX Runtime keep remains, because its JNI layer looks classes,
fields and constructors up by name. Release stack traces keep line numbers
and now carry R8's mapping id as the source file.

playstore release APK: 30431 -> 26075 referenced methods, dex 5.10 -> 4.60 MB,
277 -> 13 res/ files.

Claude-Session: https://claude.ai/code/session_01FcVpZoyr8VdRLp1QecX1Ng
…ackground

The ONNX session now runs without the CPU memory arena and allocates the
weights outside it, so the activations of a take are freed when it ends
instead of staying reserved for the life of the session. Allocation only:
the recognition output is bit-identical (same transcription, confidences
and timings on the parity fixture; engine bench unchanged).

The app also releases the loaded model on TRIM_MEMORY_BACKGROUND and
reloads it on the next take. A take holds the load lock while it runs, so
a release can never close the session under it.

Emulator (arm64, dumpsys meminfo native heap): 416 -> 378 MB after scoring,
447 -> 394 MB after a second model is closed, 394 -> 36 MB once trimmed.

Claude-Session: https://claude.ai/code/session_01FcVpZoyr8VdRLp1QecX1Ng
@IgnacioLD
IgnacioLD merged commit 02ef981 into main Nov 10, 2026
4 checks passed
@IgnacioLD
IgnacioLD deleted the build/agp9-r8-memory branch November 10, 2026 17:06
IgnacioLD added a commit that referenced this pull request Nov 10, 2026
…Android

The C shim now disables the CPU memory arena and sets
session.use_device_allocator_for_initializers=1, as the JVM/Android
recognizer does since #16. Over the 77-clip parity run the footprint
after scoring drops from 666 MB to 468 MB (peak 706 -> 592 MB); the iOS
logits are bit-identical with and without the options.

Claude-Session: https://claude.ai/code/session_01FcVpZoyr8VdRLp1QecX1Ng
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.

1 participant