iris android-app: strip+LTO+cgu1+opt-level=s halve libmain.so, no feature trim needed

Baseline had panic=abort only. Measured each setting in order (docs/RUST.md's
new "APK size (2026-09-07)" subsection has the full table and crate
breakdown): strip=true, lto="fat", codegen-units=1, opt-level="s" take
libmain.so from 18,546,488 to 11,193,608 bytes (-39.7%) and the release APK
from 20,678,956 to 13,326,076 bytes (-35.5%), arm64-v8a. opt-level="z" was
measured (another ~800KB) but not adopted without a frame-time check.

Investigated naga/wgpu backend features and tabs-ui/tabs-screen as trim
candidates; both are already fully eliminated by the linker on Android
(0 symbols in `llvm-nm` on the baseline .so), so no Cargo feature change
would shrink the binary -- left as documented findings rather than a diff.

Embedded Noto Sans fonts (3.6 MB) and the wgpu/naga/font-shaping stack
account for most of what remains vs. Compose, which borrows the platform's
own renderer and fonts for free; recorded honestly in the doc rather than
trimmed, since subsetting fonts or dropping a backend would change what
iris can render.
This commit is contained in:
iris committed 2026-09-07 15:46:40 -04:00
1 parent 4274b8b8d0
commit 42af780639
2 files changed
+184

No files matched your search

