Files
SoftwareApp 66183a8596 The benchmark runs in CI, and fails when the jank metric is missing
Item 5 of the queue: .github/workflows/benchmark.yml measures on an API 35 emulator (reactivecircus/android-emulator-runner, google_apis, x86_64, SwiftShader), on demand and every Monday. Not on pull requests: a run is minutes of measurement behind twenty of booting an emulator, and the gate is meant to answer in one or two.

It installs both APKs and instruments directly instead of using connectedBenchmarkAndroidTest, because that task installs the app itself - which undoes the compile reset that makes the measurement a never-run baseline. tools/benchmark-report.py turns the JSON into two tables, keeping frameCount (a length) apart from frameDurationCpuMs and frameOverrunMs (speeds), and --require-frame-duration fails the job when the second table would be empty: the metric is the reason the job exists.

Driving the workflow's own script against the phone first paid for itself three times. It found that cmd package compile --reset needs adbd as root - a user build refuses it with SecurityException, so the workflow now does adb root first (google_apis allows it) instead of silently measuring a compiled app. It produced the second run of the 20 000-file open (16 frames median, 32 max) and the first numbers for scroll, search and cold start at that size: search costs 319 frames, which is now the biggest number in the file. And it showed the phone reporting frameDurationCpuMs and frameOverrunMs after all, so PERFORMANCE.md's 'this phone reports counts only' was a property of those earlier runs, not of the handset - corrected, with the numbers.
2026-10-17 23:00:22 +02:00

29 KiB
Raw Permalink Blame History

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. frameCount, which is what the table above is made of, is how many frames an interaction took: a length, not a speed. It says a folder opens in about a fifth of a second at 60 Hz; it cannot say whether any of those frames was slow.

The runs those numbers come from reported frameCount only — their JSON had no sampled metrics at all, which is what started the search for a device that supplies them. A later run on the same phone did supply them, so the absence was a property of those runs, not of the handset. For the 1 000-file open, measured after the listing was made progressive, frameDurationCpuMs is P50 13.9 ms, P90 26.5 ms, P99 86.5 ms and frameOverrunMs P50 19.0 ms, P90 32.2 ms, P99 80.6 ms, next to frameCount median 12 (max 48) from the same run. Against a 60 Hz budget of 16.7 ms that is "the median frame fits, one in ten takes 26 ms" — a statement the counts could never make. Why the earlier runs captured no frame timelines is not known; what is known is that a run either does or does not, and that .github/workflows/benchmark.yml now runs it on an emulator where it always does.

How it scales with folder size

The same test, run against folders of different sizes on the phone — only the fixture changed (./tools/benchmark-fixture.sh --count N):

files in the folder frames to open it (openAFolderOfAThousandFiles)
1 000 12–14 (two runs)
5 000 41
20 000 184 (min 166, max 598)

The shape is roughly linear, which is what a RecyclerView fed a pre-sorted list should do: five times the files cost 3.5 times the frames, four times the files cost 4.5 times. A superlinear jump here would mean the listing had started doing per-row work that belongs to the folder as a whole.

The max of 598 at 20 000 is the number to keep an eye on. The median says the folder opens in about three seconds' worth of frames; one iteration of five took three times that, so at that size the first open is not merely slow but occasionally stalls — worth watching rather than explaining away, and the reason the 20 000 point is the one a release should be checked against.

What the outlier is not. The obvious suspect was the sort: AllFilesSource.list already hops to Dispatchers.IO, but BrowserViewModel.recompute filtered and sorted the result on the caller's thread, and the default order is natural order, which parses the digits out of every name on every comparison. Measured on the JVM, though, sorting 20 000 entries takes 3–23 ms — three orders of magnitude away from a 598-frame iteration (about ten seconds). So the sort does not explain the outlier, and saying it did would have been a story that fitted the code rather than the measurement.

Where the time actually goes is per entry: readdir plus a stat for size and date, which is what the ladder shows — going from 1 000 to 5 000 files added 27 frames (about 0.45 s), and 5 000 to 20 000 added 143 (about 2.4 s), i.e. ~0.1 ms per file with nothing superlinear in it. Twenty thousand files therefore costs roughly two seconds of listing before a pixel is drawn, and the 598-frame iteration is most likely a GC or a scheduler hiccup on top of that — unidentified, and worth a perfetto trace rather than another theory.

