Performance: the client and game core masks, from Bannerlator's Linux session

A Steam session runs two things that want different cores at the same time,
and one affinity cannot serve both (Bannerlator cfca3912).

The client is the case that prompted it: Steam pins its own interface renderer
to a subset of cores it chooses - five of eight on one device, leaving out both
little cores and the fastest one - reasonable while a game is running and wrong
when the client is all there is, where it shows as Big Picture feeling sluggish.
BL_CLIENT_CPUS overrides it, re-applied on a beat by the session script because
steamwebhelper spawns children continuously and each inherits Steam's choice.
That block is Bannerlator's, verbatim. One deliberate difference: the client's
mask is sent whenever the override is on, even when it names every core,
because undoing Steam's pin is the whole point and the scheduler's default is
exactly what that pin takes away.

A game gets BL_GAME_CPUS instead, consumed by the Proton wrappers that r27
already carries, and as in Bannerlator it is sent only when it is a real
restriction: a list naming every core is what the scheduler does unaided.

Both live behind a Performance dialog with a checkbox per core, labelled with
its ceiling, and apply at the next session start.
This commit is contained in:
The412Banner committed 2026-09-21 10:49:34 -04:00
1 parent fbe3737dc4
commit beab4e7849
8 files changed
+242

No files matched your search

