8 min read

Baseline vs Startup Profiles — And Measuring the Win

Baseline profiles and startup profiles come from the same test run and optimise different things — AOT compilation versus DEX layout. How each one works, the one Gradle argument teams miss, and a measurement methodology that produces a number you can defend.

AndroidPerformanceBaseline ProfilesMacrobenchmarkR8
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — They are not two names for one thing. A baseline profile (baseline-prof.txt) is consumed at install time and tells ART which methods to AOT-compile, removing interpretation from your startup path. A startup profile (startup-prof.txt) is consumed at build time and tells R8 how to lay out DEX files, so startup classes are grouped at the front and you take fewer page faults. Both fall out of one macrobenchmark run; the second only happens if you pass includeInStartupProfile = true. Then measure properly: against CompilationMode.None(), median of 10+ iterations, on the slowest device you support — and verify in a real build with ProfileVerifier, because silently-not-installed is the default failure mode.

Two optimisations, one test run

There's an earlier post in this series on baseline profiles as a startup win. This one is about the distinction teams miss and the measurement almost nobody does rigorously.

Baseline profile — what to compile

Without one, your app installs as mostly-uncompiled bytecode. First launch runs the interpreter while the JIT watches and gradually compiles hot methods. By the fourth or fifth launch things are fast — which is exactly why your own device lies to you.

A baseline profile ships a list of hot methods and classes in the APK/AAB. At install (or on first boot for the dexopt pass), ART AOT-compiles those methods. Launch one is now the fast launch, which matters because launch one is when a new user decides whether to keep your app.

Its effect isn't limited to startup: any hot path you exercise in the generator — a scroll through your feed, opening the player — gets included and gets compiled.

Startup profile — where to put it

Different problem entirely. Your DEX files can be tens of megabytes, and the classes needed at startup are scattered across them in whatever order the compiler emitted. Reading them means many page faults across a wide span of the file.

A startup profile is fed to R8 at build time to reorder DEX layout: startup classes get packed together at the beginning of the primary DEX. Same code, same compilation, better locality. It's a purely build-time transformation and it composes with the baseline profile — one removes interpretation, the other removes I/O.

Baseline profile Startup profile
File baseline-prof.txt startup-prof.txt
Consumed Install time, by ART Build time, by R8
Effect AOT-compiles hot methods Reorders DEX layout
Removes Interpretation and JIT Page faults, I/O
Covers Startup and hot paths Startup only

Generating both

With the androidx.baselineprofile plugin and a macrobenchmark module:

@RunWith(AndroidJUnit4::class)
class StartupProfileGenerator {

    @get:Rule val rule = BaselineProfileRule()

    @Test
    fun generate() = rule.collect(
        packageName = "com.example.app",
        includeInStartupProfile = true,   // ← the whole difference
        maxIterations = 15,
        stableIterationsRequired = 3,
    ) {
        pressHome()
        startActivityAndWait()
        device.waitForIdle()
        scrollFeed()      // hot paths belong here too
    }
}

includeInStartupProfile = true is the line teams miss. Without it you get a baseline profile only, no error, no warning, and a build that looks entirely correct.

Then make the plugin actually apply them in the release build:

// app/build.gradle.kts
baselineProfile {
    automaticGenerationDuringBuild = false  // generate deliberately, not per build
    dexLayoutOptimization = true            // consume the startup profile
}

Generation needs a rooted emulator (an AOSP system image) or a rooted physical device. In CI, a Gradle Managed Device with an AOSP image is the maintainable option — an unrooted physical device will fail in a way whose error message doesn't say "root".

Regenerate on a schedule. A profile drifts as the code changes: after six months of refactoring it lists methods that no longer exist and misses the ones that replaced them. Weekly, or per release branch cut, is enough. A stale profile doesn't break anything — it just quietly stops helping, which is harder to notice.

Measuring the win without fooling yourself

This is where most reported numbers fall apart.

