Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions app/src/main/java/app/gamenative/powercontrol/PowerManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown
Contributor

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
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/src/main/java/app/gamenative/powercontrol/PowerManager.kt, line 1093:

<comment>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.</comment>

<file context>
@@ -1084,11 +1085,31 @@ object PowerManager {
      */
     private fun gameCores(pserver: PServerDriver): List<Int> {
-        val all = allCpuCoresSorted(pserver)
+        return clusterGameCores(
+            listOf(
+                pserver.getCpuCoresByCluster(PServerDriver.CpuCluster.EFFICIENCY),
</file context>

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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: clusterGameCores does not leave a 6+2 layout on two cores: the fallback returns six cores. Reword this rationale to describe the rejected two-core candidate.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/src/main/java/app/gamenative/powercontrol/PowerManager.kt, line 1106:

<comment>`clusterGameCores` does not leave a 6+2 layout on two cores: the fallback returns six cores. Reword this rationale to describe the rejected two-core candidate.</comment>

<file context>
@@ -1084,11 +1085,31 @@ object PowerManager {
+     * 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<Int>>): List<Int> {
</file context>
Suggested change
* [MIN_GAME_CLUSTER_CORES] cores (a 6+2 part would leave a Wine game on 2 cores).
* [MIN_GAME_CLUSTER_CORES] cores (selecting only the 2-core high cluster would be too restrictive on a 6+2 part).

*/
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/src/main/java/app/gamenative/powercontrol/PowerManager.kt, line 1112:

<comment>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.</comment>

<file context>
@@ -1084,11 +1085,31 @@ object PowerManager {
+        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)
     }
</file context>
Suggested change
if (clusters.size >= 2 && aboveLowest.size >= MIN_GAME_CLUSTER_CORES) return aboveLowest
if (clusters.size >= 2 && clusters.first().size >= 2 && aboveLowest.size >= MIN_GAME_CLUSTER_CORES) return aboveLowest

return if (all.size <= 2) all else all.drop(2)
}

Expand Down Expand Up @@ -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
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ class ClusterTuner(
cpuTempC = snapshot.cpuTempC,
gpuTempC = snapshot.gpuTempC,
capabilities = capabilities(),
repeatedFrameCount = snapshot.repeatedFrameCount,
)

val slowRatio = engine.slowFrameRatio(input)
Expand All @@ -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"
Expand All @@ -252,20 +254,32 @@ 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),
capLabel(TunerDomain.PERFORMANCE),
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",
Expand Down Expand Up @@ -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," +
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -212,6 +244,7 @@ class TunerDecisionEngine(
}
steadyCycles++
updateRenderAverages(input, slowRatio)
if (repeatUndoCooldownCycles > 0) repeatUndoCooldownCycles--

val target = input.targetFps.toFloat()
val targetPeriodMs = 1000f / target
Expand Down Expand Up @@ -258,12 +291,29 @@ class TunerDecisionEngine(
frozen.add(watched)
clearWatch()
trimQualifiedCycles = 0
return decision(TunerAction.UNDO, watched, 1, "undo:${watched.key}")
forgetTrim(watched)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/src/main/java/app/gamenative/powercontrol/autotuning/TunerDecisionEngine.kt, line 294:

<comment>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.</comment>

<file context>
@@ -258,12 +291,29 @@ class TunerDecisionEngine(
                 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")
</file context>
Suggested change
forgetTrim(watched)
forgetTrim(watched)
if (input.repeatedFrameCount > 0) {
repeatUndoCooldownCycles = TunerThresholds.REPEAT_UNDO_COOLDOWN_CYCLES
}

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 harvestWatch() does not treat a repeat as degradation. Handle repeats before the raise/harvest path and in the harvest watch, while preserving the watched domain for an undo.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/src/main/java/app/gamenative/powercontrol/autotuning/TunerDecisionEngine.kt, line 302:

<comment>Repeat handling runs too late: the out-of-target path can harvest another trim while repeats are present, and `harvestWatch()` does not treat a repeat as degradation. Handle repeats before the raise/harvest path and in the harvest watch, while preserving the watched domain for an undo.</comment>

<file context>
@@ -258,12 +291,29 @@ class TunerDecisionEngine(
             return decision(TunerAction.HOLD, watched, 0, "watch:${watched.key}")
         }
 
+        val repeated = input.repeatedFrameCount
+        if (repeated > 0) {
+            trimQualifiedCycles = 0
</file context>

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 {
Expand All @@ -277,6 +327,7 @@ class TunerDecisionEngine(
trimQualifiedCycles = 0
watchedDomain = domain
watchCyclesRemaining = tuning.trimWatchCycles
trimHistory.add(domain)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/src/main/java/app/gamenative/powercontrol/autotuning/TunerDecisionEngine.kt, line 330:

<comment>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.</comment>

<file context>
@@ -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}")
             }
</file context>

return decision(TunerAction.TRIM, domain, 1, "trim:${domain.key}")
}
return decision(TunerAction.HOLD, null, 0, "trim:no-domain")
Expand All @@ -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.
Expand Down Expand Up @@ -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}")
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -160,6 +162,16 @@ object PerformanceMetricsCollector {
deltaScratch,
PowerManager.frameSampleStride,
)
val repeatCount = FrameTimeRing.copySince(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/src/main/java/app/gamenative/powercontrol/metrics/PerformanceMetricsCollector.kt, line 165:

<comment>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.</comment>

<file context>
@@ -160,6 +162,16 @@ object PerformanceMetricsCollector {
             deltaScratch,
             PowerManager.frameSampleStride,
         )
+        val repeatCount = FrameTimeRing.copySince(
+            now - TunerThresholds.REPEAT_WINDOW_MS * 1_000_000L,
+            repeatScratch,
</file context>

now - TunerThresholds.REPEAT_WINDOW_MS * 1_000_000L,
repeatScratch,
)
val repeatedFrames = countSlowIntervals(
repeatScratch,
repeatCount,
slowFrameThresholdNs(),
PowerManager.frameSampleStride,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/src/main/java/app/gamenative/powercontrol/metrics/PerformanceMetricsCollector.kt, line 173:

<comment>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.</comment>

<file context>
@@ -160,6 +162,16 @@ object PerformanceMetricsCollector {
+            repeatScratch,
+            repeatCount,
+            slowFrameThresholdNs(),
+            PowerManager.frameSampleStride,
+        )
 
</file context>

)

val cpu = cpuSampler.sample()
val gpu = gpuSampler.sample()
Expand Down Expand Up @@ -205,6 +217,7 @@ object PerformanceMetricsCollector {
cpuTempC = cpuTemp?.celsius,
gpuTempC = gpuTemp?.celsius,
thermalHeadroom = ThermalHeadroom.latest,
repeatedFrameCount = repeatedFrames,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 repeatedFrameCount to toJsonLine so session files preserve the tuner input.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/src/main/java/app/gamenative/powercontrol/metrics/PerformanceMetricsCollector.kt, line 220:

<comment>The new repeat metric is published and logged to logcat but omitted from the JSONL records used for offline analysis. Add `repeatedFrameCount` to `toJsonLine` so session files preserve the tuner input.</comment>

<file context>
@@ -205,6 +217,7 @@ object PerformanceMetricsCollector {
             cpuTempC = cpuTemp?.celsius,
             gpuTempC = gpuTemp?.celsius,
             thermalHeadroom = ThermalHeadroom.latest,
+            repeatedFrameCount = repeatedFrames,
         )
 
</file context>

)

publish(snapshot)
Expand All @@ -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() ?: "-",
Expand Down
Loading