Skip to content

Place the frame thread on the prime cluster and keep spinners off it - #2370

Closed
utkarshdalal wants to merge 3 commits into
masterfrom
frame-thread-placement
Closed

utkarshdalal wants to merge 3 commits into
masterfrom
frame-thread-placement

Conversation

@utkarshdalal

@utkarshdalal utkarshdalal commented Nov 8, 2026 •

Copy link
Copy Markdown
Owner

Description

On big.LITTLE devices the thread that sets the frame often does not land on the fastest core. On the Retroid Pocket 6 (Cult of the Lamb), once the spinner was throttled in wine (proton-wine PR 119), the prime core (core 7) sat at 8 % while the 72 %-busy main thread stayed on the gold cores, and fps stayed at 26.

This adds FrameThreadPlacement. Every 2 s it reads the game's threads, wineserver and the Steam processes (steam.exe, steamservice.exe, steamhost*.exe) from /proc, sorts the threads into roles and sets their affinity with sched_setaffinity (same uid, no privilege). Nothing is configured per game.

  • Frame thread: the busiest game thread (>= 15 % of a core) that wakes 20 to 5000 times a second and is not a spinner. It goes on the highest-capacity cluster (RP6: core 7, Odin 3: cores 6-7). The role is sticky: another thread takes it only after beating it by more than 20 % CPU for 2 windows in a row. If the frame thread exits or starts spinning, it is replaced straight away.
  • Spinner: a game thread at >= 90 % of a core with < 50 voluntary switches a second. It goes on every core outside the prime cluster, and never on the low-capacity cores alone.
  • IPC threads: wineserver's main thread, Steam's IPC:CSteamEngine thread (its comm reads IPC:CSteamEngin), and any wineserver or Steam thread above 10 % CPU. They go on every core outside the low-capacity clusters.
  • When the game is held to a core set (the power-control pin, or the container's explicit CPU list), each role's cores are cut down to the cores that set shares. A role with no shared cores is skipped and logged.
  • Masks are applied again every window, because a winhandler pin or a cpuset move on activity resume resets them. Only role changes and releases are logged.
  • A thread inherits the affinity of the thread that created it, so while a game thread is placed, game threads that appeared since the last window get the game pin (all cores if there is no pin) instead of staying on the frame or spinner cores.
  • When a thread loses its role, a game thread gets the game pin back (all cores if there is no pin). A wineserver or Steam thread gets back the Cpus_allowed_list it had before it was first placed. Everything placed is released at session end, and also when power control is switched off mid-session.

Gate: this needs a PServer driver with cluster capacities (placementClusters()) and the profile's power control on. Without them nothing is placed. Kill switches, with no UI: PrefManager frameThreadPlacement (default true) and the container env var GN_FRAME_PLACEMENT=0.

game_exited gets frame_thread_tid, frame_thread_comm, spinner_tids and frame_placement_applied from the same extension point as the process telemetry. This is not gated on analytics.

Unit tests cover role classification, frame stickiness, the cluster masks for the RP6, the Odin 3 and edge layouts, game-pin intersection, the window apply/release flow and the session summary.

Device verification is pending: RP6 with Cult of the Lamb (frame thread on core 7), and Odin 3 with DOOM 2016.

Recording

Not applicable (no UI change). Device verification pending.

Type of Change

  • Bug fix
  • Performance / stability improvement
  • Compatibility improvements
  • Other (requires prior approval)

Checklist

  • If I have access to #code-changes, I have discussed this change there and it has been green-lighted. If I do not have access, I have still provided clear context in this PR. If I skip both, I accept that this change may face delays in review, may not be reviewed at all, or may be closed.
  • This change aligns with the current project scope (core functionality, stability, or performance). If not, it has been explicitly approved beforehand.
  • I have attached a recording of the change.
  • I have read and agree to the contribution guidelines in CONTRIBUTING.md.

Summary by cubic

Places the frame thread on the highest-capacity cluster on big.LITTLE devices so it no longer lands on a slower core and caps FPS. FrameThreadPlacement re-reads the game's threads, wineserver, and Steam from /proc every 2 s, classifies them by role, and pins each role to a core set with sched_setaffinity (same uid, no privileges, nothing configured per game).

Behavior

  • The frame thread — the busiest game thread (≥15% CPU) that wakes 20–5000×/s and is not a spinner — is pinned to the highest-capacity cluster. It is sticky: replaced only after being beaten by >20% CPU for two windows, or immediately if it exits or starts spinning.
  • Spinners (game threads at ≥90% CPU with <50 voluntary switches/s) go on every core outside the prime cluster; IPC threads (wineserver's main thread, Steam's engine IPC thread, busy wineserver/Steam threads) go on every core outside the low-capacity clusters.
  • Masks are re-applied every window because a winhandler pin or a cpuset move on activity resume resets them. New threads created by a placed game thread inherit the game pin, since a thread inherits its creator's mask.
  • A thread that loses its role gets the game pin back (game threads) or the mask it had before placement (wineserver/Steam threads); everything placed is released at session end and when power control is switched off.
  • Placement is gated on a PServer driver providing cluster capacities and the profile's power control being on. Kill switches, no UI: frameThreadPlacement pref (default true) and container env var GN_FRAME_PLACEMENT=0.
  • Session telemetry now reports frame_thread_tid, frame_thread_comm, spinner_tids, and frame_placement_applied.
  • Unit tests cover classification, frame stickiness, cluster masks for several layouts, game-pin intersection, new-thread inheritance, and the apply/release flow.
  • Device verification is pending on RP6 with Cult of the Lamb and Odin 3 with DOOM 2016.

Written for commit 20bb456. Summary will update on new commits.

View guided diff Turn on auto-fix

Summary by CodeRabbit

  • New Features
    • Added automatic CPU core placement for game threads on supported devices when power control is enabled. Placement can be disabled through the frame-thread placement setting.
    • Added frame-thread placement details to session exit reports.

@coderabbitai

coderabbitai Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Explain Change →

Caution

Review failed

The pull request is closed.

Walkthrough

The change adds configurable frame-thread placement for game sessions. It samples processes and threads, classifies frame, spinner, and IPC roles, plans core affinities using cluster capacities and game pins, and reports placement properties at session end.

Changes: Frame thread placement

Layer / File(s) Summary
Placement settings and session wiring
app/src/main/java/app/gamenative/PrefManager.kt, app/src/main/java/app/gamenative/powercontrol/PowerManager.kt, app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt
A persisted preference controls placement. PowerManager loads container data, exposes game-core and cluster inputs, and starts or stops placement with the game session.
Thread sampling and role selection
app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt, app/src/test/java/app/gamenative/powercontrol/FrameThreadPlacementTest.kt
The scanner samples eligible processes and threads. Classification and selection logic identifies frame, spinner, and IPC threads, including challenger hysteresis.
Core planning and affinity updates
app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt, app/src/main/java/app/gamenative/utils/SessionReport.kt, app/src/test/java/app/gamenative/powercontrol/FrameThreadPlacementTest.kt
Role plans use cluster capacities and game pins. The placement loop applies and restores thread affinities. Session reports include placement properties, and tests cover plans, affinity changes, release, and reporting.

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant PowerManager
  participant FrameThreadPlacement
  participant Procfs
  participant SessionReport
  PowerManager->>FrameThreadPlacement: Start placement for the game session
  FrameThreadPlacement->>PowerManager: Read game pin and placement clusters
  FrameThreadPlacement->>Procfs: Sample eligible processes and thread statistics
  FrameThreadPlacement->>FrameThreadPlacement: Select roles and apply affinity masks
  SessionReport->>FrameThreadPlacement: Collect placement properties
Loading

Possibly related PRs

Suggested reviewers: joshuatam

Merge Risk: Low · up to 20bb4

Placement can target the wrong thread or process and override game-specific CPU restrictions, potentially reducing performance. Merge risk is bounded by device eligibility and disable switches, but these limitations warrant owner awareness and correction.

- [ ] Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

4 issues found and verified against the latest diff

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt">

<violation number="1" location="app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt:333">
P2: Disabling power control leaves existing thread masks active until the next placement tick, and a quick re-enable can prevent release altogether. Release placement synchronously from the power-control toggle path instead of relying on polling.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt:346">
P2: An empty scan skips role cleanup, so previously placed TIDs remain tracked after all tracked processes disappear or a scan fails. Release the existing placements when the scan is empty before returning.</violation>

<violation number="3" location="app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt:389">
P2: Placement state identifies threads by TID alone, so a reused TID can inherit stale placement and restoration state from a previous process. Key the state by `(pid, tid)` and release mismatched entries before applying the new role.</violation>
</file>

<file name="app/src/main/java/app/gamenative/powercontrol/PowerManager.kt">

<violation number="1" location="app/src/main/java/app/gamenative/powercontrol/PowerManager.kt:1179">
P2: Disabling power control can race with a tick that already captured active clusters, allowing `applyWindow()` to place threads after the toggle is off. Recheck the toggle under the placement lock or release placements synchronously when disabling power control.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

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]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Placement state identifies threads by TID alone, so a reused TID can inherit stale placement and restoration state from a previous process. Key the state by (pid, tid) and release mismatched entries before applying the new role.

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