Compare compilation modes, not builds

The wrong comparison is "before we added it" versus "after", measured on your desk. Your device has run this app forty times today and ART has already JIT-compiled everything. You're measuring a warm device, and the user you care about has a cold one.

@RunWith(AndroidJUnit4::class)
class StartupBenchmark {

    @get:Rule val rule = MacrobenchmarkRule()

    @Test fun coldNoProfile() = startup(CompilationMode.None())

    @Test fun coldWithProfile() = startup(
        CompilationMode.Partial(
            baselineProfileMode = BaselineProfileMode.Require
        )
    )

    private fun startup(mode: CompilationMode) = rule.measureRepeated(
        packageName = "com.example.app",
        metrics = listOf(StartupTimingMetric()),
        compilationMode = mode,
        startupMode = StartupMode.COLD,
        iterations = 20,
    ) {
        pressHome()
        startActivityAndWait()
    }
}

BaselineProfileMode.Require fails the test if the profile isn't installed, rather than silently measuring an unoptimised build and reporting no win. Use Require, never UseIfAvailable, in a benchmark whose purpose is to prove the profile works.

Read the right statistic

  • Median, not mean. Startup distributions have a long right tail; one background sync moves the mean by 80ms and tells you nothing.
  • 20 iterations minimum for a number you'd put in a document. 10 for a quick check.
  • Report P90 alongside the median. The median tells you the typical launch; P90 is closer to what your frustrated users are experiencing, and it's often where the profile helps most.

Measure TTFD, not just TTID

StartupTimingMetric gives you timeToInitialDisplay — the first frame. Users experience timeToFullDisplay: the first frame with real content in it, not skeletons.

// In your Activity, when real content is on screen
reportFullyDrawn()

Without that call TTFD is unavailable and you'll optimise for a fast empty screen, which is a real trap: a skeleton that appears in 300ms and fills at 2.1s is worse than content at 1.4s, and only TTFD can tell you.

Measure on the right device

A Pixel 9 will tell you the profile bought 4% and the work was pointless. A three-year-old mid-tier device — which is what most of your users have — will show 20–30%. The gain is largest exactly where the device is slowest, which is the population most likely to abandon your app during launch.

Benchmark on the slowest device you officially support. That's the number that means something.

Verify it actually shipped

A baseline profile that silently didn't install is the normal failure. Two checks:

# What ART did with your app
adb shell dumpsys package dexopt | grep -A 2 com.your.app
# want: [status=speed-profile] [reason=install]

And in the app itself, in a staging build:

val status = ProfileVerifier
    .getCompilationStatusAsync()
    .get()

Log.i("Profile", when {
    status.isCompiledWithProfile -> "compiled with profile ✓"
    status.hasProfileEnqueuedForCompilation -> "enqueued, not yet compiled"
    else -> "NO PROFILE — ${status.profileInstallResultCode}"
})

Log that to your analytics in staging and you'll know within a day of any release whether the profile is real. Without it, "we ship a baseline profile" is a statement about your repository, not about your users' devices.

Two things that legitimately break it: installing via adb install doesn't always trigger the same dexopt path Play does (test with a Play Internal App Sharing build before concluding anything), and a device with low storage will skip background dexopt entirely.

What about Play's cloud profiles?

Play aggregates real-world profiles and distributes them to later installers. Genuinely useful — and irrelevant to the problem baseline profiles solve, because it needs enough installs on a given version to aggregate, so it arrives days after release.

Your baseline profile covers the release-day cohort, every new version's early adopters, and every user on a version that never accumulates enough installs. Those are the cohorts where a bad first launch costs you the user.

The rule

One is what to compile; the other is where to put it. Ship both, then prove it. Generate them in the same run with includeInStartupProfile = true, benchmark None() against Partial(Require) on your slowest supported device, report the median and P90 of TTFD — and put ProfileVerifier in staging, because an unverified profile is a comment in your build file.

Keep reading