diff --git a/app/src/main/cpp/gnaffinity/PROVENANCE.md b/app/src/main/cpp/gnaffinity/PROVENANCE.md new file mode 100644 index 0000000000..85099fe8be --- /dev/null +++ b/app/src/main/cpp/gnaffinity/PROVENANCE.md @@ -0,0 +1,11 @@ +# libgnaffinity.so provenance + +`app/src/main/jniLibs/arm64-v8a/libgnaffinity.so` is a prebuilt (the app does not run CMake during packaging), +built from `jni/gnaffinity.c`: + +- sha256: `61272eb70f568de532333ebc6574866eb23b7bd88119975aba2cc16c526650b9` +- NDK 27.3.13750724, arm64-v8a, android-26, MinSizeRel, `-z max-page-size=16384`, stripped; a rebuild yields the same sha256 +- build: `app/src/main/cpp/gnaffinity/build.sh`, then + `cp app/src/main/cpp/gnaffinity/out/libgnaffinity.so app/src/main/jniLibs/arm64-v8a/` + +Exports only `Java_app_gamenative_powercontrol_TaskAffinity_nativeSetTaskAffinity`. Only dependencies: libc, libm, libdl. diff --git a/app/src/main/cpp/gnaffinity/build.sh b/app/src/main/cpp/gnaffinity/build.sh new file mode 100755 index 0000000000..f4a80024fd --- /dev/null +++ b/app/src/main/cpp/gnaffinity/build.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Builds out/libgnaffinity.so (arm64-v8a, stripped, 16 KB pages), then +# cp out/libgnaffinity.so ../../jniLibs/arm64-v8a/ +set -euo pipefail +cd "$(dirname "$0")" +NDK=${ANDROID_NDK:-$HOME/Library/Android/sdk/ndk/27.3.13750724} +rm -rf build out; mkdir -p out +cmake -S jni -B build -G Ninja -DCMAKE_TOOLCHAIN_FILE="$NDK/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-26 -DCMAKE_BUILD_TYPE=MinSizeRel \ + -DCMAKE_C_FLAGS="-ffile-prefix-map=$PWD=." +cmake --build build +HOST=$(ls "$NDK/toolchains/llvm/prebuilt/") +"$NDK/toolchains/llvm/prebuilt/$HOST/bin/llvm-strip" --strip-all -o out/libgnaffinity.so build/libgnaffinity.so +shasum -a 256 out/libgnaffinity.so diff --git a/app/src/main/cpp/gnaffinity/jni/CMakeLists.txt b/app/src/main/cpp/gnaffinity/jni/CMakeLists.txt new file mode 100644 index 0000000000..a4dfba551e --- /dev/null +++ b/app/src/main/cpp/gnaffinity/jni/CMakeLists.txt @@ -0,0 +1,5 @@ +cmake_minimum_required(VERSION 3.13.5) +project(gnaffinity C) +add_library(gnaffinity SHARED gnaffinity.c) +set_target_properties(gnaffinity PROPERTIES C_VISIBILITY_PRESET hidden) +target_link_options(gnaffinity PRIVATE -Wl,-z,max-page-size=16384 -Wl,--build-id=none) diff --git a/app/src/main/cpp/gnaffinity/jni/gnaffinity.c b/app/src/main/cpp/gnaffinity/jni/gnaffinity.c new file mode 100644 index 0000000000..fcd3d123c0 --- /dev/null +++ b/app/src/main/cpp/gnaffinity/jni/gnaffinity.c @@ -0,0 +1,21 @@ +#define _GNU_SOURCE +#include +#include +#include + +/* + * sched_setaffinity on one task (a thread id, or a pid for its main thread). Linux allows it on + * any task of the caller's uid without CAP_SYS_NICE: the app's own threads and the processes it + * spawned. Returns 0, or the errno (EPERM, ESRCH, EINVAL). + */ +JNIEXPORT jint JNICALL +Java_app_gamenative_powercontrol_TaskAffinity_nativeSetTaskAffinity(JNIEnv *env, jclass clazz, jint tid, jint mask) { + (void) env; + (void) clazz; + cpu_set_t set; + CPU_ZERO(&set); + for (int cpu = 0; cpu < 32; cpu++) { + if ((unsigned) mask & (1u << cpu)) CPU_SET(cpu, &set); + } + return sched_setaffinity(tid, sizeof(set), &set) == 0 ? 0 : errno; +} diff --git a/app/src/main/java/app/gamenative/display/GamescopePayload.kt b/app/src/main/java/app/gamenative/display/GamescopePayload.kt index 957b592de5..6b48f89ef1 100644 --- a/app/src/main/java/app/gamenative/display/GamescopePayload.kt +++ b/app/src/main/java/app/gamenative/display/GamescopePayload.kt @@ -11,7 +11,7 @@ import java.io.File /** Versioned gamescope payload (gamescope, XWayland, scaling clients), fetched on the first gamescope launch. */ object GamescopePayload { - const val VERSION = "20261101.6" + const val VERSION = "20261102.3" const val ARCHIVE = "gamescope-gamenative-$VERSION.tzst" private const val STAMP = ".payload.version" diff --git a/app/src/main/java/app/gamenative/display/GamescopeServerComponent.java b/app/src/main/java/app/gamenative/display/GamescopeServerComponent.java index 1ce042bab0..50341d5032 100644 --- a/app/src/main/java/app/gamenative/display/GamescopeServerComponent.java +++ b/app/src/main/java/app/gamenative/display/GamescopeServerComponent.java @@ -1,5 +1,6 @@ package app.gamenative.display; +import app.gamenative.powercontrol.PowerManager; import com.winlator.container.Container; import com.winlator.xenvironment.EnvironmentComponent; import com.winlator.xenvironment.ImageFs; @@ -52,6 +53,7 @@ private void awaitConnected() { for (int i = 0; i < START_POLL_TRIES && !stopped; i++) { if (gamescopeAnswered()) { GamescopeCommandQueue.setApplicable(sessionId, true); + PowerManager.applyCompositorAffinity("gamescope session start"); return; } try { @@ -83,6 +85,7 @@ public void stop() { stopped = true; GamescopeCommandQueue.reset(); GameNativeDrm.nativeInjectorStop(); + PowerManager.releaseCompositorAffinity("gamescope session end"); } public void pause() { diff --git a/app/src/main/java/app/gamenative/powercontrol/PowerManager.kt b/app/src/main/java/app/gamenative/powercontrol/PowerManager.kt index 3353a075bd..2d934ace00 100644 --- a/app/src/main/java/app/gamenative/powercontrol/PowerManager.kt +++ b/app/src/main/java/app/gamenative/powercontrol/PowerManager.kt @@ -58,6 +58,8 @@ 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 const val LOW_CAPACITY_RATIO = 0.6 private val json = Json { encodeDefaults = true @@ -90,6 +92,27 @@ object PowerManager { private val _uiState = MutableStateFlow(PowerControlUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() + /** + * True while the running session presents through gamescope. Set by the session before the + * game is pinned; the cluster game core set and the tuner's repeated-frame input apply only + * then, and X11 sessions keep the rules they had. + */ + @Volatile + var gamescopeSession: Boolean = false + + /** Refresh rate the gamescope session reports as its display mode, 0 when none is reported. */ + @Volatile + var panelRefreshHz: Int = 0 + + /** + * The frame rate the tuners aim for. Under gamescope a capped game is FIFO on the display's + * vblank grid, so a cap above the display's rate (the slider goes to twice it) cannot be + * reached: the tuners aim for the display's rate there, while the cap sent to gamescope and + * reported everywhere else stays what the user set. + */ + internal fun tunerTargetFps(capFps: Int, gamescope: Boolean, panelHz: Int): Int = + if (gamescope && panelHz > 0 && capFps > panelHz) panelHz else capFps + var targetFps: Int = 0 set(value) { // Enforce non-negative values and round/clamp if necessary @@ -178,6 +201,8 @@ object PowerManager { */ fun autoStart(rootDir: File) { containerDir = rootDir + compositorRestoreCores = null + compositorOffLogged = false resolveGameAffinityOwnership() loadCurrentProfile() if (isProfilePowerControlEnabled()) { @@ -191,6 +216,10 @@ object PowerManager { appContext.let { PerformanceMetricsCollector.start(it) } isGameStarted = true + if (gamescopeSession) { + // The gamescope session may have connected before the profile was loaded. + Thread({ applyCompositorAffinity("power control start") }, "compositor-affinity").start() + } } /** @@ -297,6 +326,8 @@ object PowerManager { isGameStarted = false fpsCapApplier = null frameSampleStride = 1 + gamescopeSession = false + panelRefreshHz = 0 containerDir = null pinnedGameProcessName = null pinnedGamePid = null @@ -326,6 +357,11 @@ object PowerManager { driver.start() applyCurrentProfile() } + if (gamescopeSession) { + // An activity resume moves this process between cpusets, and before Linux 6.2 that + // resets the gndrm threads' affinity: place the chain again, off the main thread. + Thread({ applyCompositorAffinity("activity resume") }, "compositor-affinity").start() + } if (currentProfile.adaptiveFpsCapEnabled) { AdaptiveFpsCapController.start(containerDir, tunerLogDirectory()) } @@ -438,7 +474,7 @@ object PowerManager { }, applyGpuMaxLevel = { level -> pserver.setMaxGpuPowerLevel(level) }, metricsProvider = { latestMetrics }, - targetFpsProvider = { targetFps }, + targetFpsProvider = { tunerTargetFps(targetFps, gamescopeSession, panelRefreshHz) }, fanSampleProvider = { FanController.latestSample }, strategyProvider = { currentProfile.tuningStrategy }, fpsCapProvider = { AdaptiveFpsCapController.snapshot() }, @@ -1084,12 +1120,94 @@ object PowerManager { allCpuCoresSorted(pserver).take(count) /** - * Cores the game should use: all cores except the 2 lowest-frequency ones - * reserved for audio/background. + * PServer's clusters, lowest to highest frequency, each with the capacity the driver read for + * it (see [knownCapacities]). + */ + private fun coreClusters(pserver: PServerDriver): List = + knownCapacities( + listOf( + PServerDriver.CpuCluster.EFFICIENCY, + PServerDriver.CpuCluster.PERFORMANCE, + PServerDriver.CpuCluster.PRIME, + ).map { cluster -> + CoreCluster( + cluster.name.lowercase(), + pserver.getCpuCoresByCluster(cluster), + pserver.getCpuClusterCapacity(cluster), + ) + }, + pserver.cpuClusterCapacitySource, + ) + + /** + * Capacities only count when they are the kernel's cpu_capacity: a max frequency does not + * tell a little core apart (the RP6 littles run at 63 % of the prime core's frequency with a + * third of its capacity), so with any other [source] every capacity is unknown (0) and + * nothing is placed. + */ + internal fun knownCapacities(clusters: List, source: String): List = + if (source == "cpu_capacity") clusters else clusters.map { it.copy(capacity = 0L) } + + /** + * PServer's clusters when the device has a PServer driver with its cluster grouping, else + * null. Core placement for gamescope (the compositor chain and the gamescope game core rule) + * depends on the device, not on the container's power control toggle: it only reads the + * grouping and moves this app's own threads, and clock control stays behind the toggle. + */ + private fun placementClusters(): List? { + if (!::driver.isInitialized) return null + val pserver = driver as? PServerDriver ?: return null + return coreClusters(pserver).takeIf { clusters -> clusters.any { it.cores.isNotEmpty() } } + } + + private const val CAPACITY_UNKNOWN = "cluster capacity unknown (max_freq only); placement skipped" + + /** One CPU cluster from PServer's grouping, with its capacity in the driver's units. */ + internal data class CoreCluster(val name: String, val cores: List, val capacity: Long) + + /** Game core set and the rule that chose it, for the log. */ + internal data class GameCoreSet(val cores: List, val rule: String) + + /** + * Cores of the low-capacity clusters: capacity below [LOW_CAPACITY_RATIO] of the device's + * largest. The Snapdragon 8 Gen 2 little cores (about a third of the prime core) are; the + * Snapdragon 8 Elite's 6 performance cores (792 of 1024) are not. Clusters of unknown + * capacity are never low. */ - private fun gameCores(pserver: PServerDriver): List { - val all = allCpuCoresSorted(pserver) - return if (all.size <= 2) all else all.drop(2) + internal fun lowCapacityCores(clusters: List): List { + val max = clusters.filter { it.cores.isNotEmpty() }.maxOfOrNull { it.capacity } ?: return emptyList() + if (max <= 0L) return emptyList() + return clusters + .filter { it.cores.isNotEmpty() && it.capacity > 0L && it.capacity < LOW_CAPACITY_RATIO * max } + .flatMap { it.cores } + .sorted() + } + + /** + * Game cores from clusters ordered lowest to highest frequency. [gamescope] (a gamescope + * session on a PServer device): every core outside the low-capacity clusters, 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). Everything else keeps the rule on master, all cores but the 2 slowest: X11 + * sessions, devices without a low-capacity cluster, and parts where fewer than + * [MIN_GAME_CLUSTER_CORES] cores are outside it (a Wine game on 2 cores). + */ + internal fun clusterGameCores(clustersLowToHigh: List, gamescope: Boolean): GameCoreSet { + val all = clustersLowToHigh.flatMap { it.cores } + val unchanged = if (all.size <= 2) all else all.drop(2) + if (!gamescope) return GameCoreSet(unchanged, "(unchanged rule)") + if (clustersLowToHigh.none { it.cores.isNotEmpty() && it.capacity > 0L }) { + return GameCoreSet(unchanged, "(unchanged rule) $CAPACITY_UNKNOWN") + } + val low = lowCapacityCores(clustersLowToHigh) + if (low.isEmpty()) return GameCoreSet(unchanged, "(unchanged rule) no low-capacity cluster") + val above = all.filter { it !in low } + if (above.size < MIN_GAME_CLUSTER_CORES) { + return GameCoreSet( + unchanged, + "(unchanged rule) only ${above.size} cores outside the low-capacity cluster [${formatCores(low)}]", + ) + } + return GameCoreSet(above, "low-capacity [${formatCores(low)}] left out") } /** @@ -1278,6 +1396,7 @@ object PowerManager { Timber.tag("PowerManager").i( "Pinned $processName (PID: $pid) to CPUs ${gameCores.joinToString()} after $attempt attempts, verified=$verified ($reason)" ) + if (gamescopeSession) applyCompositorAffinity(reason) return@Thread } @@ -1326,13 +1445,216 @@ object PowerManager { }.start() } + /** Every core, to hand the gamescope chain back on release; null while it is not placed. */ + @Volatile + internal var compositorRestoreCores: List? = null + + /** The "no PServer driver" line goes out once per session. */ + @Volatile + private var compositorOffLogged = false + + /** + * Places the gamescope compositor chain (every thread of gamescope and of its Xwayland, and + * the gndrm* threads the display daemon runs inside this app process) off the low-capacity + * clusters, on devices with a PServer driver whatever the container's power control toggle + * says. All of them run under this app's uid, so sched_setaffinity needs no privilege. Called + * when the gamescope session connects, when the session's power manager starts, on an + * activity resume and after every game pin in a gamescope session. Before the session's + * profile is loaded it does nothing: the start places it. + */ + @JvmStatic + fun applyCompositorAffinity(reason: String) { + if (!isGameStarted) return + applyCompositorAffinity(reason, placementClusters(), isProfilePowerControlEnabled(), TaskAffinity::set) + } + /** - * 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. + * [clusters] is null on a device without a PServer driver: nothing is moved then. + * [powerControlOn] only changes the log: with the toggle off the chain is placed all the same. + */ + @Synchronized + internal fun applyCompositorAffinity( + reason: String, + clusters: List?, + powerControlOn: Boolean, + setAffinity: (Int, Int) -> Int, + chainThreads: () -> ChainThreads = ::findChainThreads, + ) { + if (clusters == null) { + if (!compositorOffLogged) { + compositorOffLogged = true + Timber.tag("PowerManager").i("gamescope chain left on all cores (no PServer driver) ($reason)") + } + return + } + val placement = compositorCoreSet(clusters) + if (placement.cores.isEmpty()) { + Timber.tag("PowerManager").i("${placement.skipReason} ($reason; ${describeClusters(clusters)})") + return + } + compositorRestoreCores = clusters.flatMap { it.cores }.sorted() + if (!powerControlOn) { + Timber.tag("PowerManager").i( + "gamescope chain placed on ${formatRanges(placement.cores)} (power control off: placement only) ($reason)" + ) + } + placeCompositorChain(placement.cores, "$reason; ${describeClusters(clusters)}", setAffinity, chainThreads) + } + + /** `3-7`, `0-2,4` style core list. */ + internal fun formatRanges(cores: Collection): String { + val sorted = cores.distinct().sorted() + val parts = mutableListOf() + var i = 0 + while (i < sorted.size) { + var j = i + while (j + 1 < sorted.size && sorted[j + 1] == sorted[j] + 1) j++ + parts.add(if (j > i) "${sorted[i]}-${sorted[j]}" else "${sorted[i]}") + i = j + 1 + } + return parts.joinToString(",") + } + + /** + * Hands the gamescope chain every core back at the end of the session. Nothing happens when + * the chain was never placed. + */ + @JvmStatic + fun releaseCompositorAffinity(reason: String) { + releaseCompositorAffinity(reason, TaskAffinity::set) + } + + @Synchronized + internal fun releaseCompositorAffinity( + reason: String, + setAffinity: (Int, Int) -> Int, + chainThreads: () -> ChainThreads = ::findChainThreads, + ) { + val cores = compositorRestoreCores ?: return + compositorRestoreCores = null + placeCompositorChain(cores, reason, setAffinity, chainThreads) + } + + /** Cores for the gamescope chain, empty with the reason when it stays on all cores. */ + internal data class CompositorPlacement(val cores: List, val skipReason: String? = null) + + /** + * Every core outside the low-capacity clusters. Those clusters carry SurfaceFlinger, the HWC + * composer and the audio realtime threads at a third of the big cores' capacity (0-2 on the + * Retroid Pocket 6), and a chain thread there is preempted and posts frames after the latch. + * A device without a low-capacity cluster (the Snapdragon 8 Elite's 6+2), one where fewer than + * [MIN_GAME_CLUSTER_CORES] cores are outside it (6 little + 2 big), or one whose capacities + * are unknown is left alone. + */ + internal fun compositorCoreSet(clusters: List): CompositorPlacement { + val all = clusters.flatMap { it.cores }.sorted() + if (clusters.none { it.cores.isNotEmpty() && it.capacity > 0L }) { + return CompositorPlacement(emptyList(), CAPACITY_UNKNOWN) + } + val low = lowCapacityCores(clusters) + if (low.isEmpty()) { + return CompositorPlacement(emptyList(), "no low-capacity cluster; gamescope chain left on all cores") + } + val remaining = all.filter { it !in low } + if (remaining.size < MIN_GAME_CLUSTER_CORES) { + return CompositorPlacement( + emptyList(), + "only ${remaining.size} core(s) outside the low-capacity cluster [${formatCores(low)}]; gamescope chain left on all cores", + ) + } + return CompositorPlacement(remaining) + } + + private fun describeClusters(clusters: List): String = + "clusters " + clusters.joinToString(" ") { "${it.name}=[${it.cores.joinToString()}]@${it.capacity}" } + + " (${if (::driver.isInitialized) (driver as? PServerDriver)?.cpuClusterCapacitySource ?: "-" else "-"})" + + /** The chain's processes and threads. */ + internal data class ChainThreads( + val gamescopePid: Int?, + val xwaylandPid: Int?, + val daemonTids: List, + val tids: List, + ) + + private fun findChainThreads(): ChainThreads { + val gamescopePid = findOwnProcess("gamescope") + val xwaylandPid = findOwnProcess("Xwayland") + val daemonTids = File("/proc/self/task").listFiles().orEmpty().mapNotNull { task -> + val comm = runCatching { File(task, "comm").readText().trim() }.getOrNull() + task.name.toIntOrNull()?.takeIf { comm?.startsWith("gndrm") == true } + } + val tids = listOfNotNull(gamescopePid, xwaylandPid).flatMap { pid -> + File("/proc/$pid/task").listFiles().orEmpty().mapNotNull { it.name.toIntOrNull() } + } + daemonTids + return ChainThreads(gamescopePid, xwaylandPid, daemonTids, tids) + } + + /** + * Core list in [path], from the line starting with [key] when given, empty when unreadable. + */ + private fun readCpuList(path: String, key: String? = null): Set = runCatching { + val lines = File(path).readLines() + val value = if (key == null) lines.first() else lines.first { it.startsWith(key) }.substringAfter(':') + parseCpuList(value.trim()) + }.getOrDefault(emptySet()) + + private fun placeCompositorChain( + cores: List, + reason: String, + setAffinity: (Int, Int) -> Int, + chainThreads: () -> ChainThreads, + ) { + try { + val chain = chainThreads() + val mask = cores.fold(0) { acc, core -> acc or (1 shl core) } + var applied = 0 + chain.tids.forEach { tid -> + val errno = setAffinity(tid, mask) + if (errno == 0) { + applied++ + } else { + Timber.tag("PowerManager").w("sched_setaffinity on TID $tid failed, errno $errno") + } + } + val now = chain.gamescopePid?.let { readCpuList("/proc/$it/status", "Cpus_allowed_list") } + Timber.tag("PowerManager").i( + "Applied affinity ${cores.joinToString()} (mask 0x${Integer.toHexString(mask)}) to gamescope (PID: ${chain.gamescopePid ?: "none"}), " + + "Xwayland (PID: ${chain.xwaylandPid ?: "none"}), gndrm threads (TIDs: ${chain.daemonTids.joinToString()}), " + + "$applied of ${chain.tids.size} threads, gamescope now ${now?.let { formatCores(it) }} ($reason)" + ) + } catch (e: Exception) { + Timber.tag("PowerManager").e(e, "Failed to apply affinity to the gamescope chain") + } + } + + /** + * PID of a process this app can see whose program is [binary], given as the first argument or + * the second behind /system/bin/linker64, or null when none runs. + */ + private fun findOwnProcess(binary: String): Int? = + File("/proc").listFiles().orEmpty().firstNotNullOfOrNull { dir -> + val pid = dir.name.toIntOrNull() ?: return@firstNotNullOfOrNull null + val args = runCatching { File(dir, "cmdline").readText() }.getOrNull() + ?.split('\u0000')?.take(2) ?: return@firstNotNullOfOrNull null + pid.takeIf { args.any { it == binary || it.endsWith("/$binary") } } + } + + /** + * Cores a pinned game runs on (see [clusterGameCores]). The low-capacity rule applies only in + * a gamescope session. The 2 lowest-frequency cores stay reserved for + * PulseAudio and other background/Wine processes. */ private fun gamePinCores(pserver: PServerDriver): List { - return gameCores(pserver) + val clusters = coreClusters(pserver) + val gamescope = gamescopeSession + val set = clusterGameCores(clusters, gamescope) + val session = if (gamescope) "gamescope" else "x11" + Timber.tag("PowerManager").i( + "Game core set ${set.cores.joinToString()} ${set.rule} ($session; " + + "${describeClusters(clusters)}, background=${lowestCores(pserver, 2).joinToString()})" + ) + return set.cores } /** diff --git a/app/src/main/java/app/gamenative/powercontrol/TaskAffinity.kt b/app/src/main/java/app/gamenative/powercontrol/TaskAffinity.kt new file mode 100644 index 0000000000..a6c3c49793 --- /dev/null +++ b/app/src/main/java/app/gamenative/powercontrol/TaskAffinity.kt @@ -0,0 +1,25 @@ +package app.gamenative.powercontrol + +import timber.log.Timber + +/** + * sched_setaffinity for single tasks of this app's uid (its own threads and the processes it + * spawned), which Linux allows without privilege. Backed by libgnaffinity.so. + */ +object TaskAffinity { + private var libraryLoaded: Boolean? = null + + @JvmStatic + private external fun nativeSetTaskAffinity(tid: Int, mask: Int): Int + + /** + * @return 0 on success, the errno on failure, or -1 when the library is unavailable + */ + @Synchronized + fun set(tid: Int, mask: Int): Int { + val loaded = libraryLoaded ?: runCatching { System.loadLibrary("gnaffinity") } + .onFailure { Timber.tag("PowerManager").w(it, "libgnaffinity unavailable") } + .isSuccess.also { libraryLoaded = it } + return if (loaded) nativeSetTaskAffinity(tid, mask) else -1 + } +} 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..6df19fe181 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 ?: 0, ) 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=%s 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?.toString() ?: "-", + 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) > 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", @@ -401,6 +415,12 @@ class ClusterTuner( applied = applyWarmDomain(TunerDomain.GPU, state.gpuIndex, state.gpuStepCount) || applied if (applied) { + engine.noteWarmStart( + TunerDomain.entries.associateWith { domain -> + val last = lastIndexOf(domain) + if (last <= 0) 0 else (last - (stepIndex[domain] ?: last)).coerceAtLeast(0) + }, + ) Timber.tag(TAG).i( "Warm-start caps applied [prime=%s perf=%s gpu=%s]", capLabel(TunerDomain.PRIME), @@ -468,7 +488,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 +500,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/PerformanceAutoTuner.kt b/app/src/main/java/app/gamenative/powercontrol/autotuning/PerformanceAutoTuner.kt index c47f1cfc07..b272ad7124 100644 --- a/app/src/main/java/app/gamenative/powercontrol/autotuning/PerformanceAutoTuner.kt +++ b/app/src/main/java/app/gamenative/powercontrol/autotuning/PerformanceAutoTuner.kt @@ -247,7 +247,11 @@ class PerformanceAutoTuner( // Skip first ${WARMUP_CYCLES} cycles regardless of FPS to allow game to start if (!skipWarmupCycles && ++warmUpCycles < WARMUP_CYCLES) return - val targetFps = PowerManager.targetFps.toDouble() + val targetFps = PowerManager.tunerTargetFps( + PowerManager.targetFps, + PowerManager.gamescopeSession, + PowerManager.panelRefreshHz, + ).toDouble() val currentFps = PowerManager.currentFps.toDouble() // Skip tuning when targetFps is 0 (FPS limiter disabled) or currentFps is 0 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..b8262d8e28 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,35 @@ 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() + + /** + * Repeat step-backs that did not cut the repeats in the window after them before repeats + * stop being a step-back signal for the session. A game that hitches on its own (shader + * compiles, GC pauses, streaming) repeats whatever the clocks are; repeats still block trims. + */ + const val REPEAT_UNDO_INEFFECTIVE_LIMIT = 3 } data class StrategyTuning( @@ -141,6 +176,9 @@ private enum class Bottleneck(val key: String) { CPU("cpu"), } +/** One trimmed step: made by this session, or carried in by a warm start. */ +private data class TrimEntry(val domain: TunerDomain, val warmStart: Boolean) + /** * Pure decision logic for [ClusterTuner]: a snapshot plus the current per-domain * capabilities in, one action out. Holds all tuning state and has no Android or @@ -170,6 +208,15 @@ 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 repeatUndoBaseline = -1 + private var repeatUndoWarm = false + private var ineffectiveRepeatUndos = 0 + private var repeatStepBackOff = false + private var lastTargetFps = 0 + private var lastBottleneck: Bottleneck? = null private var harvestBottleneck: Bottleneck? = null private var harvestQualifiedCycles = 0 @@ -212,6 +259,18 @@ class TunerDecisionEngine( } steadyCycles++ updateRenderAverages(input, slowRatio) + if (input.targetFps != lastTargetFps) { + // A new cap re-scores the window: a step-back in flight can no longer be judged. + if (lastTargetFps != 0) { + repeatUndoCooldownCycles = 0 + repeatUndoBaseline = -1 + } + lastTargetFps = input.targetFps + } + if (repeatUndoCooldownCycles > 0) { + repeatUndoCooldownCycles-- + if (repeatUndoCooldownCycles == 0) judgeRepeatUndo(input.repeatedFrameCount) + } val target = input.targetFps.toFloat() val targetPeriodMs = 1000f / target @@ -258,12 +317,33 @@ 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 entry = repeatUndoTarget(input) + if (entry != null) { + val domain = entry.domain + frozen.add(domain) + trimHistory.remove(entry) + repeatUndoCooldownCycles = TunerThresholds.REPEAT_UNDO_COOLDOWN_CYCLES + repeatUndoBaseline = repeated + repeatUndoWarm = entry.warmStart + return decision(TunerAction.UNDO, domain, 1, "repeat-undo:${domain.key} ${repeatLabel(input)}") + } + } + val note = if (repeatStepBackOff) " step-back off" else "" + return decision(TunerAction.HOLD, null, 0, "repeat-hold ${repeatLabel(input)}$note") + } + if (isHealthy(input, slowRatio, targetPeriodMs, tuning)) { trimQualifiedCycles++ } else { @@ -277,6 +357,7 @@ class TunerDecisionEngine( trimQualifiedCycles = 0 watchedDomain = domain watchCyclesRemaining = tuning.trimWatchCycles + trimHistory.add(TrimEntry(domain, warmStart = false)) return decision(TunerAction.TRIM, domain, 1, "trim:${domain.key}") } return decision(TunerAction.HOLD, null, 0, "trim:no-domain") @@ -285,6 +366,22 @@ class TunerDecisionEngine( return decision(TunerAction.HOLD, null, 0, if (steadyEnough) "hold" else "warmup") } + /** + * Records warm-start caps as trims made this session, [stepsByDomain] steps below the top per + * domain, so repeats can step them back like any other trim. A state file written by an + * earlier session can hold a domain at its floor; without this the floor would only ever be + * held. Entries interleave, the most-trimmed domain newest, so step-backs start there. + */ + fun noteWarmStart(stepsByDomain: Map) { + val most = stepsByDomain.values.maxOrNull() ?: return + for (step in 0 until most) { + stepsByDomain.entries + .filter { it.value > step } + .sortedBy { it.value } + .forEach { trimHistory.add(TrimEntry(it.key, warmStart = true)) } + } + } + /** * Drops the frozen set and any in-flight trim watch, the same trim state a raise clears. * For owners that open every domain outside the decision path. @@ -296,6 +393,9 @@ class TunerDecisionEngine( clearRenderAverages() trimQualifiedCycles = 0 lastBottleneck = null + trimHistory.clear() + repeatUndoCooldownCycles = 0 + repeatUndoBaseline = -1 } fun slowFrameRatio(input: TunerInput): Float { @@ -303,6 +403,42 @@ class TunerDecisionEngine( return input.slowFrameCount.toFloat() / input.totalFrameCount.toFloat() } + /** + * The domain a repeat step-back raises: the latest trim made this session that can still go + * up. Without one the repeats are not the tuner's doing, and nothing is raised. + */ + private fun repeatUndoTarget(input: TunerInput): TrimEntry? { + return trimHistory.lastOrNull { entry -> + (entry.warmStart || !repeatStepBackOff) && input.capabilities[entry.domain]?.canRaise == true + } + } + + /** + * Judges a repeat step-back once its window has turned over: one that left at least as many + * repeats as it started from did not help, and after + * [TunerThresholds.REPEAT_UNDO_INEFFECTIVE_LIMIT] of those repeats stop stepping back. + */ + private fun judgeRepeatUndo(repeated: Int) { + val baseline = repeatUndoBaseline + repeatUndoBaseline = -1 + // A warm-start floor is the known-bad state: stepping it back is never held against + // the repeat signal, it goes on until the floor is gone or the repeats stop. + if (repeatUndoWarm || baseline < 0 || repeated < baseline) return + ineffectiveRepeatUndos++ + if (ineffectiveRepeatUndos >= TunerThresholds.REPEAT_UNDO_INEFFECTIVE_LIMIT) { + repeatStepBackOff = true + } + } + + private fun forgetTrim(domain: TunerDomain) { + val index = trimHistory.indexOfLast { it.domain == domain && !it.warmStart } + .takeIf { it >= 0 } ?: trimHistory.indexOfLast { it.domain == 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 +497,7 @@ class TunerDecisionEngine( harvestBaselineFps = averageFps ?: input.fps harvestBaselineP95Ms = averageP95Ms ?: input.frameTimeP95Ms harvestBaselineSlowRatio = averageSlowRatio ?: slowRatio + trimHistory.add(TrimEntry(domain, warmStart = false)) return decision(TunerAction.TRIM, domain, 1, "harvest:${domain.key}") } @@ -387,6 +524,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 +612,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/drivers/PServerDriver.kt b/app/src/main/java/app/gamenative/powercontrol/drivers/PServerDriver.kt index bf52ab9b4b..36e70fb383 100644 --- a/app/src/main/java/app/gamenative/powercontrol/drivers/PServerDriver.kt +++ b/app/src/main/java/app/gamenative/powercontrol/drivers/PServerDriver.kt @@ -98,6 +98,14 @@ class PServerDriver(private val context: Context? = null) : PerformanceDriver() // CPU cluster mapping for affinity control private var cpuClusters: Map> = emptyMap() + // Per-cluster capacity: the kernel's cpu_capacity (1024 = the fastest core) when every + // policy reports one, else cpuinfo_max_freq in kHz. Read with the cluster grouping. + private var cpuClusterCapacities: Map = emptyMap() + + /** What [getCpuClusterCapacity] is measured in: "cpu_capacity", "max_freq" or "none". */ + var cpuClusterCapacitySource: String = "none" + private set + // Track modified sysfs files for permission restoration private val modifiedSysfsFiles = mutableSetOf() @@ -1538,6 +1546,8 @@ class PServerDriver(private val context: Context? = null) : PerformanceDriver() } } + identifyClusterCapacities(clusters, policiesWithFreq) + Timber.tag(TAG).i("Identified CPU clusters:") clusters.forEach { (cluster, cores) -> val freq = policiesWithFreq.find { cores.intersect(it.cpuCores.toSet()).isNotEmpty() }?.maxFrequency ?: 0 @@ -1547,6 +1557,28 @@ class PServerDriver(private val context: Context? = null) : PerformanceDriver() return clusters } + /** + * Sets [cpuClusterCapacities]: per cluster the largest cpu_capacity of its policies (read from + * each policy's first core), or the largest cpuinfo_max_freq when any policy has no + * cpu_capacity. Capacity is the better measure: on a Snapdragon 8 Gen 2 the little cores run + * at 63 % of the prime core's frequency but have about a third of its capacity. + */ + private fun identifyClusterCapacities(clusters: Map>, policies: List) { + val capacities = policies.map { policy -> + policy.cpuCores.firstOrNull()?.let { cpu -> + readSysfsFile("/sys/devices/system/cpu/cpu$cpu/cpu_capacity")?.toLongOrNull() + }?.takeIf { it > 0 } + } + val useCapacity = capacities.isNotEmpty() && capacities.all { it != null } + cpuClusterCapacitySource = if (useCapacity) "cpu_capacity" else "max_freq" + cpuClusterCapacities = clusters.mapValues { (_, cores) -> + policies.indices + .filter { i -> policies[i].cpuCores.any { it in cores } } + .maxOfOrNull { i -> if (useCapacity) capacities[i]!! else policies[i].maxFrequency } ?: 0L + } + Timber.tag(TAG).i("CPU cluster capacities ($cpuClusterCapacitySource): $cpuClusterCapacities") + } + // ======================================== // CPU Affinity / Process Pinning // ======================================== @@ -1561,6 +1593,13 @@ class PServerDriver(private val context: Context? = null) : PerformanceDriver() return cpuClusters[cluster] ?: emptyList() } + /** + * Capacity of [cluster] in [cpuClusterCapacitySource] units, 0 when unknown. + */ + fun getCpuClusterCapacity(cluster: CpuCluster): Long { + return cpuClusterCapacities[cluster] ?: 0L + } + /** * Get the number of CPU clusters identified. * Used to determine optimal pinning strategy. 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..5b16ccb023 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,49 @@ 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 } + +/** + * Whether repeated frames are counted: only in gamescope sessions, where the ring takes one entry + * per frame gamescope publishes on its vblank grid, and not with frame generation on + * ([sampleStride] > 1), where generated frames tick the ring. X11 sessions feed the ring from + * their own present hooks and keep the tuner as it was. + */ +fun repeatCountingActive(gamescopeSession: Boolean, sampleStride: Int): Boolean = + gamescopeSession && sampleStride <= 1 + +/** + * Interval above which a published frame counts as a repeat in a gamescope session, or 0 when + * the display rate is unknown. Gamescope runs a capped game at a whole number of vblanks per + * frame, divisor = max(1, floor(refresh / cap)) (cap above refresh, or no cap: 1), so a frame is + * expected one divisor of display periods after the last, and a repeat adds at least one more + * vblank: the threshold sits half a display period above the expected interval. + * Cap 60 @ 60 Hz: 25 ms; cap 60 @ 120: 20.8 ms; cap 30 @ 60: 41.7 ms; cap 40 @ 120: 29.2 ms. + */ +fun repeatThresholdNs(capFps: Int, refreshHz: Int): Long { + if (refreshHz <= 0) return 0L + val periodNs = 1_000_000_000.0 / refreshHz + val divisor = if (capFps > 0) (refreshHz / capFps).coerceAtLeast(1) else 1 + return (periodNs * divisor + periodNs * 0.5).toLong() +} + +/** + * Number of intervals between [count] ascending timestamps in [timestampsNs] (taken + * [sampleStride] frames apart, as in [computeFrameWindowStats]) longer than [thresholdNs]. + * With [repeatThresholdNs] 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..2a4fda6aa2 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,9 @@ 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, + /** + * Repeated frames in the last 10 s (the tuner's repeat window, see [repeatThresholdNs]); null + * outside gamescope sessions and with frame generation on, where it is not counted. + */ + val repeatedFrameCount: Int? = null, ) 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..e620b467e0 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,9 @@ object PerformanceMetricsCollector { private val frameScratch = LongArray(FrameTimeRing.capacity()) private val deltaScratch = LongArray(FrameTimeRing.capacity()) + private val repeatScratch = LongArray(FrameTimeRing.capacity()) + private var repeatWindowKey: List? = null + private var repeatWindowStartNs = 0L private val sessionLog = JsonlSessionLog(TAG, "metrics-", MAX_LOG_BYTES, MAX_SESSION_FILES) private var sampleCount = 0L @@ -160,6 +164,7 @@ object PerformanceMetricsCollector { deltaScratch, PowerManager.frameSampleStride, ) + val repeatedFrames = countRepeatedFrames(now) val cpu = cpuSampler.sample() val gpu = gpuSampler.sample() @@ -205,6 +210,7 @@ object PerformanceMetricsCollector { cpuTempC = cpuTemp?.celsius, gpuTempC = gpuTemp?.celsius, thermalHeadroom = ThermalHeadroom.latest, + repeatedFrameCount = repeatedFrames, ) publish(snapshot) @@ -215,11 +221,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=%s in 10s cpu=%s%%(%s) gpu=%s%% cpuTemp=%s gpuTemp=%s", snapshot.fps, snapshot.frameTimeP95Ms, snapshot.slowFrameCount, snapshot.totalFrameCount, + snapshot.repeatedFrameCount?.toString() ?: "-", snapshot.cpuUsagePercent?.toInt()?.toString() ?: "-", snapshot.cpuUsageSource.name, snapshot.gpuUsagePercent?.toInt()?.toString() ?: "-", @@ -237,6 +244,30 @@ object PerformanceMetricsCollector { PowerManager.gpuUsageKnown = snapshot.gpuUsagePercent != null } + /** + * Repeated frames over the tuner's repeat window, or null when they are not counted: only + * gamescope sessions publish one ring entry per frame gamescope flipped on its vblank grid, + * and with frame generation on the ring carries generated frames, so neither X11 sessions nor + * generation count repeats. A change of cap or display rate re-scores the window, so it + * restarts empty then. + */ + private fun countRepeatedFrames(now: Long): Int? { + if (!repeatCountingActive(PowerManager.gamescopeSession, PowerManager.frameSampleStride)) { + repeatWindowKey = null + return null + } + val refreshHz = PowerManager.panelRefreshHz.takeIf { it > 0 } ?: Math.round(displayRefreshRate) + val capFps = PowerManager.targetFps + val key = listOf(capFps, refreshHz) + if (key != repeatWindowKey) { + repeatWindowKey = key + repeatWindowStartNs = now + } + val since = maxOf(now - TunerThresholds.REPEAT_WINDOW_MS * 1_000_000L, repeatWindowStartNs) + val count = FrameTimeRing.copySince(since, repeatScratch) + return countSlowIntervals(repeatScratch, count, repeatThresholdNs(capFps, refreshHz)) + } + private fun slowFrameThresholdNs(): Long { val targetFps = PowerManager.targetFps val referenceFps = if (targetFps > 0) targetFps.toFloat() else displayRefreshRate diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index 4449c5668c..07c920cb50 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -749,6 +749,7 @@ fun XServerScreen( fun applyPanelRefresh(panelHz: Int = detectRefreshRateHz(context, ui.xServerView as? View)) { if (isX11Display || panelHz <= 0) return GameNativeDrm.nativeSetPanelRefreshHz(panelHz) + PowerManager.panelRefreshHz = panelHz // The daemon's connector advertises the new rate from here on, but gamescope reads the mode // once, when the session starts: dirty its backend so a display change becomes the mode now // rather than on the next launch. Only on a change — a nudge forces a modeset — and not on @@ -2555,6 +2556,8 @@ fun XServerScreen( immersiveHooks, ) + // The game core set and the tuner's repeated-frame input differ by session type. + PowerManager.gamescopeSession = sessionDisplay == SessionDisplay.GAMESCOPE // Autostart performance driver after environment is set up PowerManager.autoStart(container.rootDir) diff --git a/app/src/main/jniLibs/arm64-v8a/libgamenative_drm.so b/app/src/main/jniLibs/arm64-v8a/libgamenative_drm.so index 163bb0b45e..2ffe3e5c30 100755 Binary files a/app/src/main/jniLibs/arm64-v8a/libgamenative_drm.so and b/app/src/main/jniLibs/arm64-v8a/libgamenative_drm.so differ diff --git a/app/src/main/jniLibs/arm64-v8a/libgnaffinity.so b/app/src/main/jniLibs/arm64-v8a/libgnaffinity.so new file mode 100755 index 0000000000..ba89f158e7 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libgnaffinity.so differ 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..a8396af26c --- /dev/null +++ b/app/src/test/java/app/gamenative/powercontrol/GameCoreSetTest.kt @@ -0,0 +1,251 @@ +package app.gamenative.powercontrol + +import app.gamenative.powercontrol.PowerManager.ChainThreads +import app.gamenative.powercontrol.PowerManager.CoreCluster +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test + +/** + * Core placement with the cluster layouts PServerDriver reports on real devices, lowest to + * highest frequency, each with its cpu_capacity. + */ +class GameCoreSetTest { + + // Retroid Pocket 6 (Snapdragon 8 Gen 2): c0-2 little at about a third of the prime core, + // c3-6 big, c7 prime. + private val rp6 = listOf( + CoreCluster("efficiency", listOf(0, 1, 2), 325), + CoreCluster("performance", listOf(3, 4, 5, 6), 825), + CoreCluster("prime", listOf(7), 1024), + ) + + // The same RP6 when the kernel reports no cpu_capacity: max frequency in kHz. The little + // cores run at 63 % of the prime core's frequency, above the 0.6 line. + private val rp6ByFrequency = listOf( + CoreCluster("efficiency", listOf(0, 1, 2), 2_016_000), + CoreCluster("performance", listOf(3, 4, 5, 6), 2_803_200), + CoreCluster("prime", listOf(7), 3_187_200), + ) + + // AYN Odin 3 (Snapdragon 8 Elite class 'sun'): 6 cores at capacity 792, 2 at 1024. PServer + // groups a dual-cluster part as performance + prime. + private val odin3 = listOf( + CoreCluster("efficiency", emptyList(), 0), + CoreCluster("performance", listOf(0, 1, 2, 3, 4, 5), 792), + CoreCluster("prime", listOf(6, 7), 1024), + ) + + // A 4+4 phone: 4 little cores at about 400, 4 big at 1024. + private val fourFour = listOf( + CoreCluster("efficiency", emptyList(), 0), + CoreCluster("performance", listOf(0, 1, 2, 3), 400), + CoreCluster("prime", listOf(4, 5, 6, 7), 1024), + ) + + // A 6+2 part whose 6 cores are little (about 400 of 1024). + private val sixLittleTwoBig = listOf( + CoreCluster("efficiency", emptyList(), 0), + CoreCluster("performance", listOf(0, 1, 2, 3, 4, 5), 400), + CoreCluster("prime", listOf(6, 7), 1024), + ) + + private val chain = ChainThreads(gamescopePid = 4242, xwaylandPid = 4243, daemonTids = listOf(100, 101), tids = listOf(4242, 4250, 4243, 100, 101)) + + private val calls = mutableListOf>() + private val recordAffinity: (Int, Int) -> Int = { tid, mask -> calls.add(tid to mask); 0 } + private val noAffinity: (Int, Int) -> Int = { tid, _ -> fail("unexpected sched_setaffinity on $tid"); 0 } + private val noThreads: () -> ChainThreads = { fail("unexpected chain lookup"); chain } + + @Before + @After + fun clearPlacement() { + PowerManager.compositorRestoreCores = null + calls.clear() + } + + // ---- game core set + + @Test + fun `RP6 gamescope game runs outside the little cluster`() { + val set = PowerManager.clusterGameCores(rp6, gamescope = true) + assertEquals(listOf(3, 4, 5, 6, 7), set.cores) + assertEquals("low-capacity [0, 1, 2] left out", set.rule) + } + + @Test + fun `x11 keeps all cores but the two slowest`() { + assertEquals(listOf(2, 3, 4, 5, 6, 7), PowerManager.clusterGameCores(rp6, gamescope = false).cores) + assertEquals(listOf(2, 3, 4, 5, 6, 7), PowerManager.clusterGameCores(fourFour, gamescope = false).cores) + assertEquals(listOf(2, 3, 4, 5, 6, 7), PowerManager.clusterGameCores(odin3, gamescope = false).cores) + assertEquals("(unchanged rule)", PowerManager.clusterGameCores(rp6, gamescope = false).rule) + } + + @Test + fun `Odin 3 gamescope game keeps the unchanged rule`() { + val set = PowerManager.clusterGameCores(odin3, gamescope = true) + assertEquals(listOf(2, 3, 4, 5, 6, 7), set.cores) + assertTrue(set.rule, set.rule.startsWith("(unchanged rule)")) + } + + @Test + fun `four-four gamescope game runs on the big cluster`() { + val set = PowerManager.clusterGameCores(fourFour, gamescope = true) + assertEquals(listOf(4, 5, 6, 7), set.cores) + } + + @Test + fun `six little two big does not pin the chain`() { + val placement = PowerManager.compositorCoreSet(sixLittleTwoBig) + assertEquals(emptyList(), placement.cores) + assertTrue(placement.skipReason!!, placement.skipReason!!.startsWith("only 2 core(s) outside the low-capacity cluster")) + PowerManager.applyCompositorAffinity("gamescope session start", sixLittleTwoBig, true, noAffinity, noThreads) + assertNull(PowerManager.compositorRestoreCores) + } + + @Test + fun `fewer than four cores outside the low-capacity cluster keeps the unchanged rule`() { + val set = PowerManager.clusterGameCores(sixLittleTwoBig, gamescope = true) + assertEquals(listOf(2, 3, 4, 5, 6, 7), set.cores) + assertTrue(set.rule, set.rule.startsWith("(unchanged rule) only 2 cores")) + } + + @Test + fun `single cluster keeps the unchanged rule`() { + val single = listOf( + CoreCluster("efficiency", emptyList(), 0), + CoreCluster("performance", listOf(0, 1, 2, 3), 1024), + CoreCluster("prime", emptyList(), 0), + ) + assertEquals(listOf(2, 3), PowerManager.clusterGameCores(single, gamescope = true).cores) + } + + @Test + fun `unknown capacities keep the unchanged rule`() { + val unknown = rp6.map { it.copy(capacity = 0) } + assertEquals(listOf(2, 3, 4, 5, 6, 7), PowerManager.clusterGameCores(unknown, gamescope = true).cores) + } + + // ---- low-capacity criterion + + @Test + fun `low capacity is below 0_6 of the device max`() { + assertEquals(listOf(0, 1, 2), PowerManager.lowCapacityCores(rp6)) + assertEquals(emptyList(), PowerManager.lowCapacityCores(odin3)) + assertEquals(listOf(0, 1, 2, 3), PowerManager.lowCapacityCores(fourFour)) + // RP6 by max frequency: 2016 / 3187 MHz = 0.63, not low. + assertEquals(emptyList(), PowerManager.lowCapacityCores(rp6ByFrequency)) + } + + // ---- compositor chain set + + @Test + fun `RP6 chain goes outside the little cluster`() { + val placement = PowerManager.compositorCoreSet(rp6) + assertEquals(listOf(3, 4, 5, 6, 7), placement.cores) + assertNull(placement.skipReason) + } + + @Test + fun `Odin 3 chain is not pinned`() { + val placement = PowerManager.compositorCoreSet(odin3) + assertEquals(emptyList(), placement.cores) + assertEquals("no low-capacity cluster; gamescope chain left on all cores", placement.skipReason) + } + + @Test + fun `four-four chain goes on the big cluster`() { + assertEquals(listOf(4, 5, 6, 7), PowerManager.compositorCoreSet(fourFour).cores) + } + + // ---- placement through the gate + + @Test + fun `a device without a PServer driver makes no affinity call and does not look up the chain`() { + PowerManager.applyCompositorAffinity("gamescope session start", null, true, noAffinity, noThreads) + PowerManager.applyCompositorAffinity("activity resume", null, false, noAffinity, noThreads) + PowerManager.applyCompositorAffinity("game start", null, true, noAffinity, noThreads) + assertNull(PowerManager.compositorRestoreCores) + PowerManager.releaseCompositorAffinity("gamescope session end", noAffinity, noThreads) + } + + @Test + fun `RP6 places every chain thread on 3-7 and releases to all cores`() { + PowerManager.applyCompositorAffinity("gamescope session start", rp6, true, recordAffinity) { chain } + assertEquals(chain.tids.map { it to 0xF8 }, calls) + assertNotNull(PowerManager.compositorRestoreCores) + + calls.clear() + PowerManager.releaseCompositorAffinity("gamescope session end", recordAffinity) { chain } + assertEquals(chain.tids.map { it to 0xFF }, calls) + assertNull(PowerManager.compositorRestoreCores) + + // A second release has nothing to hand back. + PowerManager.releaseCompositorAffinity("gamescope session end", noAffinity, noThreads) + } + + @Test + fun `Odin 3 makes no affinity call at session start or end`() { + PowerManager.applyCompositorAffinity("gamescope session start", odin3, true, noAffinity, noThreads) + PowerManager.releaseCompositorAffinity("gamescope session end", noAffinity, noThreads) + } + + @Test + fun `four-four places the chain on 4-7`() { + PowerManager.applyCompositorAffinity("game start", fourFour, true, recordAffinity) { chain } + assertEquals(chain.tids.map { it to 0xF0 }, calls) + } + + @Test + fun `RP6 with power control off in the profile still places the chain on 3-7`() { + PowerManager.applyCompositorAffinity("gamescope session start", rp6, false, recordAffinity) { chain } + assertEquals(chain.tids.map { it to 0xF8 }, calls) + assertEquals((0..7).toList(), PowerManager.compositorRestoreCores) + assertEquals("3-7", PowerManager.formatRanges(listOf(3, 4, 5, 6, 7))) + assertEquals("0-2,4,6-7", PowerManager.formatRanges(listOf(7, 0, 1, 2, 4, 6))) + } + + // ---- capacity source + + @Test + fun `max frequency capacities never pin the chain nor change the game rule`() { + val byFrequency = PowerManager.knownCapacities(rp6ByFrequency, "max_freq") + assertTrue(byFrequency.all { it.capacity == 0L }) + val placement = PowerManager.compositorCoreSet(byFrequency) + assertEquals(emptyList(), placement.cores) + assertEquals("cluster capacity unknown (max_freq only); placement skipped", placement.skipReason) + val game = PowerManager.clusterGameCores(byFrequency, gamescope = true) + assertEquals(listOf(2, 3, 4, 5, 6, 7), game.cores) + assertEquals("(unchanged rule) cluster capacity unknown (max_freq only); placement skipped", game.rule) + PowerManager.applyCompositorAffinity("gamescope session start", byFrequency, true, noAffinity, noThreads) + // Even a frequency spread that would cross the 0.6 line is ignored. + val wideSpread = PowerManager.knownCapacities(fourFour, "max_freq") + assertEquals(emptyList(), PowerManager.compositorCoreSet(wideSpread).cores) + assertEquals(fourFour, PowerManager.knownCapacities(fourFour, "cpu_capacity")) + } + + // ---- gamescope-only fences + + @Test + fun `tuner aims for the panel rate only on the gamescope route`() { + assertEquals(60, PowerManager.tunerTargetFps(120, gamescope = true, panelHz = 60)) + assertEquals(60, PowerManager.tunerTargetFps(60, gamescope = true, panelHz = 120)) + assertEquals(45, PowerManager.tunerTargetFps(45, gamescope = true, panelHz = 60)) + assertEquals(120, PowerManager.tunerTargetFps(120, gamescope = true, panelHz = 0)) + assertEquals(0, PowerManager.tunerTargetFps(0, gamescope = true, panelHz = 60)) + } + + @Test + fun `x11 tuner target is the cap whatever the panel rate`() { + for (cap in listOf(0, 30, 45, 60, 90, 120, 144)) { + for (panel in listOf(0, 60, 90, 120)) { + assertEquals(cap, PowerManager.tunerTargetFps(cap, gamescope = false, panelHz = panel)) + } + } + } +} diff --git a/app/src/test/java/app/gamenative/powercontrol/autotuning/MasterTunerDecisionEngine.kt b/app/src/test/java/app/gamenative/powercontrol/autotuning/MasterTunerDecisionEngine.kt new file mode 100644 index 0000000000..a3f63a17e4 --- /dev/null +++ b/app/src/test/java/app/gamenative/powercontrol/autotuning/MasterTunerDecisionEngine.kt @@ -0,0 +1,394 @@ +package app.gamenative.powercontrol.autotuning + +// TunerDecisionEngine as on origin/master, verbatim apart from the class names, kept as the +// reference TunerMasterEquivalenceTest compares the current engine against. + +/** + * Which side of the pipeline holds the frame rate back while the target is out of reach. + * GPU busy is trusted directly; CPU-boundness is inferred from an idle GPU, because a + * single-thread-bound game never pushes the total CPU percent high. + */ +private enum class MasterBottleneck(val key: String) { + GPU("gpu"), + CPU("cpu"), +} + +/** + * Pure decision logic for [ClusterTuner]: a snapshot plus the current per-domain + * capabilities in, one action out. Holds all tuning state and has no Android or + * sysfs dependency, so it can be exercised on the JVM. + */ +class MasterTunerDecisionEngine( + private val strategyTuning: () -> StrategyTuning = { StrategyTuning.BALANCED }, +) { + + private val frozen = LinkedHashSet() + + var steadyCycles: Int = 0 + private set + + /** + * True while the engine is harvesting: the target is out of reach, the side holding the + * frame rate back has nothing left to give, and the steps being taken come off a domain + * that is not the bottleneck. Owners use it to tell "we are saving power we do not need" + * apart from "we are still holding performance back". + */ + var harvesting: Boolean = false + private set + + private var cycleCount = 0 + private var raiseCycles = 0 + private var trimQualifiedCycles = 0 + private var watchCyclesRemaining = 0 + private var watchedDomain: TunerDomain? = null + + private var lastBottleneck: MasterBottleneck? = null + private var harvestBottleneck: MasterBottleneck? = null + private var harvestQualifiedCycles = 0 + private var harvestWatchCyclesRemaining = 0 + private var harvestWatchedDomain: TunerDomain? = null + private var harvestBaselineFps = 0f + private var harvestBaselineP95Ms = 0f + private var harvestBaselineSlowRatio = 0f + private var harvestSettleCyclesRemaining = 0 + private var harvestDegradedCycles = 0 + + private var averageFps: Float? = null + private var averageP95Ms: Float? = null + private var averageSlowRatio: Float? = null + + val frozenDomains: Set + get() = frozen.toSet() + + fun decide(input: TunerInput): TunerDecision { + cycleCount++ + harvesting = false + if (cycleCount % TunerThresholds.REPROBE_INTERVAL_CYCLES == 0) { + frozen.clear() + } + + val slowRatio = slowFrameRatio(input) + + if (input.targetFps <= 0) { + return decision(TunerAction.HOLD, null, 0, "no-target") + } + + if (input.fps <= 0f || input.totalFrameCount <= 0) { + steadyCycles = 0 + raiseCycles = 0 + trimQualifiedCycles = 0 + clearHarvest() + clearRenderAverages() + lastBottleneck = null + return decision(TunerAction.HOLD, null, 0, "not-steady") + } + steadyCycles++ + updateRenderAverages(input, slowRatio) + + val target = input.targetFps.toFloat() + val targetPeriodMs = 1000f / target + val tuning = strategyTuning() + + val missingTarget = input.fps < target - tuning.fpsRaiseDeficitFps + val slowFrameSpike = slowRatio > TunerThresholds.SLOW_FRAME_RAISE_RATIO + + if (missingTarget || slowFrameSpike) { + val bottleneck = classifyBottleneck(input) + if (bottleneck != null && isBottleneckMaxed(input, bottleneck)) { + return harvest(input, slowRatio, bottleneck, tuning) + } + + raiseCycles++ + trimQualifiedCycles = 0 + clearWatch() + clearHarvest() + + val domain = raiseTarget(input) + if (domain == null) { + return decision(TunerAction.HOLD, null, 0, "raise:uncapped") + } + + val steps = if (raiseCycles >= tuning.raiseCyclesBeforeUncap) { + TunerThresholds.STEPS_UNCAPPED + } else { + TunerThresholds.RAISE_STEP_COUNT + } + return decision(TunerAction.RAISE, domain, steps, "raise:${domain.key}") + } + raiseCycles = 0 + harvestBottleneck = null + harvestQualifiedCycles = 0 + + val harvestWatched = harvestWatchedDomain + if (harvestWatchCyclesRemaining > 0 && harvestWatched != null) { + return harvestWatch(input, slowRatio, harvestWatched) + } + + val watched = watchedDomain + if (watchCyclesRemaining > 0 && watched != null) { + if (isDegraded(input, slowRatio, targetPeriodMs, tuning)) { + frozen.add(watched) + clearWatch() + trimQualifiedCycles = 0 + return decision(TunerAction.UNDO, watched, 1, "undo:${watched.key}") + } + watchCyclesRemaining-- + return decision(TunerAction.HOLD, watched, 0, "watch:${watched.key}") + } + + if (isHealthy(input, slowRatio, targetPeriodMs, tuning)) { + trimQualifiedCycles++ + } else { + trimQualifiedCycles = 0 + } + + val steadyEnough = steadyCycles >= TunerThresholds.STEADY_RENDER_CYCLES + if (steadyEnough && trimQualifiedCycles >= tuning.trimQualifyCycles) { + val domain = trimTarget(input) + if (domain != null) { + trimQualifiedCycles = 0 + watchedDomain = domain + watchCyclesRemaining = tuning.trimWatchCycles + return decision(TunerAction.TRIM, domain, 1, "trim:${domain.key}") + } + return decision(TunerAction.HOLD, null, 0, "trim:no-domain") + } + + return decision(TunerAction.HOLD, null, 0, if (steadyEnough) "hold" else "warmup") + } + + /** + * Drops the frozen set and any in-flight trim watch, the same trim state a raise clears. + * For owners that open every domain outside the decision path. + */ + fun releaseHolds() { + frozen.clear() + clearWatch() + clearHarvest() + clearRenderAverages() + trimQualifiedCycles = 0 + lastBottleneck = null + } + + fun slowFrameRatio(input: TunerInput): Float { + if (input.totalFrameCount <= 0) return 0f + return input.slowFrameCount.toFloat() / input.totalFrameCount.toFloat() + } + + 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. + val gpuUsage = input.gpuUsagePercent + val gpuBound = gpuUsage != null && gpuUsage >= TunerThresholds.GPU_BOUND_USAGE_PERCENT + val order = if (gpuBound) { + listOf(TunerDomain.GPU, TunerDomain.PRIME, TunerDomain.PERFORMANCE) + } else { + listOf(TunerDomain.PRIME, TunerDomain.PERFORMANCE, TunerDomain.GPU) + } + return order.firstOrNull { input.capabilities[it]?.canRaise == true } + } + + /** + * Runs while the target is out of reach and the bottleneck side has no headroom left: + * qualifies over consecutive cycles that agree on the bottleneck, then trims one step + * off a domain that is not the bottleneck and watches the result against the frame + * rate that step started from. + */ + private fun harvest( + input: TunerInput, + slowRatio: Float, + bottleneck: MasterBottleneck, + tuning: StrategyTuning, + ): TunerDecision { + harvesting = true + raiseCycles = 0 + trimQualifiedCycles = 0 + clearWatch() + + val watched = harvestWatchedDomain + if (harvestWatchCyclesRemaining > 0 && watched != null) { + return harvestWatch(input, slowRatio, watched) + } + + if (bottleneck != harvestBottleneck) { + harvestBottleneck = bottleneck + harvestQualifiedCycles = 1 + } else { + harvestQualifiedCycles++ + } + + val steadyEnough = steadyCycles >= TunerThresholds.STEADY_RENDER_CYCLES + if (!steadyEnough || harvestQualifiedCycles < tuning.trimQualifyCycles) { + return decision(TunerAction.HOLD, null, 0, "harvest-qualify:${bottleneck.key}") + } + + val domain = harvestTarget(input, bottleneck) + ?: return decision(TunerAction.HOLD, null, 0, "harvest-none:${bottleneck.key}") + + harvestQualifiedCycles = 0 + harvestWatchedDomain = domain + harvestWatchCyclesRemaining = TunerThresholds.HARVEST_WATCH_CYCLES + harvestSettleCyclesRemaining = TunerThresholds.HARVEST_SETTLE_CYCLES + harvestDegradedCycles = 0 + harvestBaselineFps = averageFps ?: input.fps + harvestBaselineP95Ms = averageP95Ms ?: input.frameTimeP95Ms + harvestBaselineSlowRatio = averageSlowRatio ?: slowRatio + return decision(TunerAction.TRIM, domain, 1, "harvest:${domain.key}") + } + + /** + * The frames measured on the cycle right after a trim were mostly rendered before it + * landed, so the first cycles of a watch only settle. After that a single degraded + * reading is treated as noise; [TunerThresholds.HARVEST_DEGRADED_CYCLES] consecutive + * ones give the step back. + */ + private fun harvestWatch(input: TunerInput, slowRatio: Float, watched: TunerDomain): TunerDecision { + harvesting = true + harvestWatchCyclesRemaining-- + + if (harvestSettleCyclesRemaining > 0) { + harvestSettleCyclesRemaining-- + if (harvestWatchCyclesRemaining <= 0) { + clearHarvestWatch() + } + return decision(TunerAction.HOLD, watched, 0, "harvest-settle:${watched.key}") + } + + if (isHarvestDegraded(input, slowRatio)) { + harvestDegradedCycles++ + if (harvestDegradedCycles >= TunerThresholds.HARVEST_DEGRADED_CYCLES) { + frozen.add(watched) + clearHarvest() + return decision(TunerAction.UNDO, watched, 1, "harvest-undo:${watched.key}") + } + } else { + harvestDegradedCycles = 0 + } + + if (harvestWatchCyclesRemaining <= 0) { + clearHarvestWatch() + } + return decision(TunerAction.HOLD, watched, 0, "harvest-watch:${watched.key}") + } + + private fun updateRenderAverages(input: TunerInput, slowRatio: Float) { + val alpha = TunerThresholds.HARVEST_BASELINE_ALPHA + averageFps = averageFps?.let { it + alpha * (input.fps - it) } ?: input.fps + averageP95Ms = averageP95Ms?.let { it + alpha * (input.frameTimeP95Ms - it) } ?: input.frameTimeP95Ms + averageSlowRatio = averageSlowRatio?.let { it + alpha * (slowRatio - it) } ?: slowRatio + } + + private fun clearRenderAverages() { + averageFps = null + averageP95Ms = null + averageSlowRatio = null + } + + /** + * The target is unreachable during a harvest, so the absolute health test says nothing. + * A harvested step is kept while the frame rate stays within + * [TunerThresholds.HARVEST_FPS_DROP_RATIO] of the average rate the trim started from, + * the p95 frame time stays within [TunerThresholds.HARVEST_P95_TRIM_FACTOR] of its + * pre-trim average, and the slow-frame ratio gains no more than + * [TunerThresholds.HARVEST_SLOW_FRAME_GAIN_RATIO] over its pre-trim average. + */ + private fun isHarvestDegraded(input: TunerInput, slowRatio: Float): Boolean { + return input.fps < harvestBaselineFps * TunerThresholds.HARVEST_FPS_DROP_RATIO || + input.frameTimeP95Ms > harvestBaselineP95Ms * TunerThresholds.HARVEST_P95_TRIM_FACTOR || + slowRatio > harvestBaselineSlowRatio + TunerThresholds.HARVEST_SLOW_FRAME_GAIN_RATIO + } + + /** + * GPU-boundness latches: entering needs [TunerThresholds.GPU_BOUND_USAGE_PERCENT], but + * leaving needs a drop to [TunerThresholds.GPU_BOUND_RELEASE_PERCENT], so a busy figure + * that dips for one cycle does not turn a harvest into a raise. + */ + private fun classifyBottleneck(input: TunerInput): MasterBottleneck? { + val gpu = input.gpuUsagePercent ?: return null + val cpu = input.cpuUsagePercent ?: return null + val gpuBoundThreshold = if (lastBottleneck == MasterBottleneck.GPU) { + TunerThresholds.GPU_BOUND_RELEASE_PERCENT + } else { + TunerThresholds.GPU_BOUND_USAGE_PERCENT + } + val classified = when { + gpu >= gpuBoundThreshold && cpu < TunerThresholds.HARVEST_CPU_HEADROOM_PERCENT -> MasterBottleneck.GPU + gpu <= TunerThresholds.HARVEST_GPU_IDLE_PERCENT -> MasterBottleneck.CPU + else -> null + } + lastBottleneck = classified + return classified + } + + private fun isBottleneckMaxed(input: TunerInput, bottleneck: MasterBottleneck): Boolean { + val domains = when (bottleneck) { + MasterBottleneck.GPU -> listOf(TunerDomain.GPU) + MasterBottleneck.CPU -> listOf(TunerDomain.PRIME, TunerDomain.PERFORMANCE) + } + return domains.none { input.capabilities[it]?.canRaise == true } + } + + private fun harvestTarget(input: TunerInput, bottleneck: MasterBottleneck): TunerDomain? { + val order = when (bottleneck) { + MasterBottleneck.GPU -> listOf(TunerDomain.PRIME, TunerDomain.PERFORMANCE) + MasterBottleneck.CPU -> listOf(TunerDomain.GPU) + } + return order.firstOrNull { domain -> + domain !in frozen && input.capabilities[domain]?.canTrim == true + } + } + + private fun trimTarget(input: TunerInput): TunerDomain? { + return TunerDomain.entries.firstOrNull { domain -> + domain !in frozen && input.capabilities[domain]?.canTrim == true + } + } + + private fun isHealthy(input: TunerInput, slowRatio: Float, targetPeriodMs: Float, tuning: StrategyTuning): Boolean { + val target = input.targetFps.toFloat() + return input.fps >= target - tuning.fpsTrimToleranceFps && + input.frameTimeP95Ms <= targetPeriodMs * TunerThresholds.FRAME_TIME_P95_TRIM_FACTOR && + slowRatio < TunerThresholds.SLOW_FRAME_TRIM_RATIO + } + + private fun isDegraded(input: TunerInput, slowRatio: Float, targetPeriodMs: Float, tuning: StrategyTuning): Boolean { + return !isHealthy(input, slowRatio, targetPeriodMs, tuning) + } + + private fun clearWatch() { + watchCyclesRemaining = 0 + watchedDomain = null + } + + private fun clearHarvest() { + harvestBottleneck = null + harvestQualifiedCycles = 0 + clearHarvestWatch() + } + + private fun clearHarvestWatch() { + harvestWatchCyclesRemaining = 0 + harvestWatchedDomain = null + harvestSettleCyclesRemaining = 0 + harvestDegradedCycles = 0 + harvestBaselineFps = 0f + harvestBaselineP95Ms = 0f + harvestBaselineSlowRatio = 0f + } + + private fun decision( + action: TunerAction, + domain: TunerDomain?, + steps: Int, + reason: String, + ): TunerDecision { + return TunerDecision( + action = action, + domain = domain, + steps = steps, + reason = reason, + steadyCycles = steadyCycles, + frozenDomains = frozen.toSet(), + ) + } +} diff --git a/app/src/test/java/app/gamenative/powercontrol/autotuning/TunerMasterEquivalenceTest.kt b/app/src/test/java/app/gamenative/powercontrol/autotuning/TunerMasterEquivalenceTest.kt new file mode 100644 index 0000000000..eb6577060a --- /dev/null +++ b/app/src/test/java/app/gamenative/powercontrol/autotuning/TunerMasterEquivalenceTest.kt @@ -0,0 +1,99 @@ +package app.gamenative.powercontrol.autotuning + +import kotlin.random.Random +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * X11 sessions, frame-generation sessions and every session where the collector counts no + * repeats hand the tuner repeatedFrameCount = 0 (a null count becomes 0 in ClusterTuner). There + * the engine must decide exactly as on master: same action, domain, steps, reason, steady count + * and frozen set on every cycle, including after warm-start caps are noted. + */ +class TunerMasterEquivalenceTest { + + private val strategies = listOf( + StrategyTuning.BALANCED, + StrategyTuning.POWER_EFFICIENT, + StrategyTuning.AGGRESSIVE, + StrategyTuning.CONSERVATIVE, + ) + + /** Step index per domain, 0 = uncapped, [maxSteps] = floor, moved by the decisions. */ + private class Caps(val maxSteps: Int, val gpuAvailable: Boolean) { + val index = TunerDomain.entries.associateWith { 0 }.toMutableMap() + fun capabilities() = TunerDomain.entries.associateWith { d -> + val available = d != TunerDomain.GPU || gpuAvailable + DomainCapability( + available = available, + canRaise = available && index[d]!! > 0, + canTrim = available && index[d]!! < maxSteps, + ) + } + fun apply(decision: TunerDecision) { + val d = decision.domain ?: return + when (decision.action) { + TunerAction.TRIM -> index[d] = (index[d]!! + decision.steps).coerceAtMost(maxSteps) + TunerAction.RAISE, TunerAction.UNDO -> index[d] = (index[d]!! - decision.steps).coerceAtLeast(0) + TunerAction.HOLD -> Unit + } + } + } + + private fun randomInput(r: Random, target: Int, caps: Caps): TunerInput { + val period = if (target > 0) 1000f / target else 16.7f + // Mostly on target, with stretches of deficit, heavy load and missing readings. + val mode = r.nextInt(10) + val fps = when { + target == 0 -> 40f + r.nextFloat() * 60f + mode < 6 -> target - r.nextFloat() * 1.2f + mode < 8 -> target - 2f - r.nextFloat() * 15f + else -> target * (0.5f + r.nextFloat() * 0.4f) + } + val total = r.nextInt(60, 130) + return TunerInput( + fps = fps, + targetFps = target, + frameTimeP50Ms = period * (0.9f + r.nextFloat() * 0.3f), + frameTimeP95Ms = period * (0.9f + r.nextFloat() * (if (mode < 6) 0.3f else 1.5f)), + slowFrameCount = if (mode < 6) r.nextInt(0, 2) else r.nextInt(0, total / 3), + totalFrameCount = total, + cpuUsagePercent = if (r.nextInt(12) == 0) null else r.nextFloat() * 100f, + gpuUsagePercent = if (r.nextInt(12) == 0) null else r.nextFloat() * 100f, + cpuTempC = 40 + r.nextInt(50), + gpuTempC = 40 + r.nextInt(50), + capabilities = caps.capabilities(), + ) + } + + @Test + fun `without repeats every decision matches master`() { + var cycles = 0 + for (seed in 0 until 200) { + val r = Random(seed) + val strategy = strategies[seed % strategies.size] + val current = TunerDecisionEngine { strategy } + val master = MasterTunerDecisionEngine { strategy } + val caps = Caps(maxSteps = 4 + seed % 12, gpuAvailable = seed % 5 != 0) + if (seed % 3 == 0) { + // A warm start carried caps in; on master it left no trace in the engine. + current.noteWarmStart(TunerDomain.entries.associateWith { r.nextInt(0, caps.maxSteps) }) + } + var target = listOf(30, 40, 45, 60, 90, 120, 0)[seed % 7] + repeat(600) { cycle -> + if (r.nextInt(150) == 0) target = listOf(30, 45, 60, 120)[r.nextInt(4)] + if (r.nextInt(200) == 0) { + current.releaseHolds() + master.releaseHolds() + } + val input = randomInput(r, target, caps) + val expected = master.decide(input) + val actual = current.decide(input) + assertEquals("seed $seed cycle $cycle", expected, actual) + caps.apply(actual) + cycles++ + } + } + assertEquals(200 * 600, cycles) + } +} 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..213ecdfff2 --- /dev/null +++ b/app/src/test/java/app/gamenative/powercontrol/autotuning/TunerRepeatedFramesTest.kt @@ -0,0 +1,192 @@ +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 hold and raise nothing`() { + val engine = TunerDecisionEngine() + repeat(TunerThresholds.STEADY_RENDER_CYCLES - 1) { engine.decide(input(repeated = 0)) } + val decision = engine.decide(input(repeated = 5)) + assertEquals(TunerAction.HOLD, decision.action) + assertTrue(decision.reason, decision.reason.startsWith("repeat-hold")) + } + + @Test + fun `three step-backs that did not cut repeats turn step-backs off`() { + val engine = TunerDecisionEngine() + repeat(4) { + runToTrim(engine) + finishWatch(engine) + } + var undos = 0 + repeat(TunerThresholds.REPEAT_UNDO_COOLDOWN_CYCLES * 6) { + val decision = engine.decide(input(repeated = 4)) + if (decision.action == TunerAction.UNDO) undos++ + assertTrue(decision.reason, decision.action != TunerAction.TRIM) + } + assertEquals(TunerThresholds.REPEAT_UNDO_INEFFECTIVE_LIMIT, undos) + val held = engine.decide(input(repeated = 4)) + assertEquals(TunerAction.HOLD, held.action) + assertTrue(held.reason, held.reason.endsWith("step-back off")) + } + + @Test + fun `step-backs that cut repeats stay on`() { + val engine = TunerDecisionEngine() + repeat(4) { + runToTrim(engine) + finishWatch(engine) + } + var undos = 0 + repeat(4) { + if (engine.decide(input(repeated = 4)).action == TunerAction.UNDO) undos++ + // The window after each step-back holds fewer repeats than the step-back saw. + repeat(TunerThresholds.REPEAT_UNDO_COOLDOWN_CYCLES) { engine.decide(input(repeated = 1)) } + } + assertEquals(4, undos) + } + + @Test + fun `a cap change drops the cooldown of a step-back in flight`() { + val engine = TunerDecisionEngine() + runToTrim(engine) + finishWatch(engine) + runToTrim(engine) + finishWatch(engine) + assertEquals(TunerAction.UNDO, engine.decide(input(repeated = 3)).action) + assertEquals(TunerAction.HOLD, engine.decide(input(repeated = 3)).action) + assertEquals(TunerAction.UNDO, engine.decide(input(repeated = 3).copy(targetFps = 30, fps = 30f)).action) + } + + @Test + fun `warm-start caps step back on repeats like trims made this session`() { + val engine = TunerDecisionEngine() + repeat(TunerThresholds.STEADY_RENDER_CYCLES - 1) { engine.decide(input()) } + // A state file left perf at its floor, 10 steps down, and prime 2 steps down. + engine.noteWarmStart(mapOf(TunerDomain.PRIME to 2, TunerDomain.PERFORMANCE to 10, TunerDomain.GPU to 0)) + val undo = engine.decide(input(repeated = 2)) + assertEquals(TunerAction.UNDO, undo.action) + assertEquals(TunerDomain.PERFORMANCE, undo.domain) + assertTrue(undo.reason, undo.reason.startsWith("repeat-undo:perf repeated=2")) + } + + @Test + fun `a warm-start floor keeps stepping back while repeats go on`() { + val engine = TunerDecisionEngine() + repeat(TunerThresholds.STEADY_RENDER_CYCLES - 1) { engine.decide(input()) } + engine.noteWarmStart(mapOf(TunerDomain.PERFORMANCE to 10)) + var undos = 0 + repeat(TunerThresholds.REPEAT_UNDO_COOLDOWN_CYCLES * 12) { + val decision = engine.decide(input(repeated = 4)) + if (decision.action == TunerAction.UNDO) { + undos++ + assertEquals(TunerDomain.PERFORMANCE, decision.domain) + } + } + // The ineffective-step-back cutoff does not apply: every warm-start step comes back. + assertEquals(10, undos) + } +} 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..de5e7888ea --- /dev/null +++ b/app/src/test/java/app/gamenative/powercontrol/metrics/RepeatedFrameCountTest.kt @@ -0,0 +1,92 @@ +package app.gamenative.powercontrol.metrics + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RepeatedFrameCountTest { + + private fun ms(nanos: Long) = nanos / 1_000_000.0 + + /** Timestamps from intervals given in display periods of a [refreshHz] panel, plus jitter in ms. */ + private fun timeline(refreshHz: Int, vararg intervals: Pair): LongArray { + val periodNs = 1_000_000_000.0 / refreshHz + val out = LongArray(intervals.size + 1) + out[0] = 1_000_000_000L + for (i in intervals.indices) { + val (vblanks, jitterMs) = intervals[i] + out[i + 1] = out[i] + (vblanks * periodNs + jitterMs * 1_000_000.0).toLong() + } + return out + } + + private fun steady(vblanks: Int, n: Int, jitterMs: Double = 0.0) = + Array(n) { vblanks to if (it % 2 == 0) jitterMs else -jitterMs } + + private fun count(ts: LongArray, cap: Int, refresh: Int) = + countSlowIntervals(ts, ts.size, repeatThresholdNs(cap, refresh)) + + @Test + fun `thresholds sit half a display period above the expected interval`() { + assertEquals(25.0, ms(repeatThresholdNs(60, 60)), 0.01) + assertEquals(20.83, ms(repeatThresholdNs(60, 120)), 0.01) + assertEquals(41.67, ms(repeatThresholdNs(30, 60)), 0.01) + assertEquals(25.0, ms(repeatThresholdNs(45, 60)), 0.01) + assertEquals(29.17, ms(repeatThresholdNs(40, 120)), 0.01) + // Cap above the refresh rate, and no cap: one display period expected. + assertEquals(25.0, ms(repeatThresholdNs(90, 60)), 0.01) + assertEquals(12.5, ms(repeatThresholdNs(0, 120)), 0.01) + assertEquals(0L, repeatThresholdNs(60, 0)) + } + + @Test + fun `cap 60 at 60 Hz counts each extra vblank`() { + val ts = timeline(60, *steady(1, 20, 2.0), 2 to 0.0, *steady(1, 20, 2.0)) + assertEquals(1, count(ts, 60, 60)) + } + + @Test + fun `cap 60 at 120 Hz counts a 3-vblank interval and not jitter`() { + assertEquals(0, count(timeline(120, *steady(2, 40, 2.0)), 60, 120)) + assertEquals(1, count(timeline(120, *steady(2, 20, 2.0), 3 to 0.0, *steady(2, 20)), 60, 120)) + } + + @Test + fun `cap 30 at 60 Hz counts a 3-vblank interval and not jitter`() { + assertEquals(0, count(timeline(60, *steady(2, 40, 2.0)), 30, 60)) + assertEquals(1, count(timeline(60, *steady(2, 20), 3 to 0.0, *steady(2, 20)), 30, 60)) + } + + @Test + fun `cap 45 at 60 Hz runs at one vblank and a doubled one is a repeat`() { + assertEquals(0, count(timeline(60, *steady(1, 40, 2.0)), 45, 60)) + assertEquals(1, count(timeline(60, *steady(1, 20), 2 to 0.0, *steady(1, 20)), 45, 60)) + } + + @Test + fun `cap 40 at 120 Hz counts a 4-vblank interval and not jitter`() { + assertEquals(0, count(timeline(120, *steady(3, 40, 2.0)), 40, 120)) + assertEquals(1, count(timeline(120, *steady(3, 20), 4 to 0.0, *steady(3, 20)), 40, 120)) + } + + @Test + fun `cap above refresh and no cap expect one display period`() { + assertEquals(1, count(timeline(60, *steady(1, 20), 2 to 0.0), 120, 60)) + assertEquals(1, count(timeline(120, *steady(1, 20, 1.0), 2 to 0.0), 0, 120)) + } + + @Test + fun `repeats are counted only in gamescope sessions without frame generation`() { + assertTrue(repeatCountingActive(gamescopeSession = true, sampleStride = 1)) + assertFalse("X11 sessions keep the tuner as on master", repeatCountingActive(gamescopeSession = false, sampleStride = 1)) + assertFalse("LSFG x2 publishes generated frames", repeatCountingActive(gamescopeSession = true, sampleStride = 2)) + assertFalse(repeatCountingActive(gamescopeSession = false, sampleStride = 3)) + } + + @Test + fun `no threshold counts nothing`() { + val ts = timeline(60, 1 to 0.0, 5 to 0.0) + assertEquals(0, countSlowIntervals(ts, ts.size, 0L)) + } +}