Sitemap

A Custom Baseline Profile made Ritune’s first screen load 10% faster. The experiment before it saved me from the wrong fix

9 min read6 days ago

--

Press enter or click to view image in full size

Part 1 of performance monitoring on Ritune ended with a hypothesis. On a Redmi Note 10 Pro, Ritune’s Today screen took a long time to show its data after the first frame, and I had a fix in mind: its database queries.. Before writing it, I replaced the adb loops from part 1 with Macrobenchmark and changed one thing at a time. Changing only the amount of data moved Today’s load by less than the noise. The harness then showed where the time goes, screen by screen, and it pointed at code running for the first time. A Baseline Profile, measured in an alternating A/B on the same commit, cut compiled cold-start TTFD by 83 ms (−10.2%) on the Redmi.

This is part 2 of a series. Part 1 covers the devices, compilation states and the first trace. The README covers what Ritune is and how it is built.

Phase 2: Macrobenchmark

From adb loops to a harness

The part 1 loop, am start -W ten times, gives TTID number, the time to the first frame. It can't give TTFD (the time until the screen shows real data), frame timings, or the duration of my own trace sections. It also can't seed a database or tap through the app.

Macrobenchmark does all of that. A Macrobenchmark test launches or drives the app, repeats it N times, records a system trace on every iteration and reads its metrics from those traces.

Press enter or click to view image in full size
A separate test APK drives a release-like build of Ritune and seeds its data first.

One thing from part 1 carried over. MIUI, Xiaomi’s version of Android, skips compilation started from the shell, so Macrobenchmark’s compilation modes would set nothing and report success. To mitigate this, every journey uses CompilationMode.Ignore() and records the compilation state before and after the run. A run whose state changed is thrown away.

Timing a screen, not just startup

Startup has TTID and TTFD built in. Opening a screen has nothing, so I added the instrumentation for it. Each screen gets an async trace section that begins on the tap and ends on the first frame that draws its content.

It has to be the frame that draws the content, not the end of the data load. A section around only the query would miss navigation, composition, measuring and drawing, which is most of what the user waits for.

Example 1. Ending a trace section on the first draw (simplified code).

fun Modifier.endTraceOnFirstDraw(
section: String,
): Modifier = this then EndTraceElement(section)

private class EndTraceNode(
var section: String,
) : Modifier.Node(), DrawModifierNode {
private var ended = false
override fun ContentDrawScope.draw() {
drawContent()
if (!ended) {
ended = true
Trace.endAsyncSection(
section, TraceSections.COOKIE,
)
}
}
}

The routines list applies it next to its test tag, and the tab tap calls TraceSections.beginScreenOpen(ROUTINES_OPEN). Only one screen opens at a time, so a fixed cookie pairs each begin with its end.

The benchmark then measures only the tap. Everything before it happens in setupBlock, which isn't timed.

Example 2. Opening a tab, in OpenScreenBenchmark.kt.

private fun open(
tab: String, content: String, section: String,
) = rule.measureRepeated(
packageName = PACKAGE,
metrics = listOf(
TraceSectionMetric(section, Mode.First),
),
compilationMode = CompilationMode.Ignore(),
startupMode = null,
iterations = 15,
setupBlock = {
killProcess()
startActivityAndWait()
device.waitForTag(TestTags.TODAY_CONTENT)
},
) {
device.findObject(By.res(tab)).click()
device.waitForTag(content)
device.waitForIdle()
}

The same pattern covers Routines, Rhythm, a routine’s detail screen, its editor and starting the player.

Press enter or click to view image in full size
Every Phase 2 journey, with the session it ran in.

My hypotheses before the runs

Before any run, I wrote down what I expected, with the check to validate it.

1. Today is slow because of its queries. Today reads only today’s events, but it reads every routine with every step, even though it shows only a routine’s name. I ran cold startup with the seed split into history and routines, one variable at a time, on the same build. I predicted history would change nothing, and routines would slow Today down.

