mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-16 17:59:07 -04:00
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:
57
.github/workflows/scheduled-updates.yml
vendored
57
.github/workflows/scheduled-updates.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
32
baselineprofile/README.md
Normal 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.
|
||||
64
baselineprofile/build.gradle.kts
Normal file
64
baselineprofile/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -133,4 +133,5 @@ include(
|
||||
":core:barcode",
|
||||
":feature:widget",
|
||||
":screenshot-tests",
|
||||
":baselineprofile",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user