perf: add Baseline Profile generation for :androidApp (#5735)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-05 05:15:11 -05:00
parent 76847dd638
commit a09e1368f4
8 changed files with 310 additions and 3 deletions

View File

@@ -2,7 +2,7 @@ name: Scheduled Updates (Firmware, Hardware, Translations)
on:
schedule:
- cron: '0 */4 * * *' # Run every 4 hours (was hourly — reduced to cut cascade CI cost)
- cron: '0 */6 * * *' # Run every 6 hours (raised from 4h to absorb the added baseline-profile step)
workflow_dispatch: # Allow manual triggering
jobs:
@@ -111,6 +111,45 @@ jobs:
run: ./gradlew graphUpdate
continue-on-error: true
# ── Baseline Profile regeneration ───────────────────────────────────
# Runs on every scheduled tick (and manual dispatch). Generation needs a booted emulator
# (~10 min); continue-on-error keeps flakiness from blocking the firmware/translation PR.
- name: Enable KVM (for the emulator)
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Generate Baseline Profile
id: generate_baseline
continue-on-error: true # Emulator flakiness must not block the firmware/translation PR.
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
target: google_apis # google flavor needs GMS (Maps) on the device image
arch: x86_64
profile: pixel_6
disable-animations: true
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
# Writes androidApp/src/google/generated/baselineProfiles/ via the androidx.baselineprofile plugin.
script: ./gradlew :androidApp:generateGoogleReleaseBaselineProfile -Pci=true
- name: Detect baseline profile changes
id: baseline
run: |
profile_dir="androidApp/src/google/generated/baselineProfiles"
outcome="${{ steps.generate_baseline.outcome }}"
if [ "$outcome" = "skipped" ]; then
echo "status=skipped" >> "$GITHUB_OUTPUT"
elif [ "$outcome" != "success" ]; then
echo "::warning::Baseline profile generation failed (outcome: $outcome). Skipping."
echo "status=error" >> "$GITHUB_OUTPUT"
elif [ -n "$(git status --porcelain "$profile_dir" 2>/dev/null)" ]; then
echo "status=updated" >> "$GITHUB_OUTPUT"
else
echo "status=unchanged" >> "$GITHUB_OUTPUT"
fi
- name: Build PR body
id: pr_body
@@ -119,6 +158,7 @@ jobs:
firmware_detail="${{ steps.firmware.outputs.detail }}"
hardware_status="${{ steps.hardware.outputs.status }}"
hardware_detail="${{ steps.hardware.outputs.detail }}"
baseline_status="${{ steps.baseline.outputs.status }}"
body="This PR includes automated updates from the scheduled workflow:"
body+=$'\n'
@@ -139,6 +179,15 @@ jobs:
*) body+=$'\n'"- ❓ \`device_hardware.json\` — unknown status." ;;
esac
# Baseline profile (daily / manual only)
case "$baseline_status" in
updated) body+=$'\n'"- ✅ \`androidApp\` baseline profile regenerated on an emulator." ;;
unchanged) body+=$'\n'"- ✔️ \`androidApp\` baseline profile regenerated — no changes detected." ;;
error) body+=$'\n'"- ⚠️ \`androidApp\` baseline profile generation failed — skipped (see workflow logs)." ;;
skipped) ;; # Not a daily/manual run — omit the line entirely.
*) ;;
esac
# Crowdin & graphs (always attempted)
body+=$'\n'"- Source strings were uploaded to Crowdin."
body+=$'\n'"- Latest translations were downloaded from Crowdin (if available)."
@@ -158,7 +207,7 @@ jobs:
with:
token: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
commit-message: |
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs)
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs, Baseline)
Automated updates for:
- Firmware releases list
@@ -166,7 +215,8 @@ jobs:
- Crowdin source string uploads
- Crowdin translation downloads
- Module dependency graphs
title: 'chore: Scheduled updates (Firmware, Hardware, Translations, Graphs)'
- androidApp baseline profile
title: 'chore: Scheduled updates (Firmware, Hardware, Translations, Graphs, Baseline)'
body: ${{ steps.pr_body.outputs.content }}
branch: 'scheduled-updates'
base: 'main'
@@ -174,6 +224,7 @@ jobs:
add-paths: |
androidApp/src/main/assets/firmware_releases.json
androidApp/src/main/assets/device_hardware.json
androidApp/src/google/generated/baselineProfiles/**
fastlane/metadata/android/**
**/strings.xml
**/README.md

