Skip to content

Commit 2c6ede1

Browse files
jamesarichclaude
andauthored
perf: add Baseline Profile generation for :androidApp (#5735)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0b3ca59 commit 2c6ede1

8 files changed

Lines changed: 310 additions & 3 deletions

File tree

.github/workflows/scheduled-updates.yml

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: Scheduled Updates (Firmware, Hardware, Translations)
22

33
on:
44
schedule:
5-
- cron: '0 */4 * * *' # Run every 4 hours (was hourly — reduced to cut cascade CI cost)
5+
- cron: '0 */6 * * *' # Run every 6 hours (raised from 4h to absorb the added baseline-profile step)
66
workflow_dispatch: # Allow manual triggering
77

88
jobs:
@@ -111,6 +111,45 @@ jobs:
111111
run: ./gradlew graphUpdate
112112
continue-on-error: true
113113

114+
# ── Baseline Profile regeneration ───────────────────────────────────
115+
# Runs on every scheduled tick (and manual dispatch). Generation needs a booted emulator
116+
# (~10 min); continue-on-error keeps flakiness from blocking the firmware/translation PR.
117+
- name: Enable KVM (for the emulator)
118+
run: |
119+
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
120+
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
121+
sudo udevadm control --reload-rules
122+
sudo udevadm trigger --name-match=kvm
123+
124+
- name: Generate Baseline Profile
125+
id: generate_baseline
126+
continue-on-error: true # Emulator flakiness must not block the firmware/translation PR.
127+
uses: reactivecircus/android-emulator-runner@v2
128+
with:
129+
api-level: 34
130+
target: google_apis # google flavor needs GMS (Maps) on the device image
131+
arch: x86_64
132+
profile: pixel_6
133+
disable-animations: true
134+
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
135+
# Writes androidApp/src/google/generated/baselineProfiles/ via the androidx.baselineprofile plugin.
136+
script: ./gradlew :androidApp:generateGoogleReleaseBaselineProfile -Pci=true
137+
138+
- name: Detect baseline profile changes
139+
id: baseline
140+
run: |
141+
profile_dir="androidApp/src/google/generated/baselineProfiles"
142+
outcome="${{ steps.generate_baseline.outcome }}"
143+
if [ "$outcome" = "skipped" ]; then
144+
echo "status=skipped" >> "$GITHUB_OUTPUT"
145+
elif [ "$outcome" != "success" ]; then
146+
echo "::warning::Baseline profile generation failed (outcome: $outcome). Skipping."
147+
echo "status=error" >> "$GITHUB_OUTPUT"
148+
elif [ -n "$(git status --porcelain "$profile_dir" 2>/dev/null)" ]; then
149+
echo "status=updated" >> "$GITHUB_OUTPUT"
150+
else
151+
echo "status=unchanged" >> "$GITHUB_OUTPUT"
152+
fi
114153
115154
- name: Build PR body
116155
id: pr_body
@@ -119,6 +158,7 @@ jobs:
119158
firmware_detail="${{ steps.firmware.outputs.detail }}"
120159
hardware_status="${{ steps.hardware.outputs.status }}"
121160
hardware_detail="${{ steps.hardware.outputs.detail }}"
161+
baseline_status="${{ steps.baseline.outputs.status }}"
122162
123163
body="This PR includes automated updates from the scheduled workflow:"
124164
body+=$'\n'
@@ -139,6 +179,15 @@ jobs:
139179
*) body+=$'\n'"- ❓ \`device_hardware.json\` — unknown status." ;;
140180
esac
141181
182+
# Baseline profile (daily / manual only)
183+
case "$baseline_status" in
184+
updated) body+=$'\n'"- ✅ \`androidApp\` baseline profile regenerated on an emulator." ;;
185+
unchanged) body+=$'\n'"- ✔️ \`androidApp\` baseline profile regenerated — no changes detected." ;;
186+
error) body+=$'\n'"- ⚠️ \`androidApp\` baseline profile generation failed — skipped (see workflow logs)." ;;
187+
skipped) ;; # Not a daily/manual run — omit the line entirely.
188+
*) ;;
189+
esac
190+
142191
# Crowdin & graphs (always attempted)
143192
body+=$'\n'"- Source strings were uploaded to Crowdin."
144193
body+=$'\n'"- Latest translations were downloaded from Crowdin (if available)."
@@ -158,22 +207,24 @@ jobs:
158207
with:
159208
token: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
160209
commit-message: |
161-
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs)
210+
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs, Baseline)
162211
163212
Automated updates for:
164213
- Firmware releases list
165214
- Device hardware list
166215
- Crowdin source string uploads
167216
- Crowdin translation downloads
168217
- Module dependency graphs
169-
title: 'chore: Scheduled updates (Firmware, Hardware, Translations, Graphs)'
218+
- androidApp baseline profile
219+
title: 'chore: Scheduled updates (Firmware, Hardware, Translations, Graphs, Baseline)'
170220
body: ${{ steps.pr_body.outputs.content }}
171221
branch: 'scheduled-updates'
172222
base: 'main'
173223
delete-branch: true
174224
add-paths: |
175225
androidApp/src/main/assets/firmware_releases.json
176226
androidApp/src/main/assets/device_hardware.json
227+
androidApp/src/google/generated/baselineProfiles/**
177228
fastlane/metadata/android/**
178229
**/strings.xml
179230
**/README.md

