diff --git a/benchmarks/multiplatform/README.md b/benchmarks/multiplatform/README.md index 05eea2f007c..51bee4b9993 100644 --- a/benchmarks/multiplatform/README.md +++ b/benchmarks/multiplatform/README.md @@ -4,10 +4,18 @@ - `./gradlew :benchmarks:run` ## Run native on iOS -Open the project in Fleet or Android Studio with KMM plugin installed and +Open the project in Fleet or Android Studio with KMM plugin installed and choose `iosApp` run configuration. Make sure that you build the app in `Release` configuration. Alternatively you may open `iosApp/iosApp` project in XCode and run the app from there. +## Run automated iOS benchmarks +1. To run on device, open `iosApp/iosApp.xcodeproj` and properly configure the Signing section on the Signing & Capabilities project tab. +2. Use the following command to get list of all iOS devices: +- `xcrun xctrace list devices` +3. From the benchmarks directory run: +- `./iosApp/run_ios_benchmarks.sh ` +4. Results are saved as `.txt` files in `benchmarks_result/`. + ## Run native on MacOS - `./gradlew :benchmarks:runReleaseExecutableMacosArm64` (Works on Arm64 processors) - `./gradlew :benchmarks:runReleaseExecutableMacosX64` (Works on Intel processors) @@ -48,3 +56,6 @@ Please run your browser with manual GC enabled before running the benchmark, lik | LazyList | [benchmarks/src/commonMain/kotlin/benchmarks/complexlazylist/components/MainUI.kt](benchmarks/src/commonMain/kotlin/benchmarks/complexlazylist/components/MainUI.kt) | Tests the performance of a complex LazyColumn implementation with features like pull-to-refresh, loading more items, and continuous scrolling. | | MultipleComponents | [benchmarks/src/commonMain/kotlin/benchmarks/example1/Example1.kt](benchmarks/src/commonMain/kotlin/benchmarks/multipleComponents/MultipleComponents.kt) | Tests the performance of a comprehensive UI that showcases various Compose components including layouts, animations, and styled text. | | MultipleComponents-NoVectorGraphics | [benchmarks/src/commonMain/kotlin/benchmarks/example1/Example1.kt](benchmarks/src/commonMain/kotlin/benchmarks/multipleComponents/MultipleComponents.kt) | Same as MultipleComponents but skips the Composables with vector graphics rendering. | +| TextLayout | [benchmarks/src/commonMain/kotlin/benchmarks/textlayout/TextLayout.kt](benchmarks/src/commonMain/kotlin/benchmarks/textlayout/TextLayout.kt) | Tests text layout and rendering performance by continuously scrolling column with big number of heady to layout items. | +| CanvasDrawing | [benchmarks/src/commonMain/kotlin/benchmarks/canvasdrawing/CanvasDrawing.kt](benchmarks/src/commonMain/kotlin/benchmarks/canvasdrawing/CanvasDrawing.kt) | Tests Canvas drawing performance by scrolling items with massive amount of graphic shapes. | +| HeavyShader | [benchmarks/src/commonMain/kotlin/benchmarks/heavyshader/HeavyShader.kt](benchmarks/src/commonMain/kotlin/benchmarks/heavyshader/HeavyShader.kt) | Tests GPU shader performance by scrolling items with a complex GPU shader. | diff --git a/benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/Benchmarks.kt b/benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/Benchmarks.kt index 661bb5e9cc6..ecdaebfef60 100644 --- a/benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/Benchmarks.kt +++ b/benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/Benchmarks.kt @@ -15,6 +15,9 @@ import benchmarks.complexlazylist.components.MainUiNoImageUseModel import benchmarks.multipleComponents.MultipleComponentsExample import benchmarks.lazygrid.LazyGrid import benchmarks.visualeffects.NYContent +import benchmarks.textlayout.TextLayout +import benchmarks.canvasdrawing.CanvasDrawing +import benchmarks.heavyshader.HeavyShader import kotlinx.coroutines.delay import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -298,8 +301,11 @@ fun getBenchmarks(): List = listOf( Benchmark("MultipleComponents") { MultipleComponentsExample() }, Benchmark("MultipleComponents-NoVectorGraphics") { MultipleComponentsExample(isVectorGraphicsSupported = false) - } -) + }, + Benchmark("TextLayout") { TextLayout() }, + Benchmark("CanvasDrawing") { CanvasDrawing() }, + Benchmark("HeavyShader") { HeavyShader() } +).sortedBy { it.name } suspend fun runBenchmark( benchmark: Benchmark, @@ -403,7 +409,7 @@ fun BenchmarkRunner( val stats = BenchmarkResult( name = benchmark.name, frameBudget = nanosPerFrame.nanoseconds, - conditions = BenchmarkConditions(benchmark.frameCount, 0), + conditions = BenchmarkConditions(benchmark.frameCount, warmupCount = Config.warmupCount), averageFrameInfo = FrameInfo(duration / benchmark.frameCount, Duration.ZERO), averageFPSInfo = FPSInfo(benchmark.frameCount.toDouble() / duration.toDouble(DurationUnit.SECONDS)), frames = frames diff --git a/benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/benchmarks/canvasdrawing/CanvasDrawing.kt b/benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/benchmarks/canvasdrawing/CanvasDrawing.kt new file mode 100644 index 00000000000..bad963ec2dd --- /dev/null +++ b/benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/benchmarks/canvasdrawing/CanvasDrawing.kt @@ -0,0 +1,159 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package benchmarks.canvasdrawing + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.unit.dp +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin +import kotlin.random.Random +import kotlinx.coroutines.isActive + +private const val ITEM_COUNT = 1200 + +@Composable +fun CanvasDrawing() { + val listState = rememberLazyListState() + var scrollForward by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + while (isActive) { + withFrameMillis { } + val currentItem = listState.firstVisibleItemIndex + if (currentItem == 0) scrollForward = true + if (currentItem > ITEM_COUNT - 100) scrollForward = false + listState.scrollBy(if (scrollForward) 33f else -33f) + } + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().background(Color.Black) + ) { + items(ITEM_COUNT) { index -> + CanvasDrawingItem(index) + } + } +} + +@Composable +private fun CanvasDrawingItem(index: Int) { + val animatedValue by rememberInfiniteTransition().animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(3000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ) + ) + + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .padding(8.dp) + ) { + val seed = index * 12345L + val random = Random(seed) + + drawRect( + brush = Brush.verticalGradient( + colors = listOf( + Color(random.nextInt(0xFFFFFF) or 0xFF000000.toInt()), + Color(random.nextInt(0xFFFFFF) or 0xFF000000.toInt()) + ) + ) + ) + + repeat(200) { i -> + val angle = animatedValue + i * 7.2f + val radius = size.minDimension / 4f + val x = size.width / 2 + cos(angle * PI / 180.0).toFloat() * radius * random.nextFloat() + val y = size.height / 2 + sin(angle * PI / 180.0).toFloat() * radius * random.nextFloat() + + val path = Path().apply { + moveTo(x, y) + repeat(40) { j -> + val pointAngle = angle + j * 45f + animatedValue + val pointRadius = 20f + random.nextFloat() * 30f + val px = x + cos(pointAngle * PI / 180.0).toFloat() * pointRadius + val py = y + sin(pointAngle * PI / 180.0).toFloat() * pointRadius + if (j % 2 == 0) { + lineTo(px, py) + } else { + quadraticBezierTo( + x + random.nextFloat() * 50f - 25f, + y + random.nextFloat() * 50f - 25f, + px, py + ) + } + } + close() + } + + drawPath( + path = path, + brush = Brush.radialGradient( + colors = listOf( + Color(random.nextInt(0xFFFFFF) or 0x80000000.toInt()), + Color(random.nextInt(0xFFFFFF) or 0x40000000.toInt()) + ), + center = Offset(x, y) + ) + ) + } + + repeat(70) { i -> + val lineY = i * (size.height / 30) + drawLine( + color = Color(random.nextInt(0xFFFFFF) or 0xFF000000.toInt()), + start = Offset(0f, lineY), + end = Offset(size.width, lineY + sin(animatedValue * i).toFloat() * 20f), + strokeWidth = 2f + ) + } + + repeat(70) { i -> + val circleX = (i % 8) * (size.width / 8) + (size.width / 16) + val circleY = (i / 8) * (size.height / 5) + (size.height / 10) + val circleRadius = 15f + sin(animatedValue + i).toFloat() * 10f + + drawCircle( + color = Color(random.nextInt(0xFFFFFF) or 0xFF000000.toInt()), + radius = circleRadius, + center = Offset(circleX, circleY), + alpha = 0.7f + ) + } + } +} diff --git a/benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/benchmarks/heavyshader/HeavyShader.kt b/benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/benchmarks/heavyshader/HeavyShader.kt new file mode 100644 index 00000000000..633e6c7ba80 --- /dev/null +++ b/benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/benchmarks/heavyshader/HeavyShader.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package benchmarks.heavyshader + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin +import kotlinx.coroutines.isActive + +private const val ITEM_COUNT = 800 + +@Composable +fun HeavyShader() { + val listState = rememberLazyListState() + var scrollForward by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + while (isActive) { + withFrameMillis { } + val currentItem = listState.firstVisibleItemIndex + if (currentItem == 0) scrollForward = true + if (currentItem > ITEM_COUNT - 100) scrollForward = false + listState.scrollBy(if (scrollForward) 33f else -33f) + } + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().background(Color.Black) + ) { + items(ITEM_COUNT) { + HeavyShaderItem() + } + } +} + +@Composable +private fun HeavyShaderItem() { + val time by rememberInfiniteTransition().animateFloat( + initialValue = 0f, + targetValue = 100f, + animationSpec = infiniteRepeatable( + animation = tween(5000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ) + ) + + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .padding(8.dp) + ) { + val layers = 60 + + repeat(layers) { layer -> + val offset = layer * 10f + val alpha = 1f - (layer.toFloat() / layers) + + drawRect( + brush = Brush.radialGradient( + colors = listOf( + Color(0xFF00FF00).copy(alpha = alpha), + Color(0xFF0000FF).copy(alpha = alpha * 0.7f), + Color(0xFFFF0000).copy(alpha = alpha * 0.5f), + Color(0xFFFFFF00).copy(alpha = alpha * 0.3f), + Color(0xFF00FFFF).copy(alpha = alpha * 0.1f) + ), + center = Offset( + size.width / 2 + cos(time + layer).toFloat() * 150f, + size.height / 2 + sin(time + layer).toFloat() * 150f + ), + radius = size.minDimension / 2 + offset + ), + blendMode = when (layer % 3) { + 0 -> BlendMode.Screen + 1 -> BlendMode.Overlay + else -> BlendMode.Multiply + } + ) + } + + repeat(60) { i -> + val angle = i * 7.2f + time * 10 + val distance = size.minDimension / 2 + val x = size.width / 2 + cos(angle * PI / 180.0).toFloat() * distance + val y = size.height / 2 + sin(angle * PI / 180.0).toFloat() * distance + + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + Color.White.copy(alpha = 0.8f), + Color.Transparent + ), + center = Offset(x, y), + radius = 30f + ), + radius = 30f, + center = Offset(x, y), + blendMode = BlendMode.Plus + ) + } + } +} diff --git a/benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/benchmarks/textlayout/TextLayout.kt b/benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/benchmarks/textlayout/TextLayout.kt new file mode 100644 index 00000000000..c0e3efff09c --- /dev/null +++ b/benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/benchmarks/textlayout/TextLayout.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package benchmarks.textlayout + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.random.Random +import kotlinx.coroutines.isActive + +private const val ITEM_COUNT = 2000 + +@Composable +fun TextLayout() { + val listState = rememberLazyListState() + var frame by remember { mutableStateOf(0) } + var scrollForward by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + while (isActive) { + withFrameMillis { } + val currentItem = listState.firstVisibleItemIndex + if (currentItem == 0) scrollForward = true + if (currentItem > ITEM_COUNT - 100) scrollForward = false + listState.scrollBy(if (scrollForward) 67f else -67f) + frame++ + } + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize() + ) { + items(ITEM_COUNT) { index -> + TextLayoutItem(index, frame) + } + } +} + +@Composable +private fun TextLayoutItem(index: Int, frame: Int) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF2A2A2A)) + ) { + repeat(12) { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + repeat(10) { col -> + Column( + modifier = Modifier + .weight(1f) + .background(Color(0xFF3A3A3A)) + ) { + Text( + "$frame R$row:C$col", + color = Color.White, + fontSize = 6.sp + ) + Row { + Box( + modifier = Modifier + .size(10.dp) + .background(Color(Random.nextInt(0xFFFFFF) or 0xFF000000.toInt())) + ) + Column { + Text("$frame #$index", color = Color.Gray, fontSize = 6.sp) + Text("Item", color = Color.Gray, fontSize = 6.sp) + } + } + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + repeat(10) { + Box( + modifier = Modifier + .size(10.dp) + .background(Color(0xFF4A4A4A)) + ) + } + } + } + } + } + } + } +} diff --git a/benchmarks/multiplatform/iosApp/run_ios_benchmarks.sh b/benchmarks/multiplatform/iosApp/run_ios_benchmarks.sh new file mode 100755 index 00000000000..77d4065d44c --- /dev/null +++ b/benchmarks/multiplatform/iosApp/run_ios_benchmarks.sh @@ -0,0 +1,262 @@ +#!/bin/bash +# +# run_ios_benchmarks.sh +# +# Builds the iosApp, installs it on a real device or simulator, then runs +# every benchmark (from Benchmarks.kt) with parallel=true and parallel=false, +# ATTEMPTS times each. Console output is saved to: +# +# benchmarks_result/__parallel___.txt +# +# Requirements: +# - Xcode 15+ (uses xcrun devicectl for real devices, xcrun simctl for simulators) +# - For real device: connected via USB and trusted, valid code-signing identity +# - For simulator: any booted or available simulator +# +# Usage: bash run_ios_benchmarks.sh [] +# +# If no UDID is provided the first connected real device is used. +# Pass a simulator UDID to target a simulator instead. +# + +set -euo pipefail + +# ── Configuration ────────────────────────────────────────────────────────────── + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$SCRIPT_DIR" +MULTIPLATFORM_DIR="$SCRIPT_DIR/.." +OUTPUT_DIR="$MULTIPLATFORM_DIR/benchmarks_result" +SCHEME="iosApp" +CONFIGURATION="Release" +ATTEMPTS=5 +BUILD_DIR="$MULTIPLATFORM_DIR/.benchmark_build" + +BENCHMARKS=( + "AnimatedVisibility" + "LazyGrid" + "LazyGrid-ItemLaunchedEffect" + "LazyGrid-SmoothScroll" + "LazyGrid-SmoothScroll-ItemLaunchedEffect" + "VisualEffects" + "LazyList" + "MultipleComponents" + "MultipleComponents-NoVectorGraphics" + "TextLayout" + "CanvasDrawing" + "HeavyShader" +) + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +die() { echo ""; echo "ERROR: $*" >&2; exit 1; } + +# ── 1. Detect target device or simulator ────────────────────────────────────── + +echo "" +echo "==> [1/4] Detecting target..." + +echo " $ xcrun xctrace list devices" +XCTRACE_OUT=$(xcrun xctrace list devices 2>&1) + +# Real device lines (between "== Devices ==" and the next "=="). +# xctrace format: "Device Name (iOS Version) (UDID)" +ALL_REAL_LINES=$(awk \ + '/^== Devices ==/{p=1; next} /^== /{p=0} p && NF{print}' \ + <<< "$XCTRACE_OUT" | grep -E '\([0-9]+\.[0-9]' | grep -v " Mac " || true) + +# Simulator lines (between "== Simulators ==" and the next "=="). +ALL_SIM_LINES=$(awk \ + '/^== Simulators ==/{p=1; next} /^== /{p=0} p && NF{print}' \ + <<< "$XCTRACE_OUT" | grep -E '\([0-9]+\.[0-9]' || true) + +ALL_DEVICE_LINES=$(printf '%s\n%s\n' "$ALL_REAL_LINES" "$ALL_SIM_LINES" | grep -v '^$' || true) + +if [[ -z "$ALL_DEVICE_LINES" ]]; then + echo "$XCTRACE_OUT" + die "No iOS device or simulator found." +fi + +# If a UDID was passed as an argument, look for that specific target; otherwise use the first real device. +ARG_UDID="${1:-}" +if [[ -n "$ARG_UDID" ]]; then + DEVICE_LINE=$(grep "$ARG_UDID" <<< "$ALL_DEVICE_LINES" || true) + if [[ -z "$DEVICE_LINE" ]]; then + echo "Available devices and simulators:" + echo "$ALL_DEVICE_LINES" + die "Device with UDID '$ARG_UDID' not found among the above." + fi +else + if [[ -n "$ALL_REAL_LINES" ]]; then + DEVICE_LINE=$(head -1 <<< "$ALL_REAL_LINES") + else + DEVICE_LINE=$(head -1 <<< "$ALL_SIM_LINES") + fi +fi + +# Determine if the selected target is a simulator. +IS_SIMULATOR=false +if [[ -n "$ALL_SIM_LINES" ]] && grep -qF "$DEVICE_LINE" <<< "$ALL_SIM_LINES"; then + IS_SIMULATOR=true +fi + +# Parse "Device Name (iOS Version) (UDID)" +# UDID is the last parenthesised token on the line. +DEVICE_ID=$( grep -oE '\([0-9A-Fa-f-]+\)' <<< "$DEVICE_LINE" | tail -1 | tr -d '()') +DEVICE_IOS=$( grep -oE '\([0-9]+\.[0-9.]+\)' <<< "$DEVICE_LINE" | head -1 | tr -d '()') +DEVICE_NAME=$(sed 's/ ([0-9].*//' <<< "$DEVICE_LINE" | xargs) + +# Normalize for filenames: lowercase, spaces→underscores, keep only [a-z0-9._-] +DEVICE_PREFIX=$(printf '%s_%s' "$DEVICE_NAME" "$DEVICE_IOS" \ + | tr '[:upper:]' '[:lower:]' \ + | tr ' ' '_' \ + | LC_ALL=C tr -cd 'a-z0-9._-') + +echo " Name : $DEVICE_NAME" +echo " iOS : $DEVICE_IOS" +echo " UDID : $DEVICE_ID" +echo " Simulator : $IS_SIMULATOR" +echo " Prefix : ${DEVICE_PREFIX}_parallel___.txt" + +# ── 2. Build ─────────────────────────────────────────────────────────────────── + +echo "" +echo "==> [2/4] Building '$SCHEME' ($CONFIGURATION)..." +echo " $ mkdir -p $BUILD_DIR" +mkdir -p "$BUILD_DIR" + +XCODE_LOG="$BUILD_DIR/xcodebuild.log" + +# Clean stale Kotlin Native build artifacts to avoid klib ABI version mismatches. +echo " $ cd $MULTIPLATFORM_DIR && ./gradlew clean" +(cd "$MULTIPLATFORM_DIR" && ./gradlew clean) 2>&1 + +echo " $ xcodebuild build -project $PROJECT_DIR/iosApp.xcodeproj -scheme $SCHEME -configuration $CONFIGURATION -destination id=$DEVICE_ID ONLY_ACTIVE_ARCH=YES SYMROOT=$BUILD_DIR" +set +e +xcodebuild build \ + -project "$PROJECT_DIR/iosApp.xcodeproj" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -destination "id=$DEVICE_ID" \ + ONLY_ACTIVE_ARCH=YES \ + SYMROOT="$BUILD_DIR" \ + >"$XCODE_LOG" 2>&1 +BUILD_EXIT=$? +set -e + +if [[ $BUILD_EXIT -ne 0 ]]; then + echo "Build failed. Last 50 lines of xcodebuild output:" + echo "----------------------------------------------------" + tail -50 "$XCODE_LOG" + echo "----------------------------------------------------" + echo "Full log: $XCODE_LOG" + exit 1 +fi + +if [[ "$IS_SIMULATOR" == "true" ]]; then + APP_PATH="$BUILD_DIR/${CONFIGURATION}-iphonesimulator/ComposeBenchmarks.app" +else + APP_PATH="$BUILD_DIR/${CONFIGURATION}-iphoneos/ComposeBenchmarks.app" +fi +[[ -d "$APP_PATH" ]] || die "App bundle not found at expected path: $APP_PATH" + +echo " $ /usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' $APP_PATH/Info.plist" +BUNDLE_ID=$(/usr/libexec/PlistBuddy -c "Print CFBundleIdentifier" "$APP_PATH/Info.plist") +echo " Build : OK" +echo " Bundle : $BUNDLE_ID" + +# ── 3. Install ───────────────────────────────────────────────────────────────── + +echo "" +echo "==> [3/4] Installing..." + +if [[ "$IS_SIMULATOR" == "true" ]]; then + # Boot the simulator if it is not already running. + SIM_STATE=$(xcrun simctl list devices | grep "$DEVICE_ID" | grep -oE '\(Booted\)|\(Shutdown\)' | tr -d '()' || true) + if [[ "$SIM_STATE" != "Booted" ]]; then + echo " $ xcrun simctl boot $DEVICE_ID" + xcrun simctl boot "$DEVICE_ID" + fi + echo " $ xcrun simctl install $DEVICE_ID $APP_PATH" + xcrun simctl install "$DEVICE_ID" "$APP_PATH" +else + echo " $ xcrun devicectl device install app --device $DEVICE_ID $APP_PATH" + xcrun devicectl device install app \ + --device "$DEVICE_ID" \ + "$APP_PATH" +fi +echo " Installed." + +echo " $ mkdir -p $OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +# ── 4. Run benchmarks ────────────────────────────────────────────────────────── + +TOTAL=$(( ${#BENCHMARKS[@]} * 2 * ATTEMPTS )) +CURRENT=0 + +echo "" +echo "==> [4/4] Running $TOTAL benchmark sessions" +echo " ${#BENCHMARKS[@]} benchmarks × 2 parallel modes × $ATTEMPTS attempts" +echo "" + +for BENCHMARK in "${BENCHMARKS[@]}"; do + for PARALLEL in "true" "false"; do + for (( ATTEMPT=1; ATTEMPT<=ATTEMPTS; ATTEMPT++ )); do + CURRENT=$(( CURRENT + 1 )) + + OUT_FILE="$OUTPUT_DIR/${DEVICE_PREFIX}_parallel_${PARALLEL}_${BENCHMARK}_${ATTEMPT}.txt" + + printf " [%3d/%3d] %-52s parallel=%-5s attempt=%d\n" \ + "$CURRENT" "$TOTAL" "$BENCHMARK" "$PARALLEL" "$ATTEMPT" + printf " → %s\n" "$(basename "$OUT_FILE")" + + set +e + if [[ "$IS_SIMULATOR" == "true" ]]; then + # simctl launch --console streams stdout and waits for the process to exit. + echo " $ xcrun simctl launch --console $DEVICE_ID $BUNDLE_ID benchmarks=$BENCHMARK parallel=$PARALLEL warmupCount=100 modes=REAL reportAtTheEnd=true" + xcrun simctl launch \ + --console \ + "$DEVICE_ID" \ + "$BUNDLE_ID" \ + "benchmarks=$BENCHMARK" \ + "parallel=$PARALLEL" \ + "warmupCount=100" \ + "modes=REAL" \ + "reportAtTheEnd=true" \ + 2>&1 | tee "$OUT_FILE" + else + echo " $ xcrun devicectl device process launch --console --device $DEVICE_ID $BUNDLE_ID -- benchmarks=$BENCHMARK parallel=$PARALLEL warmupCount=100 modes=REAL reportAtTheEnd=true" + xcrun devicectl device process launch \ + --console \ + --device "$DEVICE_ID" \ + "$BUNDLE_ID" \ + -- \ + "benchmarks=$BENCHMARK" \ + "parallel=$PARALLEL" \ + "warmupCount=100" \ + "modes=REAL" \ + "reportAtTheEnd=true" \ + 2>&1 | tee "$OUT_FILE" + fi + RUN_STATUS=${PIPESTATUS[0]} + set -e + + if [[ $RUN_STATUS -ne 0 ]]; then + printf " ⚠ WARNING: process exited with code %d\n" "$RUN_STATUS" + else + printf " ✓ done\n" + fi + + # Brief cooldown between runs so the device settles + echo " $ sleep 3" + sleep 3 + + done + done +done + +echo "" +echo "==> All done!" +printf " %d output files written to: %s\n" "$TOTAL" "$OUTPUT_DIR" +echo ""