DEV Community

Cover image for Telling ART What Matters Before the Crowd Does: Baseline Profiles vs. Cloud Profiles
Android Expert
Android Expert

Posted on Originally published at Medium

Telling ART What Matters Before the Crowd Does: Baseline Profiles vs. Cloud Profiles

Most Compose performance advice ends at “add the plugin and run the generator.” I wanted to know what that generated file changes on a real device, and that question pulled me below Compose to the runtime that executes every line we ship. This post follows that thread: what ART does with bytecode, where its compilation hints come from in production, why those hints arrive late for a brand-new release, and how a Baseline Profile moves them earlier. Along the way you’ll get Kotlin and Gradle you can adapt, plus the quiet ways a profile stops working.

How ART executes your code

Your Kotlin compiles to JVM bytecode, then D8/R8 turns it into DEX. ART has three ways of running that DEX.

art-execution-paths

  • Interpreter. Steps through the bytecode and carries out each instruction in turn. It needs no preparation, so anything can run right away, but it is the slowest option once code is hot.
  • JIT (just-in-time). Watches what runs and turns methods that keep getting called into machine code while the app is live. It follows real behavior, but it burns CPU and battery in the foreground.
  • AOT (ahead-of-time). Builds machine code for a chosen subset of the app before that code is first needed. The price is storage and compile time, paid off the critical path.

Note the word selected. Compiling everything ahead of time is possible, but it inflates install size, dexopt time, and storage use. Modern ART compiles selectively, which raises the real question.

Why AOT matters for startup

An interpreted method pays an overhead on every call. A method waiting on the JIT pays twice: it runs slowly first, and the compile work competes with your app for CPU. AOT-compiled code skips both. That is why cold start, the first navigation, and the first scroll are the moments where AOT shows up most.

Jetpack Compose raises the stakes. Compose and its dependencies ship with your app as library code rather than living in the platform image. A cold launch therefore runs a lot of runtime and layout code that nobody has compiled yet on a fresh install.

Where does ART get its hints?

ART needs a profile: a list of classes and methods worth precompiling. In production, the first source of that list is the Cloud Profile.

cloud-profile-pipeline

As people use the app, their devices note which methods get hot. Google Play merges those observations across many users and hands the result to later installs of the same version. Its strength is honesty: it reflects how the app is actually used, not how the team pictures it.

The gap Cloud Profiles can’t fill

Look at the left edge of that pipeline. A Cloud Profile can’t exist until people have used the release. Two consequences follow:

  1. A new version starts cold. Early adopters, often the users who rate you first, get the least optimized experience.
  2. You can’t steer one. A redesigned onboarding flow or a new home screen can’t be added by hand. Those code paths show up only after enough users have walked through them.

day-zero-gap

The chart above is an illustration of the idea, not measured data. The shaded area is the day-zero gap.

What Baseline Profiles actually are

A Baseline Profile is a list of rules, shipped inside your app, that names the classes and methods you consider important. A few details are easy to get wrong:

  • It is a hint, not a compiler. ART still does the compiling; the profile only shapes which code it picks.
  • You author it before release. You script the journeys that matter (startup, first navigation, a key scroll) and the profile is generated from them.
  • It complements Cloud Profiles rather than replacing them. Yours covers what you already know is critical at launch; the cloud one keeps tracking real behavior.

cloud-vs-baseline

One is learned from the crowd after launch; the other is written by the team before it.

What a profile rule looks like

The generated file, baseline-prof.txt, uses one rule per line. A flag prefix says why the method matters: H for hot, S for startup, P for post-startup.

HSPLcom/tidelog/app/TideLogApplication;-><init>()V
HSPLcom/tidelog/app/MainActivity;->onCreate(Landroid/os/Bundle;)V
HSPLcom/tidelog/harbors/HarborListScreenKt;->HarborListScreen(Landroidx/compose/runtime/Composer;I)V
PLcom/tidelog/harbors/HarborDetailViewModel;->loadForecast(Ljava/lang/String;)V
Lcom/tidelog/harbors/HarborListItemKt;
Enter fullscreen mode Exit fullscreen mode

