From 65e3137e6c2508ed9b41d3eab49dc68784677ec7 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Mon, 2 Nov 2026 12:23:27 +0530 Subject: [PATCH] Power tuner counts repeated frames, game pinned to the clusters above the little one A capped game read 59.9 fps with a p95 of one period as steady while it repeated a frame every few seconds on trimmed clocks (RP6, SteamWorld Dig 2: perf cluster at 499 MHz, 25 repeats in 70 s). The tuner now reads frame intervals over 1.5 target periods in the last 10 s from the frame ring: any repeat blocks the next trim and fails a trim watch, two or more step the last trim back once per window. The game was pinned to all cores but the two slowest, which left it a little core (11 ms per frame there against 4.4 ms on the prime core). It now runs on every cluster above the lowest one when those hold at least 4 cores. --- .../gamenative/powercontrol/PowerManager.kt | 42 +++++-- .../powercontrol/autotuning/ClusterTuner.kt | 21 +++- .../autotuning/TunerDecisionEngine.kt | 77 +++++++++++- .../powercontrol/metrics/FrameTimeRing.kt | 22 ++++ .../powercontrol/metrics/MetricsSnapshot.kt | 2 + .../metrics/PerformanceMetricsCollector.kt | 16 ++- .../powercontrol/GameCoreSetTest.kt | 32 +++++ .../autotuning/TunerRepeatedFramesTest.kt | 116 ++++++++++++++++++ .../metrics/RepeatedFrameCountTest.kt | 34 +++++ 9 files changed, 349 insertions(+), 13 deletions(-) create mode 100644 app/src/test/java/app/gamenative/powercontrol/GameCoreSetTest.kt create mode 100644 app/src/test/java/app/gamenative/powercontrol/autotuning/TunerRepeatedFramesTest.kt create mode 100644 app/src/test/java/app/gamenative/powercontrol/metrics/RepeatedFrameCountTest.kt diff --git a/app/src/main/java/app/gamenative/powercontrol/PowerManager.kt b/app/src/main/java/app/gamenative/powercontrol/PowerManager.kt index 6acc4135ac..0ce7469676 100644 --- a/app/src/main/java/app/gamenative/powercontrol/PowerManager.kt +++ b/app/src/main/java/app/gamenative/powercontrol/PowerManager.kt @@ -58,6 +58,7 @@ object PowerManager { private const val AFFINITY_SETTLE_MS = 1500L private const val GAME_PIN_MAX_RETRIES = 10 private const val GAME_PIN_RETRY_DELAY_MS = 1000L + private const val MIN_GAME_CLUSTER_CORES = 4 private val json = Json { encodeDefaults = true @@ -1084,11 +1085,31 @@ object PowerManager { allCpuCoresSorted(pserver).take(count) /** - * Cores the game should use: all cores except the 2 lowest-frequency ones - * reserved for audio/background. + * Cores the game should use: every cluster above the lowest-frequency one, so the game's + * threads never land on a little core (an RP6 frame costs 11 ms on c2 against 4.4 ms on the + * prime core). The 2 lowest-frequency cores stay reserved for audio/background. */ private fun gameCores(pserver: PServerDriver): List { - val all = allCpuCoresSorted(pserver) + return clusterGameCores( + listOf( + pserver.getCpuCoresByCluster(PServerDriver.CpuCluster.EFFICIENCY), + pserver.getCpuCoresByCluster(PServerDriver.CpuCluster.PERFORMANCE), + pserver.getCpuCoresByCluster(PServerDriver.CpuCluster.PRIME), + ), + ) + } + + /** + * Game cores from clusters ordered lowest to highest frequency: the cores of every + * cluster above the lowest one. Falls back to all cores but the 2 slowest when there is + * only one cluster, or when the clusters above the lowest hold fewer than + * [MIN_GAME_CLUSTER_CORES] cores (a 6+2 part would leave a Wine game on 2 cores). + */ + internal fun clusterGameCores(clustersLowToHigh: List>): List { + val clusters = clustersLowToHigh.filter { it.isNotEmpty() } + val all = clusters.flatten() + val aboveLowest = clusters.drop(1).flatten() + if (clusters.size >= 2 && aboveLowest.size >= MIN_GAME_CLUSTER_CORES) return aboveLowest return if (all.size <= 2) all else all.drop(2) } @@ -1435,12 +1456,19 @@ object PowerManager { } /** - * Cores a pinned game runs on: all cores except the 2 lowest-frequency ones, - * which are reserved for PulseAudio and other background/Wine processes. - * This applies uniformly to single, dual, and tri-cluster devices. + * Cores a pinned game runs on: the clusters above the lowest-frequency one (see + * [clusterGameCores]). The 2 lowest-frequency cores stay reserved for PulseAudio and + * other background/Wine processes. */ private fun gamePinCores(pserver: PServerDriver): List { - return gameCores(pserver) + val cores = gameCores(pserver) + Timber.tag("PowerManager").i( + "Game core set ${cores.joinToString()} (clusters efficiency=[${pserver.getCpuCoresByCluster(PServerDriver.CpuCluster.EFFICIENCY).joinToString()}] " + + "performance=[${pserver.getCpuCoresByCluster(PServerDriver.CpuCluster.PERFORMANCE).joinToString()}] " + + "prime=[${pserver.getCpuCoresByCluster(PServerDriver.CpuCluster.PRIME).joinToString()}], " + + "background=${lowestCores(pserver, 2).joinToString()})" + ) + return cores } /** diff --git a/app/src/main/java/app/gamenative/powercontrol/autotuning/ClusterTuner.kt b/app/src/main/java/app/gamenative/powercontrol/autotuning/ClusterTuner.kt index f1f5499921..4f93cc5b55 100644 --- a/app/src/main/java/app/gamenative/powercontrol/autotuning/ClusterTuner.kt +++ b/app/src/main/java/app/gamenative/powercontrol/autotuning/ClusterTuner.kt @@ -212,6 +212,7 @@ class ClusterTuner( cpuTempC = snapshot.cpuTempC, gpuTempC = snapshot.gpuTempC, capabilities = capabilities(), + repeatedFrameCount = snapshot.repeatedFrameCount, ) val slowRatio = engine.slowFrameRatio(input) @@ -236,7 +237,8 @@ class ClusterTuner( applied = domain != null && moveTo(domain, raiseIndex(domain, 1)) } TunerAction.HOLD -> { - applied = applyWarmStateIfDue() + // Warm-start caps wait for a window without repeated frames, like a trim does. + applied = input.repeatedFrameCount == 0 && applyWarmStateIfDue() if (applied) { action = "warmstart" reason = "warmstart" @@ -252,13 +254,15 @@ class ClusterTuner( if (applied) { persistWarmState() Timber.tag(TAG).i( - "%s %s: fps=%.1f/%d p95=%.1fms slow=%.1f%% cpu=%s%% gpu=%s%% caps[prime=%s perf=%s gpu=%s] frozen=%s", + "%s %s: fps=%.1f/%d p95=%.1fms slow=%.1f%% repeated=%d in %s cpu=%s%% gpu=%s%% caps[prime=%s perf=%s gpu=%s] frozen=%s", action, reason, snapshot.fps, targetFps, snapshot.frameTimeP95Ms, slowRatio * 100f, + snapshot.repeatedFrameCount, + TunerThresholds.REPEAT_WINDOW_LABEL, snapshot.cpuUsagePercent?.toInt()?.toString() ?: "-", snapshot.gpuUsagePercent?.toInt()?.toString() ?: "-", capLabel(TunerDomain.PRIME), @@ -266,6 +270,16 @@ class ClusterTuner( capLabel(TunerDomain.GPU), decision.frozenDomains.joinToString(",") { it.key }.ifEmpty { "-" }, ) + } else if (snapshot.repeatedFrameCount > 0) { + // Repeat holds go out at info so a tester sees why the tuner stopped trimming. + Timber.tag(TAG).i( + "hold %s: fps=%.1f/%d p95=%.1fms steady=%d", + reason, + snapshot.fps, + targetFps, + snapshot.frameTimeP95Ms, + decision.steadyCycles, + ) } else { Timber.tag(TAG).d( "hold %s: fps=%.1f/%d p95=%.1fms steady=%d", @@ -468,7 +482,7 @@ class ClusterTuner( val fpsCap = fpsCapProvider() val line = String.format( Locale.US, - "{\"t\":%d,\"fps\":%s,\"target\":%d,\"p50\":%s,\"p95\":%s,\"slowRatio\":%.4f," + + "{\"t\":%d,\"fps\":%s,\"target\":%d,\"p50\":%s,\"p95\":%s,\"slowRatio\":%.4f,\"repeated\":%s," + "\"cpu\":%s,\"cpuSrc\":\"%s\",\"gpu\":%s,\"cpuTemp\":%s,\"gpuTemp\":%s," + "\"capPrime\":%s,\"capPerf\":%s,\"capGpu\":%s,\"frozen\":[%s]," + "\"action\":\"%s\",\"reason\":\"%s\",\"steadyCount\":%d," + @@ -480,6 +494,7 @@ class ClusterTuner( snapshot?.let { String.format(Locale.US, "%.2f", it.frameTimeP50Ms) } ?: "null", snapshot?.let { String.format(Locale.US, "%.2f", it.frameTimeP95Ms) } ?: "null", slowRatio, + snapshot?.repeatedFrameCount?.toString() ?: "null", snapshot?.cpuUsagePercent?.let { String.format(Locale.US, "%.1f", it) } ?: "null", snapshot?.cpuUsageSource?.name ?: "UNAVAILABLE", snapshot?.gpuUsagePercent?.let { String.format(Locale.US, "%.1f", it) } ?: "null", diff --git a/app/src/main/java/app/gamenative/powercontrol/autotuning/TunerDecisionEngine.kt b/app/src/main/java/app/gamenative/powercontrol/autotuning/TunerDecisionEngine.kt index 8e3c4d8fd7..777f248364 100644 --- a/app/src/main/java/app/gamenative/powercontrol/autotuning/TunerDecisionEngine.kt +++ b/app/src/main/java/app/gamenative/powercontrol/autotuning/TunerDecisionEngine.kt @@ -37,6 +37,12 @@ data class TunerInput( val cpuTempC: Int?, val gpuTempC: Int?, val capabilities: Map, + /** + * Frame intervals longer than 1.5 target periods over the last + * [TunerThresholds.REPEAT_WINDOW_MS], from the app's own frame timestamps. At a cap each + * one is a frame the display showed twice. + */ + val repeatedFrameCount: Int = 0, ) data class TunerDecision( @@ -98,6 +104,28 @@ object TunerThresholds { const val REPROBE_INTERVAL_CYCLES = 60 const val STEPS_UNCAPPED = Int.MAX_VALUE + + /** + * Window the repeated-frame count covers. The fps and p95 of a capped game read as steady + * while it repeats a frame every few seconds (59.9 fps, p95 one period), so repeats are + * counted over a window long enough to see them. + */ + const val REPEAT_WINDOW_MS = 10_000L + const val REPEAT_WINDOW_LABEL = "10s" + + /** + * Any repeat in the window blocks the next trim. Repeats at or above this count step the + * last trim back. A capped game with power control off repeats about once in 90 s + * (RP6, SteamWorld Dig 2: 1 in 90 s), so two in one 10 s window almost never happens + * on its own, while the trimmed run repeated 25 times in 70 s. + */ + const val REPEAT_UNDO_COUNT = 2 + + /** + * Cycles after a repeat step-back before the next one: the repeats that caused it stay in + * the window until it has turned over, and must not step back a second domain. + */ + const val REPEAT_UNDO_COOLDOWN_CYCLES = (REPEAT_WINDOW_MS / CYCLE_INTERVAL_MS).toInt() } data class StrategyTuning( @@ -170,6 +198,10 @@ class TunerDecisionEngine( private var watchCyclesRemaining = 0 private var watchedDomain: TunerDomain? = null + /** Domains trimmed this session, newest last, so a repeat step-back undoes the latest. */ + private val trimHistory = ArrayList() + private var repeatUndoCooldownCycles = 0 + private var lastBottleneck: Bottleneck? = null private var harvestBottleneck: Bottleneck? = null private var harvestQualifiedCycles = 0 @@ -212,6 +244,7 @@ class TunerDecisionEngine( } steadyCycles++ updateRenderAverages(input, slowRatio) + if (repeatUndoCooldownCycles > 0) repeatUndoCooldownCycles-- val target = input.targetFps.toFloat() val targetPeriodMs = 1000f / target @@ -258,12 +291,29 @@ class TunerDecisionEngine( frozen.add(watched) clearWatch() trimQualifiedCycles = 0 - return decision(TunerAction.UNDO, watched, 1, "undo:${watched.key}") + forgetTrim(watched) + val cause = if (input.repeatedFrameCount > 0) " ${repeatLabel(input)}" else "" + return decision(TunerAction.UNDO, watched, 1, "undo:${watched.key}$cause") } watchCyclesRemaining-- return decision(TunerAction.HOLD, watched, 0, "watch:${watched.key}") } + val repeated = input.repeatedFrameCount + if (repeated > 0) { + trimQualifiedCycles = 0 + if (repeated >= TunerThresholds.REPEAT_UNDO_COUNT && repeatUndoCooldownCycles == 0) { + val domain = repeatUndoTarget(input) + if (domain != null) { + frozen.add(domain) + forgetTrim(domain) + repeatUndoCooldownCycles = TunerThresholds.REPEAT_UNDO_COOLDOWN_CYCLES + return decision(TunerAction.UNDO, domain, 1, "repeat-undo:${domain.key} ${repeatLabel(input)}") + } + } + return decision(TunerAction.HOLD, null, 0, "repeat-hold ${repeatLabel(input)}") + } + if (isHealthy(input, slowRatio, targetPeriodMs, tuning)) { trimQualifiedCycles++ } else { @@ -277,6 +327,7 @@ class TunerDecisionEngine( trimQualifiedCycles = 0 watchedDomain = domain watchCyclesRemaining = tuning.trimWatchCycles + trimHistory.add(domain) return decision(TunerAction.TRIM, domain, 1, "trim:${domain.key}") } return decision(TunerAction.HOLD, null, 0, "trim:no-domain") @@ -296,6 +347,8 @@ class TunerDecisionEngine( clearRenderAverages() trimQualifiedCycles = 0 lastBottleneck = null + trimHistory.clear() + repeatUndoCooldownCycles = 0 } fun slowFrameRatio(input: TunerInput): Float { @@ -303,6 +356,23 @@ class TunerDecisionEngine( return input.slowFrameCount.toFloat() / input.totalFrameCount.toFloat() } + /** + * The domain a repeat step-back raises: the latest trim that can still go up, or, for caps + * that came from a warm start rather than a trim this session, the domain a frame-rate + * drop would raise first. + */ + private fun repeatUndoTarget(input: TunerInput): TunerDomain? { + return trimHistory.lastOrNull { input.capabilities[it]?.canRaise == true } ?: raiseTarget(input) + } + + private fun forgetTrim(domain: TunerDomain) { + val index = trimHistory.lastIndexOf(domain) + if (index >= 0) trimHistory.removeAt(index) + } + + private fun repeatLabel(input: TunerInput): String = + "repeated=${input.repeatedFrameCount} in ${TunerThresholds.REPEAT_WINDOW_LABEL}" + private fun raiseTarget(input: TunerInput): TunerDomain? { // A missing GPU reading is not evidence of a GPU-bound render: raise the CPU first, the // same order a device without GPU control ends up with. @@ -361,6 +431,7 @@ class TunerDecisionEngine( harvestBaselineFps = averageFps ?: input.fps harvestBaselineP95Ms = averageP95Ms ?: input.frameTimeP95Ms harvestBaselineSlowRatio = averageSlowRatio ?: slowRatio + trimHistory.add(domain) return decision(TunerAction.TRIM, domain, 1, "harvest:${domain.key}") } @@ -387,6 +458,7 @@ class TunerDecisionEngine( if (harvestDegradedCycles >= TunerThresholds.HARVEST_DEGRADED_CYCLES) { frozen.add(watched) clearHarvest() + forgetTrim(watched) return decision(TunerAction.UNDO, watched, 1, "harvest-undo:${watched.key}") } } else { @@ -474,7 +546,8 @@ class TunerDecisionEngine( private fun isHealthy(input: TunerInput, slowRatio: Float, targetPeriodMs: Float, tuning: StrategyTuning): Boolean { val target = input.targetFps.toFloat() - return input.fps >= target - tuning.fpsTrimToleranceFps && + return input.repeatedFrameCount == 0 && + input.fps >= target - tuning.fpsTrimToleranceFps && input.frameTimeP95Ms <= targetPeriodMs * TunerThresholds.FRAME_TIME_P95_TRIM_FACTOR && slowRatio < TunerThresholds.SLOW_FRAME_TRIM_RATIO } diff --git a/app/src/main/java/app/gamenative/powercontrol/metrics/FrameTimeRing.kt b/app/src/main/java/app/gamenative/powercontrol/metrics/FrameTimeRing.kt index 89eb6657fe..557eb7c457 100644 --- a/app/src/main/java/app/gamenative/powercontrol/metrics/FrameTimeRing.kt +++ b/app/src/main/java/app/gamenative/powercontrol/metrics/FrameTimeRing.kt @@ -123,3 +123,25 @@ private fun percentileMs(sortedDeltas: LongArray, size: Int, percentile: Double) val index = Math.ceil(percentile * size).toInt() - 1 return sortedDeltas[index.coerceIn(0, size - 1)] / 1_000_000f } + +/** + * Number of intervals between [count] ascending timestamps in [timestampsNs] (taken + * [sampleStride] frames apart, as in [computeFrameWindowStats]) longer than [thresholdNs]. + * At a frame cap each such interval is a frame the display showed again. + */ +fun countSlowIntervals( + timestampsNs: LongArray, + count: Int, + thresholdNs: Long, + sampleStride: Int = 1, +): Int { + val stride = sampleStride.coerceAtLeast(1) + if (thresholdNs <= 0L) return 0 + var slow = 0 + var index = stride + while (index < count) { + if (timestampsNs[index] - timestampsNs[index - stride] > thresholdNs) slow++ + index += stride + } + return slow +} diff --git a/app/src/main/java/app/gamenative/powercontrol/metrics/MetricsSnapshot.kt b/app/src/main/java/app/gamenative/powercontrol/metrics/MetricsSnapshot.kt index 1488c2286e..3f5ba5af3d 100644 --- a/app/src/main/java/app/gamenative/powercontrol/metrics/MetricsSnapshot.kt +++ b/app/src/main/java/app/gamenative/powercontrol/metrics/MetricsSnapshot.kt @@ -19,4 +19,6 @@ data class MetricsSnapshot( val gpuTempC: Int?, /** Latest finite thermal headroom of the session (refreshed every 10 s), null until one is read. */ val thermalHeadroom: Float? = null, + /** Frame intervals over 1.5 target periods in the last 10 s (the tuner's repeat window). */ + val repeatedFrameCount: Int = 0, ) diff --git a/app/src/main/java/app/gamenative/powercontrol/metrics/PerformanceMetricsCollector.kt b/app/src/main/java/app/gamenative/powercontrol/metrics/PerformanceMetricsCollector.kt index 4c1168b4d3..0424858a4f 100644 --- a/app/src/main/java/app/gamenative/powercontrol/metrics/PerformanceMetricsCollector.kt +++ b/app/src/main/java/app/gamenative/powercontrol/metrics/PerformanceMetricsCollector.kt @@ -5,6 +5,7 @@ import android.hardware.display.DisplayManager import android.view.Display import app.gamenative.powercontrol.PowerBaselineScripts import app.gamenative.powercontrol.PowerManager +import app.gamenative.powercontrol.autotuning.TunerThresholds import java.io.File import java.util.Locale import kotlinx.coroutines.CoroutineScope @@ -44,6 +45,7 @@ object PerformanceMetricsCollector { private val frameScratch = LongArray(FrameTimeRing.capacity()) private val deltaScratch = LongArray(FrameTimeRing.capacity()) + private val repeatScratch = LongArray(FrameTimeRing.capacity()) private val sessionLog = JsonlSessionLog(TAG, "metrics-", MAX_LOG_BYTES, MAX_SESSION_FILES) private var sampleCount = 0L @@ -160,6 +162,16 @@ object PerformanceMetricsCollector { deltaScratch, PowerManager.frameSampleStride, ) + val repeatCount = FrameTimeRing.copySince( + now - TunerThresholds.REPEAT_WINDOW_MS * 1_000_000L, + repeatScratch, + ) + val repeatedFrames = countSlowIntervals( + repeatScratch, + repeatCount, + slowFrameThresholdNs(), + PowerManager.frameSampleStride, + ) val cpu = cpuSampler.sample() val gpu = gpuSampler.sample() @@ -205,6 +217,7 @@ object PerformanceMetricsCollector { cpuTempC = cpuTemp?.celsius, gpuTempC = gpuTemp?.celsius, thermalHeadroom = ThermalHeadroom.latest, + repeatedFrameCount = repeatedFrames, ) publish(snapshot) @@ -215,11 +228,12 @@ object PerformanceMetricsCollector { sampleCount++ if (sampleCount % LOG_EVERY_N_SAMPLES == 0L) { Timber.tag(TAG).i( - "fps=%.1f p95=%.1fms slow=%d/%d cpu=%s%%(%s) gpu=%s%% cpuTemp=%s gpuTemp=%s", + "fps=%.1f p95=%.1fms slow=%d/%d repeated=%d in 10s cpu=%s%%(%s) gpu=%s%% cpuTemp=%s gpuTemp=%s", snapshot.fps, snapshot.frameTimeP95Ms, snapshot.slowFrameCount, snapshot.totalFrameCount, + snapshot.repeatedFrameCount, snapshot.cpuUsagePercent?.toInt()?.toString() ?: "-", snapshot.cpuUsageSource.name, snapshot.gpuUsagePercent?.toInt()?.toString() ?: "-", diff --git a/app/src/test/java/app/gamenative/powercontrol/GameCoreSetTest.kt b/app/src/test/java/app/gamenative/powercontrol/GameCoreSetTest.kt new file mode 100644 index 0000000000..6076039ee8 --- /dev/null +++ b/app/src/test/java/app/gamenative/powercontrol/GameCoreSetTest.kt @@ -0,0 +1,32 @@ +package app.gamenative.powercontrol + +import org.junit.Assert.assertEquals +import org.junit.Test + +class GameCoreSetTest { + + @Test + fun `tri-cluster game runs on every cluster above the little one`() { + // Retroid Pocket 6 (Snapdragon 8 Gen 2): c0-2 little, c3-6 big, c7 prime. + val cores = PowerManager.clusterGameCores(listOf(listOf(0, 1, 2), listOf(3, 4, 5, 6), listOf(7))) + assertEquals(listOf(3, 4, 5, 6, 7), cores) + } + + @Test + fun `four-four dual cluster runs on the big cluster`() { + val cores = PowerManager.clusterGameCores(listOf(emptyList(), listOf(0, 1, 2, 3), listOf(4, 5, 6, 7))) + assertEquals(listOf(4, 5, 6, 7), cores) + } + + @Test + fun `a small big cluster keeps all but the two slowest`() { + val cores = PowerManager.clusterGameCores(listOf(emptyList(), listOf(0, 1, 2, 3, 4, 5), listOf(6, 7))) + assertEquals(listOf(2, 3, 4, 5, 6, 7), cores) + } + + @Test + fun `a single cluster keeps all but the two slowest`() { + val cores = PowerManager.clusterGameCores(listOf(emptyList(), listOf(0, 1, 2, 3), emptyList())) + assertEquals(listOf(2, 3), cores) + } +} diff --git a/app/src/test/java/app/gamenative/powercontrol/autotuning/TunerRepeatedFramesTest.kt b/app/src/test/java/app/gamenative/powercontrol/autotuning/TunerRepeatedFramesTest.kt new file mode 100644 index 0000000000..a9dfe37de1 --- /dev/null +++ b/app/src/test/java/app/gamenative/powercontrol/autotuning/TunerRepeatedFramesTest.kt @@ -0,0 +1,116 @@ +package app.gamenative.powercontrol.autotuning + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Covers the repeated-frame steady rule against the shape seen on the Retroid Pocket 6 in + * SteamWorld Dig 2: a 60 fps cap held at 59.9 fps with a p95 of one period, while the trimmed + * clocks make the game repeat a frame every few seconds. + */ +class TunerRepeatedFramesTest { + + private val capabilities = mapOf( + TunerDomain.PRIME to DomainCapability(available = true, canRaise = true, canTrim = true), + TunerDomain.PERFORMANCE to DomainCapability(available = true, canRaise = true, canTrim = true), + TunerDomain.GPU to DomainCapability(available = true, canRaise = true, canTrim = true), + ) + + /** Capped at 60 and meeting it, CPU and GPU both well under the bound thresholds. */ + private fun input(repeated: Int = 0) = TunerInput( + fps = 59.9f, + targetFps = 60, + frameTimeP50Ms = 16.7f, + frameTimeP95Ms = 17f, + slowFrameCount = 0, + totalFrameCount = 120, + cpuUsagePercent = 30f, + gpuUsagePercent = 40f, + cpuTempC = 60, + gpuTempC = 60, + capabilities = capabilities, + repeatedFrameCount = repeated, + ) + + private fun runToTrim(engine: TunerDecisionEngine): TunerDecision { + repeat(20) { + val decision = engine.decide(input()) + if (decision.action == TunerAction.TRIM) return decision + } + throw AssertionError("never trimmed") + } + + /** Runs a clean window until the trim watch has ended. */ + private fun finishWatch(engine: TunerDecisionEngine) { + repeat(TunerThresholds.TRIM_WATCH_CYCLES) { + assertEquals(TunerAction.HOLD, engine.decide(input()).action) + } + } + + @Test + fun `a clean capped render trims`() { + val trim = runToTrim(TunerDecisionEngine()) + assertEquals(TunerDomain.PRIME, trim.domain) + } + + @Test + fun `one repeat in the window blocks the next trim`() { + val engine = TunerDecisionEngine() + runToTrim(engine) + finishWatch(engine) + repeat(20) { + val decision = engine.decide(input(repeated = 1)) + assertEquals("trimmed through a repeat: ${decision.reason}", TunerAction.HOLD, decision.action) + assertTrue(decision.reason, decision.reason.startsWith("repeat-hold repeated=1 in 10s")) + } + } + + @Test + fun `a repeat during the trim watch gives that step back`() { + val engine = TunerDecisionEngine() + runToTrim(engine) + val undo = engine.decide(input(repeated = 1)) + assertEquals(TunerAction.UNDO, undo.action) + assertEquals(TunerDomain.PRIME, undo.domain) + assertTrue(undo.reason, undo.reason.contains("repeated=1")) + } + + @Test + fun `repeats after the watch step the last trim back once per window`() { + val engine = TunerDecisionEngine() + runToTrim(engine) + finishWatch(engine) + val second = runToTrim(engine) + finishWatch(engine) + + val undo = engine.decide(input(repeated = 3)) + assertEquals(TunerAction.UNDO, undo.action) + assertEquals(second.domain, undo.domain) + assertEquals("repeat-undo:${second.domain!!.key} repeated=3 in 10s", undo.reason) + + // The same repeats are still in the window: hold until it has turned over. + repeat(TunerThresholds.REPEAT_UNDO_COOLDOWN_CYCLES - 1) { + assertEquals(TunerAction.HOLD, engine.decide(input(repeated = 3)).action) + } + assertEquals(TunerAction.UNDO, engine.decide(input(repeated = 3)).action) + } + + @Test + fun `a single repeat holds but does not step back`() { + val engine = TunerDecisionEngine() + runToTrim(engine) + finishWatch(engine) + val decision = engine.decide(input(repeated = 1)) + assertEquals(TunerAction.HOLD, decision.action) + } + + @Test + fun `repeats with no trim this session raise the domain a frame drop would`() { + val engine = TunerDecisionEngine() + repeat(TunerThresholds.STEADY_RENDER_CYCLES) { engine.decide(input(repeated = 0)) } + val undo = engine.decide(input(repeated = 2)) + assertEquals(TunerAction.UNDO, undo.action) + assertEquals(TunerDomain.PRIME, undo.domain) + } +} diff --git a/app/src/test/java/app/gamenative/powercontrol/metrics/RepeatedFrameCountTest.kt b/app/src/test/java/app/gamenative/powercontrol/metrics/RepeatedFrameCountTest.kt new file mode 100644 index 0000000000..ee8ebd11a5 --- /dev/null +++ b/app/src/test/java/app/gamenative/powercontrol/metrics/RepeatedFrameCountTest.kt @@ -0,0 +1,34 @@ +package app.gamenative.powercontrol.metrics + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RepeatedFrameCountTest { + + private val periodNs = 16_666_667L + private val thresholdNs = (1.5 * periodNs).toLong() + + private fun timeline(vararg gapsInPeriods: Int): LongArray { + val out = LongArray(gapsInPeriods.size + 1) + for (i in gapsInPeriods.indices) out[i + 1] = out[i] + gapsInPeriods[i] * periodNs + return out + } + + @Test + fun `steady intervals count no repeats`() { + val ts = timeline(1, 1, 1, 1, 1) + assertEquals(0, countSlowIntervals(ts, ts.size, thresholdNs)) + } + + @Test + fun `each doubled interval is one repeat`() { + val ts = timeline(1, 2, 1, 1, 2, 1, 3) + assertEquals(3, countSlowIntervals(ts, ts.size, thresholdNs)) + } + + @Test + fun `no threshold counts nothing`() { + val ts = timeline(1, 5) + assertEquals(0, countSlowIntervals(ts, ts.size, 0L)) + } +}