Default Snapdragon 8 Gen 2 core allocation (#654)

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-12 16:26:24 -04:00
1 parent a7662ae13a
commit 5e5ffee93e
9 files changed
+209 -19

No files matched your search

@@ -0,0 +1,29 @@
package com.droiddeck.launcher.core
import com.droiddeck.launcher.gpu.GpuInfo
import com.droiddeck.launcher.gpu.SocNames
/** Device defaults only: affinity does not reserve cores or lock their clocks. */
object CpuAllocationDefaults {
data class Allocation(val client: String, val game: String)
val device: Allocation? by lazy {
forDevice(SocNames.model(), GpuInfo.systemProperty("ro.board.platform"),
CpuCores.all.associateWith(CpuCores::maxGhz))
}
/** Select by topology, including faster SM8550 variants; never guess core indices. */
fun forDevice(model: String, platform: String, frequencies: Map<Int, Double?>): Allocation? {
val chip = model.trim().uppercase().substringBefore('-')
val gen2 = chip in setOf("SM8550", "QCS8550") ||
(chip.isEmpty() && platform.trim().equals("kalama", ignoreCase = true))
if (!gen2 || frequencies.size != 8 || frequencies.keys.any { it < 0 } ||
frequencies.values.any { it == null || !it.isFinite() || it <= 0.0 }) return null
val clusters = frequencies.entries.groupBy { it.value }.toSortedMap(compareBy { it ?: 0.0 }).values.toList()
if (clusters.map { it.size } != listOf(3, 4, 1)) return null
return Allocation(
client = CpuCores.format(clusters[2].map { it.key }.toSet()),
game = CpuCores.format(clusters[1].map { it.key }.toSet()),
)
}
}
@@ -28,6 +28,10 @@ object CpuCores {
return format(if (cores.isEmpty()) all.toSet() else cores)
}
/** An all-core game must undo the affinity inherited from a pinned Steam client. */
fun gameLaunchList(list: String, clientPinned: Boolean): String =
restrictionOrEmpty(list).ifEmpty { if (clientPinned) listOrAll("") else "" }
/**
* 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
@@ -147,7 +147,7 @@ object DeviceReport {
h("Settings in effect")
k("Client core override", SessionPrefs.clientCpusOverride(context))
k(" client cores", CpuCores.listOrAll(SessionPrefs.clientCpus(context)))
k(" game cores", CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(context)).ifEmpty { "every core (nothing sent)" })
k(" game cores", CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(context)).ifEmpty { "every core" })
k("Turnip sysmem", SessionPrefs.tuSysmem(context))
k("Zink lazy descriptors", SessionPrefs.zinkLazy(context))
k("Threaded GL (glthread)", SessionPrefs.glThread(context))
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.pm.ActivityInfo
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.CpuAllocationDefaults
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.core.TextureFiltering
import com.droiddeck.launcher.gpu.ScreenEffects
@@ -271,26 +272,34 @@ object SessionPrefs {
prefs(context).edit().putBoolean("wifiDiscoveryAsked", true).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)
internal data class CoreAllocation(val clientOverride: Boolean, val clientCpus: String, val gameCpus: String)
/** Defaults are read only for absent keys; even a saved empty/all-core choice wins. */
internal fun coreAllocation(context: Context, defaults: CpuAllocationDefaults.Allocation? = CpuAllocationDefaults.device): CoreAllocation {
val saved = prefs(context)
return CoreAllocation(
saved.getBoolean("clientCpusOverride", defaults != null && !saved.contains("clientCpus")),
saved.getString("clientCpus", defaults?.client.orEmpty()).orEmpty(),
saved.getString("gameCpus", defaults?.game.orEmpty()).orEmpty(),
)
}
/** Use the device default until the user saves an override or a client mask. */
fun clientCpusOverride(context: Context): Boolean = coreAllocation(context).clientOverride
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 clientCpus(context: Context): String = coreAllocation(context).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", "") ?: ""
/** Comma-separated core list for games (taskset -c syntax); "" = every core (explicitly restored when the client is pinned). */
fun gameCpus(context: Context): String = coreAllocation(context).gameCpus
fun setGameCpus(context: Context, list: String) {
prefs(context).edit().putString("gameCpus", list).apply()
@@ -666,16 +666,15 @@ class SessionService : Service() {
extraEnv().forEach { guest.add(it) }
// The agent bridge's experiment lines (AgentEnv), after the user's so an agent's win.
com.droiddeck.launcher.agent.AgentEnv.beginSession(this).forEach { guest.add(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 (steamHere && SessionPrefs.clientCpusOverride(this)) {
// A game launched by pinned Steam inherits its mask. Explicitly restore all game cores
// when requested, as well as applying restricted game choices in the Proton wrapper.
val clientPinned = steamHere && SessionPrefs.clientCpusOverride(this)
if (clientPinned) {
guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this)))
}
// The game cores also pin the desktop and a program from the rail; without a choice the
// session script picks every core but the slowest cluster for those (program_cores).
CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(this))
CpuCores.gameLaunchList(SessionPrefs.gameCpus(this), clientPinned)
.takeIf { it.isNotEmpty() }?.let { guest.add("BL_GAME_CPUS=$it") }
}
@@ -0,0 +1,42 @@
package com.droiddeck.launcher.core
import org.junit.Assert.*
import org.junit.Test
class CpuAllocationDefaultsTest {
private val thor = (0..7).associateWith { when (it) { in 0..2 -> 2.016; in 3..6 -> 2.8032; else -> 3.1872 } }
@Test fun gen2UsesTheFourMiddleCoresAndOnePrime() {
for (model in listOf("SM8550", "QCS8550", "sm8550-ac")) {
assertEquals(CpuAllocationDefaults.Allocation("7", "3,4,5,6"), CpuAllocationDefaults.forDevice(model, "", thor))
}
assertEquals(CpuAllocationDefaults.Allocation("7", "3,4,5,6"), CpuAllocationDefaults.forDevice("", "kalama", thor))
}
@Test fun coreNumbersAndClockVariantsAreDetectedInsteadOfHardcoded() {
val reordered = mapOf(0 to 3.36, 1 to 2.0, 2 to 2.0, 3 to 2.0, 4 to 2.8, 5 to 2.8, 6 to 2.8, 7 to 2.8)
assertEquals(CpuAllocationDefaults.Allocation("0", "4,5,6,7"), CpuAllocationDefaults.forDevice("SM8550-AC", "kalama", reordered))
}
@Test fun anotherOrUnknownChipDoesNotAcquireThisDefault() {
assertNull(CpuAllocationDefaults.forDevice("SM8650", "pineapple", thor))
assertNull(CpuAllocationDefaults.forDevice("SM8650", "kalama", thor))
assertNull(CpuAllocationDefaults.forDevice("", "", thor))
}
@Test fun missingOrUnexpectedTopologyKeepsTheSchedulerDefault() {
assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", thor - 7))
assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", thor + (7 to null)))
assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", thor + (7 to 0.0)))
assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", thor + (7 to Double.NaN)))
assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", (0..7).associateWith { 2.8 }))
}
@Test fun allCoreGamesUndoTheClientsInheritedRestriction() {
val all = CpuCores.listOrAll("")
assertEquals(all, CpuCores.gameLaunchList("", clientPinned = true))
assertEquals(all, CpuCores.gameLaunchList(all, clientPinned = true))
assertEquals("", CpuCores.gameLaunchList("", clientPinned = false))
assertEquals("0", CpuCores.gameLaunchList("0", clientPinned = true))
}
}
@@ -0,0 +1,49 @@
package com.droiddeck.launcher.session
import android.content.Context
import com.droiddeck.launcher.core.CpuAllocationDefaults
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class CoreAllocationPrefsTest {
private val app get() = RuntimeEnvironment.getApplication()
private val defaults = CpuAllocationDefaults.Allocation("7", "3,4,5,6")
private val saved get() = app.getSharedPreferences("session", Context.MODE_PRIVATE)
@Before fun clear() { saved.edit().clear().commit() }
@Test fun absentPreferencesUseTheDeviceDefaultWithoutWritingIt() {
assertEquals(SessionPrefs.CoreAllocation(true, "7", "3,4,5,6"), SessionPrefs.coreAllocation(app, defaults))
assertTrue(saved.all.isEmpty())
assertEquals(SessionPrefs.CoreAllocation(false, "", ""), SessionPrefs.coreAllocation(app, null))
}
@Test fun explicitDisableAndAllCoreChoicesSurviveAnUpdate() {
saved.edit().putBoolean("clientCpusOverride", false).putString("clientCpus", "").putString("gameCpus", "").commit()
assertEquals(SessionPrefs.CoreAllocation(false, "", ""), SessionPrefs.coreAllocation(app, defaults))
}
@Test fun existingCustomMasksArePreserved() {
saved.edit().putBoolean("clientCpusOverride", true).putString("clientCpus", "0,1").putString("gameCpus", "2,7").commit()
assertEquals(SessionPrefs.CoreAllocation(true, "0,1", "2,7"), SessionPrefs.coreAllocation(app, defaults))
}
@Test fun anOldClientMaskIsNotEnabledWithoutAnOverrideChoice() {
saved.edit().putString("clientCpus", "0,1").commit()
assertEquals(SessionPrefs.CoreAllocation(false, "0,1", "3,4,5,6"), SessionPrefs.coreAllocation(app, defaults))
}
@Test fun changingOneChoiceDoesNotReplaceTheOtherDefaults() {
SessionPrefs.setGameCpus(app, "0,1,2,3,4,5,6,7")
assertEquals(SessionPrefs.CoreAllocation(true, "7", "0,1,2,3,4,5,6,7"), SessionPrefs.coreAllocation(app, defaults))
SessionPrefs.setClientCpusOverride(app, false)
assertEquals(SessionPrefs.CoreAllocation(false, "7", "0,1,2,3,4,5,6,7"), SessionPrefs.coreAllocation(app, defaults))
}
}
@@ -177,6 +177,23 @@ export XDG_DATA_DIRS=$XDG_DATA_HOME/flatpak/exports/share:${XDG_DATA_DIRS:-/usr/
# program from the rail): the user's game cores when set (BL_GAME_CPUS), else every core but the
# slowest cluster - Android is free to park an emulator's threads on the little cores, and those
# are what compile shaders and recompile PPU/SPU code. BL_PIN_CORES=0 leaves the scheduler alone.
# A prime-only client mask can be reset without a thread-count change (Steam or Android).
# Read status with shell builtins; only invoke taskset when a single-core mask has drifted.
client_single_core_matches() {
local wanted=$1 task key cpus
shift
[[ $wanted =~ ^[0-9]+$ ]] || return 0
for task in "$@"; do
[ -r "$task/status" ] || continue
while read -r key cpus; do
[ "$key" = Cpus_allowed_list: ] || continue
[ "$cpus" = "$wanted" ] || return 1
break
done < "$task/status" 2>/dev/null
done
return 0
}
program_cores() {
[ "${BL_PIN_CORES:-1}" = 0 ] && return 0
command -v taskset >/dev/null 2>&1 || return 0
@@ -445,8 +462,8 @@ case $mode in
# 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. The beat reads /proc
# itself instead of running pgrep, and only runs taskset for a process that is new or has
# grown threads since it was last pinned: every fork is a round of traced syscalls, and a
# itself instead of running pgrep, and runs taskset for new threads or a single-core mask
# that has drifted since it was last pinned: every fork is a round of traced syscalls, and a
# beat that forked a taskset per process every 5 s did that for an hour. taskset -a, because
# without it only a process's main thread moves and the renderer's workers stay where they were.
if [ -n "${BL_CLIENT_CPUS:-}" ]; then
@@ -459,7 +476,9 @@ case $mode in
case $_c in *steamwebhelper*|*steamrtarm64/steam|*gamescope*) ;; *) continue ;; esac
_t=("$_d"/task/*)
_p=${_d#/proc/}
[ "${_bl_pinned[$_p]:-}" = "${#_t[@]}" ] && continue
if [ "${_bl_pinned[$_p]:-}" = "${#_t[@]}" ] && client_single_core_matches "$BL_CLIENT_CPUS" "${_t[@]}"; then
continue
fi
taskset -a -cp "$BL_CLIENT_CPUS" "$_p" >/dev/null 2>&1 || true
_bl_pinned[$_p]=${#_t[@]}
done
+39
View File
@@ -0,0 +1,39 @@
"""Single-core Steam affinity is rechecked even if its thread count stayed the same."""
import subprocess
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin/droiddeck-session'
SOURCE = SCRIPT.read_text()
FUNCTION = SOURCE[SOURCE.index('client_single_core_matches() {'):].split('\n}\n', 1)[0] + '\n}\n'
class ClientCoreMaskTest(unittest.TestCase):
def matches(self, wanted, tasks):
return subprocess.run(['bash', '-c', FUNCTION + '\nclient_single_core_matches "$@"',
'test', wanted, *map(str, tasks)], capture_output=True).returncode
def test_a_mask_change_without_a_new_thread_needs_repinning(self):
with tempfile.TemporaryDirectory() as tmp:
tasks = [Path(tmp) / str(i) for i in (1, 2)]
for task in tasks:
task.mkdir()
(task / 'status').write_text('Name:\tsteam\nCpus_allowed_list:\t7\n')
self.assertEqual(self.matches('7', tasks), 0)
(tasks[1] / 'status').write_text('Name:\tsteam\nCpus_allowed_list:\t0-7\n')
self.assertEqual(self.matches('7', tasks), 1)
def test_an_exited_thread_does_not_prevent_checking_live_threads(self):
with tempfile.TemporaryDirectory() as tmp:
live = Path(tmp) / '1'
live.mkdir()
(live / 'status').write_text('Cpus_allowed_list:\t3-6\n')
self.assertEqual(self.matches('7', [Path(tmp) / 'gone', live]), 1)
def test_multi_core_masks_keep_the_existing_thread_count_policy(self):
self.assertEqual(self.matches('0,1,2,3', []), 0)
if __name__ == '__main__':
unittest.main()