Skip to content

Commit ed489b5

Browse files
authored
Add Kotlin script for running benchmarks on iOS (#5563)
Add `run_ios_benchmarks.main.kts` script for running benchmarks on iOS: - all modes are supported - JSon saving supported - can run benchmarks either in a separate process or within one process - Used now in `compare_benchmarks.main.kts` for iOS target ## Release Notes N/A
1 parent d5af71e commit ed489b5

11 files changed

Lines changed: 552 additions & 29 deletions

File tree

benchmarks/multiplatform/README.md

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,48 @@
11
# Compose Multiplatform benchmarks
22

3+
This project contains performance benchmarks for Compose Multiplatform on various targets,
4+
including Desktop, iOS, MacOS, and Web (Kotlin/Wasm and Kotlin/JS).
5+
These benchmarks measure the performance of various Compose components and features,
6+
such as animations, lazy layouts, text rendering, and visual effects.
7+
8+
## Benchmark Modes
9+
10+
The benchmarks can be run in different modes, which determine how performance is measured and reported:
11+
12+
- **`SIMPLE`**: Measures basic frame times without considering VSync. Good for quick checks of raw rendering performance. Enabled by default if no modes are specified.
13+
- **`VSYNC_EMULATION`**: Emulates VSync behavior to estimate missed frames and provide more realistic performance metrics (CPU/GPU percentiles). Enabled by default if no modes are specified.
14+
- **`REAL`**: Runs the benchmark in a real-world scenario with actual VSync. This mode provides the most accurate results for user-perceived performance (FPS, actual missed frames)
15+
but may not catch performance regressions if a frame fits to budget.
16+
Also requires a device with a real display (may have problems with headless devices).
17+
18+
To enable specific modes, use the `modes` argument:
19+
`modes=SIMPLE,VSYNC_EMULATION,REAL`
20+
21+
## Configuration Arguments
22+
23+
You can configure benchmark runs using arguments passed to the Gradle task (via `-PrunArguments="..."`) or to the `.main.kts` script. Arguments can also be set in `gradle.properties` using the `runArguments` property.
24+
25+
| Argument | Description | Example |
26+
|----------|------------------------------------------------------------|---------|
27+
| `modes` | Comma-separated list of execution modes (`SIMPLE`, `VSYNC_EMULATION`, `REAL`). | `modes=REAL` |
28+
| `benchmarks` | Comma-separated list of benchmarks to run. Can optionally specify problem size in parentheses. | `benchmarks=LazyGrid(100),AnimatedVisibility` |
29+
| `disabledBenchmarks` | Comma-separated list of benchmarks to skip. | `disabledBenchmarks=HeavyShader` |
30+
| `warmupCount` | Number of warmup frames before starting measurements. | `warmupCount=50` |
31+
| `frameCount` | Number of frames to measure for each benchmark. | `frameCount=500` |
32+
| `emptyScreenDelay` | Delay in milliseconds between warmup and measurement (real mode only).| `emptyScreenDelay=1000` |
33+
| `parallel` | (iOS only) Enable parallel rendering. | `parallel=true` |
34+
| `saveStatsToCSV` | Save results to CSV files. | `saveStatsToCSV=true` |
35+
| `saveStatsToJSON` | Save results to JSON files. | `saveStatsToJSON=true` |
36+
| `versionInfo` | Add version information to the report. | `versionInfo=1.2.3` |
37+
| `reportAtTheEnd` | Print a summary report after all benchmarks are finished real mode only).| `reportAtTheEnd=true` |
38+
| `listBenchmarks` | List all available benchmarks and exit. | `listBenchmarks=true` |
39+
40+
### Usage Example
41+
42+
```bash
43+
./gradlew :benchmarks:run -PrunArguments="benchmarks=LazyGrid modes=REAL frameCount=200"
44+
```
45+
346
## Run Desktop
447
- `./gradlew :benchmarks:run`
548

@@ -8,13 +51,23 @@ Open the project in Fleet or Android Studio with KMM plugin installed and
851
choose `iosApp` run configuration. Make sure that you build the app in `Release` configuration.
952
Alternatively you may open `iosApp/iosApp` project in XCode and run the app from there.
1053