You rarely write these by hand. You describe journeys in code and let tooling produce them.

The workflow


baseline-profile-workflow

The examples below use an invented app, TideLog, which shows harbors, tide tables, and forecasts.

1. Wire up the modules

Create a profile-producer module with the Baseline Profile Gradle plugin and connect it to the app module.

// app/build.gradle.kts
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("androidx.baselineprofile")
}

dependencies {
    implementation("androidx.profileinstaller:profileinstaller:1.4.1")
    baselineProfile(project(":baselineprofile"))
}
Enter fullscreen mode Exit fullscreen mode

Check the current stable versions of the plugin and libraries before copying anything. They move.

2. Define the journeys

A journey helper keeps every generated profile and every benchmark exercising identical steps. That sameness matters for reasons we’ll see in the failure modes.

// baselineprofile/src/main/kotlin/com/tidelog/baselineprofile/Journeys.kt
import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.Until

private const val TIMEOUT_MS = 5_000L

/** Cold start through to the first fully rendered harbor list. */
fun MacrobenchmarkScope.startToHarborList() {
    pressHome()
    startActivityAndWait()
    device.wait(Until.hasObject(By.res("harbor_list")), TIMEOUT_MS)
        ?: error("Harbor list never appeared; fix the journey, not the timeout")
}

/** Scroll the list, then open the first harbor's detail screen. */
fun MacrobenchmarkScope.browseAndOpenHarbor() {
    val list = device.findObject(By.res("harbor_list")) ?: error("No harbor list")
    list.setGestureMargin(device.displayWidth / 5)
    repeat(3) { list.fling(Direction.DOWN) }
    device.waitForIdle()

    device.findObject(By.res("harbor_item_0"))?.click() ?: error("No first item")
    device.wait(Until.hasObject(By.res("tide_chart")), TIMEOUT_MS)
        ?: error("Detail screen did not load")
}
Enter fullscreen mode Exit fullscreen mode

Using error(...) on a missing element is deliberate. A journey that silently does nothing produces a profile that silently covers nothing.

3. Generate the profile

import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class TideLogProfileGenerator {

    @get:Rule
    val rule = BaselineProfileRule()

    @Test
    fun generate() = rule.collect(
        packageName = "com.tidelog.app",
        includeInStartupProfile = true,
    ) {
        startToHarborList()
        browseAndOpenHarbor()
    }
}
Enter fullscreen mode Exit fullscreen mode

Generation needs a suitable device: Android 9 (API 28) or higher with root access, or Android 13 (API 33) or higher without root. Generate against a build that mirrors release, not a debuggable one.

4. Ship it and let ART consume it

baseline-prof.txt is packaged into your app. On supported devices, the androidx.profileinstaller library installs the rules so ART can compile the listed code, and Google Play can also use the profile when you publish. You don't write install logic yourself, but you do need to leave the library's initialization alone.

5. Prove the effect

Don’t assume the profile helps. Measure the same journey with and without it.

import androidx.benchmark.macro.BaselineProfileMode
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class TideLogStartupBenchmark {

    @get:Rule
    val rule = MacrobenchmarkRule()

    @Test
    fun coldStartNoProfile() = measureStartup(CompilationMode.None())

    @Test
    fun coldStartWithProfile() =
        measureStartup(CompilationMode.Partial(baselineProfileMode = BaselineProfileMode.Require))

    private fun measureStartup(mode: CompilationMode) = rule.measureRepeated(
        packageName = "com.tidelog.app",
        metrics = listOf(StartupTimingMetric()),
        iterations = 10,
        startupMode = StartupMode.COLD,
        compilationMode = mode,
    ) {
        startToHarborList()
    }
}
Enter fullscreen mode Exit fullscreen mode