androidApp/build.gradle.kts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ plugins {
3030
id("meshtastic.koin")
3131
alias(libs.plugins.kotlin.parcelize)
3232
alias(libs.plugins.secrets)
33+
alias(libs.plugins.androidx.baselineprofile)
3334
id("meshtastic.aboutlibraries")
3435
id("dev.mokkery")
3536
alias(libs.plugins.devtools.ksp)
@@ -254,6 +255,9 @@ dependencies {
254255
implementation(libs.coil.network.ktor3)
255256
implementation(libs.coil.svg)
256257
implementation(libs.androidx.core.splashscreen)
258+
// Installs the baseline profile produced by :baselineprofile at app startup (API < 31)
259+
// and lets ART honor it on first launch. On API 31+ the platform installs it automatically.
260+
implementation(libs.androidx.profileinstaller)
257261
implementation(libs.kotlinx.serialization.json)
258262
implementation(libs.usb.serial.android)
259263
implementation(libs.androidx.work.runtime.ktx)
@@ -308,4 +312,8 @@ dependencies {
308312
testImplementation(libs.androidx.glance.appwidget)
309313
// JVM variant provides the host-platform native library for BundledSQLiteDriver under Robolectric
310314
testRuntimeOnly("androidx.sqlite:sqlite-bundled-jvm:2.6.2")
315+
316+
// Producer of the baseline profile consumed by the release build. The androidx.baselineprofile
317+
// plugin merges the generated rules into src/<variant>/generated/baselineProfiles at build time.
318+
baselineProfile(projects.baselineprofile)
311319
}

baselineprofile/README.md

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

baselineprofile/build.gradle.kts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
18+
19+
plugins {
20+
alias(libs.plugins.android.test)
21+
alias(libs.plugins.androidx.baselineprofile)
22+
}
23+
24+
android {
25+
namespace = "org.meshtastic.baselineprofile"
26+
compileSdk = 37
27+
28+
compileOptions {
29+
sourceCompatibility = JavaVersion.VERSION_21
30+
targetCompatibility = JavaVersion.VERSION_21
31+
}
32+
33+
defaultConfig {
34+
// Macrobenchmark / BaselineProfileRule require API 28+ on the test (device) side.
35+
// The generated profile is still installed on the app's real minSdk (26) via profileinstaller.
36+
minSdk = 28
37+
targetSdk = 37
38+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
39+
}
40+
41+
// App module whose startup we profile/benchmark.
42+
targetProjectPath = ":androidApp"
43+
44+
// The app declares a `marketplace` flavor dimension (google / fdroid). A test module must
45+
// match it. We pin to `google` — the variant the vast majority of users run (and the one with
46+
// Maps). f-droid can reuse the same profile; wire a second flavor here if it ever diverges.
47+
flavorDimensions += "marketplace"
48+
productFlavors { create("google") { dimension = "marketplace" } }
49+
}
50+
51+
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
52+
53+
baselineProfile {
54+
// Generate on an attached device/emulator. For hermetic CI, replace with a Gradle Managed
55+
// Device (see README.md) and set managedDevices + useConnectedDevices = false.
56+
useConnectedDevices = true
57+
}
58+
59+
dependencies {
60+
implementation(libs.androidx.test.ext.junit)
61+
implementation(libs.androidx.test.espresso.core)
62+
implementation(libs.androidx.uiautomator)
63+
implementation(libs.androidx.benchmark.macro.junit4)
64+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.baselineprofile
18+
19+
import androidx.benchmark.macro.junit4.BaselineProfileRule
20+
import androidx.test.ext.junit.runners.AndroidJUnit4
21+
import androidx.test.filters.LargeTest
22+
import androidx.test.platform.app.InstrumentationRegistry
23+
import org.junit.Rule
24+
import org.junit.Test
25+
import org.junit.runner.RunWith
26+
27+
/**
28+
* Generates a Baseline Profile for the app's critical user journey.
29+
*
30+
* Run it with:
31+
* ```
32+
* ./gradlew :androidApp:generateGoogleReleaseBaselineProfile
33+
* ```
34+
*
35+
* The [androidx.baselineprofile] plugin on `:androidApp` drives this against the auto-created
36+
* `nonMinifiedRelease` variant and merges the result into
37+
* `androidApp/src/google/generated/baselineProfiles/`. Commit that output so release builds ship it.
38+
*
39+
* The journey is intentionally minimal (cold start → first frame) because CI has no paired radio.
40+
* Extend it with post-connection screens (node list, map, message thread) once a fake transport or
41+
* connected device is available in the harness — the more representative the journey, the better the
42+
* profile.
43+
*/
44+
@LargeTest
45+
@RunWith(AndroidJUnit4::class)
46+
class BaselineProfileGenerator {
47+
48+
@get:Rule val baselineProfileRule = BaselineProfileRule()
49+
50+
@Test
51+
fun generate() =
52+
baselineProfileRule.collect(
53+
// The plugin injects the target applicationId (handles the google debug/release suffix).
54+
packageName = InstrumentationRegistry.getArguments().getString("targetAppId") ?: DEFAULT_APP_ID,
55+
// Also produce a startup profile (dexlayout hints) for faster cold start, not just AOT rules.
56+
includeInStartupProfile = true,
57+
) {
58+
pressHome()
59+
startActivityAndWait()
60+
device.waitForIdle()
61+
}
62+
63+
private companion object {
64+
const val DEFAULT_APP_ID = "com.geeksville.mesh"
65+
}
66+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.baselineprofile
18+
19+
import androidx.benchmark.macro.BaselineProfileMode
20+
import androidx.benchmark.macro.CompilationMode
21+
import androidx.benchmark.macro.StartupMode
22+
import androidx.benchmark.macro.StartupTimingMetric
23+
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
24+
import androidx.test.ext.junit.runners.AndroidJUnit4
25+
import androidx.test.filters.LargeTest
26+
import androidx.test.platform.app.InstrumentationRegistry
27+
import org.junit.Rule
28+
import org.junit.Test
29+
import org.junit.runner.RunWith
30+
31+
/**
32+
* Measures cold-start time with and without the Baseline Profile so the win is quantifiable.
33+
*
34+
* Run it with:
35+
* ```
36+
* ./gradlew :androidApp:benchmarkGoogleReleaseBaselineProfile
37+
* ```
38+
*
39+
* Compare `startupCompilationNone` vs `startupCompilationBaselineProfiles` in the output: the delta
40+
* is the startup improvement the shipped profile buys. `Partial(Require)` fails loudly if the
41+
* profile is missing, so this also guards against a release that silently dropped it.
42+
*/
43+
@LargeTest
44+
@RunWith(AndroidJUnit4::class)
45+
class StartupBenchmark {
46+
47+
@get:Rule val benchmarkRule = MacrobenchmarkRule()
48+
49+
@Test fun startupCompilationNone() = startup(CompilationMode.None())
50+
51+
@Test
52+
fun startupCompilationBaselineProfiles() =
53+
startup(CompilationMode.Partial(baselineProfileMode = BaselineProfileMode.Require))
54+
55+
private fun startup(compilationMode: CompilationMode) =
56+
benchmarkRule.measureRepeated(
57+
packageName = InstrumentationRegistry.getArguments().getString("targetAppId") ?: DEFAULT_APP_ID,
58+
metrics = listOf(StartupTimingMetric()),
59+
compilationMode = compilationMode,
60+
startupMode = StartupMode.COLD,
61+
iterations = 10,
62+
) {
63+
pressHome()
64+
startActivityAndWait()
65+
}
66+
67+
private companion object {
68+
const val DEFAULT_APP_ID = "com.geeksville.mesh"
69+
}
70+
}

0 commit comments

Comments
 (0)