<comment>Placement state identifies threads by TID alone, so a reused TID can inherit stale placement and restoration state from a previous process. Key the state by `(pid, tid)` and release mismatched entries before applying the new role.</comment>

<file context>
@@ -0,0 +1,548 @@
+        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
</file context>

private fun tick(scanner: Scanner) {
val gamePin = PowerManager.threadPlacementGamePin()
val clusters = PowerManager.threadPlacementClusters()
if (clusters == null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Disabling power control leaves existing thread masks active until the next placement tick, and a quick re-enable can prevent release altogether. Release placement synchronously from the power-control toggle path instead of relying on polling.

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

<comment>Disabling power control leaves existing thread masks active until the next placement tick, and a quick re-enable can prevent release altogether. Release placement synchronously from the power-control toggle path instead of relying on polling.</comment>

<file context>
@@ -0,0 +1,548 @@
+    private fun tick(scanner: Scanner) {
+        val gamePin = PowerManager.threadPlacementGamePin()
+        val clusters = PowerManager.threadPlacementClusters()
+        if (clusters == null) {
+            synchronized(this) {
+                if (!active()) return
</file context>

* [FrameThreadPlacement] moves the game's own threads and places nothing with the toggle off.
*/
internal fun threadPlacementClusters(): List<CoreCluster>? =
if (::currentProfile.isInitialized && isProfilePowerControlEnabled()) placementClusters() else null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Disabling power control can race with a tick that already captured active clusters, allowing applyWindow() to place threads after the toggle is off. Recheck the toggle under the placement lock or release placements synchronously when disabling power control.

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

<comment>Disabling power control can race with a tick that already captured active clusters, allowing `applyWindow()` to place threads after the toggle is off. Recheck the toggle under the placement lock or release placements synchronously when disabling power control.</comment>

<file context>
@@ -1160,6 +1171,19 @@ object PowerManager {
+     * [FrameThreadPlacement] moves the game's own threads and places nothing with the toggle off.
+     */
+    internal fun threadPlacementClusters(): List<CoreCluster>? =
+        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. */
</file context>

return
}
val threads = scanner.scan()
if (threads.isEmpty()) return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: An empty scan skips role cleanup, so previously placed TIDs remain tracked after all tracked processes disappear or a scan fails. Release the existing placements when the scan is empty before returning.

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

<comment>An empty scan skips role cleanup, so previously placed TIDs remain tracked after all tracked processes disappear or a scan fails. Release the existing placements when the scan is empty before returning.</comment>

<file context>
@@ -0,0 +1,548 @@
+            return
+        }
+        val threads = scanner.scan()
+        if (threads.isEmpty()) return
+        val plan = placementPlan(clusters, gamePin)
+        val clamp = PowerManager.latestTunerCaps()?.primeKhz
</file context>
Suggested change
if (threads.isEmpty()) return
+ if (threads.isEmpty()) {
+ synchronized(this) {
+ if (active()) release("no threads", TaskAffinity::set, gamePin)
+ }
+ return
+ }

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

4 issues found and verified against the latest diff

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/main/java/app/gamenative/PrefManager.kt">

<violation number="1" location="app/src/main/java/app/gamenative/PrefManager.kt:1982">
P2: This adds the advertised preference but does not expose it in the app, so users have no way to disable frame-thread placement through `frameThreadPlacement`; add a performance-settings switch and persist its value through this property.</violation>
</file>

<file name="app/src/main/java/app/gamenative/powercontrol/PowerManager.kt">

<violation number="1" location="app/src/main/java/app/gamenative/powercontrol/PowerManager.kt:1179">
P2: This gate keeps frame placement active across `PowerManager.pause()`: backgrounding stops the driver, but the profile flag remains enabled, so the 2-second loop continues moving game threads. Stop placement when pausing and restart it on resume, or gate it on an actual running state.</violation>
</file>

<file name="app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt">

<violation number="1" location="app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt:203">
P2: Affinity state is keyed only by TID, but this scanner tracks both `pid` and `tid`; after a task exits, Linux may reuse that TID for a different process during the session. `desiredPlacements` then skips taking the new task's original mask, and `releaseThread` can restore the old task's mask to the new task; key state by `(pid, tid)` or invalidate it when the PID changes.

(Based on your team's feedback about preserving lifecycle identity.)</violation>

<violation number="2" location="app/src/main/java/app/gamenative/powercontrol/FrameThreadPlacement.kt:346">
P2: An empty scan currently leaves every prior placement active instead of treating the tracked-thread set as empty. Release the entries in `placed` before returning when the scan has no threads, otherwise a transient `/proc` read failure can keep a live thread on a stale affinity mask.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

set(value) { setPref(SHARED_SHADER_CACHE, value) }

private val FRAME_THREAD_PLACEMENT = booleanPreferencesKey("frame_thread_placement")
var frameThreadPlacement: Boolean

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: This adds the advertised preference but does not expose it in the app, so users have no way to disable frame-thread placement through frameThreadPlacement; add a performance-settings switch and persist its value through this property.

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

<comment>This adds the advertised preference but does not expose it in the app, so users have no way to disable frame-thread placement through `frameThreadPlacement`; add a performance-settings switch and persist its value through this property.</comment>

<file context>
@@ -1977,4 +1977,9 @@ object PrefManager {
         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) }
</file context>

* [FrameThreadPlacement] moves the game's own threads and places nothing with the toggle off.
*/
internal fun threadPlacementClusters(): List<CoreCluster>? =
if (::currentProfile.isInitialized && isProfilePowerControlEnabled()) placementClusters() else null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: This gate keeps frame placement active across PowerManager.pause(): backgrounding stops the driver, but the profile flag remains enabled, so the 2-second loop continues moving game threads. Stop placement when pausing and restart it on resume, or gate it on an actual running state.

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

<comment>This gate keeps frame placement active across `PowerManager.pause()`: backgrounding stops the driver, but the profile flag remains enabled, so the 2-second loop continues moving game threads. Stop placement when pausing and restart it on resume, or gate it on an actual running state.</comment>

<file context>
@@ -1160,6 +1171,19 @@ object PowerManager {
+     * [FrameThreadPlacement] moves the game's own threads and places nothing with the toggle off.
+     */
+    internal fun threadPlacementClusters(): List<CoreCluster>? =
+        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. */
</file context>

return
}
val threads = scanner.scan()
if (threads.isEmpty()) return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: An empty scan currently leaves every prior placement active instead of treating the tracked-thread set as empty. Release the entries in placed before returning when the scan has no threads, otherwise a transient /proc read failure can keep a live thread on a stale affinity mask.

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

<comment>An empty scan currently leaves every prior placement active instead of treating the tracked-thread set as empty. Release the entries in `placed` before returning when the scan has no threads, otherwise a transient `/proc` read failure can keep a live thread on a stale affinity mask.</comment>

<file context>
@@ -0,0 +1,551 @@
+            return
+        }
+        val threads = scanner.scan()
+        if (threads.isEmpty()) return
+        val plan = placementPlan(clusters, gamePin)
+        val clamp = PowerManager.latestTunerCaps()?.primeKhz
</file context>

roles: Roles,
plan: PlacementPlan,
): Map<Int, Placement> {
val byTid = threads.associateBy { it.tid }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Affinity state is keyed only by TID, but this scanner tracks both pid and tid; after a task exits, Linux may reuse that TID for a different process during the session. desiredPlacements then skips taking the new task's original mask, and releaseThread can restore the old task's mask to the new task; key state by (pid, tid) or invalidate it when the PID changes.

(Based on your team's feedback about preserving lifecycle identity.)

View Feedback

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

<comment>Affinity state is keyed only by TID, but this scanner tracks both `pid` and `tid`; after a task exits, Linux may reuse that TID for a different process during the session. `desiredPlacements` then skips taking the new task's original mask, and `releaseThread` can restore the old task's mask to the new task; key state by `(pid, tid)` or invalidate it when the PID changes.

(Based on your team's feedback about preserving lifecycle identity.) </comment>

<file context>
@@ -0,0 +1,551 @@
+        roles: Roles,
+        plan: PlacementPlan,
+    ): Map<Int, Placement> {
+        val byTid = threads.associateBy { it.tid }
+        val result = LinkedHashMap<Int, Placement>()
+        val frame = roles.frame?.let { byTid[it.tid] }
</file context>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

2 existing issues remain and no new issues found across 5 files

Requires human review: Auto-approval blocked because this review re-detected 2 unresolved issues already reported by Cubic.

View guided diff | Turn on auto-fix | Re-trigger cubic

@utkarshdalal

Copy link
Copy Markdown
Owner Author

Closing: the fix should live in wine (no PServer dependency), not the app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant