Skip to content

Commit a0520d4

Browse files
authored
Merge pull request #2 from mohdaquib/feature/baseline-benchmarks
Feature/baseline benchmarks
2 parents 2af2407 + 9f285be commit a0520d4

15 files changed

Lines changed: 462 additions & 57 deletions

File tree

.github/workflows/ci.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ jobs:
1515
steps:
1616
- uses: actions/checkout@v4
1717

18+
- name: Make gradlew executable
19+
run: chmod +x gradlew
20+
1821
- uses: actions/setup-java@v4
1922
with:
2023
java-version: 17
@@ -43,6 +46,9 @@ jobs:
4346
steps:
4447
- uses: actions/checkout@v4
4548

49+
- name: Make gradlew executable
50+
run: chmod +x gradlew
51+
4652
- uses: actions/setup-java@v4
4753
with:
4854
java-version: 17
@@ -61,10 +67,17 @@ jobs:
6167
uses: reactivecircus/android-emulator-runner@v2
6268
with:
6369
api-level: 34
64-
target: aosp_atd
70+
target: default
6571
arch: x86_64
72+
emulator-boot-timeout: 600
6673
script: ./gradlew :benchmarks:connectedBenchmarkAndroidTest
6774

75+
- name: Parse Benchmark Results
76+
if: always()
77+
run: |
78+
echo "### Macrobenchmark Results" >> $GITHUB_STEP_SUMMARY
79+
python3 benchmarks/BenchmarkResultsParser.py >> $GITHUB_STEP_SUMMARY
80+
6881
- name: Upload benchmark JSON
6982
if: always()
7083
uses: actions/upload-artifact@v4

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
/.idea/navEditor.xml
99
/.idea/assetWizardSettings.xml
1010
.DS_Store
11-
/build
11+
build/
1212
/captures
1313
.externalNativeBuild
1414
.cxx
@@ -23,3 +23,4 @@ local.properties
2323
/.idea/runConfigurations.xml
2424
/.idea/studiobot.xml
2525
/.idea/vcs.xml
26+
.idea/androidTestResultsUserPreferences.xml

