From d360efc3084747a6c96bd4ad812fe726bcfacd3b Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sun, 8 Nov 2026 08:38:06 +0530 Subject: [PATCH 1/3] Place the frame thread on the prime cluster and keep spinners off it --- .../main/java/app/gamenative/PrefManager.kt | 5 + .../powercontrol/FrameThreadPlacement.kt | 548 ++++++++++++++++++ .../gamenative/powercontrol/PowerManager.kt | 40 +- .../app/gamenative/utils/SessionReport.kt | 1 + .../powercontrol/FrameThreadPlacementTest.kt | 252 ++++++++ 5 files changed, 837 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt create mode 100644 app/src/test/java/app/gamenative/powercontrol/FrameThreadPlacementTest.kt diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 4b54f87e3f..85c2e8ba67 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -1977,4 +1977,9 @@ object PrefManager { var sharedShaderCache: Boolean get() = getPref(SHARED_SHADER_CACHE, true) set(value) { setPref(SHARED_SHADER_CACHE, value) } + + private val FRAME_THREAD_PLACEMENT = booleanPreferencesKey("frame_thread_placement") + var frameThreadPlacement: Boolean + get() = getPref(FRAME_THREAD_PLACEMENT, true) + set(value) { setPref(FRAME_THREAD_PLACEMENT, value) } } diff --git a/app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt b/app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt new file mode 100644 index 0000000000..74c9f736d8 --- /dev/null +++ b/app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt @@ -0,0 +1,548 @@ +package app.gamenative.powercontrol + +import android.os.Process +import android.os.SystemClock +import android.system.Os +import android.system.OsConstants +import app.gamenative.PrefManager +import app.gamenative.powercontrol.PowerManager.CoreCluster +import app.gamenative.utils.ProcStatParser +import timber.log.Timber +import java.io.File +import java.util.Locale +import kotlin.math.roundToInt + +/** + * Places the threads that decide the frame rate, from how they behaved over the last + * [WINDOW_MS] window, on big.LITTLE devices with power control on: + * - the frame thread (the busiest game thread that sleeps and wakes per frame) on the + * highest-capacity cluster, + * - spinners (always runnable game threads) everywhere but that cluster, so they cannot push the + * frame thread off it, + * - the cross-process IPC threads (wineserver's main thread, Steam's engine IPC thread, busy + * wineserver and Steam threads) off the low-capacity cores. + * Everything else keeps its affinity. Masks are applied again every window, because a winhandler + * pin or a cpuset move on activity resume resets them. + */ +object FrameThreadPlacement { + private const val TAG = "PowerManager" + private const val WINDOW_MS = 2_000L + internal const val SPINNER_MIN_CPU = 90 + internal const val SPINNER_MAX_VOLUNTARY = 50 + internal const val FRAME_MIN_CPU = 15 + internal const val FRAME_MIN_VOLUNTARY = 20 + internal const val FRAME_MAX_VOLUNTARY = 5000 + internal const val FRAME_SWITCH_RATIO = 1.2 + internal const val FRAME_SWITCH_WINDOWS = 2 + internal const val IPC_MIN_CPU = 10 + private const val MAX_RATE_THREADS = 16 + private const val MAX_REPORTED_SPINNERS = 8 + private const val ESRCH = 3 + + // comm is cut at 15 characters: Steam's "IPC:CSteamEngine" reads as "IPC:CSteamEngin". + internal const val STEAM_ENGINE_COMM = "IPC:CSteamEngin" + + private val steamProcessNames = setOf( + "steam.exe", + "steamservice.exe", + "steamhost.exe", + "steamhost32.exe", + "steamhost64.exe", + ) + + internal enum class ProcessKind { GAME, WINESERVER, STEAM } + + internal enum class Role(val label: String) { + FRAME("frame thread"), + SPINNER("spinner"), + IPC("IPC thread"), + } + + /** One thread over the last window: [cpu] in percent of one core. */ + internal data class ThreadSample( + val pid: Int, + val tid: Int, + val comm: String, + val kind: ProcessKind, + val cpu: Int, + val voluntaryPerSec: Int?, + ) + + /** The frame thread and the thread that has beaten it for [challengerWindows] windows. */ + internal data class FrameChoice( + val tid: Int, + val comm: String, + val challengerTid: Int? = null, + val challengerWindows: Int = 0, + ) + + internal data class Roles( + val frame: FrameChoice?, + val spinners: List, + val ipc: List, + ) + + /** Cores for each role, null when the role is left alone this session, with why in [notes]. */ + internal data class PlacementPlan( + val frameCores: List?, + val spinnerCores: List?, + val ipcCores: List?, + val allCores: List, + val notes: List, + ) + + internal data class Placement( + val role: Role, + val pid: Int, + val comm: String, + val kind: ProcessKind, + val cores: List, + val reason: String, + ) + + internal fun isSpinner(t: ThreadSample): Boolean = + t.cpu >= SPINNER_MIN_CPU && t.voluntaryPerSec != null && t.voluntaryPerSec < SPINNER_MAX_VOLUNTARY + + internal fun isIpc(t: ThreadSample): Boolean = when { + t.comm.startsWith(STEAM_ENGINE_COMM) -> true + t.kind == ProcessKind.WINESERVER && t.tid == t.pid -> true + t.kind != ProcessKind.GAME && t.cpu > IPC_MIN_CPU -> true + else -> false + } + + internal fun isFrameCandidate(t: ThreadSample): Boolean = + t.kind == ProcessKind.GAME && + t.cpu >= FRAME_MIN_CPU && + t.voluntaryPerSec != null && + t.voluntaryPerSec in FRAME_MIN_VOLUNTARY..FRAME_MAX_VOLUNTARY && + !isSpinner(t) && + !isIpc(t) + + /** + * The frame thread for this window. A held frame thread is replaced at once when it exits or + * turns into a spinner; otherwise only by a candidate with more than [FRAME_SWITCH_RATIO] times + * its CPU in [FRAME_SWITCH_WINDOWS] windows in a row, so a loading spike does not move it. + */ + internal fun chooseFrame(threads: List, current: FrameChoice?): FrameChoice? { + val best = threads.filter(::isFrameCandidate).maxByOrNull { it.cpu } + val held = current?.let { c -> threads.firstOrNull { it.tid == c.tid } } + if (current == null || held == null || isSpinner(held)) return best?.let { FrameChoice(it.tid, it.comm) } + if (best == null || best.tid == held.tid || best.cpu <= held.cpu * FRAME_SWITCH_RATIO) { + return FrameChoice(held.tid, held.comm) + } + val windows = if (current.challengerTid == best.tid) current.challengerWindows + 1 else 1 + return if (windows >= FRAME_SWITCH_WINDOWS) { + FrameChoice(best.tid, best.comm) + } else { + FrameChoice(held.tid, held.comm, best.tid, windows) + } + } + + internal fun classify(threads: List, current: FrameChoice?): Roles { + val frame = chooseFrame(threads, current) + val ipc = threads.filter { it.tid != frame?.tid && isIpc(it) } + val spinners = threads.filter { + it.kind == ProcessKind.GAME && it.tid != frame?.tid && !isIpc(it) && isSpinner(it) + } + return Roles(frame, spinners, ipc) + } + + /** + * Frame thread: the highest-capacity cluster, unless every core has that capacity. Spinners: + * every core outside it, unless those are all low-capacity cores. IPC threads: every core + * outside the low-capacity clusters. A [gamePin] narrows each set to the cores it shares with + * it, and a set that shares none is left alone. + */ + internal fun placementPlan(clusters: List, gamePin: List): PlacementPlan { + val all = clusters.flatMap { it.cores }.distinct().sorted() + val known = clusters.filter { it.cores.isNotEmpty() && it.capacity > 0L } + if (known.isEmpty()) { + return PlacementPlan(null, null, null, all, listOf("cluster capacity unknown; nothing placed")) + } + val notes = mutableListOf() + val maxCapacity = known.maxOf { it.capacity } + val prime = known.filter { it.capacity == maxCapacity }.flatMap { it.cores }.sorted() + val low = PowerManager.lowCapacityCores(clusters) + + val frame = if (prime.size < all.size) prime else null + if (frame == null) notes += "every core has the same capacity; frame thread and spinners left alone" + val outsidePrime = all.filter { it !in prime } + val spinner = when { + outsidePrime.isEmpty() -> null + outsidePrime.all { it in low } -> null.also { + notes += "only low-capacity cores outside the prime cluster; spinners left alone" + } + else -> outsidePrime + } + val ipc = all.filter { it !in low }.takeIf { it.isNotEmpty() } + + fun withinPin(role: Role, cores: List?): List? { + if (cores == null || gamePin.isEmpty()) return cores + val shared = cores.filter { it in gamePin } + if (shared.isEmpty()) { + notes += "${role.label} cores ${PowerManager.formatRanges(cores)} outside the game pin " + + "${PowerManager.formatRanges(gamePin)}; ${role.label}s left alone" + return null + } + return shared + } + return PlacementPlan( + withinPin(Role.FRAME, frame), + withinPin(Role.SPINNER, spinner), + withinPin(Role.IPC, ipc), + all, + notes, + ) + } + + internal fun desiredPlacements( + threads: List, + roles: Roles, + plan: PlacementPlan, + ): Map { + val byTid = threads.associateBy { it.tid } + val result = LinkedHashMap() + val frame = roles.frame?.let { byTid[it.tid] } + if (frame != null && plan.frameCores != null) { + result[frame.tid] = Placement( + Role.FRAME, frame.pid, frame.comm, frame.kind, plan.frameCores, + "${frame.cpu} % cpu, ${frame.voluntaryPerSec ?: "-"} wakeups/s", + ) + } + if (plan.ipcCores != null) { + roles.ipc.forEach { t -> + val why = when { + t.comm.startsWith(STEAM_ENGINE_COMM) -> "Steam engine IPC" + t.kind == ProcessKind.WINESERVER && t.tid == t.pid -> "wineserver main thread" + else -> "${t.cpu} % cpu in ${t.kind.name.lowercase(Locale.US)}" + } + result[t.tid] = Placement(Role.IPC, t.pid, t.comm, t.kind, plan.ipcCores, why) + } + } + if (plan.spinnerCores != null) { + roles.spinners.forEach { t -> + result[t.tid] = Placement( + Role.SPINNER, t.pid, t.comm, t.kind, plan.spinnerCores, + "${t.cpu} % cpu, ${t.voluntaryPerSec} wakeups/s", + ) + } + } + return result + } + + internal fun mask(cores: Collection): Int = cores.fold(0) { acc, core -> acc or (1 shl core) } + + // ---- session state + + private val placed = HashMap() + private val restoreCores = HashMap>() + private var frame: FrameChoice? = null + private var notesLogged = false + private var offLogged = false + + private var reportedFrameTid: Int? = null + private var reportedFrameComm: String? = null + private val reportedSpinners = LinkedHashSet() + private var placementApplied = false + + @Volatile + private var thread: Thread? = null + + /** False once [stop] ran, also for the loop of an earlier session still waking up. */ + private fun active(): Boolean = Thread.currentThread() === thread + + /** Starts the per-window loop for a session. [envOff]: the container sets GN_FRAME_PLACEMENT=0. */ + fun start(envOff: Boolean) { + stop("restart") + val prefOff = runCatching { !PrefManager.frameThreadPlacement }.getOrDefault(false) + reset() + if (prefOff || envOff) { + Timber.tag(TAG).i( + "Frame thread placement off (${if (envOff) "GN_FRAME_PLACEMENT=0" else "frameThreadPlacement pref"})" + ) + return + } + val loopThread = Thread(::loop, "frame-placement").apply { + isDaemon = true + priority = Thread.MIN_PRIORITY + } + thread = loopThread + loopThread.start() + } + + /** Stops the loop and hands every placed thread its mask back. Keeps the session summary. */ + fun stop(reason: String) { + val loopThread = thread + thread = null + loopThread?.interrupt() + release(reason, TaskAffinity::set, PowerManager.threadPlacementGamePin()) + } + + @Synchronized + internal fun release(reason: String, setAffinity: (Int, Int) -> Int, gamePin: List) { + placed.keys.toList().forEach { tid -> releaseThread(tid, reason, setAffinity, gamePin) } + placed.clear() + restoreCores.clear() + frame = null + } + + fun properties(): Map = synchronized(this) { + buildMap { + reportedFrameTid?.let { put("frame_thread_tid", it) } + reportedFrameComm?.let { put("frame_thread_comm", it) } + if (reportedSpinners.isNotEmpty()) put("spinner_tids", reportedSpinners.toList()) + put("frame_placement_applied", placementApplied) + } + } + + @Synchronized + internal fun reset() { + placed.clear() + restoreCores.clear() + frame = null + notesLogged = false + offLogged = false + reportedFrameTid = null + reportedFrameComm = null + reportedSpinners.clear() + placementApplied = false + } + + private fun loop() { + val scanner = Scanner() + while (active()) { + try { + Thread.sleep(WINDOW_MS) + } catch (_: InterruptedException) { + return + } + if (!active()) return + try { + tick(scanner) + } catch (_: InterruptedException) { + return + } catch (e: Throwable) { + Timber.tag(TAG).w(e, "Frame thread placement window failed") + } + } + } + + private fun tick(scanner: Scanner) { + val gamePin = PowerManager.threadPlacementGamePin() + val clusters = PowerManager.threadPlacementClusters() + if (clusters == null) { + synchronized(this) { + if (!active()) return + if (placed.isNotEmpty()) release("power control off", TaskAffinity::set, gamePin) + if (!offLogged) { + offLogged = true + Timber.tag(TAG).i("Frame thread placement idle (no PServer driver or power control off)") + } + } + scanner.reset() + return + } + val threads = scanner.scan() + if (threads.isEmpty()) return + val plan = placementPlan(clusters, gamePin) + val clamp = PowerManager.latestTunerCaps()?.primeKhz + ?.takeIf { plan.frameCores?.size == 1 } + ?.let { "; prime clamped to ${it / 1000} MHz by the tuner" } + .orEmpty() + // A stop that released everything while this window was being read wins. + synchronized(this) { + if (!active()) return + applyWindow(threads, plan, gamePin, clamp, TaskAffinity::set, ::readAllowedCores) + } + } + + /** + * One window: classifies [threads], applies each role's mask, and hands a thread that lost its + * role its mask back. Logs a role change once, never the repeated apply. + */ + @Synchronized + internal fun applyWindow( + threads: List, + plan: PlacementPlan, + gamePin: List, + frameNote: String, + setAffinity: (Int, Int) -> Int, + readAllowed: (Int, Int) -> List?, + ) { + if (!notesLogged && plan.notes.isNotEmpty()) { + notesLogged = true + Timber.tag(TAG).i("Frame thread placement: ${plan.notes.joinToString("; ")}") + } + val roles = classify(threads, frame) + frame = roles.frame + roles.frame?.let { + reportedFrameTid = it.tid + reportedFrameComm = it.comm + } + roles.spinners.forEach { if (reportedSpinners.size < MAX_REPORTED_SPINNERS) reportedSpinners += it.tid } + + val desired = desiredPlacements(threads, roles, plan) + (placed.keys - desired.keys).forEach { tid -> releaseThread(tid, "role lost", setAffinity, gamePin) } + desired.forEach { (tid, p) -> + val prev = placed[tid] + if (prev == null && p.kind != ProcessKind.GAME) { + restoreCores[tid] = readAllowed(p.pid, tid) ?: plan.allCores + } + val errno = setAffinity(tid, mask(p.cores)) + if (errno == 0) placementApplied = true + if (prev?.role != p.role || prev.cores != p.cores) { + val note = if (p.role == Role.FRAME) frameNote else "" + val failed = if (errno != 0) "; sched_setaffinity errno $errno" else "" + Timber.tag(TAG).i( + "${p.role.label} tid $tid (${p.comm}) -> cores ${PowerManager.formatRanges(p.cores)} (${p.reason}$note$failed)" + ) + } + } + placed.clear() + placed.putAll(desired) + } + + private fun releaseThread( + tid: Int, + reason: String, + setAffinity: (Int, Int) -> Int, + gamePin: List, + ) { + val p = placed.remove(tid) ?: return + val saved = restoreCores.remove(tid) + val cores = if (p.kind == ProcessKind.GAME) { + gamePin.ifEmpty { PowerManager.threadPlacementAllCores() } + } else { + saved ?: PowerManager.threadPlacementAllCores() + } + if (cores.isEmpty()) return + val errno = setAffinity(tid, mask(cores)) + val outcome = when (errno) { + 0 -> "" + ESRCH -> "; thread gone" + else -> "; sched_setaffinity errno $errno" + } + Timber.tag(TAG).i( + "${p.role.label} tid $tid (${p.comm}) released -> cores ${PowerManager.formatRanges(cores)} ($reason$outcome)" + ) + } + + private fun readAllowedCores(pid: Int, tid: Int): List? = runCatching { + File("/proc/$pid/task/$tid/status").useLines { lines -> + lines.firstOrNull { it.startsWith("Cpus_allowed_list:") } + ?.substringAfter(':') + ?.trim() + ?.let { PowerManager.parseCpuList(it).sorted() } + ?.takeIf { it.isNotEmpty() } + } + }.getOrNull() + + /** Reads this app's game, wineserver and Steam threads; a window's deltas need the window before. */ + private class Scanner { + private val selfPid = Process.myPid() + private val uid = Process.myUid() + private val clkTck = runCatching { Os.sysconf(OsConstants._SC_CLK_TCK) }.getOrDefault(100L).coerceAtLeast(1L) + private var prevMs = 0L + private var prevProcTicks = HashMap() + private var prevThreadTicks = HashMap() + private var prevVoluntary = HashMap() + private val names = HashMap>() + + fun reset() { + prevMs = 0L + prevProcTicks = HashMap() + prevThreadTicks = HashMap() + prevVoluntary = HashMap() + names.clear() + } + + fun scan(): List { + val nowMs = SystemClock.elapsedRealtime() + val dtSec = if (prevMs > 0L) (nowMs - prevMs) / 1000.0 else 0.0 + prevMs = nowMs + + val procTicks = HashMap() + val procs = ArrayList>() + val dirs = File("/proc").listFiles { f -> f.name.isNotEmpty() && f.name[0].isDigit() }.orEmpty() + for (dir in dirs) { + val pid = dir.name.toIntOrNull() ?: continue + if (pid == selfPid) continue + try { + if (Os.stat(dir.path).st_uid != uid) continue + val fields = ProcStatParser.parse(File(dir, "stat").readText()) ?: continue + val ticks = fields.utime + fields.stime + procTicks[pid] = ticks + val name = names[pid]?.takeIf { it.first == fields.comm }?.second + ?: ProcStatParser.processName( + runCatching { String(File(dir, "cmdline").readBytes()) }.getOrDefault(""), + fields.comm, + ).also { names[pid] = fields.comm to it } + val prev = prevProcTicks[pid] ?: continue + if (dtSec > 0.0) procs += Triple(pid, name.lowercase(Locale.US), ProcStatParser.cpuPercent(ticks - prev, dtSec, clkTck)) + } catch (_: Exception) { + } + } + prevProcTicks = procTicks + names.keys.retainAll(procTicks.keys) + if (dtSec <= 0.0) { + prevThreadTicks = HashMap() + prevVoluntary = HashMap() + return emptyList() + } + + val game = procs.filter { it.second !in ProcStatParser.excludedProcessNames }.maxByOrNull { it.third } + val tracked = buildList { + game?.let { add(it.first to ProcessKind.GAME) } + procs.filter { it.second == "wineserver" }.forEach { add(it.first to ProcessKind.WINESERVER) } + procs.filter { it.second in steamProcessNames }.forEach { add(it.first to ProcessKind.STEAM) } + } + + val threadTicks = HashMap() + val voluntary = HashMap() + val result = ArrayList() + for ((pid, kind) in tracked) { + val threads = ArrayList>() + for (task in File("/proc/$pid/task").listFiles().orEmpty()) { + val tid = task.name.toIntOrNull() ?: continue + val fields = try { + ProcStatParser.parse(File(task, "stat").readText()) + } catch (_: Exception) { + null + } ?: continue + val ticks = fields.utime + fields.stime + threadTicks[tid] = ticks + val prev = prevThreadTicks[tid] ?: continue + threads += Triple(tid, fields.comm, ProcStatParser.cpuPercent(ticks - prev, dtSec, clkTck)) + } + val rated = if (kind == ProcessKind.GAME) { + threads.filter { it.third >= FRAME_MIN_CPU } + .sortedByDescending { it.third } + .take(MAX_RATE_THREADS) + .mapTo(HashSet()) { it.first } + } else { + emptySet() + } + for ((tid, comm, cpu) in threads) { + var rate: Int? = null + if (tid in rated) { + val count = readVoluntary(pid, tid) + if (count != null) { + voluntary[tid] = count + prevVoluntary[tid]?.let { rate = ((count - it).coerceAtLeast(0L) / dtSec).roundToInt() } + } + } + result += ThreadSample(pid, tid, comm, kind, cpu, rate) + } + } + prevThreadTicks = threadTicks + prevVoluntary = voluntary + return result + } + + private fun readVoluntary(pid: Int, tid: Int): Long? = try { + File("/proc/$pid/task/$tid/status").bufferedReader().useLines { ProcStatParser.parseCtxtSwitches(it) }?.get(0) + } catch (_: Exception) { + null + } + } +} diff --git a/app/src/main/java/app/gamenative/powercontrol/PowerManager.kt b/app/src/main/java/app/gamenative/powercontrol/PowerManager.kt index 2d934ace00..3b0b0410cc 100644 --- a/app/src/main/java/app/gamenative/powercontrol/PowerManager.kt +++ b/app/src/main/java/app/gamenative/powercontrol/PowerManager.kt @@ -20,6 +20,7 @@ import app.gamenative.powercontrol.profiles.CpuGovernor import app.gamenative.powercontrol.profiles.PerformancePreset import com.winlator.container.Container import com.winlator.core.ProcessHelper +import com.winlator.core.envvars.EnvVars import com.winlator.winhandler.WinHandler import com.winlator.xserver.extensions.PresentExtension import kotlinx.coroutines.flow.MutableStateFlow @@ -189,6 +190,10 @@ object PowerManager { var ownsGameAffinity: Boolean = false private set + /** The container's explicit CPU list, which the winhandler applies to the game; empty without one. */ + @Volatile + private var containerGameCores: List = emptyList() + /** * Bumped whenever a pin run starts or is called off, so a retry loop left over from an earlier * run stops instead of pinning the game again. @@ -203,7 +208,8 @@ object PowerManager { containerDir = rootDir compositorRestoreCores = null compositorOffLogged = false - resolveGameAffinityOwnership() + val container = loadContainer(rootDir) + resolveGameAffinityOwnership(container) loadCurrentProfile() if (isProfilePowerControlEnabled()) { startPowerControl() @@ -216,6 +222,9 @@ object PowerManager { appContext.let { PerformanceMetricsCollector.start(it) } isGameStarted = true + FrameThreadPlacement.start( + envOff = container?.let { EnvVars(it.envVars).get("GN_FRAME_PLACEMENT") } == "0", + ) if (gamescopeSession) { // The gamescope session may have connected before the profile was loaded. Thread({ applyCompositorAffinity("power control start") }, "compositor-affinity").start() @@ -320,6 +329,7 @@ object PowerManager { fun stop() { // Save the current profile if available, otherwise read from driver saveProfile() + FrameThreadPlacement.stop("session end") AdaptiveFpsCapController.stop() PerformanceMetricsCollector.stop() stopPowerControl() @@ -332,6 +342,7 @@ object PowerManager { pinnedGameProcessName = null pinnedGamePid = null pinnedGameCores = emptyList() + containerGameCores = emptyList() gamePinVerified = null ownsGameAffinity = false } @@ -1160,6 +1171,19 @@ object PowerManager { return coreClusters(pserver).takeIf { clusters -> clusters.any { it.cores.isNotEmpty() } } } + /** + * [placementClusters] behind the profile's power control toggle: unlike the compositor chain, + * [FrameThreadPlacement] moves the game's own threads and places nothing with the toggle off. + */ + internal fun threadPlacementClusters(): List? = + if (::currentProfile.isInitialized && isProfilePowerControlEnabled()) placementClusters() else null + + /** Cores the game is held to: the power control pin, else the container's CPU list, else none. */ + internal fun threadPlacementGamePin(): List = pinnedGameCores.ifEmpty { containerGameCores } + + internal fun threadPlacementAllCores(): List = + placementClusters()?.flatMap { it.cores }?.sorted() ?: parseCpuList(Container.getFallbackCPUList()).sorted() + 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. */ @@ -1777,11 +1801,12 @@ object PowerManager { * with an explicit CPU list keeps its own pin, anything else (no list, or the all-cores * default) leaves the game affinity to power control. */ - private fun resolveGameAffinityOwnership() { - val cpuList = containerCpuList(containerDir) + private fun resolveGameAffinityOwnership(container: Container?) { + val cpuList = container?.getCPUList(false) val allCores = parseCpuList(Container.getFallbackCPUList()) val explicit = cpuList != null && parseCpuList(cpuList) != allCores ownsGameAffinity = !explicit + containerGameCores = if (explicit) parseCpuList(cpuList!!).sorted() else emptyList() if (explicit) { Timber.tag("PowerManager").i( @@ -1794,18 +1819,15 @@ object PowerManager { } } - /** - * The CPU list the container stores, or null when it has none. - */ - private fun containerCpuList(dir: File?): String? { + private fun loadContainer(dir: File?): Container? { if (dir == null) return null return runCatching { val container = Container(dir.name.substringAfterLast('-')) container.rootDir = dir container.loadData(JSONObject(container.containerJson)) - container.getCPUList(false) + container }.onFailure { - Timber.tag("PowerManager").w(it, "Failed to read the CPU list of ${dir.absolutePath}") + Timber.tag("PowerManager").w(it, "Failed to read the container at ${dir.absolutePath}") }.getOrNull() } diff --git a/app/src/main/java/app/gamenative/utils/SessionReport.kt b/app/src/main/java/app/gamenative/utils/SessionReport.kt index 0e91104832..cecbeea0c1 100644 --- a/app/src/main/java/app/gamenative/utils/SessionReport.kt +++ b/app/src/main/java/app/gamenative/utils/SessionReport.kt @@ -242,6 +242,7 @@ object SessionReport { .onFailure { Timber.w(it, "SessionReport: power properties failed") } runCatching { putAll(SessionProcessTelemetry.properties()) } .onFailure { Timber.w(it, "SessionReport: process properties failed") } + runCatching { putAll(app.gamenative.powercontrol.FrameThreadPlacement.properties()) } put("fps_cap_configured", app.gamenative.powercontrol.PowerManager.targetFps.coerceAtLeast(0)) put("optimize_state", OptimizeState.properties()) if (frameRating != null) { diff --git a/app/src/test/java/app/gamenative/powercontrol/FrameThreadPlacementTest.kt b/app/src/test/java/app/gamenative/powercontrol/FrameThreadPlacementTest.kt new file mode 100644 index 0000000000..51fe4dea89 --- /dev/null +++ b/app/src/test/java/app/gamenative/powercontrol/FrameThreadPlacementTest.kt @@ -0,0 +1,252 @@ +package app.gamenative.powercontrol + +import app.gamenative.powercontrol.FrameThreadPlacement.FrameChoice +import app.gamenative.powercontrol.FrameThreadPlacement.ProcessKind +import app.gamenative.powercontrol.FrameThreadPlacement.ThreadSample +import app.gamenative.powercontrol.PowerManager.CoreCluster +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class FrameThreadPlacementTest { + + // Retroid Pocket 6: c0-2 little, 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), + ) + + // AYN Odin 3: 6 cores at 792, 2 at 1024. + private val odin3 = listOf( + CoreCluster("efficiency", emptyList(), 0), + CoreCluster("performance", listOf(0, 1, 2, 3, 4, 5), 792), + CoreCluster("prime", listOf(6, 7), 1024), + ) + + private val unknownCapacity = rp6.map { it.copy(capacity = 0L) } + + private val calls = mutableListOf>() + private val recordAffinity: (Int, Int) -> Int = { tid, mask -> calls.add(tid to mask); 0 } + private val noAllowedRead: (Int, Int) -> List? = { _, _ -> null } + + private fun game(tid: Int, cpu: Int, wakeups: Int?, comm: String = "t$tid") = + ThreadSample(pid = 1000, tid = tid, comm = comm, kind = ProcessKind.GAME, cpu = cpu, voluntaryPerSec = wakeups) + + private fun wineserver(tid: Int, cpu: Int) = + ThreadSample(pid = 2000, tid = tid, comm = "wineserver", kind = ProcessKind.WINESERVER, cpu = cpu, voluntaryPerSec = null) + + private fun steam(tid: Int, cpu: Int, comm: String) = + ThreadSample(pid = 3000, tid = tid, comm = comm, kind = ProcessKind.STEAM, cpu = cpu, voluntaryPerSec = null) + + @Before + @After + fun clear() { + FrameThreadPlacement.reset() + calls.clear() + } + + // ---- roles + + @Test + fun `always runnable thread is a spinner`() { + assertTrue(FrameThreadPlacement.isSpinner(game(1, cpu = 98, wakeups = 3))) + assertFalse(FrameThreadPlacement.isSpinner(game(1, cpu = 98, wakeups = 400))) + assertFalse(FrameThreadPlacement.isSpinner(game(1, cpu = 70, wakeups = 3))) + assertFalse(FrameThreadPlacement.isSpinner(game(1, cpu = 98, wakeups = null))) + } + + @Test + fun `frame thread is the busiest thread that wakes per frame`() { + val threads = listOf( + game(1, cpu = 99, wakeups = 2), + game(2, cpu = 72, wakeups = 160, comm = "Main"), + game(3, cpu = 40, wakeups = 900), + game(4, cpu = 80, wakeups = 9000), + ) + val roles = FrameThreadPlacement.classify(threads, null) + assertEquals(2, roles.frame?.tid) + assertEquals("Main", roles.frame?.comm) + assertEquals(listOf(1), roles.spinners.map { it.tid }) + } + + @Test + fun `no frame thread without a per-frame wakeup rate`() { + val roles = FrameThreadPlacement.classify(listOf(game(1, cpu = 60, wakeups = null), game(2, cpu = 10, wakeups = 100)), null) + assertNull(roles.frame) + } + + @Test + fun `frame thread is kept until another beats it by a fifth for two windows`() { + var current: FrameChoice? = FrameChoice(2, "Main") + val challenged = listOf(game(2, cpu = 50, wakeups = 120), game(3, cpu = 65, wakeups = 120)) + current = FrameThreadPlacement.chooseFrame(challenged, current) + assertEquals(2, current?.tid) + assertEquals(3, current?.challengerTid) + current = FrameThreadPlacement.chooseFrame(challenged, current) + assertEquals(3, current?.tid) + } + + @Test + fun `a close challenger does not move the frame thread`() { + var current: FrameChoice? = FrameChoice(2, "Main") + val threads = listOf(game(2, cpu = 50, wakeups = 120), game(3, cpu = 58, wakeups = 120)) + repeat(3) { current = FrameThreadPlacement.chooseFrame(threads, current) } + assertEquals(2, current?.tid) + } + + @Test + fun `one window of a challenger then a quiet window resets the count`() { + var current: FrameChoice? = FrameChoice(2, "Main") + current = FrameThreadPlacement.chooseFrame(listOf(game(2, 50, 120), game(3, 70, 120)), current) + current = FrameThreadPlacement.chooseFrame(listOf(game(2, 50, 120), game(3, 30, 120)), current) + current = FrameThreadPlacement.chooseFrame(listOf(game(2, 50, 120), game(3, 70, 120)), current) + assertEquals(2, current?.tid) + assertEquals(1, current?.challengerWindows) + } + + @Test + fun `frame thread that exits or starts spinning is replaced at once`() { + val gone = FrameThreadPlacement.chooseFrame(listOf(game(3, cpu = 40, wakeups = 120)), FrameChoice(2, "Main")) + assertEquals(3, gone?.tid) + val spinning = FrameThreadPlacement.chooseFrame( + listOf(game(2, cpu = 99, wakeups = 1), game(3, cpu = 40, wakeups = 120)), + FrameChoice(2, "Main"), + ) + assertEquals(3, spinning?.tid) + } + + @Test + fun `IPC set is the wineserver main thread, the Steam engine thread and busy wineserver or Steam threads`() { + val threads = listOf( + wineserver(2000, cpu = 4), + wineserver(2001, cpu = 2), + wineserver(2002, cpu = 15), + steam(3001, cpu = 1, comm = "IPC:CSteamEngin"), + steam(3002, cpu = 30, comm = "SteamThread"), + steam(3003, cpu = 5, comm = "idle"), + game(1, cpu = 20, wakeups = 100), + ) + val roles = FrameThreadPlacement.classify(threads, null) + assertEquals(listOf(2000, 2002, 3001, 3002), roles.ipc.map { it.tid }) + } + + // ---- masks + + @Test + fun `RP6 frame thread on the prime core, spinners off it, IPC off the little cores`() { + val plan = FrameThreadPlacement.placementPlan(rp6, gamePin = emptyList()) + assertEquals(listOf(7), plan.frameCores) + assertEquals(listOf(0, 1, 2, 3, 4, 5, 6), plan.spinnerCores) + assertEquals(listOf(3, 4, 5, 6, 7), plan.ipcCores) + } + + @Test + fun `RP6 gamescope game pin narrows every set`() { + val plan = FrameThreadPlacement.placementPlan(rp6, gamePin = listOf(3, 4, 5, 6, 7)) + assertEquals(listOf(7), plan.frameCores) + assertEquals(listOf(3, 4, 5, 6), plan.spinnerCores) + assertEquals(listOf(3, 4, 5, 6, 7), plan.ipcCores) + } + + @Test + fun `Odin 3 frame thread on the two prime cores`() { + val plan = FrameThreadPlacement.placementPlan(odin3, gamePin = listOf(2, 3, 4, 5, 6, 7)) + assertEquals(listOf(6, 7), plan.frameCores) + assertEquals(listOf(2, 3, 4, 5), plan.spinnerCores) + assertEquals(listOf(2, 3, 4, 5, 6, 7), plan.ipcCores) + } + + @Test + fun `game pin without the prime core leaves the frame thread alone`() { + val plan = FrameThreadPlacement.placementPlan(rp6, gamePin = listOf(3, 4, 5, 6)) + assertNull(plan.frameCores) + assertEquals(listOf(3, 4, 5, 6), plan.spinnerCores) + assertTrue(plan.notes.any { it.contains("outside the game pin") }) + } + + @Test + fun `unknown capacities place nothing`() { + val plan = FrameThreadPlacement.placementPlan(unknownCapacity, gamePin = emptyList()) + assertNull(plan.frameCores) + assertNull(plan.spinnerCores) + assertNull(plan.ipcCores) + } + + @Test + fun `spinners never go to the little cores only`() { + val littleAndPrime = listOf( + CoreCluster("efficiency", listOf(0, 1, 2, 3), 400), + CoreCluster("performance", emptyList(), 0), + CoreCluster("prime", listOf(4, 5, 6, 7), 1024), + ) + val plan = FrameThreadPlacement.placementPlan(littleAndPrime, gamePin = emptyList()) + assertEquals(listOf(4, 5, 6, 7), plan.frameCores) + assertNull(plan.spinnerCores) + assertEquals(listOf(4, 5, 6, 7), plan.ipcCores) + } + + @Test + fun `one capacity tier leaves the frame thread alone`() { + val flat = listOf(CoreCluster("performance", listOf(0, 1, 2, 3, 4, 5, 6, 7), 1024)) + val plan = FrameThreadPlacement.placementPlan(flat, gamePin = emptyList()) + assertNull(plan.frameCores) + assertNull(plan.spinnerCores) + assertEquals(listOf(0, 1, 2, 3, 4, 5, 6, 7), plan.ipcCores) + } + + @Test + fun `mask is one bit per core`() { + assertEquals(0x80, FrameThreadPlacement.mask(listOf(7))) + assertEquals(0xF8, FrameThreadPlacement.mask(listOf(3, 4, 5, 6, 7))) + } + + // ---- windows + + @Test + fun `window applies each role and hands a thread that lost its role the game pin back`() { + val plan = FrameThreadPlacement.placementPlan(rp6, gamePin = listOf(3, 4, 5, 6, 7)) + val pin = listOf(3, 4, 5, 6, 7) + val first = listOf(game(1, cpu = 99, wakeups = 2), game(2, cpu = 72, wakeups = 160), wineserver(2000, cpu = 5)) + FrameThreadPlacement.applyWindow(first, plan, pin, "", recordAffinity, { _, _ -> listOf(0, 1) }) + assertEquals(setOf(2 to 0x80, 1 to 0x78, 2000 to 0xF8), calls.toSet()) + + calls.clear() + val second = listOf(game(1, cpu = 40, wakeups = 300), game(2, cpu = 72, wakeups = 160), wineserver(2000, cpu = 5)) + FrameThreadPlacement.applyWindow(second, plan, pin, "", recordAffinity, noAllowedRead) + assertEquals(setOf(1 to 0xF8, 2 to 0x80, 2000 to 0xF8), calls.toSet()) + + calls.clear() + FrameThreadPlacement.release("session end", recordAffinity, pin) + assertEquals(setOf(2 to 0xF8, 2000 to 0x03), calls.toSet()) + } + + @Test + fun `nothing is placed when the plan leaves every role alone`() { + val plan = FrameThreadPlacement.placementPlan(unknownCapacity, gamePin = emptyList()) + FrameThreadPlacement.applyWindow( + listOf(game(1, cpu = 99, wakeups = 2), game(2, cpu = 72, wakeups = 160), wineserver(2000, cpu = 5)), + plan, emptyList(), "", recordAffinity, noAllowedRead, + ) + assertTrue(calls.isEmpty()) + assertEquals(false, FrameThreadPlacement.properties()["frame_placement_applied"]) + } + + @Test + fun `session summary carries the frame thread and the spinners`() { + val plan = FrameThreadPlacement.placementPlan(rp6, gamePin = emptyList()) + FrameThreadPlacement.applyWindow( + listOf(game(1, cpu = 99, wakeups = 2), game(2, cpu = 72, wakeups = 160, comm = "Main")), + plan, emptyList(), "", recordAffinity, noAllowedRead, + ) + val props = FrameThreadPlacement.properties() + assertEquals(2, props["frame_thread_tid"]) + assertEquals("Main", props["frame_thread_comm"]) + assertEquals(listOf(1), props["spinner_tids"]) + assertEquals(true, props["frame_placement_applied"]) + } +} From 78486c9e4f607960d8a2c1e9fb0d7394e5c53ac9 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sun, 8 Nov 2026 08:38:38 +0530 Subject: [PATCH 2/3] Fix the role-change check and keep the game process to Windows processes --- .../app/gamenative/powercontrol/FrameThreadPlacement.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt b/app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt index 74c9f736d8..353b7c2270 100644 --- a/app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt +++ b/app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt @@ -390,7 +390,7 @@ object FrameThreadPlacement { } val errno = setAffinity(tid, mask(p.cores)) if (errno == 0) placementApplied = true - if (prev?.role != p.role || prev.cores != p.cores) { + if (prev == null || prev.role != p.role || prev.cores != p.cores) { val note = if (p.role == Role.FRAME) frameNote else "" val failed = if (errno != 0) "; sched_setaffinity errno $errno" else "" Timber.tag(TAG).i( @@ -490,8 +490,11 @@ object FrameThreadPlacement { return emptyList() } - val game = procs.filter { it.second !in ProcStatParser.excludedProcessNames }.maxByOrNull { it.third } - val tracked = buildList { + // Only Windows processes: gamescope, Xwayland or PulseAudio can outrun a light game. + val game = procs + .filter { it.second.endsWith(".exe") && it.second !in ProcStatParser.excludedProcessNames } + .maxByOrNull { it.third } + val tracked = buildList> { game?.let { add(it.first to ProcessKind.GAME) } procs.filter { it.second == "wineserver" }.forEach { add(it.first to ProcessKind.WINESERVER) } procs.filter { it.second in steamProcessNames }.forEach { add(it.first to ProcessKind.STEAM) } From 20bb4562b18529a75aff5dbcf4adee388e61cc23 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sun, 8 Nov 2026 08:42:33 +0530 Subject: [PATCH 3/3] Give threads created by a placed game thread the game pin --- .../powercontrol/FrameThreadPlacement.kt | 26 +++++++++++++++++-- .../powercontrol/FrameThreadPlacementTest.kt | 13 ++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt b/app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt index 353b7c2270..93cd4dd26c 100644 --- a/app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt +++ b/app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt @@ -352,7 +352,7 @@ object FrameThreadPlacement { // A stop that released everything while this window was being read wins. synchronized(this) { if (!active()) return - applyWindow(threads, plan, gamePin, clamp, TaskAffinity::set, ::readAllowedCores) + applyWindow(threads, plan, gamePin, clamp, TaskAffinity::set, ::readAllowedCores, scanner.newGameTids) } } @@ -368,7 +368,9 @@ object FrameThreadPlacement { frameNote: String, setAffinity: (Int, Int) -> Int, readAllowed: (Int, Int) -> List?, + newGameTids: Collection = emptyList(), ) { + val gamePlaced = placed.values.any { it.kind == ProcessKind.GAME } if (!notesLogged && plan.notes.isNotEmpty()) { notesLogged = true Timber.tag(TAG).i("Frame thread placement: ${plan.notes.joinToString("; ")}") @@ -398,6 +400,11 @@ object FrameThreadPlacement { ) } } + if (gamePlaced) { + // A new thread inherits the mask of the thread that created it, often the frame thread. + val cores = gamePin.ifEmpty { plan.allCores } + newGameTids.forEach { setAffinity(it, mask(cores)) } + } placed.clear() placed.putAll(desired) } @@ -447,9 +454,16 @@ object FrameThreadPlacement { private var prevThreadTicks = HashMap() private var prevVoluntary = HashMap() private val names = HashMap>() + private var prevGamePid: Int? = null + + /** Game threads that appeared since the previous scan of the same game process. */ + var newGameTids: List = emptyList() + private set fun reset() { prevMs = 0L + prevGamePid = null + newGameTids = emptyList() prevProcTicks = HashMap() prevThreadTicks = HashMap() prevVoluntary = HashMap() @@ -487,6 +501,7 @@ object FrameThreadPlacement { if (dtSec <= 0.0) { prevThreadTicks = HashMap() prevVoluntary = HashMap() + newGameTids = emptyList() return emptyList() } @@ -503,6 +518,7 @@ object FrameThreadPlacement { val threadTicks = HashMap() val voluntary = HashMap() val result = ArrayList() + val newTids = ArrayList() for ((pid, kind) in tracked) { val threads = ArrayList>() for (task in File("/proc/$pid/task").listFiles().orEmpty()) { @@ -514,7 +530,11 @@ object FrameThreadPlacement { } ?: continue val ticks = fields.utime + fields.stime threadTicks[tid] = ticks - val prev = prevThreadTicks[tid] ?: continue + val prev = prevThreadTicks[tid] + if (prev == null) { + if (kind == ProcessKind.GAME && pid == prevGamePid) newTids += tid + continue + } threads += Triple(tid, fields.comm, ProcStatParser.cpuPercent(ticks - prev, dtSec, clkTck)) } val rated = if (kind == ProcessKind.GAME) { @@ -539,6 +559,8 @@ object FrameThreadPlacement { } prevThreadTicks = threadTicks prevVoluntary = voluntary + prevGamePid = game?.first + newGameTids = newTids return result } diff --git a/app/src/test/java/app/gamenative/powercontrol/FrameThreadPlacementTest.kt b/app/src/test/java/app/gamenative/powercontrol/FrameThreadPlacementTest.kt index 51fe4dea89..e98367af37 100644 --- a/app/src/test/java/app/gamenative/powercontrol/FrameThreadPlacementTest.kt +++ b/app/src/test/java/app/gamenative/powercontrol/FrameThreadPlacementTest.kt @@ -225,6 +225,19 @@ class FrameThreadPlacementTest { assertEquals(setOf(2 to 0xF8, 2000 to 0x03), calls.toSet()) } + @Test + fun `a thread created while a game thread is placed gets the game pin`() { + val plan = FrameThreadPlacement.placementPlan(rp6, gamePin = listOf(3, 4, 5, 6, 7)) + val pin = listOf(3, 4, 5, 6, 7) + val window = listOf(game(2, cpu = 72, wakeups = 160)) + FrameThreadPlacement.applyWindow(window, plan, pin, "", recordAffinity, noAllowedRead, listOf(9)) + assertEquals(listOf(2 to 0x80), calls) + + calls.clear() + FrameThreadPlacement.applyWindow(window, plan, pin, "", recordAffinity, noAllowedRead, listOf(9)) + assertEquals(setOf(2 to 0x80, 9 to 0xF8), calls.toSet()) + } + @Test fun `nothing is placed when the plan leaves every role alone`() { val plan = FrameThreadPlacement.placementPlan(unknownCapacity, gamePin = emptyList())