View File

@@ -30,6 +30,7 @@ plugins {
id("meshtastic.koin")
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.secrets)
alias(libs.plugins.androidx.baselineprofile)
id("meshtastic.aboutlibraries")
id("dev.mokkery")
alias(libs.plugins.devtools.ksp)
@@ -254,6 +255,9 @@ dependencies {
implementation(libs.coil.network.ktor3)
implementation(libs.coil.svg)
implementation(libs.androidx.core.splashscreen)
// Installs the baseline profile produced by :baselineprofile at app startup (API < 31)
// and lets ART honor it on first launch. On API 31+ the platform installs it automatically.
implementation(libs.androidx.profileinstaller)
implementation(libs.kotlinx.serialization.json)
implementation(libs.usb.serial.android)
implementation(libs.androidx.work.runtime.ktx)
@@ -308,4 +312,8 @@ dependencies {
testImplementation(libs.androidx.glance.appwidget)
// JVM variant provides the host-platform native library for BundledSQLiteDriver under Robolectric
testRuntimeOnly("androidx.sqlite:sqlite-bundled-jvm:2.6.2")
// Producer of the baseline profile consumed by the release build. The androidx.baselineprofile
// plugin merges the generated rules into src/<variant>/generated/baselineProfiles at build time.
baselineProfile(projects.baselineprofile)
}

32
baselineprofile/README.md Normal file
View File

@@ -0,0 +1,32 @@
# `:baselineprofile`
Generates a [Baseline Profile](https://developer.android.com/topic/performance/baselineprofiles/overview)
for `:androidApp` — AOT-compiling the cold-start and first-frame code paths so ART doesn't pay the
JIT cost on first launch. Targets the **google** flavor (the variant most users run).
## Generate the profile (run on a device/emulator)
```bash
./gradlew :androidApp:generateGoogleReleaseBaselineProfile
```
Output is merged into `androidApp/src/google/generated/baselineProfiles/baseline-prof.txt`.
**Commit that file** — release builds package it via `androidx.profileinstaller`.
## Quantify the win
```bash
./gradlew :androidApp:benchmarkGoogleReleaseBaselineProfile
```
Compare `startupCompilationNone` vs `startupCompilationBaselineProfiles` in the output.
## Scope / TODO
- The journey (`BaselineProfileGenerator`) is cold-start only, since CI has no paired radio.
Extend it with post-connection screens (node list, map, message thread) once a fake transport or
connected device is wired into the harness — a more representative journey yields a better profile.
- For hermetic CI generation, swap `useConnectedDevices = true` in `build.gradle.kts` for a
[Gradle Managed Device](https://developer.android.com/topic/performance/baselineprofiles/measure-baselineprofile#gradle-managed).
- f-droid currently inherits no profile (only `google` is produced). Add a second flavor here if
the f-droid startup path ever diverges enough to matter.

View File

@@ -0,0 +1,64 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.test)
alias(libs.plugins.androidx.baselineprofile)
}
android {
namespace = "org.meshtastic.baselineprofile"
compileSdk = 37
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
defaultConfig {
// Macrobenchmark / BaselineProfileRule require API 28+ on the test (device) side.
// The generated profile is still installed on the app's real minSdk (26) via profileinstaller.
minSdk = 28
targetSdk = 37
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
// App module whose startup we profile/benchmark.
targetProjectPath = ":androidApp"
// The app declares a `marketplace` flavor dimension (google / fdroid). A test module must
// match it. We pin to `google` — the variant the vast majority of users run (and the one with
// Maps). f-droid can reuse the same profile; wire a second flavor here if it ever diverges.
flavorDimensions += "marketplace"
productFlavors { create("google") { dimension = "marketplace" } }
}
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
baselineProfile {
// Generate on an attached device/emulator. For hermetic CI, replace with a Gradle Managed
// Device (see README.md) and set managedDevices + useConnectedDevices = false.
useConnectedDevices = true
}
dependencies {
implementation(libs.androidx.test.ext.junit)
implementation(libs.androidx.test.espresso.core)
implementation(libs.androidx.uiautomator)
implementation(libs.androidx.benchmark.macro.junit4)
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.baselineprofile
import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Generates a Baseline Profile for the app's critical user journey.
*
* Run it with:
* ```
* ./gradlew :androidApp:generateGoogleReleaseBaselineProfile
* ```
*
* The [androidx.baselineprofile] plugin on `:androidApp` drives this against the auto-created
* `nonMinifiedRelease` variant and merges the result into
* `androidApp/src/google/generated/baselineProfiles/`. Commit that output so release builds ship it.
*
* The journey is intentionally minimal (cold start → first frame) because CI has no paired radio.
* Extend it with post-connection screens (node list, map, message thread) once a fake transport or
* connected device is available in the harness — the more representative the journey, the better the
* profile.
*/
@LargeTest
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule val baselineProfileRule = BaselineProfileRule()
@Test
fun generate() =
baselineProfileRule.collect(
// The plugin injects the target applicationId (handles the google debug/release suffix).
packageName = InstrumentationRegistry.getArguments().getString("targetAppId") ?: DEFAULT_APP_ID,
// Also produce a startup profile (dexlayout hints) for faster cold start, not just AOT rules.
includeInStartupProfile = true,
) {
pressHome()
startActivityAndWait()
device.waitForIdle()
}
private companion object {
const val DEFAULT_APP_ID = "com.geeksville.mesh"
}
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.baselineprofile
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 androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Measures cold-start time with and without the Baseline Profile so the win is quantifiable.
*
* Run it with:
* ```
* ./gradlew :androidApp:benchmarkGoogleReleaseBaselineProfile
* ```
*
* Compare `startupCompilationNone` vs `startupCompilationBaselineProfiles` in the output: the delta
* is the startup improvement the shipped profile buys. `Partial(Require)` fails loudly if the
* profile is missing, so this also guards against a release that silently dropped it.
*/
@LargeTest
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
@get:Rule val benchmarkRule = MacrobenchmarkRule()
@Test fun startupCompilationNone() = startup(CompilationMode.None())
@Test
fun startupCompilationBaselineProfiles() =
startup(CompilationMode.Partial(baselineProfileMode = BaselineProfileMode.Require))
private fun startup(compilationMode: CompilationMode) =
benchmarkRule.measureRepeated(
packageName = InstrumentationRegistry.getArguments().getString("targetAppId") ?: DEFAULT_APP_ID,
metrics = listOf(StartupTimingMetric()),
compilationMode = compilationMode,
startupMode = StartupMode.COLD,
iterations = 10,
) {
pressHome()
startActivityAndWait()
}
private companion object {
const val DEFAULT_APP_ID = "com.geeksville.mesh"
}
}

View File

@@ -37,6 +37,14 @@ turbine = "1.2.1"
# Compose Screenshot Testing
compose-screenshot = "0.0.1-alpha15"
# Baseline Profiles / Macrobenchmark
# `benchmark` drives both the androidx.benchmark macro lib AND the androidx.baselineprofile
# Gradle plugin (same coordinates/version). Kept on the alpha track to stay compatible with
# AGP 9.x (the stable 1.4.x line predates AGP 9 support).
benchmark = "1.5.0-alpha06"
profileinstaller = "1.4.1"
androidx-uiautomator = "2.3.0"
# Compose Multiplatform
compose-multiplatform = "1.11.1"
compose-multiplatform-material3 = "1.11.0-alpha07"
@@ -148,6 +156,11 @@ androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version =
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.2" }
androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.2" }
# Baseline Profiles / Macrobenchmark
androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "profileinstaller" }
androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark" }
androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" }
# AndroidX Compose (explicit versions — BOM removed; CMP is the sole version authority)
androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidx-compose-bom-aligned" }
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose-bom-aligned" } # Required by Robolectric Compose tests (registers ComponentActivity)
@@ -303,6 +316,8 @@ meshtastic-protobufs = { module = "org.meshtastic:protobufs", version.ref = "mes
# Android
android-application = { id = "com.android.application", version.ref = "agp" }
android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
android-test = { id = "com.android.test" }
androidx-baselineprofile = { id = "androidx.baselineprofile", version.ref = "benchmark" }
compose-screenshot = { id = "com.android.compose.screenshot", version.ref = "compose-screenshot" }
# Jetbrains

View File

@@ -133,4 +133,5 @@ include(
":core:barcode",
":feature:widget",
":screenshot-tests",
":baselineprofile",
)