perf(app): lighter stress demo + profileable release build
Some checks failed
CI / build (push) Has been cancelled

- StressTestScreen: use MinMaxLTTB LOD (caps output <= pixelWidth,
  ~half the vertices vs MinMax's ~2x) and move the sample generator
  loop to Dispatchers.Default so it stops contending with the UI thread.
- SignalGenerator: run the shared producer loop off the main thread.
- Add a release buildType (R8 + resource shrink, debuggable=false,
  debug-signed for on-device profiling); proguard-rules.pro needs no
  keeps (no kotlinx.serialization; Compose/coroutines ship consumer rules).
- minSdk 26 -> 24.

Off-main producer un-starves the feed (280 -> ~1300 push/s); release
build (AOT+R8) vs debug at matched load: CPU -12%, fps +48%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trentin Davide 2026-07-23 11:39:10 +02:00
parent 0e3360e4a0
commit e827deb806
4 changed files with 74 additions and 33 deletions

View file

@ -10,7 +10,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "dev.dtrentin.chart.demo" applicationId = "dev.dtrentin.chart.demo"
minSdk = 26 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "0.1.0" versionName = "0.1.0"
@ -23,6 +23,20 @@ android {
buildFeatures { compose = true } buildFeatures { compose = true }
buildTypes {
release {
isMinifyEnabled = true // R8 — the perf lever
isShrinkResources = true // strip unused resources
isDebuggable = false // let ART fully optimize (perf test)
// debug key so the release apk installs on a dev device (profiling build, not store build)
signingConfig = signingConfigs.getByName("debug")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
sourceSets["main"].kotlin.srcDirs("src/main/kotlin") sourceSets["main"].kotlin.srcDirs("src/main/kotlin")
} }

12
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,12 @@
# Release R8 rules for :app (profiling build).
#
# Intentionally minimal. Consumer rules ship with the libraries:
# - Jetpack Compose (androidx.compose.*) bundled consumer R8 rules
# - kotlinx-coroutines bundled consumer R8 rules (keeps ServiceLoader/volatile)
# - AGP auto-keeps the manifest entry point (.MainActivity)
#
# No kotlinx.serialization in this app (chart-realtime does not apply the
# serialization plugin), so no @Serializer keep rules are required.
#
# App-specific keeps are added below ONLY when a build/run failure proves them
# necessary. Do NOT blanket-keep.

View file

@ -1,9 +1,11 @@
package dev.dtrentin.chart.demo package dev.dtrentin.chart.demo
import dev.dtrentin.chart.RealtimeChartState import dev.dtrentin.chart.RealtimeChartState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.withContext
/** /**
* Synthetic multi-signal producer. Single coroutine pushes one sample per active signal * Synthetic multi-signal producer. Single coroutine pushes one sample per active signal
@ -34,9 +36,13 @@ suspend fun CoroutineScope.runSignalGenerator(
val tickIntervalMs = (1_000L / sampleRateHz).coerceAtLeast(1L) val tickIntervalMs = (1_000L / sampleRateHz).coerceAtLeast(1L)
val dt = 1f / sampleRateHz.toFloat() val dt = 1f / sampleRateHz.toFloat()
// Push work off Main: sample generation + state.push run on Dispatchers.Default so they
// don't contend with Compose draw on the Main dispatcher. withContext inherits the caller's
// Job → cancelling the caller (e.g. leaving the composable) cancels this loop (no leak).
withContext(Dispatchers.Default) {
val startMs = System.currentTimeMillis() val startMs = System.currentTimeMillis()
var t = 0f var t = 0f
while (isActive) { while (isActive) {
val ts = startMs + (t * 1000f).toLong() val ts = startMs + (t * 1000f).toLong()
for (i in signalNames.indices) { for (i in signalNames.indices) {
@ -48,4 +54,5 @@ suspend fun CoroutineScope.runSignalGenerator(
t += dt t += dt
delay(tickIntervalMs) delay(tickIntervalMs)
} }
}
} }

View file

@ -26,7 +26,7 @@ import dev.dtrentin.chart.RealtimeChart
import dev.dtrentin.chart.RealtimeChartState import dev.dtrentin.chart.RealtimeChartState
import dev.dtrentin.chart.interaction.InteractionConfig import dev.dtrentin.chart.interaction.InteractionConfig
import dev.dtrentin.chart.interaction.rememberChartInteractionState import dev.dtrentin.chart.interaction.rememberChartInteractionState
import dev.dtrentin.chart.lod.MinMaxLodStrategy import dev.dtrentin.chart.lod.MinMaxLttbLodStrategy
import dev.dtrentin.chart.model.AxisConfig import dev.dtrentin.chart.model.AxisConfig
import dev.dtrentin.chart.model.AxisLabelMode import dev.dtrentin.chart.model.AxisLabelMode
import dev.dtrentin.chart.model.ChartConfig import dev.dtrentin.chart.model.ChartConfig
@ -39,8 +39,10 @@ import dev.dtrentin.chart.render.DecimalAxisFormatter
import dev.dtrentin.chart.render.TimeAxisFormatter import dev.dtrentin.chart.render.TimeAxisFormatter
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.sin import kotlin.math.sin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
private const val STRESS_SIGNAL_COUNT = 8 private const val STRESS_SIGNAL_COUNT = 8
private const val STRESS_SAMPLE_HZ = 200 private const val STRESS_SAMPLE_HZ = 200
@ -58,8 +60,9 @@ private val STRESS_COLORS = listOf(
/** /**
* Stress test: 8 simultaneous signals @ 200 Hz each (1600 pushes/sec total). Uses * Stress test: 8 simultaneous signals @ 200 Hz each (1600 pushes/sec total). Uses
* MinMax LoD (preserves peaks under heavy throughput). 10 s window 16k samples * MinMaxLTTB LoD (preserves min/max envelope, output capped at pixelWidth). 10 s window
* visible at once. * 16k samples visible at once. Producer runs on [Dispatchers.Default] to keep sample
* generation off the Main dispatcher (away from Compose draw).
*/ */
@Composable @Composable
fun StressTestScreen() { fun StressTestScreen() {
@ -81,7 +84,7 @@ fun StressTestScreen() {
), ),
render = RenderConfig( render = RenderConfig(
theme = chartTheme, theme = chartTheme,
lodStrategy = MinMaxLodStrategy(), lodStrategy = MinMaxLttbLodStrategy(),
), ),
), ),
) )
@ -96,6 +99,10 @@ fun StressTestScreen() {
SignalConfig(color = STRESS_COLORS[i], strokeWidth = 1.5f), SignalConfig(color = STRESS_COLORS[i], strokeWidth = 1.5f),
) )
} }
// Push work off Main: generation + state.push run on Dispatchers.Default so they
// don't contend with Compose draw on the Main dispatcher. withContext inherits the
// effect's Job → leaving the composable cancels this loop (no leak).
withContext(Dispatchers.Default) {
val tickIntervalMs = (1_000L / STRESS_SAMPLE_HZ).coerceAtLeast(1L) val tickIntervalMs = (1_000L / STRESS_SAMPLE_HZ).coerceAtLeast(1L)
val startMs = System.currentTimeMillis() val startMs = System.currentTimeMillis()
var n = 0L var n = 0L
@ -115,6 +122,7 @@ fun StressTestScreen() {
delay(tickIntervalMs) delay(tickIntervalMs)
} }
} }
}
var pushPerSec by remember { mutableIntStateOf(0) } var pushPerSec by remember { mutableIntStateOf(0) }
var totalPushes by remember { mutableLongStateOf(0L) } var totalPushes by remember { mutableLongStateOf(0L) }