BaselineProfileMode.Require makes the benchmark fail if the profile isn't installed. That failure is useful, because it catches a profile that has gone missing.

Failure modes I’d check for

These are the ways a Baseline Profile stops working without anyone noticing.

1. The profile is stale. Refactors rename and move classes. Rules that name methods that no longer exist are simply ignored, so nothing crashes and the benefit erodes. Regenerate when critical journeys or major screens change, and consider regenerating as part of release builds.

2. Obfuscation mismatch. With R8 enabled, shipped names differ from source names. The Gradle plugin and a release-like build type are designed to handle this, but a hand-edited file or a profile generated from a non-minified build can fail to match. If a profile helps in one build type but not in release, check this first.

3. Generated on the wrong build. A debuggable build behaves differently under ART, and a profile from it may not reflect your release code. Generate and benchmark against a non-debuggable, release-like variant.

4. A flaky journey. If the test sometimes lands on a login screen or an error state, you capture the wrong code. Wait on specific UI elements, fail loudly when they don’t appear, and keep any test data deterministic.

5. Profiling everything. More rules aren’t better. A huge profile pushes more code into AOT, which costs storage and compile time, and it dilutes the value of “startup” rules. Keep the journeys focused on what users hit first and most often.

6. The installer never runs. If a custom setup removes or disables ProfileInstaller’s initialization, the rules may never reach ART on devices that depend on it. A quick runtime check makes this visible:

import android.util.Log
import androidx.profileinstaller.ProfileVerifier
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.guava.await

suspend fun logProfileStatus() {
    val label = try {
        val status = ProfileVerifier.getCompilationStatusAsync().await()
        when {
            status.isCompiledWithProfile -> "compiled-with-profile"
            status.hasProfileEnqueuedForCompilation() -> "profile-enqueued"
            else -> "no-profile (code=${status.profileInstallResultCode})"
        }
    } catch (e: CancellationException) {
        throw e // never swallow coroutine cancellation
    } catch (e: Exception) {
        // The check can fail on restricted devices or with a corrupted package state.
        // Telemetry must never break the app, so record the failure and move on.
        "verifier-failed (${e::class.java.simpleName})"
    }
    Log.i("TideLog", "ART profile status: $label")
}
Enter fullscreen mode Exit fullscreen mode

Send that label to your analytics. The verifier-failed branch matters too: a spike in it tells you the check itself is unreliable on some devices, which is different from the profile being absent. Then "did the profile install?" becomes a question you can answer from real devices rather than guess.

7. Benchmarking the wrong thing. Comparing a profile build against a warm app, or comparing different journeys, produces numbers that look like wins. Use cold starts, identical journeys, and enough iterations that noise doesn’t decide the result.

8. Treating the profile as one-time work. Cloud Profiles keep adapting to users. A Baseline Profile only changes when you regenerate it. Put regeneration in your release checklist or CI.

🔚 Takeaways

  • ART has three execution paths. Precompiling the right subset spares users both slow interpretation and JIT warm-up.
  • Cloud Profiles reflect real usage, but they can only exist after that usage happens, and you can’t hand-edit them.
  • A Baseline Profile is a prioritized list you author from scripted journeys. ART still does the compiling.
  • Use both: ship with a head start, then let production data keep refining it.
  • Measure CompilationMode.None() against a profile-backed mode, and verify installation on real devices.

Ship with a head start, and let production data do the fine-tuning.

📱 Go Beyond Using Jetpack Compose

If you’re building on Android, understanding what happens under the hood separates developers who use Compose from those who master it. I highly recommend “Mastering Jetpack Compose Internals”. It’s a deep, architecture-first walkthrough of the composition tree, the slot table, snapshot state, and the runtime that powers modern Android UI — capped off with a full case study building a real app called Mosaic.


Originally published on Medium.

Top comments (0)