diff --git a/app/src/main/cpp/gnaffinity/PROVENANCE.md b/app/src/main/cpp/gnaffinity/PROVENANCE.md new file mode 100644 index 0000000000..85099fe8be --- /dev/null +++ b/app/src/main/cpp/gnaffinity/PROVENANCE.md @@ -0,0 +1,11 @@ +# libgnaffinity.so provenance + +`app/src/main/jniLibs/arm64-v8a/libgnaffinity.so` is a prebuilt (the app does not run CMake during packaging), +built from `jni/gnaffinity.c`: + +- sha256: `61272eb70f568de532333ebc6574866eb23b7bd88119975aba2cc16c526650b9` +- NDK 27.3.13750724, arm64-v8a, android-26, MinSizeRel, `-z max-page-size=16384`, stripped; a rebuild yields the same sha256 +- build: `app/src/main/cpp/gnaffinity/build.sh`, then + `cp app/src/main/cpp/gnaffinity/out/libgnaffinity.so app/src/main/jniLibs/arm64-v8a/` + +Exports only `Java_app_gamenative_powercontrol_TaskAffinity_nativeSetTaskAffinity`. Only dependencies: libc, libm, libdl. diff --git a/app/src/main/cpp/gnaffinity/build.sh b/app/src/main/cpp/gnaffinity/build.sh new file mode 100755 index 0000000000..f4a80024fd --- /dev/null +++ b/app/src/main/cpp/gnaffinity/build.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Builds out/libgnaffinity.so (arm64-v8a, stripped, 16 KB pages), then +# cp out/libgnaffinity.so ../../jniLibs/arm64-v8a/ +set -euo pipefail +cd "$(dirname "$0")" +NDK=${ANDROID_NDK:-$HOME/Library/Android/sdk/ndk/27.3.13750724} +rm -rf build out; mkdir -p out +cmake -S jni -B build -G Ninja -DCMAKE_TOOLCHAIN_FILE="$NDK/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-26 -DCMAKE_BUILD_TYPE=MinSizeRel \ + -DCMAKE_C_FLAGS="-ffile-prefix-map=$PWD=." +cmake --build build +HOST=$(ls "$NDK/toolchains/llvm/prebuilt/") +"$NDK/toolchains/llvm/prebuilt/$HOST/bin/llvm-strip" --strip-all -o out/libgnaffinity.so build/libgnaffinity.so +shasum -a 256 out/libgnaffinity.so diff --git a/app/src/main/cpp/gnaffinity/jni/CMakeLists.txt b/app/src/main/cpp/gnaffinity/jni/CMakeLists.txt new file mode 100644 index 0000000000..a4dfba551e --- /dev/null +++ b/app/src/main/cpp/gnaffinity/jni/CMakeLists.txt @@ -0,0 +1,5 @@ +cmake_minimum_required(VERSION 3.13.5) +project(gnaffinity C) +add_library(gnaffinity SHARED gnaffinity.c) +set_target_properties(gnaffinity PROPERTIES C_VISIBILITY_PRESET hidden) +target_link_options(gnaffinity PRIVATE -Wl,-z,max-page-size=16384 -Wl,--build-id=none) diff --git a/app/src/main/cpp/gnaffinity/jni/gnaffinity.c b/app/src/main/cpp/gnaffinity/jni/gnaffinity.c new file mode 100644 index 0000000000..fcd3d123c0 --- /dev/null +++ b/app/src/main/cpp/gnaffinity/jni/gnaffinity.c @@ -0,0 +1,21 @@ +#define _GNU_SOURCE +#include +#include +#include + +/* + * sched_setaffinity on one task (a thread id, or a pid for its main thread). Linux allows it on + * any task of the caller's uid without CAP_SYS_NICE: the app's own threads and the processes it + * spawned. Returns 0, or the errno (EPERM, ESRCH, EINVAL). + */ +JNIEXPORT jint JNICALL +Java_app_gamenative_powercontrol_TaskAffinity_nativeSetTaskAffinity(JNIEnv *env, jclass clazz, jint tid, jint mask) { + (void) env; + (void) clazz; + cpu_set_t set; + CPU_ZERO(&set); + for (int cpu = 0; cpu < 32; cpu++) { + if ((unsigned) mask & (1u << cpu)) CPU_SET(cpu, &set); + } + return sched_setaffinity(tid, sizeof(set), &set) == 0 ? 0 : errno; +} diff --git a/app/src/main/java/app/gamenative/display/GamescopePayload.kt b/app/src/main/java/app/gamenative/display/GamescopePayload.kt index 957b592de5..8559476dec 100644 --- a/app/src/main/java/app/gamenative/display/GamescopePayload.kt +++ b/app/src/main/java/app/gamenative/display/GamescopePayload.kt @@ -11,7 +11,7 @@ import java.io.File /** Versioned gamescope payload (gamescope, XWayland, scaling clients), fetched on the first gamescope launch. */ object GamescopePayload { - const val VERSION = "20261101.6" + const val VERSION = "20261102.1" const val ARCHIVE = "gamescope-gamenative-$VERSION.tzst" private const val STAMP = ".payload.version" diff --git a/app/src/main/java/app/gamenative/display/GamescopeServerComponent.java b/app/src/main/java/app/gamenative/display/GamescopeServerComponent.java index 1ce042bab0..50341d5032 100644 --- a/app/src/main/java/app/gamenative/display/GamescopeServerComponent.java +++ b/app/src/main/java/app/gamenative/display/GamescopeServerComponent.java @@ -1,5 +1,6 @@ package app.gamenative.display; +import app.gamenative.powercontrol.PowerManager; import com.winlator.container.Container; import com.winlator.xenvironment.EnvironmentComponent; import com.winlator.xenvironment.ImageFs; @@ -52,6 +53,7 @@ private void awaitConnected() { for (int i = 0; i < START_POLL_TRIES && !stopped; i++) { if (gamescopeAnswered()) { GamescopeCommandQueue.setApplicable(sessionId, true); + PowerManager.applyCompositorAffinity("gamescope session start"); return; } try { @@ -83,6 +85,7 @@ public void stop() { stopped = true; GamescopeCommandQueue.reset(); GameNativeDrm.nativeInjectorStop(); + PowerManager.releaseCompositorAffinity("gamescope session end"); } public void pause() { diff --git a/app/src/main/java/app/gamenative/powercontrol/PowerManager.kt b/app/src/main/java/app/gamenative/powercontrol/PowerManager.kt index 3353a075bd..6acc4135ac 100644 --- a/app/src/main/java/app/gamenative/powercontrol/PowerManager.kt +++ b/app/src/main/java/app/gamenative/powercontrol/PowerManager.kt @@ -1278,6 +1278,7 @@ object PowerManager { Timber.tag("PowerManager").i( "Pinned $processName (PID: $pid) to CPUs ${gameCores.joinToString()} after $attempt attempts, verified=$verified ($reason)" ) + applyCompositorAffinity(reason) return@Thread } @@ -1326,6 +1327,113 @@ object PowerManager { }.start() } + /** + * Moves the gamescope compositor chain onto [compositorCores]: every thread of gamescope and of + * its Xwayland, and the gndrm* threads the display daemon runs inside this app process. All of + * them run under this app's uid, so sched_setaffinity needs no privilege, and this runs whatever + * the driver, the profile or the container CPU list say. Called when the gamescope session + * connects and again whenever the game pin re-applies. Nothing is found and nothing changes in + * an X11 session. + */ + @JvmStatic + fun applyCompositorAffinity(reason: String) { + val cores = compositorCores() + if (cores.isEmpty()) { + Timber.tag("PowerManager").w("No cores clear of the display server, gamescope chain left alone ($reason)") + return + } + placeCompositorChain(cores, reason) + } + + /** + * Hands the gamescope chain every online core back at the end of the session. + */ + @JvmStatic + fun releaseCompositorAffinity(reason: String) { + val online = readCpuList("/sys/devices/system/cpu/online") + if (online.isNotEmpty()) placeCompositorChain(online.sorted(), reason) + } + + /** + * All online cores minus the ones SurfaceFlinger may run on. SurfaceFlinger, the HWC composer + * and the audio realtime threads are confined to that cluster (0-2 on the Retroid Pocket 6), and + * the chain must not share a core with the display server's own realtime threads: there it is + * preempted and frames miss their vblank. When SurfaceFlinger's mask cannot be read, the + * lowest-capacity cluster is left out instead. + */ + private fun compositorCores(): List { + val online = readCpuList("/sys/devices/system/cpu/online") + val surfaceFlinger = File("/proc").listFiles().orEmpty().firstOrNull { dir -> + dir.name.toIntOrNull() != null && + runCatching { File(dir, "comm").readText().trim() }.getOrNull() == "surfaceflinger" + }?.let { readCpuList(it.path + "/status", "Cpus_allowed_list") }.orEmpty() + val excluded = if (surfaceFlinger.isNotEmpty() && !surfaceFlinger.containsAll(online)) { + surfaceFlinger + } else { + val capacity = online.associateWith { cpu -> + runCatching { File("/sys/devices/system/cpu/cpu$cpu/cpu_capacity").readText().trim().toInt() }.getOrNull() + } + val lowest = capacity.values.filterNotNull().minOrNull() + if (lowest == null || capacity.values.all { it == lowest }) emptySet() + else capacity.filterValues { it == lowest }.keys + } + return (online - excluded).sorted() + } + + /** + * Core list in [path], from the line starting with [key] when given, empty when unreadable. + */ + private fun readCpuList(path: String, key: String? = null): Set = runCatching { + val lines = File(path).readLines() + val value = if (key == null) lines.first() else lines.first { it.startsWith(key) }.substringAfter(':') + parseCpuList(value.trim()) + }.getOrDefault(emptySet()) + + private fun placeCompositorChain(cores: List, reason: String) { + try { + val gamescopePid = findOwnProcess("gamescope") + val xwaylandPid = findOwnProcess("Xwayland") + val daemonTids = File("/proc/self/task").listFiles().orEmpty().mapNotNull { task -> + val comm = runCatching { File(task, "comm").readText().trim() }.getOrNull() + task.name.toIntOrNull()?.takeIf { comm?.startsWith("gndrm") == true } + } + + val mask = ProcessHelper.getAffinityMask(cores.joinToString(",")) + val tids = listOfNotNull(gamescopePid, xwaylandPid).flatMap { pid -> + File("/proc/$pid/task").listFiles().orEmpty().mapNotNull { it.name.toIntOrNull() } + } + daemonTids + var applied = 0 + tids.forEach { tid -> + val errno = TaskAffinity.set(tid, mask) + if (errno == 0) { + applied++ + } else { + Timber.tag("PowerManager").w("sched_setaffinity on TID $tid failed, errno $errno") + } + } + val now = gamescopePid?.let { readCpuList("/proc/$it/status", "Cpus_allowed_list") } + Timber.tag("PowerManager").i( + "Applied affinity ${cores.joinToString()} (mask 0x${Integer.toHexString(mask)}) to gamescope (PID: ${gamescopePid ?: "none"}), " + + "Xwayland (PID: ${xwaylandPid ?: "none"}), gndrm threads (TIDs: ${daemonTids.joinToString()}), " + + "$applied of ${tids.size} threads, gamescope now ${now?.let { formatCores(it) }} ($reason)" + ) + } catch (e: Exception) { + Timber.tag("PowerManager").e(e, "Failed to apply affinity to the gamescope chain") + } + } + + /** + * PID of a process this app can see whose program is [binary], given as the first argument or + * the second behind /system/bin/linker64, or null when none runs. + */ + private fun findOwnProcess(binary: String): Int? = + File("/proc").listFiles().orEmpty().firstNotNullOfOrNull { dir -> + val pid = dir.name.toIntOrNull() ?: return@firstNotNullOfOrNull null + val args = runCatching { File(dir, "cmdline").readText() }.getOrNull() + ?.split('\u0000')?.take(2) ?: return@firstNotNullOfOrNull null + pid.takeIf { args.any { it == binary || it.endsWith("/$binary") } } + } + /** * Cores a pinned game runs on: all cores except the 2 lowest-frequency ones, * which are reserved for PulseAudio and other background/Wine processes. diff --git a/app/src/main/java/app/gamenative/powercontrol/TaskAffinity.kt b/app/src/main/java/app/gamenative/powercontrol/TaskAffinity.kt new file mode 100644 index 0000000000..a6c3c49793 --- /dev/null +++ b/app/src/main/java/app/gamenative/powercontrol/TaskAffinity.kt @@ -0,0 +1,25 @@ +package app.gamenative.powercontrol + +import timber.log.Timber + +/** + * sched_setaffinity for single tasks of this app's uid (its own threads and the processes it + * spawned), which Linux allows without privilege. Backed by libgnaffinity.so. + */ +object TaskAffinity { + private var libraryLoaded: Boolean? = null + + @JvmStatic + private external fun nativeSetTaskAffinity(tid: Int, mask: Int): Int + + /** + * @return 0 on success, the errno on failure, or -1 when the library is unavailable + */ + @Synchronized + fun set(tid: Int, mask: Int): Int { + val loaded = libraryLoaded ?: runCatching { System.loadLibrary("gnaffinity") } + .onFailure { Timber.tag("PowerManager").w(it, "libgnaffinity unavailable") } + .isSuccess.also { libraryLoaded = it } + return if (loaded) nativeSetTaskAffinity(tid, mask) else -1 + } +} diff --git a/app/src/main/jniLibs/arm64-v8a/libgamenative_drm.so b/app/src/main/jniLibs/arm64-v8a/libgamenative_drm.so index 163bb0b45e..7ad13236ac 100755 Binary files a/app/src/main/jniLibs/arm64-v8a/libgamenative_drm.so and b/app/src/main/jniLibs/arm64-v8a/libgamenative_drm.so differ diff --git a/app/src/main/jniLibs/arm64-v8a/libgnaffinity.so b/app/src/main/jniLibs/arm64-v8a/libgnaffinity.so new file mode 100755 index 0000000000..ba89f158e7 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libgnaffinity.so differ