Merge pull request #3919 from vitorpamplona/build/baseline-profile-generator

build: add a baseline profile generator (:baselineprofile)
This commit is contained in:
Vitor Pamplona
2026-08-13 20:39:37 -04:00
committed by GitHub
6 changed files with 31658 additions and 0 deletions
+15
View File
@@ -9,6 +9,7 @@ plugins {
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
alias(libs.plugins.googleKsp)
alias(libs.plugins.androidxBaselineProfile)
}
fun getCurrentBranch(workingDir: java.io.File): String =
@@ -374,6 +375,17 @@ composeCompiler {
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
baselineProfile {
// One profile for the whole app rather than per-flavour: the ingest path being
// captured is identical in play and fdroid, and a shared profile is what the
// fdroid build needs — it never receives Play Cloud Profiles.
mergeIntoMain = true
// Keep the generated profile in source control so release builds do not depend on
// a device being attached at build time.
saveInSrc = true
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
@@ -402,6 +414,9 @@ dependencies {
// and no Cloud Profiles at all — there, this library is the only thing that gets
// the shipped baseline profile into ART.
implementation(libs.androidx.profileinstaller)
// Profile produced by :baselineprofile from a real cold-start + ingest journey.
baselineProfile(project(":baselineprofile"))
implementation(libs.androidx.activity.compose)
// Hardened WebView host for sandboxed napplet/nsite rendering (origin-restricted message bridge).
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.test")
alias(libs.plugins.androidxBaselineProfile)
}
android {
namespace = "com.vitorpamplona.amethyst.baselineprofile"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
// Baseline Profile generation needs API 28+; non-rooted generation needs API 33+.
minSdk = 28
targetSdk = libs.versions.android.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// :amethyst has a `channel` dimension (play/fdroid). The recorded journey is the
// same either way — ingest does not differ by store — so record against play and
// let the consumer's mergeIntoMain share one profile with both flavours.
missingDimensionStrategy("channel", "play")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
// The app whose journeys are recorded.
targetProjectPath = ":amethyst"
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
baselineProfile {
// Generate against a connected device. Macrobenchmark 1.2.0-alpha06+ can do this
// without root on API 33+; below that it needs root or an `aosp` Gradle Managed Device.
useConnectedDevices = true
}
dependencies {
implementation(libs.androidx.junit)
implementation(libs.androidx.runner)
implementation(libs.androidx.uiautomator)
implementation(libs.androidx.benchmark.macro.junit4)
}
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.baselineprofile
import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
/**
* Records the methods worth AOT-compiling, replacing the hand-authored
* `amethyst/src/main/baseline-prof.txt`.
*
* What this captures and why: a symbol profile of a release build during cold-start
* ingest found **42.9% of the DefaultDispatcher workers' CPU inside ART's nterp
* interpreter** and only 10.5% in compiled app code — the app was spending 36x more
* CPU interpreting bytecode than verifying signatures, because the shipped profile
* covered only androidx/Compose and none of the relay client, decoder, LocalCache or
* Jackson paths.
*
* So the journey is deliberately NOT just a startup trace. It launches cold and then
* sits on the feed while events stream in, because the expensive path is ingest, and
* ingest only runs once relays start delivering. Scrolling is included so the note
* rendering path is captured too.
*
* Run with:
* ./gradlew :amethyst:generateBaselineProfile
*
* Requires a connected device on API 33+ (no root needed with Macrobenchmark
* 1.2.0-alpha06 and higher), or an `aosp` Gradle Managed Device below that.
*/
class BaselineProfileGenerator {
@get:Rule
val rule = BaselineProfileRule()
@Test
fun startupAndIngest() =
rule.collect(packageName = PACKAGE) {
pressHome()
startActivityAndWait()
// Let the relay pool connect and start delivering. The ingest burst is the
// whole point of this profile; a startup-only journey would miss it.
device.waitForIdle()
Thread.sleep(INGEST_SETTLE_MS)
// Exercise the feed rendering path over whatever arrived.
val feed = device.findObject(By.scrollable(true))
if (feed != null) {
repeat(3) {
feed.scroll(Direction.DOWN, 1.0f)
device.waitForIdle()
}
feed.scroll(Direction.UP, 1.0f)
}
device.wait(Until.hasObject(By.pkg(PACKAGE).depth(0)), 5_000)
}
companion object {
const val PACKAGE = "com.vitorpamplona.amethyst"
/**
* Long enough for relays to connect and deliver, short enough to stay inside the
* memory ceiling of low-RAM test devices — an SM-T220 (3 GB) gets lmkd-killed
* around 20 s into a release-build ingest.
*/
const val INGEST_SETTLE_MS = 12_000L
}
}
+5
View File
@@ -16,6 +16,7 @@ androidxJunit = "1.3.0"
appcompat = "1.7.1"
audiowaveform = "1.1.2"
benchmark = "1.5.0-alpha07"
uiautomator = "2.3.0"
biometricKtx = "1.2.0-alpha05"
coil = "3.5.0"
composeBom = "2026.06.01"
@@ -111,6 +112,7 @@ androidx-appfunctions = { group = "androidx.appfunctions", name = "appfunctions"
androidx-appfunctions-service = { group = "androidx.appfunctions", name = "appfunctions-service", version.ref = "appfunctions" }
androidx-appfunctions-compiler = { group = "androidx.appfunctions", name = "appfunctions-compiler", version.ref = "appfunctions" }
androidx-benchmark-junit4 = { group = "androidx.benchmark", name = "benchmark-junit4", version.ref = "benchmark" }
androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmark" }
androidx-biometric-ktx = { group = "androidx.biometric", name = "biometric-ktx", version.ref = "biometricKtx" }
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidxCamera" }
androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidxCamera" }
@@ -142,6 +144,7 @@ androidx-media3-session = { group = "androidx.media3", name = "media3-session",
androidx-media3-ui-compose-material3 = { group = "androidx.media3", name = "media3-ui-compose-material3", version.ref = "media3" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" }
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
androidx-security-crypto-ktx = { group = "androidx.security", name = "security-crypto-ktx", version.ref = "securityCryptoKtx" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
@@ -242,6 +245,8 @@ androidx-health-connect-client = { group = "androidx.health.connect", name = "co
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidBenchmark = { id = "androidx.benchmark", version.ref = "benchmark" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
androidTest = { id = "com.android.test", version.ref = "agp" }
androidxBaselineProfile = { id = "androidx.baselineprofile", version.ref = "benchmark" }
diffplugSpotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
googleServices = { id = "com.google.gms.google-services", version.ref = "gms" }
jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
+1
View File
@@ -33,6 +33,7 @@ rootProject.name = "Amethyst"
include(":amethyst")
include(":nappletHost")
include(":benchmark")
include(":baselineprofile")
include(":quartz")
include(":geode")
include(":commons")