2. Opening Routines costs more as the list grows. The list is a Column with verticalScroll, not a LazyColumn, so every row is composed when the screen opens, including the ones off screen. To check it, I opened Routines with 7, 37 and 67 routines. I predicted a straight line.

The fix the data talked me out of

Press enter or click to view image in full size
The gap between TTID and TTFD stays near 290 ms, even with an empty database.

All four runs landed within about 45 ms of each other on TTFD, against about 40 ms of run-to-run noise on the Redmi. An empty database still took about 290 ms from the first frame to Today’s data. Hypothesis 1 was wrong.

The real difference was the compilation state. Every Phase 2 run was at verify, so nothing was compiled ahead of time. Today's load runs Room, Flow, the mappers and Compose for the first time in the process, interpreted. The same benchmark at speed-profile, in the same session, cut the TTFD median by 118 ms (735 to 617 ms).

Screen by screen

Routines took 316 ms to open, and one frame was 217 ms of it: recomposition 77 ms and measuring 100 ms. That is about 13 missed frames at 60 Hz.

Press enter or click to view image in full size
Press enter or click to view image in full size
Each extra routine adds about 4.6 ms to opening the list. Rhythm has almost no data and still pays 54 ms.

Hypothesis 2 was correct. With 7, 37 and 67 routines the medians were 167, 308 and 441 ms: about 135 ms of fixed cost plus 4.6 ms per routine. Scrolling the same list was smooth, every frame under the 16.7 ms deadline at P99, because the opening frame had already paid for every row.

Rhythm opens in 140 ms. Its data loads in 3 ms, while the JIT runs for 107 ms alongside.

Detail and Editor both open in about 171 ms, but for different reasons. The editor’s frame is heavier, because each step row has buttons. Detail pays first-use costs instead: it is the first push navigation in the process, so it compiles shaders and JITs code the editor finds warm.

Starting a routine takes 151 ms, including the foreground service. The player draws an empty frame first.

Press enter or click to view image in full size
The player’s state starts as Loading, so the first frame is an empty box and the content arrives one frame later.

So Phase 2 found two kinds of cost. Some grow with each row, and a lazy list is the fix. The rest is code running for the first time, and that is what a Baseline Profile targets.

Phase 3: Baseline Profiles

What a Baseline Profile is

A Baseline Profile is a list of classes and methods shipped inside the APK. When the Play Store installs the app, ART compiles those methods to machine code, so the first launch already runs compiled code. Libraries such as Compose ship their own profiles. Part 1 found them in Ritune’s APK before Ritune had one.

Get Tanvi Goyal’s stories in your inbox

Join Medium for free to get updates from this writer.

The generator is a test that walks through the app while ART records which code runs. It drives the release build with no seeding, because SeedActivity doesn't exist there. Logging an anchor on Today creates the data the other screens show.

Example 3. The journeys the profile covers, in BaselineProfileGenerator.kt.

@Test
fun startup() = rule.collect(
packageName = PACKAGE,
includeInStartupProfile = true,
) {
pressHome()
startActivityAndWait()
device.waitForTag(TestTags.TODAY_CONTENT)
}

@Test
fun journeys() = rule.collect(PACKAGE) {
device.executeShellCommand(
"pm grant $PACKAGE android.permission" +
".POST_NOTIFICATIONS",
)
pressHome()
startActivityAndWait()
device.waitForTag(TestTags.TODAY_CONTENT)
logAnchor()
openRhythm()
openRoutinesAndScroll()
openDetailAndEditor()
startAndStopRoutine()
openSettings()
}

startup() also produces a Startup Profile. The build uses it to put startup classes first in the primary DEX file, so they load first. The final profile has 24,311 lines, 2,548 of them for Ritune's own code.

A fair A/B

