Repository navigation
Power tuner counts repeated frames; game pinned to the clusters above the little one #2321
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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<Int> { | ||||||
| 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). | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Prompt for AI agents
Suggested change
|
||||||
| */ | ||||||
| internal fun clusterGameCores(clustersLowToHigh: List<List<Int>>): List<Int> { | ||||||
| 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 | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: This branch can make game and background affinities overlap on devices whose lowest-frequency cluster has one core. Require at least two cores in the lowest cluster before using the cluster-based branch, otherwise retain the all-but-two fallback. Prompt for AI agents
Suggested change
|
||||||
| 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<Int> { | ||||||
| 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 | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -37,6 +37,12 @@ data class TunerInput( | |||||||||||
| val cpuTempC: Int?, | ||||||||||||
| val gpuTempC: Int?, | ||||||||||||
| val capabilities: Map<TunerDomain, DomainCapability>, | ||||||||||||
| /** | ||||||||||||
| * 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<TunerDomain>() | ||||||||||||
| 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) | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: A repeat-triggered watch undo never starts the repeat cooldown. The same count can undo another trim on the next cycle; set the cooldown whenever a repeat causes a watch undo. Prompt for AI agents
Suggested change
|
||||||||||||
| 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 | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Repeat handling runs too late: the out-of-target path can harvest another trim while repeats are present, and Prompt for AI agents |
||||||||||||
| 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) | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: This history remains stale after an ordinary raise. Reconcile the domain’s trim history whenever a raise reverses it, or track active trim steps so a repeat undo cannot raise a cap unrelated to the latest live trim. Prompt for AI agents |
||||||||||||
| return decision(TunerAction.TRIM, domain, 1, "trim:${domain.key}") | ||||||||||||
| } | ||||||||||||
| return decision(TunerAction.HOLD, null, 0, "trim:no-domain") | ||||||||||||
|
|
@@ -296,13 +347,32 @@ class TunerDecisionEngine( | |||||||||||
| clearRenderAverages() | ||||||||||||
| trimQualifiedCycles = 0 | ||||||||||||
| lastBottleneck = null | ||||||||||||
| trimHistory.clear() | ||||||||||||
| repeatUndoCooldownCycles = 0 | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| fun slowFrameRatio(input: TunerInput): Float { | ||||||||||||
| if (input.totalFrameCount <= 0) return 0f | ||||||||||||
| 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 | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The new 10-second repeat history spans background pauses, so the first frame after a short pause is counted as a repeated frame. Clear the repeat baseline on pause/resume or exclude the interval crossing the pause before feeding the tuner. Prompt for AI agents |
||
| now - TunerThresholds.REPEAT_WINDOW_MS * 1_000_000L, | ||
| repeatScratch, | ||
| ) | ||
| val repeatedFrames = countSlowIntervals( | ||
| repeatScratch, | ||
| repeatCount, | ||
| slowFrameThresholdNs(), | ||
| PowerManager.frameSampleStride, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: This applies the current frame-generation stride to timestamps recorded before a stride change, so changing LSFG from 1× to 2× can report false repeats for the remainder of the 10-second window and make the tuner hold or undo a valid trim. Reset or segment the repeat window whenever the stride changes, or retain the stride with each timestamp. Prompt for AI agents |
||
| ) | ||
|
|
||
| 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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The new repeat metric is published and logged to logcat but omitted from the JSONL records used for offline analysis. Add Prompt for AI agents |
||
| ) | ||
|
|
||
| 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() ?: "-", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: The new pinning path assumes cluster labels are valid even when frequency discovery failed. Fall back to the previous all-but-two policy unless the driver confirms frequency-based classification, or expose and validate that status before selecting clusters.
Prompt for AI agents