+18
View File
@@ -196,6 +196,24 @@ staged into the runtime at every launch like the session scripts, so a fix reach
runtime without re-hosting it. Proven in Bannerlator's Linux session with voice in Steam's own
tester; ⚠️ not yet exercised in this app.
## Cores
**Performance** on the main screen carries the two core masks Bannerlator's Linux session has, kept
separate because they are wanted at the same time and suit different things:
- **Steam client cores** — Steam pins its own interface renderer to a subset of cores it chooses (on
one device 5 of 8, leaving out both little cores and the fastest), which is reasonable while a
game runs and makes Big Picture sluggish when the client is all there is. Turning the override
on pins the client, its UI helper and gamescope to the cores you tick, re-applied every few
seconds because the UI keeps spawning helpers that inherit Steam's choice. Every core ticked is
the usual fix — and, unlike the game mask, it *is* sent when it names every core, since undoing
Steam's pin is the whole point.
- **Game cores** — applied by the Proton wrapper, which execs the game through `taskset` so every
thread inherits the mask from its first instruction. Leaving every core ticked sends nothing;
untick the small cores to keep a heavy game off them.
Both apply at the next session start.
## Compatibility tools
**Compatibility tools** (main screen and drawer) installs GE-Proton or proton-cachyos — native
@@ -33,7 +33,10 @@ import com.steamdeck.launcher.session.ProtonExtras
import com.steamdeck.launcher.session.SessionPrefs
import com.steamdeck.launcher.ui.ProtonDialog
import com.steamdeck.launcher.ui.ProtonRow
import com.steamdeck.launcher.core.CpuCores
import com.steamdeck.launcher.ui.AudioDialog
import com.steamdeck.launcher.ui.CoreRow
import com.steamdeck.launcher.ui.PerformanceDialog
import com.steamdeck.launcher.ui.DriverDialog
import com.steamdeck.launcher.ui.DriverRow
import com.steamdeck.launcher.ui.ConfirmDialog
@@ -75,6 +78,10 @@ class MainActivity : ComponentActivity() {
private var protonRows by mutableStateOf<List<ProtonRow>>(emptyList())
private var showDrivers by mutableStateOf(false)
private var showAudio by mutableStateOf(false)
private var showPerformance by mutableStateOf(false)
private var clientOverride by mutableStateOf(false)
private var clientCores by mutableStateOf<Set<Int>>(emptySet())
private var gameCores by mutableStateOf<Set<Int>>(emptySet())
private var directAudio by mutableStateOf(false)
private var mic by mutableStateOf(false)
private var linuxRows by mutableStateOf<List<DriverRow>>(emptyList())
@@ -121,6 +128,7 @@ class MainActivity : ComponentActivity() {
onProtons = { refreshProtons(); showProtons = true },
onDrivers = { refreshDrivers(); showDrivers = true },
onAudio = { directAudio = SessionPrefs.directAudio(this); mic = SessionPrefs.micEnabled(this); showAudio = true },
onPerformance = { refreshCores(); showPerformance = true },
onCredits = { showCredits = true },
)
if (showApps) DesktopAppsDialog(
@@ -161,6 +169,20 @@ class MainActivity : ComponentActivity() {
},
onDismiss = { showAudio = false },
)
if (showPerformance) PerformanceDialog(
cores = CpuCores.all.map { c -> CoreRow(c, "cpu$c" + (CpuCores.maxGhz(c)?.let { String.format(java.util.Locale.US, " · %.1f GHz", it) } ?: "")) },
clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores,
onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on },
onClientCore = { core, on ->
clientCores = if (on) clientCores + core else clientCores - core
SessionPrefs.setClientCpus(this, CpuCores.format(clientCores))
},
onGameCore = { core, on ->
gameCores = if (on) gameCores + core else gameCores - core
SessionPrefs.setGameCpus(this, CpuCores.format(gameCores))
},
onDismiss = { showPerformance = false },
)
if (showRemove) ConfirmDialog(
title = "Remove Linux runtime",
text = "This deletes the runtime, the Steam client inside it, and every game installed there.",
@@ -293,6 +315,13 @@ class MainActivity : ComponentActivity() {
null
}
/** The two masks as the dialog shows them; an empty stored list shows as every core ticked. */
private fun refreshCores() {
clientOverride = SessionPrefs.clientCpusOverride(this)
clientCores = CpuCores.parse(SessionPrefs.clientCpus(this)).ifEmpty { CpuCores.all.toSet() }
gameCores = CpuCores.parse(SessionPrefs.gameCpus(this)).ifEmpty { CpuCores.all.toSet() }
}
private fun refreshProtons() {
protonRows = ProtonExtras.tools.map { ProtonRow(it.id, it.name, ProtonExtras.installed(this, it), ProtonExtras.queued(this, it)) }
}
@@ -0,0 +1,41 @@
package com.steamdeck.launcher.core
import java.io.File
/**
* The device's cores as taskset names them, for the two masks a Steam session can carry.
*
* A list is stored the way taskset's `-c` takes it - comma-separated core numbers - so what the
* dialog produces is what the session script and the Proton wrappers consume unchanged.
*/
object CpuCores {
/** Core numbers present on this device, in order. */
val all: List<Int> by lazy { (0 until Runtime.getRuntime().availableProcessors()).toList() }
/** The core's ceiling in GHz for the dialog's labels, or null where the kernel hides it. */
fun maxGhz(core: Int): Double? =
FileUtils.readString(File("/sys/devices/system/cpu/cpu$core/cpufreq/cpuinfo_max_freq"))
?.trim()?.toLongOrNull()?.let { it / 1_000_000.0 }
fun parse(list: String): Set<Int> =
list.split(',').mapNotNull { it.trim().toIntOrNull() }.filter { it in all }.toSet()
fun format(cores: Set<Int>): String = cores.sorted().joinToString(",")
/** The stored list, or every core when nothing (or nothing valid) is stored. */
fun listOrAll(list: String): String {
val cores = parse(list)
return format(if (cores.isEmpty()) all.toSet() else cores)
}
/**
* The list only when it is a real restriction, else "". Empty, absent, or naming every core
* all mean "no preference": pinning a process to all cores is what the scheduler does
* unaided, and passing it would only put a meaningless line in the session log. Bannerlator's
* rule, kept for the game mask.
*/
fun restrictionOrEmpty(list: String): String {
val cores = parse(list)
return if (cores.isEmpty() || cores.size >= all.size) "" else format(cores)
}
}
@@ -80,6 +80,31 @@ object SessionPrefs {
prefs(context).edit().putBoolean("mic", on).apply()
}
/**
* Whether the client's own core pick is overridden. When on, BL_CLIENT_CPUS is sent even when
* it names every core - unlike a game mask, the point here is to undo a pin Steam applies to
* itself, and the scheduler's default is exactly what Steam's choice takes away.
*/
fun clientCpusOverride(context: Context): Boolean = prefs(context).getBoolean("clientCpusOverride", false)
fun setClientCpusOverride(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("clientCpusOverride", on).apply()
}
/** Comma-separated core list for the client (taskset -c syntax); "" = every core. */
fun clientCpus(context: Context): String = prefs(context).getString("clientCpus", "") ?: ""
fun setClientCpus(context: Context, list: String) {
prefs(context).edit().putString("clientCpus", list).apply()
}
/** Comma-separated core list for games (taskset -c syntax); "" = every core = nothing sent. */
fun gameCpus(context: Context): String = prefs(context).getString("gameCpus", "") ?: ""
fun setGameCpus(context: Context, list: String) {
prefs(context).edit().putString("gameCpus", list).apply()
}
/** The Android driver the compositor loads: "" = pick by GPU, else a bundled or imported id. */
@JvmStatic
fun androidDriver(context: Context): String = prefs(context).getString("androidDriver", "") ?: ""
@@ -21,6 +21,7 @@ import com.steamdeck.launcher.R
import com.steamdeck.launcher.SessionActivity
import com.steamdeck.launcher.audio.DirectAudioRelayComponent
import com.steamdeck.launcher.audio.PulseAudioComponent
import com.steamdeck.launcher.core.CpuCores
import com.steamdeck.launcher.core.EnvVars
import com.steamdeck.launcher.core.EnvironmentComponent
import com.steamdeck.launcher.core.FileUtils
@@ -134,6 +135,17 @@ class SessionService : Service() {
// imported driver from the A710/A720/A722 legs gets "sysmem" on its own, which is what both
// its authors advise for those GPUs and what nothing else in the list needs.
tuDebug(linuxDriverId)?.let { guest.add("TU_DEBUG=$it") }
// Core masks, Bannerlator's two (cfca3912). The client's is sent whenever the override is
// on, even naming every core: it exists to undo the pin Steam applies to its own interface
// renderer, and the scheduler's default is exactly what that pin takes away. A game's is
// sent only when it is a real restriction - a game has no pin of its own to undo.
if (SessionState.mode == MODE_STEAM) {
if (SessionPrefs.clientCpusOverride(this)) {
guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this)))
}
CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(this))
.takeIf { it.isNotEmpty() }?.let { guest.add("BL_GAME_CPUS=$it") }
}
// PulseAudio always: the client is a native Linux program and has no other way to make a
// sound - its menus, its music and its voice chat all go through here. DirectAudio is not
@@ -68,6 +68,7 @@ fun MainScreen(
onProtons: () -> Unit,
onDrivers: () -> Unit,
onAudio: () -> Unit,
onPerformance: () -> Unit,
onOffline: () -> Unit,
onCredits: () -> Unit,
) {
@@ -187,6 +188,10 @@ fun MainScreen(
onClick = onAudio,
modifier = Modifier.fillMaxWidth().padding(top = 5.dp * k).height(buttonHeight + 5.dp * k),
) { Text("Audio", fontSize = 13.sp * k) }
OutlinedButton(
onClick = onPerformance,
modifier = Modifier.fillMaxWidth().padding(top = 5.dp * k).height(buttonHeight + 5.dp * k),
) { Text("Performance", fontSize = 13.sp * k) }
Spacer(Modifier.height(10.dp * k))
Text(
"by The412Banner and maxjivi05 · credits",
@@ -0,0 +1,95 @@
package com.steamdeck.launcher.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/** One core as the dialog labels it: its number and, where known, its ceiling. */
class CoreRow(val core: Int, val label: String)
/**
* The two core masks a Steam session carries, kept separate because they are wanted at the same
* time and suit different things: the client's menus and a game each get their own. Both apply
* at the next session start; the game's is applied by the Proton wrapper at each launch.
*/
@Composable
fun PerformanceDialog(
cores: List<CoreRow>,
clientOverride: Boolean,
clientCores: Set<Int>,
gameCores: Set<Int>,
onClientOverride: (Boolean) -> Unit,
onClientCore: (Int, Boolean) -> Unit,
onGameCore: (Int, Boolean) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Performance") },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text("Steam client cores", style = MaterialTheme.typography.titleSmall)
Text(
"Steam pins its own interface to a subset of cores it chooses — on one device 5 of 8, leaving out the fastest — " +
"which suits a running game and makes the menus sluggish when the client is all there is. Turning this on " +
"pins the client, its UI helper and gamescope to the cores ticked below instead, re-applied every few seconds " +
"because the UI keeps spawning helpers that inherit Steam's choice. Every core ticked is the usual fix.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text("Override Steam's own core choice", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Switch(checked = clientOverride, onCheckedChange = onClientOverride)
}
CoreGrid(cores, clientCores, enabled = clientOverride, onToggle = onClientCore)
Spacer(Modifier.height(12.dp))
HorizontalDivider()
Spacer(Modifier.height(10.dp))
Text("Game cores", style = MaterialTheme.typography.titleSmall)
Text(
"The cores a game the client launches may run on, applied by exec'ing it through taskset so every thread " +
"inherits the mask from its first instruction. Leaving every core ticked sends nothing — that is what the " +
"scheduler does unaided. Untick the small cores to keep a heavy game off them.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
CoreGrid(cores, gameCores, enabled = true, onToggle = onGameCore)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } },
)
}
@Composable
private fun CoreGrid(cores: List<CoreRow>, selected: Set<Int>, enabled: Boolean, onToggle: (Int, Boolean) -> Unit) {
for (row in cores.chunked(2)) {
Row(modifier = Modifier.fillMaxWidth()) {
for (c in row) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
Checkbox(checked = c.core in selected, enabled = enabled, onCheckedChange = { onToggle(c.core, it) })
Text(c.label, style = MaterialTheme.typography.bodySmall)
}
}
if (row.size == 1) Spacer(Modifier.weight(1f))
}
}
}
@@ -186,6 +186,23 @@ case $mode in
done < "$HOME/.bl-steam-urls"
rm -f "$HOME/.bl-steam-urls"
fi
# Cores for the client itself. Steam pins its own interface renderer to a subset - on the
# device it took 5 of 8, leaving out both little cores and the fastest one - which is a
# reasonable thing to do while a game is running and the wrong thing when the client is all
# there is. BL_CLIENT_CPUS overrides it. Re-applied on a beat because steamwebhelper spawns
# children continuously and each one inherits Steam's choice, not ours.
if [ -n "${BL_CLIENT_CPUS:-}" ]; then
step "client cores: $BL_CLIENT_CPUS"
(
for _ in $(seq 1 720); do
for _p in $(pgrep -f 'steamwebhelper|steamrtarm64/steam|gamescope' 2>/dev/null); do
taskset -cp "$BL_CLIENT_CPUS" "$_p" >/dev/null 2>&1 || true
done
sleep 5
done
) &
fi
step "registering the app's game library with Steam"
bannerlator-steam-library "$steam_root" || true
step "setting up the ARM64 Proton compatibility tool"