recompute still sorts large folders off the main thread, as a precaution rather than a fix: 3–23 ms is cheap in absolute terms, but it is work the frame has no reason to wait for, and the threshold keeps small folders on the simple synchronous path.

The same cost, delivered in pieces

Those two seconds of readdir and stat cannot be made much cheaper — they are round trips to another process — so the fix is to draw while they happen:

  • StorageSource.listBatches hands a folder over 200 entries at a time. AllFilesSource implements it with Files.newDirectoryStream, which is lazy: it pulls readdir results as the loop asks for them, where File.listFiles materialised the whole folder before returning anything. The default implementation is still a single batch, so the SAF backend and every test fake behave exactly as before.
  • BrowserViewModel.load merges each batch into the list already on screen (core/SortedMerge, O(n + k) instead of re-sorting the folder after every batch), so rows arrive in the user's order and the list is never a partly ordered jumble that shuffles into place afterwards.
  • loading stays true until the last batch, which is what the existing progress bar above the list already draws — the feedback that keeps a slow folder from feeling like a frozen one.

At 200 entries per batch, the first rows should appear after roughly 20 ms of reading plus a frame, against two seconds of empty screen before. That expectation is not measured yet: confirming it needs a 20 000-file fixture, which takes about half an hour to build through MIUI's storage layer, so the numbers to beat are the ones in the table above and the first thing to do with that fixture is to re-run openAFolderOfAThousandFiles and look at the min iteration rather than the median — the minimum is what "first rows visible" now governs.

Measured: the batched read

The fixture was rebuilt and the same benchmark run against it, on the same phone, with only the listing changed:

20 000 files, openAFolderOfAThousandFiles one batch 200-entry batches
min 166 frames 10
median 184 frames 12
max 598 frames 48

Ten to twelve frames is about 0.17–0.20 s — inside the range where an interaction reads as instant — against roughly three seconds before, and the outlier fell from 598 to 48. The change is a factor of fifteen on the median and it costs nothing: the same reads happen, the same stats, they are simply delivered as they complete instead of after the last one.

What that number is not: the time to use the whole folder. The first batch is what the test's wait sees (file-0.txt is inside it), so these frames measure "first rows on screen". The remaining nineteen-odd batches still take the ~2 s described above — they arrive behind the progress bar, into a list the user is already scrolling rather than into an empty screen. Both halves matter, and only the first one is in this table.

The whole suite at 20 000 files, twice

The script that the CI workflow runs was also run against the same 20 000-file fixture — it is device-agnostic apart from the emulator runner — which gave a second, independent run of the open benchmark and, for the first time, the other three at that size:

20 000 files one value per iteration min–max frameDurationCpuMs P50 / P90 / P99
openAFolderOfAThousandFiles 16 frames 13–32 7.8 / 21.6 / 100.6 ms
scrollAFolderOfAThousandFiles 162 frames 148–172 9.0 / 21.0 / 25.2 ms
searchInsideAFolderOfAThousandFiles 319 frames 300–653 6.5 / 11.5 / 18.5 ms
coldStart 599 ms to first display 593–604 —

Two things in it are worth keeping:

  • The batching result holds in a second run. The table above has 12 median / 48 max from one run; this one has 16 median / 32 max on a build that also carries the M16 picker. That is the same number within the noise, and the 598-frame stall has not come back.
  • Search is the next outlier. 319 frames is about five seconds from the query to file-99.txt, against 38–39 frames at 1 000 files: twenty times the entries for eight times the frames, so sublinear — but still long enough to notice, and the per-frame numbers say drawing is not the reason (P90 11.5 ms). Handing twenty thousand rows to a list in one go is; that is where a "large folder" pass would start. coldStart's 599 ms and 162 frames of scrolling are recorded here as the baseline those two get compared against, not as findings.

And the fixture now survives: tools/benchmark-fixture.sh --count 20000 reuses a folder that already holds at least that many files, and --fresh rebuilds it. Building it takes about half an hour through MIUI's storage layer, which is the whole reason to keep it.

