Skip to content

Prototype performance measurement

Compose compiler reports

From the repository root, run:

scripts/android/compose-compiler-report.sh

The script compiles :control-proxy:compileReleaseKotlin --rerun with -Pautomobile.composeCompilerReports=true. Only the exact property value true enables compiler reports and metrics; normal builds do not configure their output destinations. The rerun forces compiler execution even with cached outputs. The reports land in the compose-compiler-reports directory of the control-proxy module’s Gradle build output: control-proxy-composables.txt is at its root and the module metrics are in release/control-proxy-module.json. The property alone does not force a recompile, so an up-to-date or cached compile writes nothing.

To summarize existing reports without Gradle, use --reports-dir DIR. Use --package PREFIX to select fully qualified function names starting with PREFIX. (default: dev.jasonpearson.automobile.ctrlproxy.prototype). Use --write-doc docs/design-docs/plat/android/prototype-compose-compiler-report.md to overwrite the generated Compose compiler report page; stdout still contains the same deterministic summary.

The totals count listed functions, including value-returning helpers and overloads. The table sorts simple function names, preserving report order for overloads. Restartable means Compose can independently restart the function; skippable means it can skip a call when its arguments have not changed. Unstable parameters list explicit unstable markers; the final column lists parameters with no compiler stability marker. Stable parameters are omitted, and — means none. Default-value annotations are removed. The module-wide line reports counts for the entire module, not just the selected package.

With strong skipping on (featureFlags.StrongSkipping = true), every restartable composable is skippable, so the useful signal is the unstable and unmarked parameters, which are compared by instance identity rather than equals when unstable at runtime. The captured module aggregate has different totals from this package listing; compare the same report scope across measurements.

Frame-time benchmark