A profile only helps once something compiles it, so the two sides of the test had to go through exactly the same compilation steps. B is the current commit. A is the same commit with the generated profile deleted, so the profile is the only difference.

The compiled state is the one a Play user starts in. Macrobenchmark’s CompilationMode.Partial(BaselineProfile.Require) is meant to set it, but it compiles from the shell, and MIUI skips that. So the test does it the way the phone does instead.

Example 4. Reaching the compiled state on MIUI from inside the test, in SeededAppRule.kt.

private fun compileBundledProfile() {
val install = device.executeShellCommand(
"am broadcast -a androidx.profileinstaller" +
".action.INSTALL_PROFILE " +
"-n $PACKAGE/androidx.profileinstaller" +
".ProfileInstallReceiver"
)
check("result=1" in install) {
"Profile not installed: $install"
}
device.executeShellCommand(
"cmd package bg-dexopt-job $PACKAGE",
)
val state = dexoptState()
check("status=speed-profile" in state) {
"Not compiled after bg-dexopt-job:\n$state"
}
}

profileinstaller’s receiver writes the bundled profile where ART reads it. bg-dexopt-job is the job that compiles apps overnight, and MIUI runs it. Both sides then ran in one session on the Redmi, alternating A, B, A, B, 15 iterations per run.

Results

Press enter or click to view image in full size
The same commit with and without Ritune’s own profile.
Press enter or click to view image in full size
Both B runs beat both A runs, so the gap is larger than the spread between runs.

Compiled cold startup is the win. TTFD dropped by 83 ms (812.6 to 729.7 ms), TTID by 29 ms, and InitializeApp by 11 ms.

Day one showed no gain because Gradle installed the APK with adb, so nothing compiled the profile, and B ran the same interpreted code as A.
A Play install compiles the profile at install, so this state only applies to sideloads.

Scrolling didn’t change, because the libraries’ rules already cover the list code. Opening Detail gained 4 to 5 ms, at the edge of the noise.

The bigger lesson sits inside A alone. Compiling at all took opening Detail from 186 to 137 ms and cold TTFD from 913 to 813 ms. Ritune’s own rules then took startup a further 83 ms.

Outcomes

  • Hypothesis 1, that Today is slow because of its queries, was wrong.. Data size moved TTFD by less than the noise. The cost was code running for the first time.
  • Hypothesis 2, that opening Routines grows with the list, held: about 4.6 ms per routine.
Press enter or click to view image in full size
The Baseline Profile covers first-run code. The rest is Phase 4.

Working with Claude Code

Where it helped

On MIUI, Claude Code found the way to reach the compiled state from inside the test using profileinstaller’s broadcast, then bg-dexopt-job, then a check on the state . In part 1 it had suggested switching phones.

It wrote the A/B script, kept each run’s results in its own folder, and checked every run’s compilation state.

Where it fell short

After the start-routine journey, it jumped straight into writing the Phase 3 generator. That skipped the rest of Phase 2, the Phase 2 pull request and Phase 3’s Learn step. It had found the gaps, but it went to solutions without thinking them through first. I stopped it and restated the plan and the outcome each phase had to prove.

The plan also changed where the phone disagreed. Rhythm got no scroll test, because it fits on one screen. CompilationMode.Full was dropped, because MIUI won't run it.

Lessons

  • Change one input before fixing what you suspect. The query fix would have saved almost nothing.
  • Give every comparison the same compilation steps, and check the state inside the test, before and after.
  • Use the libraries’ profile as the baseline, not “no profile”. Compose already ships its own profile”.
  • End a screen’s trace section on its first drawn frame, not when its data arrives.

The code, the benchmarks and every result are in github.com/Tanvi-Goyal/Ritune, with the numbers in docs/performance.md.

Next, in part 3: the fixes Phase 2 pointed at, a lazy routines list and a player that draws its content on the first frame.

Sources

--

--

Written by Tanvi Goyal

Senior Software Engineer at Pratilipi