d243e1ba7b
count is now a parameter: ./tools/benchmark-fixture.sh --count 20000 builds the folder the performance doc has been promising 5000 and 20000-file numbers for. The script validates the argument rather than quietly building the default folder, which is the failure mode that would otherwise waste a run. Verified with 1500 files and then removed.
245 lines
17 KiB
Markdown
245 lines
17 KiB
Markdown
# Performance
|
||
|
||
What has been measured, on what, and — explicitly — what has **not** been measured at all. The rule
|
||
this file exists to enforce comes from the competitive survey: the market leader's top complaint is
|
||
thumbnails that load late, its own support reply to a speed complaint was *"your SD card is slow"*,
|
||
and the lesson recorded there was to measure what was measured and never close a performance
|
||
complaint by blaming the hardware.
|
||
|
||
Until this file existed, the app had **no performance number of any kind**.
|
||
|
||
## Cold start
|
||
|
||
| build | device | runs (ms) | median |
|
||
|---|---|---|---|
|
||
| debug, `1.0.0-debug` | Xiaomi phone, Android 15 | 1 758 · 1 773 · 1 814 · 1 926 · 2 043 | **1 814 ms** |
|
||
| `benchmark` (R8, not debuggable, profileable) | same | — | **532 ms** |
|
||
|
||
The first is `./tools/measure-startup.sh 5` (`am start -W`, one warm-up start discarded because the first
|
||
launch after an install pays for dexopt). The second is `BrowserBenchmark#coldStart` in the `:benchmark`
|
||
module: `StartupTimingMetric`, five iterations, `CompilationMode.None()`, measured as time to initial
|
||
display — min 499 ms, median 532 ms, max 805 ms. Measured six times, on five different runs: 615 ms
|
||
before the storage grant was in place (the app was showing the permission screen), 532 ms after it, 560 ms
|
||
(min 539, max 698), 574 ms (min 554, max 730), 519.5 ms (min 503.6, max 544.7) and 531.6 ms (min 475.6,
|
||
max 552.0) — the last two on the runs that used `CompilationMode.Ignore()` and measured all four
|
||
benchmarks. They agree within a phone's patience; the 532 ms figure above is the middle one.
|
||
|
||
Two caveats belong to that number rather than to a footnote. The tool itself warns *"Unable to clear
|
||
Runtime Image, subsequent launches/iterations may exhibit faster startup than production due to
|
||
accelerated class loading"*, so it is optimistic by an unmeasured amount on this Android version. And
|
||
`CompilationMode.None()` means no compilation profile is applied — what a fresh install does — so a
|
||
baseline profile would lower it further and is the obvious next measurement.
|
||
|
||
The debug build is deliberately the *slow* case — StrictMode on, LeakCanary attached, no R8 — which is
|
||
why the same screen takes it three times as long. That is the honest illustration of why a debug number
|
||
must never be published as the app's performance.
|
||
|
||
### What StrictMode found on the first cold start
|
||
|
||
The same debug build now logs StrictMode violations, and the first cold start produced five, against a
|
||
1 814 ms start:
|
||
|
||
| duration | where | ours? |
|
||
|---|---|---|
|
||
| 492 ms | `BrowserViewModel.Factory` → `app.cacheDir` | **yes** — `Context.getCacheDir()` checks and creates the private cache directory while the view model is being created, on the main thread |
|
||
| 397 ms | `BrowserViewModel.rootsOf` → `StorageProvider.space` → `VolumeRepository.stat` | **yes** — free space for every volume is read with `StatFs` inside `refresh()`, which the screen calls from a `LaunchedEffect` |
|
||
| 477 ms | `FileManagerTheme` → `typography = FileManagerTypography` | no — the platform loading its own typefaces on first composition |
|
||
| 54 ms × 2 | `miui.util.font.FontSettings` via `MainActivity.onCreate` | no — the OEM's font hook doing `File.exists` while the activity is created |
|
||
|
||
That is **~1.5 s of main-thread disk I/O inside a 1.8 s start**, of which **~0.9 s was ours**. Both of
|
||
ours were mechanical to move — the cache directory is only needed when a preview copies something, and the
|
||
free-space line can arrive a frame later — and both are fixed now; what they bought, measured, is below.
|
||
|
||
### After the fix: what moved, and what did not
|
||
|
||
Both are now fixed — `scratchRoot` is a `Lazy<File>` resolved on first use, and the free-space probes run
|
||
in `Dispatchers.IO` and update the state when they return (`fillRootFreeSpace`, `fillFreeSpace`). Measured
|
||
afterwards:
|
||
|
||
| what | before | after |
|
||
|---|---|---|
|
||
| StrictMode at start, ours | 492 ms `cacheDir`, 397 ms `StatFs` | **gone** |
|
||
| StrictMode at start, not ours | 477 ms typefaces, 54 ms × 2 MIUI font hook | 50–78 ms in pairs, all from `miui.util.font.FontSettings.loadFontSetting` / `TypefaceUtils.loadFontSettings` / `contentcatcher.InterceptorProxy.create` via `MainActivity.onCreate` |
|
||
| debug cold start, 5 runs | median 1 814 ms | median 1 803 ms (1 697 · 1 752 · 1 803 · 1 823 · 2 507) |
|
||
| `coldStart` on the benchmark build | 519.5 / 531.6 ms | 485.6 / 568.9 / 600.0 ms |
|
||
|
||
**The numbers did not improve, and that is worth stating rather than dressing up.** The two violations were
|
||
*first-call* costs: creating the cache directory happens once per install, and `StatFs` is slow when its
|
||
page cache is cold. Neither is paid on a warm device, which is what all five timed runs are — and the
|
||
run-to-run spread on this phone (±115 ms between the three benchmark runs above) is larger than anything
|
||
the change could have bought. What the fix does buy is that the main thread no longer does this work at
|
||
all, which is exactly the case the benchmark's *first* iteration shows: its max is 648 ms after a fresh
|
||
install, against 550 ms on a warm one.
|
||
|
||
The honest conclusion is that the remaining 1.8 s of a debug start is not made of these two calls. It is
|
||
R8-less code, Compose's first composition, the typefaces, and MIUI's font hook — and the release-like
|
||
build already pays 0.5 s, not 1.8 s. Making a *number* out of any further work needs either a quieter
|
||
device or many more runs; making the *app* faster needs a baseline profile (next section).
|
||
|
||
## Measured on demand: the `:benchmark` module
|
||
|
||
`benchmark/` holds four macrobenchmark tests that measure the interaction numbers above on a benchmark
|
||
build of the app: release rules (R8 on, not debuggable) and profileable, **under the app's own package id
|
||
and signed with the release key**, so installing it *updates* an installed release build and keeps its
|
||
data. A separate package id (`.perf`) was the first design and is the tidier one, but on this MIUI phone a
|
||
new package id cannot be installed without a human: `adb install` is refused with
|
||
`INSTALL_FAILED_USER_RESTRICTED`, and installing by tapping the APK left the package visible to
|
||
`pm list packages` yet invisible to `PackageManager`, which is what macrobenchmark reports as *"Unable to
|
||
find target package"*. The trade-off is written in `:app`'s build file: **this build replaces a release
|
||
install**, and reinstalling the release APK puts it back.
|
||
|
||
| benchmark | metric | what it answers |
|
||
|---|---|---|
|
||
| `coldStart` | `StartupTimingMetric` | tap → first frame |
|
||
| `openAFolderOfAThousandFiles` | `FrameTimingMetric` | jank while a 1 000-file folder fills in |
|
||
| `scrollAFolderOfAThousandFiles` | `FrameTimingMetric` | jank while scrolling that folder |
|
||
| `searchInsideAFolderOfAThousandFiles` | `FrameTimingMetric` | jank while typing into search |
|
||
|
||
They share a fixture — `/sdcard/AABenchmark`, a thousand empty files, created by `tools/benchmark-fixture.sh`
|
||
and removed with `--remove`. `CompilationMode.None()` is deliberate: no profile is applied, which is the
|
||
honest cold-install baseline. `CompilationMode.Partial()` plus a baseline profile is the follow-up, and
|
||
needs the same module.
|
||
|
||
### What the module has measured
|
||
|
||
The two runs that completed all four tests — same APK, `CompilationMode.Ignore()`, 5 iterations each, no
|
||
install during either run:
|
||
|
||
| benchmark | run 1 | run 2 | what it is |
|
||
|---|---|---|---|
|
||
| `coldStart` | **519.5 ms** (503.6–544.7) | **531.6 ms** (475.6–552.0) | time to initial display |
|
||
| `openAFolderOfAThousandFiles` | **14 frames** (11–17) | **12 frames** (12–17) | frames from the folder tap until `file-0.txt` is on screen |
|
||
| `scrollAFolderOfAThousandFiles` | **94 frames** (48–96) | **97 frames** (92–100) | frames across four swipes through the folder |
|
||
| `searchInsideAFolderOfAThousandFiles` | **38 frames** (33–44) | **39 frames** (36–40) | frames from opening search until `file-99.txt` is the result |
|
||
|
||
The two runs agree closely, which is the point of running them twice: the method is stable enough that a
|
||
change in the app would show up as a change in these numbers rather than as noise.
|
||
|
||
**Those frame counts are not a jank metric, and the difference matters.** `FrameTimingMetric` normally
|
||
publishes `frameDurationCpuMs` and `frameOverrunMs` — how long each frame took and how far past its
|
||
deadline it ran. This phone reports neither: the same run's JSON contains `frameCount` and nothing else,
|
||
so "14 frames to open the folder" is how many frames the interaction took, not how long any of them
|
||
lasted. It says the interaction is short (14 frames is about a quarter of a second at 60 Hz); it cannot
|
||
say whether any of those frames was slow. A device that reports frame durations — an emulator or any
|
||
non-MIUI phone — is what turns this into a jank number.
|
||
|
||
One bug had to be fixed before `search` could be measured, and it is worth knowing about because it looked
|
||
like a UI problem and was a state problem: the search iteration ends with search mode open, and with
|
||
`Ignore()` nothing reinstalls the app between iterations, so the *next* iteration found a search field
|
||
where it expected an app bar — the assertion fired every time. The hierarchy dump taken at the point of
|
||
failure showed a `TextView` whose *text* was "Search this folder" and a `desc='Clear search'` beside it:
|
||
the app was already searching. `openFixture` now calls `killProcess()` before every iteration, so each one
|
||
starts on the roots screen. The dump itself stays: on a missing action it writes
|
||
`search-action-missing.xml` into the test app's external files directory, which is what made this
|
||
diagnosable instead of a guess.
|
||
|
||
### The install moved out of the test — that is the fix
|
||
|
||
The problem was never the dialog; it was *who* installs. `CompilationMode.None()` reports
|
||
`shouldReset = true`, and that is what makes `resetAndCompile` uninstall the app and reinstall it with
|
||
`pm install -t --skip-verification` before every single test. On this phone that install is intercepted by
|
||
`com.miui.securitycenter/.permcenter.install.AdbInstallActivity`, which the test cannot answer: the install
|
||
call blocks until `pm` exits, so no test code is running while the dialog is up.
|
||
|
||
`CompilationMode.Ignore()` reports `shouldReset = **false**` — the library installs nothing and measures
|
||
what is there. So the APK is installed once, from the host, and the run contains no install at all:
|
||
|
||
```bash
|
||
./tools/benchmark-fixture.sh # /sdcard/AABenchmark, 1000 empty files
|
||
adb install -r app/build/outputs/apk/benchmark/app-benchmark.apk
|
||
adb install -r benchmark/build/outputs/apk/benchmark/benchmark-benchmark.apk
|
||
adb shell am instrument -w -e class de.softwareapp_hb.filemanager.benchmark.BrowserBenchmark \
|
||
de.softwareapp_hb.filemanager.benchmark/androidx.test.runner.AndroidJUnitRunner
|
||
```
|
||
|
||
Measured: with that command the four tests run with **zero installs** — `tools/benchmark-watch-install.sh`
|
||
logs heartbeats and nothing else — and three of them pass. `Ignore()` applies no compilation profile either,
|
||
so the numbers still describe code that has never run; a freshly installed APK has no profile to begin with.
|
||
Running through Gradle still works, but Gradle's own install steps happen before the tests and are gated
|
||
too, which is why `am instrument` is the documented path.
|
||
|
||
`tools/benchmark-watch-install.sh` is kept for the one thing it is good at: the two host-side `adb install`
|
||
commands above. It reads the Install button off the screen and taps only while the installer is focused. It
|
||
is *not* a solution for a run — MIUI keeps `mCurrentFocus` on the installer for a moment after the window is
|
||
gone, so a tap sent on that signal can land on the home screen behind it (observed: it opened Telegram).
|
||
|
||
Why the dialog could not simply be answered from the test, kept because it explains the shape of all this:
|
||
|
||
- The dialog is `FLAG_SECURE`: a `screencap` taken while `mCurrentFocus` was the installer shows the home
|
||
screen behind it, which is why screenshots of "the dialog" show nothing.
|
||
- Its lifetime is not stable. One 0.1 s focus trace caught it as
|
||
`13:51:27.70 AdbInstallActivity` → `13:51:28.38 null` — too short to read, let alone tap — while another
|
||
showed the same window focused for 8.5 s.
|
||
- `pm install` started from an *app* uid does not even reach the gate: MIUI's `XSpaceManagerServiceImpl`
|
||
throws `SecurityException: You either need MANAGE_USERS or CREATE_USERS permission to: query users`.
|
||
The rule's install therefore runs through the shell, the path MIUI intercepts.
|
||
- `MIUI`'s SecurityCenter cannot be disabled to get out of the way: `pm disable-user` answers
|
||
`SecurityException: Cannot disable miui core packages`.
|
||
|
||
### The four switches that make an unattended run possible at all
|
||
|
||
1. **The app under test is the app itself** — same package id, release key — because a new package id
|
||
cannot be installed on this phone (`INSTALL_FAILED_USER_RESTRICTED`, and an APK installed by tapping
|
||
ended up visible to `pm list packages` but invisible to `PackageManager`). The cost is recorded in
|
||
`:app`'s build file: **this build replaces a release install**; reinstalling the release APK puts it
|
||
back.
|
||
2. **The test module declares a `benchmark` build type.** AGP 9's `com.android.test` extension has no
|
||
`testBuildType` and no `targetVariant` — only `targetProjectPath` — and the target variant is matched
|
||
*by name* against the test module's own build types. Without that type every run installed
|
||
`app-debug.apk`, so the numbers would have been debug numbers. The type also has to
|
||
`initWith(getByName("debug"))`: a declared type starts unsigned, and an unsigned test APK fails with
|
||
`INSTALL_PARSE_FAILED_NO_CERTIFICATES`.
|
||
3. **The storage grant survives because nothing uninstalls any more.** An uninstall clears app-ops, so
|
||
while the rule was reinstalling the app, a grant applied by the fixture script was gone by the time the
|
||
app started — and `appops set` from inside the test does not work either (even under
|
||
`adoptShellPermissionIdentity` it fails to stderr, which `executeShellCommand` does not return, and
|
||
`appops get` still reads `No operations`). With `Ignore()` the grant lands once and stays.
|
||
`grantStorageAccess` in `BrowserBenchmark` still walks the app's own "Grant full access" flow when the
|
||
app comes up without access, which costs nothing when it is not needed.
|
||
4. **The fixture is created through adb**, because `UiAutomation.executeShellCommand` runs with the *test
|
||
app's* uid and cannot write `/sdcard`.
|
||
|
||
### The workflow
|
||
|
||
```bash
|
||
./tools/benchmark-fixture.sh # /sdcard/AABenchmark, 1000 empty files
|
||
adb install -r app/build/outputs/apk/benchmark/app-benchmark.apk # the build under measure
|
||
adb install -r benchmark/build/outputs/apk/benchmark/benchmark-benchmark.apk
|
||
adb shell am instrument -w -e class de.softwareapp_hb.filemanager.benchmark.BrowserBenchmark \
|
||
de.softwareapp_hb.filemanager.benchmark/androidx.test.runner.AndroidJUnitRunner
|
||
```
|
||
|
||
One test can be run by naming it: `-e class …BrowserBenchmark#coldStart`. The numbers are printed in the
|
||
instrumentation output itself, and the per-test JSON and Perfetto traces are left in
|
||
`/sdcard/Android/media/de.softwareapp_hb.filemanager.benchmark/` — they used to be deleted with the test app
|
||
when a run ended, which is why the earlier numbers had to be copied mid-run.
|
||
|
||
## Still not measured
|
||
|
||
| number | status |
|
||
|---|---|
|
||
| Folder open with 5 000 / 20 000 files | not measured yet: `./tools/benchmark-fixture.sh --count 20000` builds that fixture, and the open/scroll numbers follow from re-running the same command |
|
||
| Thumbnail paint while scrolling a photo grid | not measured: no test for it yet |
|
||
| Any jank figure at all | not measurable here: this phone's `FrameTimingMetric` output is `frameCount` only, with no `frameDurationCpuMs` or `frameOverrunMs` |
|
||
|
||
What the interaction numbers still need is a device that reports frame durations — an emulator or any
|
||
non-MIUI phone would do, and the fixture is a folder of empty files, so nothing device-specific is being
|
||
measured. The install side is solved: `CompilationMode.Ignore()` plus installing from the host means a run
|
||
touches the package manager zero times.
|
||
|
||
## What is measured instead, meanwhile
|
||
|
||
The gate measures things that are not latency but do not rot: the test count, lint, detekt against its
|
||
baseline, and whether `docs/CODE_MAP.md` still matches the tree. Those are the numbers that can be
|
||
kept true by automation; the three above need a device and a decision about which device.
|
||
|
||
## Two rules
|
||
|
||
- **No number that was not taken from a real build on a real device.** Not from a debug build
|
||
pretending to be release, and not from reading the code.
|
||
- **No performance change judged by reading the code.** The bigger-thumbnail change (28 → 48 dp in a
|
||
row, 40 → 96 dp in a grid) is the standing example: it may be exactly right, and nobody measured
|
||
either side of it. `docs/COMPETITIVE_ANALYSIS.md` had already written "a thumbnail that is bigger and
|
||
slower is strictly worse than one that is smaller and instant" — which is a hypothesis until it is a
|
||
number.
|