11-
## Run automated iOS benchmarks
54+
## Run iOS benchmarks via scripts
1255
1. To run on device, open `iosApp/iosApp.xcodeproj` and properly configure the Signing section on the Signing & Capabilities project tab.
1356
2. Use the following command to get list of all iOS devices:
1457
- `xcrun xctrace list devices`
1558
3. From the benchmarks directory run:
16-
- `./iosApp/run_ios_benchmarks.sh <DEVICE ID>`
17-
4. Results are saved as `.txt` files in `benchmarks_result/`.
59+
- `./run_ios_benchmarks.main.kts <DEVICE ID>` (supports all modes of running benchmarks, configured the same way as for other targets:
60+
script arguments or `runArguments` property of `gradle.properties`)
61+
- or `./iosApp/run_ios_benchmarks.sh <DEVICE ID>` (shell script supporting `real` mode benchmarks running with multiple attempts)
62+
63+
To run specific benchmarks:
64+
- `./run_ios_benchmarks.main.kts <DEVICE ID> benchmarks=AnimatedVisibility,LazyGrid`
65+
66+
To run all benchmarks in a single process (faster but may be less reliable as some
67+
benchmarks may affect others within one process):
68+
- `./run_ios_benchmarks.main.kts <DEVICE ID> separateProcess=false`
69+
70+
4. Results are saved in `benchmarks/build/benchmarks/text-reports/` (when using `.main.kts`) or `benchmarks_result/` (when using `.sh`).
1871

1972
## Run native on MacOS
2073
- `./gradlew :benchmarks:runReleaseExecutableMacosArm64` (Works on Arm64 processors)

benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/Benchmarks.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,11 @@ suspend fun runBenchmark(
327327
content = benchmark.content
328328
).generateStats()
329329
stats.prettyPrint()
330+
if (Config.saveStatsToJSON && isIosTarget) {
331+
println("JSON_START")
332+
println(stats.toJsonString())
333+
println("JSON_END")
334+
}
330335
if (Config.saveStats()) {
331336
saveBenchmarkStats(name = benchmark.name, stats = stats)
332337
}
@@ -341,6 +346,12 @@ suspend fun runBenchmarks(
341346
warmupCount: Int = Config.warmupCount,
342347
graphicsContext: GraphicsContext? = null
343348
) {
349+
if (Config.listBenchmarks) {
350+
println("AVAILABLE_BENCHMARKS_START")
351+
benchmarks.forEach { println(it.name) }
352+
println("AVAILABLE_BENCHMARKS_END")
353+
return
354+
}
344355
println()
345356
println("Running emulating $targetFps FPS")
346357
println()
@@ -419,6 +430,11 @@ fun BenchmarkRunner(
419430
} else {
420431
results.add(stats)
421432
}
433+
if (Config.saveStatsToJSON && isIosTarget) {
434+
println("JSON_START")
435+
println(stats.toJsonString())
436+
println("JSON_END")
437+
}
422438
if (Config.saveStats()) {
423439
saveBenchmarkStats(name = benchmark.name, stats = stats)
424440
}

benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/BenchmarksSave.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ fun saveBenchmarkStatsOnDisk(name: String, stats: BenchmarkStats) {
7070
*/
7171
expect fun saveBenchmarkStats(name: String, stats: BenchmarkStats)
7272

73+
expect val isIosTarget: Boolean
74+
7375
private fun RawSource.readText() = use {
7476
it.buffered().readByteArray().decodeToString()
7577
}

benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/Config.kt

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ object Args {
2727
* @param args an array of strings representing the command line arguments.
2828
* Each argument can specify either of these settings:
2929
* modes, benchmarks, disabledBenchmarks - comma separated values,
30-
* versionInfo, saveStatsToCSV, saveStatsToJSON, parallel, warmupCount, frameCount, emptyScreenDelay, reportAtTheEnd - single values.
30+
* versionInfo, saveStatsToCSV, saveStatsToJSON, parallel, warmupCount, frameCount, emptyScreenDelay, reportAtTheEnd, listBenchmarks - single values.
3131
*
3232
* Example: benchmarks=AnimatedVisibility(100),modes=SIMPLE,versionInfo=Kotlin_2_1_20,saveStatsToCSV=true,warmupCount=50,frameCount=100,emptyScreenDelay=2000,reportAtTheEnd=true
3333
*/
@@ -44,6 +44,7 @@ object Args {
4444
var frameCount: Int? = null
4545
var emptyScreenDelay: Long? = null
4646
var reportAtTheEnd: Boolean = false
47+
var listBenchmarks: Boolean = false
4748

4849
for (arg in args) {
4950
if (arg.startsWith("modes=", ignoreCase = true)) {
@@ -70,6 +71,8 @@ object Args {
7071
emptyScreenDelay = arg.substringAfter("=").toLong()
7172
} else if (arg.startsWith("reportAtTheEnd=", ignoreCase = true)) {
7273
reportAtTheEnd = arg.substringAfter("=").toBoolean()
74+
} else if (arg.startsWith("listBenchmarks=", ignoreCase = true)) {
75+
listBenchmarks = arg.substringAfter("=").toBoolean()
7376
} else {
7477
println("WARNING: unknown argument $arg")
7578
}
@@ -89,7 +92,8 @@ object Args {
8992
warmupCount = warmupCount ?: defaultWarmupCount,
9093
frameCount = frameCount ?: 1000,
9194
emptyScreenDelay = emptyScreenDelay ?: 2000L,
92-
reportAtTheEnd = reportAtTheEnd
95+
reportAtTheEnd = reportAtTheEnd,
96+
listBenchmarks = listBenchmarks
9397
)
9498
}
9599
}
@@ -110,6 +114,7 @@ object Args {
110114
* @property frameCount Number of frames to run for each benchmark.
111115
* @property emptyScreenDelay Delay in milliseconds between warmup and benchmark.
112116
* @property reportAtTheEnd Flag indicating whether we should report results at the end of all benchmarks.
117+
* @property listBenchmarks Flag indicating whether we should print available benchmarks and exit.
113118
*/
114119
data class Config(
115120
val modes: Set<Mode> = emptySet(),
@@ -123,7 +128,8 @@ data class Config(
123128
val warmupCount: Int = 100,
124129
val frameCount: Int = 1000,
125130
val emptyScreenDelay: Long = 2000L,
126-
val reportAtTheEnd: Boolean = false
131+
val reportAtTheEnd: Boolean = false,
132+
val listBenchmarks: Boolean = false
127133
) {
128134
/**
129135
* Checks if a specific mode is enabled based on the configuration.
@@ -185,6 +191,9 @@ data class Config(
185191
val reportAtTheEnd: Boolean
186192
get() = global.reportAtTheEnd
187193

194+
val listBenchmarks: Boolean
195+
get() = global.listBenchmarks
196+
188197
fun setGlobal(global: Config) {
189198
this.global = global
190199
}

benchmarks/multiplatform/benchmarks/src/desktopMain/kotlin/BenchmarksSave.desktop.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@
44
*/
55

66
actual fun saveBenchmarkStats(name: String, stats: BenchmarkStats) = saveBenchmarkStatsOnDisk(name, stats)
7+
8+
actual val isIosTarget: Boolean = false

benchmarks/multiplatform/benchmarks/src/iosMain/kotlin/main.ios.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi
77
import androidx.compose.ui.window.ComposeUIViewController
88
import kotlinx.coroutines.MainScope
99
import kotlinx.coroutines.launch
10+
import platform.UIKit.UIApplication
1011
import platform.UIKit.UIScreen
1112
import platform.UIKit.UIViewController
1213
import kotlin.system.exitProcess
@@ -18,12 +19,16 @@ fun setGlobalFromArgs(args : List<String>) {
1819
fun runReal() = Config.isModeEnabled(Mode.REAL)
1920

2021
fun runBenchmarks() {
22+
UIApplication.sharedApplication.setIdleTimerDisabled(true)
2123
MainScope().launch {
2224
runBenchmarks(graphicsContext = graphicsContext())
2325
println("Completed!")
26+
exitProcess(0)
2427
}
2528
}
2629

30+
actual val isIosTarget: Boolean = true
31+
2732
@OptIn(ExperimentalComposeUiApi::class)
2833
fun MainViewController(): UIViewController {
2934
return ComposeUIViewController(configure = { parallelRendering = Config.parallelRendering }) {

benchmarks/multiplatform/benchmarks/src/macosMain/kotlin/main.macos.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import platform.AppKit.NSScreen
1212
import platform.AppKit.maximumFramesPerSecond
1313
import kotlin.system.exitProcess
1414

15+
actual val isIosTarget: Boolean = true
16+
1517
fun main(args : Array<String>) {
1618
Config.setGlobalFromArgs(args)
1719
if (Config.isModeEnabled(Mode.REAL)) {

benchmarks/multiplatform/benchmarks/src/webMain/kotlin/BenchmarksSave.web.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ actual fun saveBenchmarkStats(name: String, stats: BenchmarkStats) {
3030
}
3131
}
3232

33+
actual val isIosTarget: Boolean = false
34+
3335
/**
3436
* Client for sending benchmark results to the server
3537
*/

benchmarks/multiplatform/compare_benchmarks.main.kts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ fun main(args: Array<String>) {
1717
val v2 = argMap["v2"] ?: args.getOrNull(1)
1818

1919
if (v1 == null || v2 == null) {
20-
println("Usage: compare_benchmarks.main.kts v1=<version1> v2=<version2> [runs=3] [benchmarks=<benchmarkName>] [platform=macos|desktop|web]")
20+
println("Usage: ./compare_benchmarks.main.kts v1=<version1> v2=<version2> [runs=3] [benchmarks=<benchmarkName>] [platform=macos|desktop|web|ios]")
2121
return
2222
}
2323

@@ -26,28 +26,29 @@ fun main(args: Array<String>) {
2626
val platform = argMap["platform"] ?: "macos"
2727
val runServer = platform == "web"
2828
val isWeb = platform == "web"
29+
val isIos = platform == "ios"
2930

3031
println("Comparing Compose versions: $v1 and $v2")
3132
println("Number of runs: $runs")
3233
println("Platform: $platform")
3334
benchmarkName?.let { println("Filtering by benchmark: $it") }
3435

35-
val resultsV1 = runBenchmarksForVersion(v1, runs, benchmarkName, platform, runServer, isWeb)
36-
val resultsV2 = runBenchmarksForVersion(v2, runs, benchmarkName, platform, runServer, isWeb)
36+
val resultsV1 = runBenchmarksForVersion(v1, runs, benchmarkName, platform, runServer, isWeb, isIos)
37+
val resultsV2 = runBenchmarksForVersion(v2, runs, benchmarkName, platform, runServer, isWeb, isIos)
3738

3839
compareResults(v1, resultsV1, v2, resultsV2)
3940
}
4041

4142
data class BenchmarkResult(val name: String, val totalMs: Double)
4243

43-
fun runBenchmarksForVersion(version: String, runs: Int, benchmarkName: String?, platform: String, runServer: Boolean, isWeb: Boolean): Map<String, List<Double>> {
44+
fun runBenchmarksForVersion(version: String, runs: Int, benchmarkName: String?, platform: String, runServer: Boolean, isWeb: Boolean, isIos: Boolean): Map<String, List<Double>> {
4445
println("\n=== Running benchmarks for version: $version ===")
4546

4647
val allRunsResults = mutableMapOf<String, MutableList<Double>>()
4748

4849
for (i in 1..runs) {
4950
println("Run $i/$runs...")
50-
executeBenchmarks(version, i, benchmarkName, platform, runServer, isWeb)
51+
executeBenchmarks(version, i, benchmarkName, platform, runServer, isWeb, isIos)
5152
val runResults = collectResults(version, i)
5253
runResults.forEach { (name, value) ->
5354
allRunsResults.getOrPut(name) { mutableListOf() }.add(value)
@@ -65,7 +66,7 @@ fun updateComposeVersion(version: String) {
6566
println("Updated gradle/libs.versions.toml to version $version")
6667
}
6768

68-
fun executeBenchmarks(version: String, runIndex: Int, benchmarkName: String?, platform: String, runServer: Boolean, isWeb: Boolean) {
69+
fun executeBenchmarks(version: String, runIndex: Int, benchmarkName: String?, platform: String, runServer: Boolean, isWeb: Boolean, isIos: Boolean) {
6970
val (versionedExecutable, defaultExecutable, task) = when (platform) {
7071
"macos" -> Triple(
7172
File("benchmarks/build/bin/macosArm64/releaseExecutable/benchmarks-$version.kexe"),
@@ -82,6 +83,11 @@ fun executeBenchmarks(version: String, runIndex: Int, benchmarkName: String?, pl
8283
null,
8384
":benchmarks:wasmJsBrowserProductionRun"
8485
)
86+
"ios" -> Triple(
87+
null,
88+
null,
89+
"ios"
90+
)
8591
else -> throw IllegalArgumentException("Unsupported platform: $platform")
8692
}
8793

@@ -131,7 +137,7 @@ fun executeBenchmarks(version: String, runIndex: Int, benchmarkName: String?, pl
131137
Thread.sleep(5000)
132138

133139
try {
134-
executeBenchmarksOnce(version, platform, task, runArgs, versionedExecutable, defaultExecutable, isWeb, serverStopped)
140+
executeBenchmarksOnce(version, platform, task, runArgs, versionedExecutable, defaultExecutable, isWeb, isIos, serverStopped)
135141
} finally {
136142
println("Stopping benchmark server...")
137143
serverProcess.destroy()
@@ -140,7 +146,7 @@ fun executeBenchmarks(version: String, runIndex: Int, benchmarkName: String?, pl
140146
monitorThread.interrupt()
141147
}
142148
} else {
143-
executeBenchmarksOnce(version, platform, task, runArgs, versionedExecutable, defaultExecutable, isWeb, null)
149+
executeBenchmarksOnce(version, platform, task, runArgs, versionedExecutable, defaultExecutable, isWeb, isIos, null)
144150
}
145151
}
146152

@@ -152,8 +158,24 @@ fun executeBenchmarksOnce(
152158
versionedExecutable: File?,
153159
defaultExecutable: File?,
154160
isWeb: Boolean,
161+
isIos: Boolean,
155162
serverStopped: java.util.concurrent.atomic.AtomicBoolean?
156163
) {
164+
if (isIos) {
165+
println("Running version $version on iOS...")
166+
updateComposeVersion(version)
167+
val processBuilder = ProcessBuilder(
168+
"./run_ios_benchmarks.main.kts",
169+
*runArgs.toTypedArray()
170+
).inheritIO()
171+
val process = processBuilder.start()
172+
val exitCode = process.waitFor()
173+
if (exitCode != 0) {
174+
println("Warning: iOS Benchmark run failed with exit code $exitCode")
175+
}
176+
return
177+
}
178+
157179
if (versionedExecutable != null && versionedExecutable.exists()) {
158180
println("Using existing executable for version $version: ${versionedExecutable.absolutePath}")
159181

benchmarks/multiplatform/iosApp/run_ios_benchmarks.sh

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,6 @@ CONFIGURATION="Release"
3232
ATTEMPTS=5
3333
BUILD_DIR="$MULTIPLATFORM_DIR/.benchmark_build"
3434

35-
BENCHMARKS=(
36-
"AnimatedVisibility"
37-
"LazyGrid"
38-
"LazyGrid-ItemLaunchedEffect"
39-
"LazyGrid-SmoothScroll"
40-
"LazyGrid-SmoothScroll-ItemLaunchedEffect"
41-
"VisualEffects"
42-
"LazyList"
43-
"MultipleComponents"
44-
"MultipleComponents-NoVectorGraphics"
45-
"TextLayout"
46-
"CanvasDrawing"
47-
"HeavyShader"
48-
)
49-
5035
# ── Helpers ────────────────────────────────────────────────────────────────────
5136

5237
die() { echo ""; echo "ERROR: $*" >&2; exit 1; }
@@ -192,6 +177,18 @@ mkdir -p "$OUTPUT_DIR"
192177

193178
# ── 4. Run benchmarks ──────────────────────────────────────────────────────────
194179

180+
echo ""
181+
echo "==> [4/4] Fetching benchmarks list via Gradle..."
182+
TEMP_LIST=$(mktemp)
183+
(cd "$MULTIPLATFORM_DIR" && ./gradlew :benchmarks:run -PrunArguments=listBenchmarks=true) > "$TEMP_LIST" 2>&1
184+
185+
BENCHMARKS=($(awk '/AVAILABLE_BENCHMARKS_START/{p=1;next} /AVAILABLE_BENCHMARKS_END/{p=0} p && NF{print}' "$TEMP_LIST"))
186+
rm "$TEMP_LIST"
187+
188+
if [[ ${#BENCHMARKS[@]} -eq 0 ]]; then
189+
die "No benchmarks found to run."
190+
fi
191+
195192
TOTAL=$(( ${#BENCHMARKS[@]} * 2 * ATTEMPTS ))
196193
CURRENT=0
197194

0 commit comments

Comments
 (0)