+172
View File
@@ -43,6 +43,178 @@ gated on her verdict**, so this pass works the P0 defects and the pure
prerequisites in this order. Each item is ticked here by the agent that
closes it.
### APK size (2026-09-07)
Iris's question: the iris bench APK is about double the Compose bench APK
(20.6 MB vs 10.1 MB, both release). Measured before this pass: 18,088 KiB of
`lib/arm64-v8a/libmain.so`, stored uncompressed (`extractNativeLibs=false`),
plus 2 MB of dex; the Compose APK's dex is 25 MB raw, compressed to 9 MB in
the APK. `iris/android-app/Cargo.toml`'s `[profile.release]` set only
`panic = "abort"` -- no `lto`, no `codegen-units`, no `strip`, default
`opt-level`. All numbers below are `arm64-v8a` release, built with
`./build-apk.sh release` (this checkout, `nice -n 10`, no `CARGO_TARGET_DIR`
override, reusing the incremental `target/`), and are the raw file sizes
(`ls -la`), not what `du` would round to.
| profile.release | APK bytes | `libmain.so` bytes | delta vs previous |
|---|---|---|---|
| `panic="abort"` only (baseline) | 20,678,956 | 18,546,488 | -- |
| + `strip = true` | 16,435,156 | 14,302,688 | -4,243,800 |
| + `lto = "fat"` | 15,751,212 | 13,618,744 | -683,944 |
| + `codegen-units = 1` | 15,185,204 | 13,052,736 | -566,008 |
| + `opt-level = "s"` | 13,326,076 | 11,193,608 | -1,859,128 |
| + `opt-level = "z"` (not adopted, see below) | 12,507,276 | 10,374,808 | -818,800 |
Adopted: `strip = true`, `lto = "fat"`, `codegen-units = 1`, `opt-level = "s"`.
Baseline to final: `libmain.so` **18,546,488 -> 11,193,608 bytes (-39.7%)**,
APK **20,678,956 -> 13,326,076 bytes (-35.5%)**.
**`opt-level = "z"` was measured but not adopted.** It is smaller still --
another 818,800 bytes off `libmain.so` (7.9 MiB total vs `s`'s 8.7 MiB) --
but `z` trims more aggressively than `s` in ways that can cost frame time
(fewer inlines, more size-motivated codegen choices, per rustc's own docs),
and this pass did not have an iris-side frame-time benchmark run against
it (the app's own `run-bench.sh`/render report was not exercised here,
per this task's scope, and this checkout has no emulator currently up).
Trading an unmeasured runtime cost for ~700 KB more off the download is not
a call to make blind, so `s` is what shipped, and `z` is left as something
to try only alongside a `transcript-bench.sh`/`stream-bench.sh`-equivalent
run for iris to confirm it does not regress.
Not stripped (baseline) had a live `.symtab` (`llvm-readelf -S`): section
25, `SYMTAB`, 0x17ed40 bytes (~1.49 MiB) covering 65,223 raw symbols. `strip
= true` removes it at build time -- notably, `stripReleaseDebugSymbols`
(AGP's own strip task) had already logged "Unable to strip the following
libraries, packaging them as they are: libmain.so" on the baseline, so
Cargo's own strip is also the fix for that.
Baseline section sizes (`llvm-readelf -S`, before any profile change):
| section | bytes |
|---|---|
| `.text` | 6,463,032 |
| `.rodata` | 6,548,192 |
| `.eh_frame` | 721,872 |
| `.data.rel.ro` | 441,328 |
| `.gcc_except_table` | 13,652 |
| `.symtab` | 1,563,456 |
No `bloaty` on this machine (`which bloaty` empty); used
`llvm-nm -S --size-sort -C` on the baseline (unstripped) `.so`, summed by
the symbol's leading crate/module name. 7.02 MB of the 18.5 MB `.so` carries
a name at all (the rest is `.rodata` blobs -- embedded data, padding,
relocations -- that never get a symbol). Top 8 by that accounting:
| crate | bytes (named symbols only) |
|---|---|
| `naga` | 1,100,099 |
| `core` (std) | 698,862 |
| `wgpu_core` | 599,720 |
| `harfrust` | 372,694 |
| `alloc` | 347,324 |
| `read_fonts` | 326,824 |
| `wgpu_hal` | 299,648 |
| `skrifa` | 291,332 |
Also notable further down: `hashbrown` 244,712, `jni` 199,660, `std`
199,126, `serde` 168,256, `zeno` 157,320, `pulldown_cmark` 116,568,
`iris_core` 95,112, `parley` 78,816, `iris` 76,616, `swash` 72,940,
`serde_json` 72,312, `fontique` 45,464.
**The other ~11.5 MB of `.rodata`/unnamed data is mostly the embedded
fonts**: `iris/core/src/primitive/text.rs` `include_bytes!`s six Noto Sans
TTFs (`iris/core/assets/fonts/`) -- Regular/Bold/Italic/BoldItalic for Noto
Sans plus Regular/Bold for Noto Sans Mono -- totalling **3.6 MB** of raw
font data (`du -ch iris/core/assets/fonts/*.ttf`). That is real render
data, not something to strip: unlike the Compose app's Nerd Fonts icon
subset (`app/build-icon-font.sh`, which subsets because the app only ever
draws ~100 fixed glyphs), iris's Noto Sans embedding backs arbitrary text
in a chat transcript, so a subset would have to be a Unicode-coverage
subset (Latin/Latin-Extended/common punctuation, dropping CJK/Cyrillic/etc)
rather than a fixed-codepoint one -- a real behaviour change (text in a
language outside the subset would fall back to tofu or a missing glyph) and
out of scope for a size-only pass. Left as a follow-up, flagged for Iris:
subsetting would plausibly save 1-2 MB but changes what scripts render
correctly, which is a product decision.
**naga/wgpu backend features: investigated, not trimmed, because the
trim would not change the binary.** `iris/core/Cargo.toml` and
`iris/Cargo.toml` depend on `wgpu = "28.0.0"` with default features, which
via `wgpu`'s own defaults (`dx12`, `metal`, `gles`, `vulkan`, `wgsl`,
`webgpu`) forward `naga/hlsl-out`, `naga/msl-out`, `naga/glsl-out`,
`naga/spv-out`, `naga/wgsl-in`, `naga/wgsl-out` -- Cargo feature
unification is not per-target, so all of those are nominally "on" for the
Android build too, not just the ones Android actually uses (`glsl-out` for
GLES, `spv-out` for Vulkan). But `wgpu-hal`'s own `build.rs`
(`cfg_aliases!`) gates the *modules* themselves on the real target:
`dx12: target_os = "windows" AND feature = "dx12"`, `metal: target_vendor =
"apple" AND feature = "metal"` (`wgpu-hal-28.0.0/build.rs`,
`wgpu-hal-28.0.0/src/lib.rs`'s `#[cfg(dx12)] pub mod dx12;` etc). So on
`aarch64-linux-android` the dx12/metal modules never compile, nothing calls
into `naga::back::hlsl` or `naga::back::msl`, and the linker's normal
dead-code elimination already drops them: `grep -c
"naga::back::hlsl\|naga::back::msl\|naga::front::spv\|naga::front::glsl"
/tmp/nm_size.txt` on the **baseline** (no LTO yet) `.so` returns **0** --
none of that code reached the linked binary in the first place. `regex`
(pulled in transitively by `env_filter`, which `android_logger` uses for
`RUST_LOG`-style filtering) is in the same position: present in
`Cargo.lock` but only a handful of small generic-drop symbols in `nm`, not
a real contributor. Neither is worth a Cargo-level feature trim (which
would also need a per-target dependency table to avoid stripping dx12/metal
off the desktop build, adding real complexity for a change that measures
as zero). No emulator use was needed for this finding since no feature
flag changed; the earlier per-step size measurements (strip/LTO/cgu/opt-level)
were likewise not re-verified on the emulator, since this task's brief
scoped emulator use to the naga-trim step specifically, and that step's
answer was "don't."
**`tabs-ui`/`tabs-screen`: also investigated, also already dead.**
`build-apk.sh`'s default features are `"transcript-screen bench"`, passed
without `--no-default-features`, so the crate's own `default =
["tabs-screen"]` (`iris/android-app/Cargo.toml`) is *also* on for every
build this script produces, including the bench APK. `src/lib.rs`'s doc
comment already says the three screens are mutually exclusive at runtime
(`ActiveClient` gives `bench` priority over `transcript-screen`, which
takes priority over the default `tabs-screen`), and checking the actual
`#[cfg(...)]` gates confirms it is mutually exclusive at *compile* time
too: the `Client` struct and its one call to `tabs_ui::build` are behind
`#[cfg(not(feature = "transcript-screen"))]`, which is false whenever
`transcript-screen` is on, so that code does not even get generated, let
alone linked. `llvm-nm -C` on both the baseline and the final `.so` confirm
it: `grep -ci "tabs_ui\|sungals"` is **0** in both. So there is nothing to
trim here either -- `tabs-ui` and its `sungals.png` (8.9 KB) never reach
the linked binary in a `transcript-screen`/`bench` build, regardless of the
feature being nominally "on" in `Cargo.toml`.
**Comparison Iris asked for, honestly**: most of the remaining ~13.3 MB vs
Compose's 10.1 MB is not a build-settings gap, it is what each app links.
Compose's APK carries ~9 MB of *compressed* dex and links Android's own
platform renderer, text shaper (HarfBuzz/Minikin) and font files from the
system image at zero cost to the APK -- none of that is bytes Compose ships.
Iris ships its own copy of all of that: `wgpu`+`naga`+`wgpu_hal` (a
software/hardware-portable GPU backend and shader cross-compiler, roughly
2 MB of named symbols alone), `harfrust`+`read_fonts`+`skrifa`+`swash`+
`parley`+`fontique` (a full third-party font-loading/shaping/rasterizing
pipeline, another ~1.2 MB of named symbols), and 3.6 MB of embedded font
data because it cannot borrow the platform's fonts the way Compose does.
Build settings (this pass) closed real ground -- 39.7% off `libmain.so` --
but did not remove any of those linked systems, because removing them would
mean iris stops being a self-contained native renderer, which is the whole
point of the port (`AGENTS.md`'s "no Dioxus and nothing that draws through
a WebView", `no-dioxus-or-webview-ui-true-native-only` memory). Install size
(what `dumpsys package`/`du` on the installed `lib/arm64-v8a/` directory
would show) was not separately measured this pass: with
`extractNativeLibs=false` the `.so` is mapped directly out of the APK
rather than copied onto disk a second time, so install size tracks the APK
size closely for the native library and is not a second, larger number the
way it would be under the old `extractNativeLibs=true` default -- checking
this precisely needs the emulator, which this task scoped to the naga-trim
verification only.
Committed: `iris/android-app/Cargo.toml`'s `[profile.release]` now reads
`panic = "abort"`, `strip = true`, `lto = "fat"`, `codegen-units = 1`,
`opt-level = "s"`, with a comment naming the measured savings.
### Queue, 2026-09-07 (orchestrator)
In order; two builders at a time. Each is ticked here by the agent that
+12
View File
@@ -73,6 +73,18 @@ bench = ["transcript-screen", "dep:transcript-fixture", "dep:libc", "dep:tokio"]
[profile.release]
panic = "abort"
# Measured 2026-09-07 (docs/RUST.md's "APK size" subsection): together these
# take libmain.so from 18,546,488 to 11,193,608 bytes (-39.7%) and the APK
# from 20,678,956 to 13,326,076 bytes (-35.5%), arm64-v8a release. `strip`
# also works around AGP's own stripReleaseDebugSymbols failing silently on
# this .so ("packaging them as they are"). `opt-level = "s"` over `"z"`:
# `z` measured another ~800 KB smaller but was not checked against iris's
# own frame-time bench, so it is not worth the unmeasured risk -- see the
# doc for the number and the follow-up this leaves.
strip = true
lto = "fat"
codegen-units = 1
opt-level = "s"
[profile.dev]
panic = "abort"