Why it is kept on the device rather than shipped as an archive. The obvious refinement — tar the folder once, push it, extract it on a fresh device — was measured and does not help: 1 000 empty files take 32.8 s to create with the shell loop and 39.6 s to extract from a tarball on the same phone. The cost is a per-file round trip into the storage layer, not the bytes or the source (the tarball itself is 7 KB for 1 000 files, ~150 KB for 20 000). .nomedia in the folder, which would keep the media scanner away, made creation slower (46.8 s), so the scanner was not the culprit either.

What that leaves is: reuse the folder that is already there, and if a device is wiped, build the size you actually need — 1 000 takes half a minute, 5 000 a few minutes, 20 000 half an hour.

Building that fixture is the expensive part, not measuring it: 20 000 files took about thirty minutes through MIUI's storage layer (: > in a loop), against roughly four minutes of benchmark on top. Worth knowing before anyone re-creates it, and an argument for the fixture being built once and kept.

The emulator supplied the jank metric this phone cannot, at the 1 000-file point: frameDurationCpuMs P50 218 ms, P90 330 ms, and frameOverrunMs P50 315 ms. Software-rendered, so the values are not comparable with the phone's — what they prove is that the metric exists where frame timelines exist.

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:

./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

./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.

The same thing in CI

.github/workflows/benchmark.yml runs those four commands on an emulator (reactivecircus/android-emulator-runner, API 35, google_apis, x86_64, SwiftShader), on demand and every Monday morning. It is deliberately not part of the pull-request gate: a run is minutes of measurement behind twenty minutes of booting an emulator, and the gate is meant to answer in one or two minutes.

Two details make the numbers meaningful rather than merely present:

  • The app is installed and cmd package compile --reset before the instrumentation starts, and the run is driven with am instrument rather than connectedBenchmarkAndroidTest — the Gradle task installs the app itself, which would undo that reset and quietly measure a compiled app.
  • tools/benchmark-report.py --require-frame-duration turns the results into a table on the run's page and fails the job when frameDurationCpuMs is missing. That metric is the reason the job exists; a run without it is not a green run with a footnote.

The emulator's milliseconds are not a phone's — software rendering, a different CPU, no thermal limits — so they are read as shape and trend (which benchmark is slow relative to the others, whether a change moved it), not as an absolute.

Still not measured

number status
Folder open with 5 000 / 20 000 files measured: see the scaling table above (--count N builds the fixture)
Thumbnail paint while scrolling a photo grid not measured: no test for it yet
Any jank figure at all measured: frameDurationCpuMs and frameOverrunMs both appear in the phone's 1 000-file run above, and the CI emulator run reports them every time

What is left for the interaction numbers is a device class decision rather than a measurement: the phone numbers are one handset's, the emulator's are software-rendered, and neither is a claim about a mid-range device. 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.

The baseline profile

app/src/main/baseline-prof.txt is generated, committed and bundled — the release APK now contains assets/dexopt/baseline.prof. It is produced by :baselineprofile, a test module that walks the first journey (roots screen, one volume opened) on a device:

./gradlew :app:generateReleaseBaselineProfile      # needs a device with API 33+

Collection requires API 33 or a rooted device: BaselineProfileRule refuses this phone (Android 12) with Baseline Profile collection requires API 33+, or a rooted device. It was generated on the phone35 AVD (API 35, software-rendered) instead, which also needs androidx.benchmark.suppressErrors=EMULATOR for any macrobenchmark.

Its effect is not measured here, and that is stated rather than glossed over. The phone cannot collect a profile, and the emulator is too noisy to resolve the delta: the same APK measured 7 404 ms median without the profile and 9 005 ms with it, run to run, because a software-rendered emulator varies by more than the profile could ever buy. What the profile needs to be justified is one measurement on a physical API 33+ device, with and without:

adb shell cmd package compile --reset de.softwareapp_hb.filemanager          # no profile
adb shell cmd package compile -m speed-profile -f de.softwareapp_hb.filemanager  # profile applied

The emulator did answer one open question, though: on that path FrameTimingMetric reports both frameDurationCpuMs and frameOverrunMs (P50 233 ms and 341 ms for opening the folder, which is software rendering and not a number anyone should quote). The metric exists as soon as the device captures frame timelines — which is what .github/workflows/benchmark.yml now guarantees, and what a physical API 33+ phone would combine with the profile delta.