Prerequisites: a connected Android device explicitly selected by ID, a compatible CtrlProxy with its accessibility service enabled, adb, bun, jq, and an already running AutoMobile daemon. The script never starts or configures a daemon. If the device belongs to a session, export its AUTOMOBILE_SESSION_UUID; the script passes it as --session-uuid on every CLI call. Measure a release-like build; debug numbers are only comparable with debug numbers (see #12129).

scripts/android/prototype-frame-benchmark.sh --device DEVICE_ID
scripts/android/prototype-frame-benchmark.sh --list
scripts/android/prototype-frame-benchmark.sh --dry-run --device DEVICE_ID
scripts/android/prototype-frame-benchmark.sh --device DEVICE_ID --scenario tab-switch --markdown

The fixed fullscreen spec is benchmark/prototype-frame/spec.json, ID perf-benchmark. It expands to approximately 900 mapped nodes, including hidden subtrees, 150 list rows, 30 images, four state-selected tab bodies, four pager pages, and more than 70 visibility conditions. Individual repeats stay within the 128-item contract limit. The pager, controls, tab body, and collapsing section have bounded regions so the long list can scroll without moving the scenario controls offscreen.

The image contract requires asset IDs. The 30 nodes reference benchmark-image; missing assets render placeholders. The owed asset step is to upload a representative PNG/JPEG/WebP as benchmark-image through prototype’s assets argument before recording an image-decoding baseline. The default script measures placeholders and must be labelled that way; it does not push assets.

benchmark/prototype-frame/scenarios.json is the single source of truth. Each entry has name, description, repeat, settleMs, and steps; idle additionally has durationMs. Each step has tool and text; swipes additionally have direction. The script converts text to a tap selector or swipe container and explicitly uses layer: prototype. It sleeps after each complete repetition, or for idle’s specified duration. Other steps have no extra sleep between commands.

Scenario Repeats Steps per repeat Settle
idle 1 No input; count frames rendered in 3 seconds 3000 ms duration
tab-switch 5 Tab 2, Tab 3, Tab 4, Tab 1 600 ms
section-collapse 4 Toggle section twice (collapse, reopen) 600 ms
pager-swipe 4 Swipe Benchmark pager left, right 600 ms
scroll 4 Swipe Benchmark list up, down 600 ms
overlay-open 1 Open overlay, Close overlay 600 ms
three-write-tap 5 three-write tap (three independent counter increments) 600 ms

The script shows the spec once and waits --show-settle-ms N (default 2000 ms) for initial composition before the first reset. N must be a non-negative integer; 0 skips this wait. It resets dumpsys gfxinfo before each scenario, captures it afterward, and always attempts to dismiss this prototype on exit. Plain output is exactly one line per scenario:

scenario=tab-switch frames=100 janky_pct=5.00 p50_ms=8 p90_ms=12 p95_ms=16 p99_ms=22 slow_ui=1
scenario=idle error=missing-fields

Percentiles and slow UI counts come directly from gfxinfo. Capture failures or missing fields produce an error line, continue with remaining scenarios, and exit 1. An idle dump with no percentile fields reports an error rather than inventing zero latency; its acceptance metric remains the actual frames rendered in 3 seconds. --markdown prints a table with the same fields and an error column. --scenario is repeatable; selected scenarios retain file order. --package, --spec, and --scenarios override defaults. --device is mandatory except for --list and --dry-run; dry-run prints every command, including sleep and cleanup, and runs none. PROTOTYPE_BENCH_ADB and PROTOTYPE_BENCH_CLI accept executable paths, such as private CLI wrappers; the default CLI is bun <repo>/src/index.ts --cli.

Baseline: not yet recorded on a device.

Feed benchmark

The feed driver reuses the gfxinfo harness with a deterministic fullscreen perf-feed lazy list (1,000 rows by default, up to the contract cap of 2,000). It needs the same Android prerequisites and an existing daemon/session. Measure a release-like build on API 37; the target is p95 < 16 ms per scenario.

scripts/android/prototype-feed-benchmark.sh --device DEVICE_ID --rows 1000 --markdown
scripts/android/prototype-feed-benchmark.sh --device DEVICE_ID --enforce
scripts/android/prototype-feed-benchmark.sh --dry-run
bun scripts/prototype/generate-feed-benchmark-spec.ts --rows 1000 --out /tmp/feed.json
bun scripts/prototype/generate-feed-benchmark-spec.ts --rows 1000 --paging --page-url http://localhost:8080/page --out /tmp/paged-feed.json
scripts/ios/prototype-feed-benchmark.sh --device SIMULATOR_UDID --rows 1000 --swipes 6

feed-scenarios.json defines feed-idle (three seconds without input), feed-scroll-down (two up/up/down/down repetitions), and feed-fling-roundtrip (three consecutive up swipes then three down). The latter uses ordinary swipeOn commands: the harness has no velocity setting and does not guarantee a physical fling. There are 14 swipe calls and 4.8 seconds of scenario waits, plus the initial two-second settle; the intended run budget is under three minutes with a responsive CLI/device. The Benchmark feed content description selects the list container.

The Android wrapper prints the harness plain output, an optional Markdown verdict table, and a verdict line per scenario. A numeric p95 of 16 ms or higher fails; zero-frame or unavailable percentiles are n/a. The default reports timing failures; --enforce makes them exit nonzero. Harness capture/CLI errors always retain their nonzero status. --package selects the gfxinfo package, and PROTOTYPE_FEED_BENCH_FRAME overrides the harness executable for offline testing.

Rows contain a local-asset avatar, author, caption, counters and a Like state action. The harness supplies the tiny benchmark-image PNG. This measures placeholder/local-asset rows, not remote-image loading or representative image decoding (remote images are unavailable, #14181). The default spec has no network actions. --paging requires an HTTP(S) --page-url reachable from the device; the fixture response is {"items":[...]} with the same row fields and globally unique IDs. request.append extends /data/feed/items; the bounded list trims oldest rows at the contract cap. Use fresh IDs and an empty final page to stop end-triggered loading. This optional fixture variant needs a separate show/scroll acceptance run; the wrapper measures the pure-scroll variant.

The iOS driver shows the same spec, uses placeholder avatars, scrolls up for the first half of the calls and down for the rest, then dismisses it. It requires an already available compatible prototype host and daemon, accepts PROTOTYPE_BENCH_CLI, and forwards AUTOMOBILE_SESSION_UUID. It reports elapsed wall-clock milliseconds for each CLI swipe step and for the scroll loop, including CLI/transport overhead and excluding show/dismiss. These are not frame times; simulators expose no gfxinfo. --swipes accepts 1–100 (default 6); --dry-run prints commands without executing them.

Feed baseline (2026-10-26, 1,000 rows, debug CtrlProxy on an API 37 arm64 emulator with software rendering, host load average above 140): feed-scroll-down p95 250 ms and feed-fling-roundtrip p95 300 ms (about 98% janky), feed-idle no frames. This is a smoke baseline, not a verdict: it misses the 16 ms target, but the debug build, software GL and a saturated host dominate it. Re-measure a release build on an idle host or device. iOS simulator (iPhone 17, iOS 26.5): six scripted swipes took 50.5 s wall time including CLI overhead, which is not a frame-time measure.

Trace sections

All nine trace sections below are in the renderer (#12128). The names are stable so Perfetto queries can be shared between measurements. All sections use the framework android.os.Trace (no new dependency). All sections are emitted only while tracing is enabled via Trace.isEnabled (API 29+); below API 29 none is emitted and no android.os.Trace method is called. The async-section calls also need API 29.

Section name Insertion point Kind Status
Prototype.interact PrototypeController.interact, inside mutex.withLock, from after the stale-runtime check to the end Async section (the body suspends and may resume on another thread) in renderer
Prototype.validateStateWrite PrototypeRuntime.validated, whole body Synchronous in renderer
Prototype.prepareStateWrites PrototypeRuntime.validated, only the branch that calls prepareStateWrites(from) (first write, and after a host replace) Synchronous, nested in Prototype.validateStateWrite in renderer
Prototype.mapRenderTree PrototypeRenderMapper.map, the miss path only (after the same-snapshot early return) Synchronous in renderer
Prototype.mapPrototypeSpec mapPrototypeSpec in PrototypeRenderModel.kt, whole body (the uncached full mapping used outside a runtime) Synchronous in renderer
Prototype.syncAppearance PrototypeController.interact, around syncAppearance(runtime, model) and syncTextFieldFocus(model) Async section (appearance event delivery and host focus updates can suspend) in renderer
Prototype.compose PrototypeRuntimeContent, around the PrototypeSpecContent call Synchronous, begin/end without try (composable calls) in renderer
Prototype.node RenderPrototypeNodeContent, first and last statement of the body Synchronous, constant name; slices per frame = nodes recomposed in renderer
Prototype.host.show PrototypeHost.showOnMain, whole body Synchronous in renderer

Count Prototype.node slices per frame (per Choreographer#doFrame) to measure the number of nodes recomposed, which is the epic’s target metric. Those inside Prototype.compose come from a new render model; node-local recompositions can run outside that slice. Prototype.mapRenderTree should appear at most once per Prototype.interact.

Vertical scroll first-show cost (#12139)

scroll composes and measures every child up front (Box(verticalScroll)); the owner decision is to keep every row findable (no LazyColumn), so the question was whether that costs enough to justify deferring off-screen rows behind same-size placeholders. The gated PrototypeScrollMeasurementTest (PROTOTYPE_SCROLL_MEASURE=1, optional PROTOTYPE_SCROLL_MEASURE_OUT=<file>) disposes and recomposes a fullscreen scroll of 36 dp rows 15 times after 5 warm-ups under Robolectric and times setContent through waitForIdle (first composition plus measure and layout; no draw). Two runs, medians:

Scenario Render nodes Semantics nodes Median ms p90 ms
trivial column (fixed overhead) 2 3 6.4-7.6 7.7-9.6
87 rows (largest measured prototype) 176 90 4.1-4.9 4.4-8.0
benchmark list (benchmark/prototype-frame) 335 183 7.7-7.9 12.5-17.4
200 rows 402 203 4.5-5.0 5.7
500 rows 1002 503 17.6-17.9 31.9-35.8

Scenarios run in JUnit’s method-name-hash order (trivial, 500, benchmark list, 87, 200), not the order above, so the first two carry more JIT/GC warm-up than the last two and the trivial case reads slower than 87 rows for that reason. The numbers exclude spec-to-render-model mapping (the runtime’s cached render model is reused) and draw. Cost is not linear in rows: 87 to 200 rows adds under 1 ms, 200 to 500 rows adds roughly 13 ms (about 42-45 us per row, inflated by the 500-row case running early), and only 500 rows reaches a median of about 18 ms. Every row text is present in the semantics tree in every scenario (asserted). Robolectric numbers are not device frame times: the on-device figure for the benchmark list (scripts/android/prototype-frame-benchmark.sh --scenario scroll) is still owed before any deferral is built.