app/build.gradle.kts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@ android {
2626
getDefaultProguardFile("proguard-android-optimize.txt"),
2727
"proguard-rules.pro"
2828
)
29+
signingConfig = signingConfigs.getByName("debug")
2930
}
3031
// Dedicated build type for running Macrobenchmarks and generating Baseline Profiles.
3132
// Mirrors release config but keeps the debug signing cert so the benchmark module
3233
// can install it without a release keystore on CI.
3334
create("benchmark") {
34-
initWith(buildTypes.getByName("release"))
35+
initWith(getByName("release"))
3536
signingConfig = signingConfigs.getByName("debug")
3637
matchingFallbacks += listOf("release")
3738
isDebuggable = false
@@ -79,4 +80,5 @@ dependencies {
7980
androidTestImplementation(libs.androidx.espresso.core)
8081
androidTestImplementation(platform(libs.androidx.compose.bom))
8182
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
83+
androidTestImplementation(libs.kotlinx.coroutines.test)
8284
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package com.aquib.androidperflab
2+
3+
import android.os.Bundle
4+
import android.util.Log
5+
import androidx.activity.ComponentActivity
6+
import androidx.compose.runtime.Recomposer
7+
import androidx.compose.ui.test.junit4.createAndroidComposeRule
8+
import androidx.compose.ui.test.onNodeWithTag
9+
import androidx.compose.ui.test.performClick
10+
import androidx.test.ext.junit.runners.AndroidJUnit4
11+
import androidx.test.platform.app.InstrumentationRegistry
12+
import com.aquib.androidperflab.ui.DetailScreen
13+
import com.aquib.androidperflab.ui.FeedItem
14+
import com.aquib.androidperflab.ui.theme.AndroidPerfLabTheme
15+
import kotlinx.coroutines.test.TestCoroutineScheduler
16+
import kotlinx.coroutines.test.runTest
17+
import org.junit.Before
18+
import org.junit.Rule
19+
import org.junit.Test
20+
import org.junit.runner.RunWith
21+
22+
@RunWith(AndroidJUnit4::class)
23+
class RecompositionBenchmark {
24+
// Shared scheduler drives virtual time for runTest blocks in this class.
25+
private val testScheduler = TestCoroutineScheduler()
26+
27+
@get:Rule
28+
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
29+
30+
private val testItem = FeedItem(
31+
id = 42,
32+
title = "Performance Testing in Android",
33+
subtitle = "Benchmarking Compose recompositions",
34+
description = "Android performance testing involves many dimensions including startup " +
35+
"time, frame rendering, and recomposition overhead. This article explores " +
36+
"techniques for measuring each of these in depth.",
37+
author = "Mohd Aquib",
38+
imageUrl = "",
39+
timestampMillis = 1_700_000_000_000L,
40+
)
41+
42+
@Before
43+
fun setUp() {
44+
composeTestRule.setContent {
45+
AndroidPerfLabTheme {
46+
DetailScreen(item = testItem, onBack = {})
47+
}
48+
}
49+
// Pause Compose's virtual clock so the LaunchedEffect tick loop in DetailScreen
50+
// does not fire between measurement checkpoints, keeping deltas deterministic.
51+
composeTestRule.mainClock.autoAdvance = false
52+
// Drain the initial composition pass before any test captures a baseline count.
53+
composeTestRule.mainClock.advanceTimeByFrame()
54+
composeTestRule.waitForIdle()
55+
}
56+
57+
// ── Like button ──────────────────────────────────────────────────────────────
58+
59+
@Test
60+
fun likeButton_recompositionCount() {
61+
val before = totalChangeCount()
62+
63+
composeTestRule.onNodeWithTag("detail_like_button").performClick()
64+
composeTestRule.mainClock.advanceTimeByFrame()
65+
composeTestRule.waitForIdle()
66+
67+
val delta = totalChangeCount() - before
68+
record("like_button_click", delta)
69+
}
70+
71+
// ── Bookmark button ──────────────────────────────────────────────────────────
72+
73+
@Test
74+
fun bookmarkButton_recompositionCount() {
75+
val before = totalChangeCount()
76+
77+
composeTestRule.onNodeWithTag("detail_bookmark_button").performClick()
78+
composeTestRule.mainClock.advanceTimeByFrame()
79+
composeTestRule.waitForIdle()
80+
81+
val delta = totalChangeCount() - before
82+
record("bookmark_button_click", delta)
83+
}
84+
85+
// ── Tick-driven recompositions ───────────────────────────────────────────────
86+
87+
@Test
88+
fun tickEffect_recompositionCountPerInterval() = runTest(testScheduler) {
89+
val tickCount = 5
90+
var totalDelta = 0L
91+
92+
repeat(tickCount) { index ->
93+
val before = totalChangeCount()
94+
95+
// Advance Compose's main clock to unblock the delay(500L) in LaunchedEffect.
96+
composeTestRule.mainClock.advanceTimeBy(500L)
97+
// Advance TestCoroutineScheduler by the same interval so virtual time
98+
// stays in sync for any coroutines running on testScheduler.
99+
testScheduler.advanceTimeBy(500L)
100+
composeTestRule.waitForIdle()
101+
102+
val delta = totalChangeCount() - before
103+
totalDelta += delta
104+
Log.d(TAG, "Tick ${index + 1}: $delta recompositions")
105+
}
106+
107+
record("tick_effect_per_interval", totalDelta / tickCount)
108+
}
109+
110+
// ── Helpers ──────────────────────────────────────────────────────────────────
111+
112+
private fun totalChangeCount(): Long =
113+
Recomposer.runningRecomposers.value.sumOf { it.changeCount }
114+
115+
private fun record(interaction: String, recompositionCount: Long) {
116+
Log.d(TAG, "[$interaction] recompositions per interaction: $recompositionCount")
117+
val bundle = Bundle().apply { putLong(interaction, recompositionCount) }
118+
InstrumentationRegistry.getInstrumentation().sendStatus(2, bundle)
119+
}
120+
121+
companion object {
122+
private const val TAG = "RecompositionBenchmark"
123+
}
124+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#!/usr/bin/env python3
2+
import json
3+
import glob
4+
import os
5+
6+
def format_value(val):
7+
if val is None:
8+
return "-"
9+
if isinstance(val, (int, float)):
10+
return f"{val:.2f}"
11+
return str(val)
12+
13+
def main():
14+
# Search for benchmark data files in the standard output directory
15+
search_path = 'benchmarks/build/outputs/connected_android_test_additional_output/**/*-benchmarkData.json'
16+
files = glob.glob(search_path, recursive=True)
17+
18+
if not files:
19+
# Fallback to search from current directory
20+
files = glob.glob('**/*-benchmarkData.json', recursive=True)
21+
22+
if not files:
23+
print("No benchmark results found.")
24+
return
25+
26+
print("| Metric | Min | Median | Max |")
27+
print("| :--- | :---: | :---: | :---: |")
28+
29+
# Track metrics to avoid duplicates if multiple files are found
30+
seen_results = set()
31+
32+
for file_path in files:
33+
try:
34+
with open(file_path, 'r') as f:
35+
data = json.load(f)
36+
37+
if 'benchmarks' not in data:
38+
continue
39+
40+
for benchmark in data['benchmarks']:
41+
benchmark_name = benchmark.get('name', 'Unknown')
42+
metrics = benchmark.get('metrics', {})
43+
44+
for metric_name, values in metrics.items():
45+
m_min = values.get('minimum')
46+
m_median = values.get('median')
47+
m_max = values.get('maximum')
48+
49+
# Some metrics might be in nested objects depending on version
50+
# but usually minimum/median/maximum are at the top level of the metric object
51+
52+
display_name = f"{benchmark_name}_{metric_name}"
53+
result_row = (display_name, m_min, m_median, m_max)
54+
55+
if result_row not in seen_results:
56+
print(f"| {display_name} | {format_value(m_min)} | {format_value(m_median)} | {format_value(m_max)} |")
57+
seen_results.add(result_row)
58+
except Exception as e:
59+
# Print error to stderr so it doesn't mess up the markdown table on stdout
60+
import sys
61+
print(f"Error parsing {file_path}: {e}", file=sys.stderr)
62+
63+
if __name__ == "__main__":
64+
main()

benchmarks/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ android {
3737
}
3838

3939
dependencies {
40+
implementation(libs.androidx.benchmark.junit4)
4041
implementation(libs.androidx.benchmark.macro.junit4)
4142
implementation(libs.androidx.test.uiautomator)
4243
implementation(libs.androidx.junit)

benchmarks/src/androidTest/java/com/aquib/androidperflab/benchmarks/StartupBenchmark.kt

Lines changed: 0 additions & 48 deletions
This file was deleted.

benchmarks/src/androidTest/java/com/aquib/androidperflab/benchmarks/BaselineProfileGenerator.kt renamed to benchmarks/src/main/java/com/aquib/androidperflab/benchmarks/BaselineProfileGenerator.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import org.junit.runner.RunWith
1717
*/
1818
@RunWith(AndroidJUnit4::class)
1919
class BaselineProfileGenerator {
20-
2120
@get:Rule
2221
val baselineProfileRule = BaselineProfileRule()
2322

0 commit comments

Comments
 (0)