From 8cb5c2168e8ec4a3f05bc1e075eaaf968570ca69 Mon Sep 17 00:00:00 2001 From: The412Banner <205237651+The412Banner@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:22:51 -0500 Subject: [PATCH] feat(hud): GPU load and clock without root where Android blocks kgsl (#1969) On some Adreno devices (the Retroid Pocket Duo) SELinux hides kgsl's sysfs nodes from apps, so the HUD showed no GPU % and no GPU clock. Devices whose nodes are readable keep using them; nothing changes there or on GPUs without kgsl. Where the nodes are blocked: - GPU %: a native reader maps kgsl's memstore read-only (any kgsl client may) and samples the ringbuffer start/end timestamps once a millisecond while a HUD is shown. That covers all GPU work, Android's own composition included. - Backup for GPU %: an implicit Vulkan layer in the guest (tools/gpuload) and the compositor time their own GPU work with timestamp queries. It runs until the memstore stamps are seen moving, and for the whole session if they never move. - GPU clock: a small GPU job of our own reads the always-on and RBBM cycle counters (libgpuclock). The HUD diagnostics export lists which source each GPU value came from. tools/gpuload/build.sh takes the Vulkan headers from build_local.sh's NDK first and keeps NDK paths with spaces intact. --- app/src/main/cpp/CMakeLists.txt | 2 + app/src/main/cpp/gpuclock/kgsl_busy.c | 121 ++++++ app/src/main/cpp/gpuclock/kgsl_busy.h | 37 ++ app/src/main/cpp/gpuclock/kgsl_clock.c | 156 +++++++ app/src/main/cpp/waylandcomp/CMakeLists.txt | 2 + app/src/main/cpp/waylandcomp/src/gpu_busy.c | 89 ++++ app/src/main/cpp/waylandcomp/src/gpu_busy.h | 9 + app/src/main/cpp/waylandcomp/src/vk_present.c | 3 + .../launcher/hud/DroidDeckHudHost.kt | 1 + .../launcher/session/GpuStatsComponent.kt | 234 +++++++++- .../droiddeck/launcher/session/KgslBusy.kt | 50 +++ .../droiddeck/launcher/session/KgslClock.kt | 50 +++ .../launcher/session/SessionFiles.kt | 7 +- .../launcher/session/SessionService.kt | 3 +- .../launcher/wayland/WaylandCompositor.java | 4 + .../launcher/session/GpuLayerLoadTest.kt | 114 +++++ .../launcher/session/GpuMemstoreLoadTest.kt | 74 ++++ tools/build_local.sh | 7 + tools/components/recipes.json | 4 +- tools/components/recipes/runtime.sh | 2 + tools/gpuload/build.sh | 53 +++ .../layer/VK_LAYER_DROIDDECK_gpu_load.json | 20 + tools/gpuload/src/gpuload_busy.h | 22 + tools/gpuload/src/gpuload_ring.h | 215 +++++++++ .../gpuload/src/vk_layer_droiddeck_gpuload.c | 410 ++++++++++++++++++ tools/gpuload/tests/test_gpuload_busy.c | 24 + tools/gpuload/tests/test_gpuload_ring.c | 96 ++++ tools/release/runtime_inputs.py | 3 + tools/tests/kgsl_busy_harness.c | 56 +++ tools/tests/test_kgsl_busy.py | 33 ++ 30 files changed, 1891 insertions(+), 10 deletions(-) create mode 100644 app/src/main/cpp/gpuclock/kgsl_busy.c create mode 100644 app/src/main/cpp/gpuclock/kgsl_busy.h create mode 100644 app/src/main/cpp/gpuclock/kgsl_clock.c create mode 100644 app/src/main/cpp/waylandcomp/src/gpu_busy.c create mode 100644 app/src/main/cpp/waylandcomp/src/gpu_busy.h create mode 100644 app/src/main/java/com/droiddeck/launcher/session/KgslBusy.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/session/KgslClock.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/GpuLayerLoadTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/GpuMemstoreLoadTest.kt create mode 100755 tools/gpuload/build.sh create mode 100644 tools/gpuload/layer/VK_LAYER_DROIDDECK_gpu_load.json create mode 100644 tools/gpuload/src/gpuload_busy.h create mode 100644 tools/gpuload/src/gpuload_ring.h create mode 100644 tools/gpuload/src/vk_layer_droiddeck_gpuload.c create mode 100644 tools/gpuload/tests/test_gpuload_busy.c create mode 100644 tools/gpuload/tests/test_gpuload_ring.c create mode 100644 tools/tests/kgsl_busy_harness.c create mode 100644 tools/tests/test_kgsl_busy.py diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index ab06f6a56..61c7c9c2f 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -37,6 +37,8 @@ target_link_libraries(sdnative log) add_library(deviceinfo SHARED deviceinfo/vkinfo.c) target_link_libraries(deviceinfo dl) +add_library(gpuclock SHARED gpuclock/kgsl_clock.c gpuclock/kgsl_busy.c) + add_library(termux SHARED termux/termux.c) add_library(ddvenc SHARED venc/venc_server.cpp) diff --git a/app/src/main/cpp/gpuclock/kgsl_busy.c b/app/src/main/cpp/gpuclock/kgsl_busy.c new file mode 100644 index 000000000..a61114793 --- /dev/null +++ b/app/src/main/cpp/gpuclock/kgsl_busy.c @@ -0,0 +1,121 @@ +// Whole-GPU busy share without root on Adreno (KGSL) devices whose kgsl sysfs is blocked for apps: a thread +// samples the memstore ringbuffer timestamps (kgsl_busy.h) once a millisecond while running. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../adrenotools/src/hook/kgsl.h" +#include "kgsl_busy.h" + +#define SAMPLE_NS 1000000L + +struct reader { + int fd; + void *map; + size_t bytes; + int first; + uint32_t last[KGSL_BUSY_SLOTS]; + pthread_mutex_t lock; + pthread_t thread; + int thread_on; + atomic_int run; + atomic_uint_fast64_t busy, samples, moves; +}; + +static void *sample_loop(void *arg) { + struct reader *r = arg; + const struct timespec gap = {0, SAMPLE_NS}; + while (atomic_load(&r->run)) { + int moved = 0; + int busy = kgsl_busy_sample(r->map, r->first, r->last, &moved); + if (busy) atomic_fetch_add(&r->busy, 1); + if (moved) atomic_fetch_add(&r->moves, 1); + atomic_fetch_add(&r->samples, 1); + nanosleep(&gap, NULL); + } + return NULL; +} + +static void reader_run(struct reader *r, int on) { + pthread_mutex_lock(&r->lock); + if (on && !r->thread_on) { + atomic_store(&r->run, 1); + if (pthread_create(&r->thread, NULL, sample_loop, r) == 0) r->thread_on = 1; + else atomic_store(&r->run, 0); + } else if (!on && r->thread_on) { + atomic_store(&r->run, 0); + pthread_join(r->thread, NULL); + r->thread_on = 0; + } + pthread_mutex_unlock(&r->lock); +} + +static void reader_close(struct reader *r) { + if (!r) return; + reader_run(r, 0); + if (r->map && r->map != MAP_FAILED) munmap(r->map, r->bytes); + if (r->fd >= 0) close(r->fd); + pthread_mutex_destroy(&r->lock); + free(r); +} + +// Returns 0 or a negative errno; *out is set on success. +static int reader_open(struct reader **out) { + struct reader *r = calloc(1, sizeof(*r)); + if (!r) return -ENOMEM; + pthread_mutex_init(&r->lock, NULL); + r->fd = open("/dev/kgsl-3d0", O_RDWR | O_CLOEXEC); + if (r->fd < 0) { int e = -errno; reader_close(r); return e; } + struct kgsl_shadowprop shadow; + memset(&shadow, 0, sizeof(shadow)); + struct kgsl_device_getproperty prop = {.type = KGSL_PROP_DEVICE_SHADOW, .value = &shadow, .sizebytes = sizeof(shadow)}; + if (ioctl(r->fd, IOCTL_KGSL_DEVICE_GETPROPERTY, &prop)) { int e = -errno; reader_close(r); return e; } + r->bytes = shadow.size; + r->first = kgsl_busy_first_slot(shadow.size); + if (r->first < 0) { reader_close(r); return -ERANGE; } + r->map = mmap(NULL, r->bytes, PROT_READ, MAP_SHARED, r->fd, (off_t)shadow.gpuaddr); + if (r->map == MAP_FAILED) { int e = -errno; reader_close(r); return e; } + int moved = 0, started = 0; + kgsl_busy_sample(r->map, r->first, r->last, &moved); + for (int i = 0; i < KGSL_BUSY_SLOTS; i++) started |= r->last[i] != 0; + if (!started) { reader_close(r); return -ENODATA; } + *out = r; + return 0; +} + +#ifndef KGSL_BUSY_NO_JNI +#include + +JNIEXPORT jlong JNICALL Java_com_droiddeck_launcher_session_KgslBusy_nativeOpen(JNIEnv *env, jclass cls, jintArray error) { + struct reader *r = NULL; + int e = reader_open(&r); + if (e && error && (*env)->GetArrayLength(env, error) > 0) { + jint v = e; + (*env)->SetIntArrayRegion(env, error, 0, 1, &v); + } + return e ? 0 : (jlong)(intptr_t)r; +} + +JNIEXPORT void JNICALL Java_com_droiddeck_launcher_session_KgslBusy_nativeRun(JNIEnv *env, jclass cls, jlong handle, jboolean on) { + if (handle) reader_run((struct reader *)(intptr_t)handle, on ? 1 : 0); +} + +JNIEXPORT jboolean JNICALL Java_com_droiddeck_launcher_session_KgslBusy_nativeRead(JNIEnv *env, jclass cls, jlong handle, jlongArray out) { + if (!handle || !out || (*env)->GetArrayLength(env, out) < 3) return JNI_FALSE; + struct reader *r = (struct reader *)(intptr_t)handle; + jlong v[3] = {(jlong)atomic_load(&r->busy), (jlong)atomic_load(&r->samples), (jlong)atomic_load(&r->moves)}; + (*env)->SetLongArrayRegion(env, out, 0, 3, v); + return JNI_TRUE; +} + +JNIEXPORT void JNICALL Java_com_droiddeck_launcher_session_KgslBusy_nativeClose(JNIEnv *env, jclass cls, jlong handle) { + reader_close((struct reader *)(intptr_t)handle); +} +#endif diff --git a/app/src/main/cpp/gpuclock/kgsl_busy.h b/app/src/main/cpp/gpuclock/kgsl_busy.h new file mode 100644 index 000000000..2179cf533 --- /dev/null +++ b/app/src/main/cpp/gpuclock/kgsl_busy.h @@ -0,0 +1,37 @@ +#ifndef DROIDDECK_KGSL_BUSY_H +#define DROIDDECK_KGSL_BUSY_H +// kgsl lets any client map its memstore read-only (KGSL_PROP_DEVICE_SHADOW). The CP writes a ringbuffer's +// start-of-pipe timestamp there before each submission's command buffers and its end-of-pipe timestamp after them +// (adreno_gen7_ringbuffer.c gen7_ringbuffer_addcmds), so start != end means that ringbuffer has work on the GPU. +// Ringbuffer entries start at KGSL_MEMSTORE_MAX: entries - 2 - 4 levels, or entries - 1 - 4 on kernels without the +// LPAC slot. Entries - 6 .. entries - 2 cover both; a context entry in that range follows the same start/end rule. +#include +#include + +#define KGSL_BUSY_ENTRY_WORDS 10 +#define KGSL_BUSY_SOP 0 +#define KGSL_BUSY_EOP 2 +#define KGSL_BUSY_SLOTS 5 + +static inline int kgsl_busy_first_slot(size_t bytes) { + size_t entries = bytes / (KGSL_BUSY_ENTRY_WORDS * sizeof(uint32_t)); + return entries < 8 ? -1 : (int)(entries - 6); +} + +// 1 when a watched slot has a submission between its start and end timestamps. *moved is set when a start +// timestamp differs from last[], which then holds the new ones. +static inline int kgsl_busy_sample(const volatile uint32_t *memstore, int first, uint32_t last[KGSL_BUSY_SLOTS], int *moved) { + int busy = 0; + for (int i = 0; i < KGSL_BUSY_SLOTS; i++) { + const volatile uint32_t *entry = memstore + (size_t)(first + i) * KGSL_BUSY_ENTRY_WORDS; + uint32_t sop = entry[KGSL_BUSY_SOP], eop = entry[KGSL_BUSY_EOP]; + if (sop != eop) busy = 1; + if (sop != last[i]) { + *moved = 1; + last[i] = sop; + } + } + return busy; +} + +#endif diff --git a/app/src/main/cpp/gpuclock/kgsl_clock.c b/app/src/main/cpp/gpuclock/kgsl_clock.c new file mode 100644 index 000000000..b021208b4 --- /dev/null +++ b/app/src/main/cpp/gpuclock/kgsl_clock.c @@ -0,0 +1,156 @@ +// GPU clock without root on Adreno (KGSL) devices whose kgsl sysfs is blocked for apps. +// +// kgsl lets any app reserve a performance counter (IOCTL_KGSL_PERFCOUNTER_GET) but refuses to read it back while +// /sys/class/kgsl/kgsl-3d0/perfcounter is 0, and clears the counters whenever the GPU switches to another process. +// So the counter is read by the GPU itself: a small command buffer copies CP_ALWAYS_ON_COUNTER (19.2 MHz) and the +// RBBM ALWAYS_COUNT counter (GPU core cycles) to memory, runs a block of NOP packets, and copies both again. +// clock = cycles / always-on ticks * 19.2 MHz. Both reads sit in one command buffer, so a counter clear between +// them only happens if the buffer is preempted; such samples fail the range checks and are dropped. +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../adrenotools/src/hook/kgsl.h" + +#define CP_TYPE7_PKT 0x70000000u +#define CP_NOP 0x10u +#define CP_REG_TO_MEM 0x3eu +#define CP_ALWAYS_ON_COUNTER 0x980u +#define RBBM_ALWAYS_COUNT 0u +// gen7 CP protection marks registers 0x000-0x4ff readable by command buffers (adreno-gpulist.h); a counter +// kgsl places anywhere else is not read. +#define READABLE_LAST 0x4ffu +#define XO_HZ 19200000.0 +#define NOP_DWORDS 0x3fffu +#define NOP_BLOCKS 4 + +struct sampler { + int fd; + unsigned context; + unsigned counter_reg; + struct kgsl_gpumem_alloc_id ib, out; + uint32_t *ib_map; + volatile uint64_t *out_map; + size_t ib_bytes; +}; + +static unsigned odd_parity(unsigned v) { + v ^= v >> 16; + v ^= v >> 8; + v ^= v >> 4; + v &= 0xf; + return (~0x6996u >> v) & 1u; +} + +static uint32_t pkt7(unsigned op, unsigned count) { + return CP_TYPE7_PKT | count | odd_parity(count) << 15 | (op & 0x7f) << 16 | odd_parity(op) << 23; +} + +static void sampler_close(struct sampler *s) { + if (!s) return; + if (s->fd >= 0) { + if (s->ib_map && s->ib_map != MAP_FAILED) munmap(s->ib_map, s->ib.mmapsize); + if (s->out_map && s->out_map != MAP_FAILED) munmap((void *)s->out_map, s->out.mmapsize); + if (s->ib.id) { struct kgsl_gpumem_free_id f = {.id = s->ib.id}; ioctl(s->fd, IOCTL_KGSL_GPUMEM_FREE_ID, &f); } + if (s->out.id) { struct kgsl_gpumem_free_id f = {.id = s->out.id}; ioctl(s->fd, IOCTL_KGSL_GPUMEM_FREE_ID, &f); } + if (s->context) { struct kgsl_drawctxt_destroy d = {.drawctxt_id = s->context}; ioctl(s->fd, IOCTL_KGSL_DRAWCTXT_DESTROY, &d); } + if (s->counter_reg) { + struct kgsl_perfcounter_put p = {.groupid = KGSL_PERFCOUNTER_GROUP_RBBM, .countable = RBBM_ALWAYS_COUNT}; + ioctl(s->fd, IOCTL_KGSL_PERFCOUNTER_PUT, &p); + } + close(s->fd); + } + free(s); +} + +static void emit_reads(struct sampler *s, size_t *n, unsigned pass) { + const unsigned regs[2] = {CP_ALWAYS_ON_COUNTER, s->counter_reg}; + for (unsigned k = 0; k < 2; k++) { + uint64_t dst = s->out.gpuaddr + (uint64_t)(pass * 2 + k) * sizeof(uint64_t); + s->ib_map[(*n)++] = pkt7(CP_REG_TO_MEM, 3); + s->ib_map[(*n)++] = regs[k] | (2u << 18) | (1u << 30); // two dwords, 64-bit read + s->ib_map[(*n)++] = (uint32_t)dst; + s->ib_map[(*n)++] = (uint32_t)(dst >> 32); + } +} + +// Returns 0 or a negative errno; *out is set on success. +static int sampler_open(struct sampler **out) { + struct sampler *s = calloc(1, sizeof(*s)); + if (!s) return -ENOMEM; + s->fd = open("/dev/kgsl-3d0", O_RDWR | O_CLOEXEC); + if (s->fd < 0) { int e = -errno; s->fd = -1; sampler_close(s); return e; } + struct kgsl_perfcounter_get get = {.groupid = KGSL_PERFCOUNTER_GROUP_RBBM, .countable = RBBM_ALWAYS_COUNT}; + if (ioctl(s->fd, IOCTL_KGSL_PERFCOUNTER_GET, &get)) { int e = -errno; sampler_close(s); return e; } + s->counter_reg = get.offset; + if (!get.offset || get.offset > READABLE_LAST) { sampler_close(s); return -ERANGE; } + struct kgsl_drawctxt_create ctx = {.flags = KGSL_CONTEXT_NO_GMEM_ALLOC | KGSL_CONTEXT_PREAMBLE}; + if (ioctl(s->fd, IOCTL_KGSL_DRAWCTXT_CREATE, &ctx)) { int e = -errno; sampler_close(s); return e; } + s->context = ctx.drawctxt_id; + s->ib_bytes = (16 + NOP_BLOCKS * (NOP_DWORDS + 1)) * sizeof(uint32_t); + s->ib.size = (s->ib_bytes + 4095) & ~(size_t)4095; + s->out.size = 4096; + if (ioctl(s->fd, IOCTL_KGSL_GPUMEM_ALLOC_ID, &s->ib)) { int e = -errno; s->ib.id = 0; sampler_close(s); return e; } + if (ioctl(s->fd, IOCTL_KGSL_GPUMEM_ALLOC_ID, &s->out)) { int e = -errno; s->out.id = 0; sampler_close(s); return e; } + s->ib_map = mmap(NULL, s->ib.mmapsize, PROT_READ | PROT_WRITE, MAP_SHARED, s->fd, (off_t)s->ib.id << 12); + s->out_map = mmap(NULL, s->out.mmapsize, PROT_READ | PROT_WRITE, MAP_SHARED, s->fd, (off_t)s->out.id << 12); + if (s->ib_map == MAP_FAILED || s->out_map == MAP_FAILED) { int e = -errno; sampler_close(s); return e; } + size_t n = 0; + emit_reads(s, &n, 0); + for (unsigned b = 0; b < NOP_BLOCKS; b++) { + s->ib_map[n++] = pkt7(CP_NOP, NOP_DWORDS); + memset(&s->ib_map[n], 0, NOP_DWORDS * sizeof(uint32_t)); + n += NOP_DWORDS; + } + emit_reads(s, &n, 1); + s->ib_bytes = n * sizeof(uint32_t); + *out = s; + return 0; +} + +// MHz, or a negative value: -errno from kgsl, or -1000 for a sample that failed the range checks. +static int sampler_mhz(struct sampler *s) { + for (int i = 0; i < 4; i++) s->out_map[i] = 0; + struct kgsl_command_object cmd = {.gpuaddr = s->ib.gpuaddr, .size = s->ib_bytes, .flags = KGSL_CMDLIST_IB}; + struct kgsl_gpu_command submit = { + .cmdlist = (uint64_t)(uintptr_t)&cmd, .cmdsize = sizeof(cmd), .numcmds = 1, .context_id = s->context, + }; + if (ioctl(s->fd, IOCTL_KGSL_GPU_COMMAND, &submit)) return -errno; + struct kgsl_device_waittimestamp_ctxtid wait = {.context_id = s->context, .timestamp = submit.timestamp, .timeout = 250}; + if (ioctl(s->fd, IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID, &wait)) return -errno; + uint64_t xo0 = s->out_map[0], cyc0 = s->out_map[1], xo1 = s->out_map[2], cyc1 = s->out_map[3]; + if (xo1 <= xo0 || cyc1 <= cyc0) return -1000; + uint64_t xo = xo1 - xo0, cycles = cyc1 - cyc0; + // 5 us .. 50 ms of always-on time, and a clock between 50 MHz and 2 GHz. + if (xo < 96 || xo > 960000) return -1000; + double mhz = (double)cycles / (double)xo * XO_HZ / 1e6; + if (mhz < 50 || mhz > 2000) return -1000; + return (int)(mhz + 0.5); +} + +#ifndef KGSL_CLOCK_NO_JNI +#include + +JNIEXPORT jlong JNICALL Java_com_droiddeck_launcher_session_KgslClock_nativeOpen(JNIEnv *env, jclass cls, jintArray error) { + struct sampler *s = NULL; + int r = sampler_open(&s); + if (r && error && (*env)->GetArrayLength(env, error) > 0) { + jint e = r; + (*env)->SetIntArrayRegion(env, error, 0, 1, &e); + } + return r ? 0 : (jlong)(intptr_t)s; +} + +JNIEXPORT jint JNICALL Java_com_droiddeck_launcher_session_KgslClock_nativeSample(JNIEnv *env, jclass cls, jlong handle) { + return handle ? sampler_mhz((struct sampler *)(intptr_t)handle) : -EBADF; +} + +JNIEXPORT void JNICALL Java_com_droiddeck_launcher_session_KgslClock_nativeClose(JNIEnv *env, jclass cls, jlong handle) { + sampler_close((struct sampler *)(intptr_t)handle); +} +#endif diff --git a/app/src/main/cpp/waylandcomp/CMakeLists.txt b/app/src/main/cpp/waylandcomp/CMakeLists.txt index a4de38ca1..fd0b2f20a 100644 --- a/app/src/main/cpp/waylandcomp/CMakeLists.txt +++ b/app/src/main/cpp/waylandcomp/CMakeLists.txt @@ -18,6 +18,7 @@ add_library(droiddeckwayland SHARED src/wl_color_mgmt.c src/hdr_compose.c src/blend_pass.c + src/gpu_busy.c generated/color-management-v1-protocol.c generated/xdg-shell-protocol.c generated/linux-dmabuf-v1-protocol.c @@ -43,6 +44,7 @@ target_include_directories(droiddeckwayland PRIVATE prebuilt/include src ${CMAKE_CURRENT_SOURCE_DIR}/../shaders + ${CMAKE_CURRENT_SOURCE_DIR}/../../../../../tools/gpuload/src ) set(DROIDDECK_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg) diff --git a/app/src/main/cpp/waylandcomp/src/gpu_busy.c b/app/src/main/cpp/waylandcomp/src/gpu_busy.c new file mode 100644 index 000000000..48d565c20 --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/gpu_busy.c @@ -0,0 +1,89 @@ +#include "gpu_busy.h" + +#include +#include +#include + +#include "gpuload_ring.h" + +static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; +static struct gpuload_vk g_v; +static struct gpuload_ring g_ring; +static uint32_t g_family, g_bits; +static PFN_vkQueueSubmit g_submit; +static int g_was_on; +static _Atomic int g_on, g_lost, g_timing; +static _Atomic uint64_t g_busy_ns; + +static VkResult VKAPI_CALL timed_submit(VkQueue queue, uint32_t count, const VkSubmitInfo *infos, VkFence fence) { + if (!count || !atomic_load(&g_on) || atomic_load(&g_lost)) return g_submit(queue, count, infos, fence); + pthread_mutex_lock(&g_lock); + if (!atomic_load(&g_on) || atomic_load(&g_lost)) { + pthread_mutex_unlock(&g_lock); + return g_submit(queue, count, infos, fence); + } + if (g_ring.state == 0 && gpuload_ring_setup(&g_ring, &g_v, g_family, g_bits)) atomic_store(&g_timing, 1); + VkSubmitInfo *mine = NULL; + uint32_t used = 0; + if (g_ring.state == 1) { + uint64_t ns = gpuload_ring_harvest(&g_ring, &g_v); + if (g_was_on) atomic_fetch_add(&g_busy_ns, ns); // the first harvest after a pause holds work timed before it + g_was_on = 1; + mine = gpuload_ring_wrap(&g_ring, count, infos, &used); + } + VkResult r = g_submit(queue, count, mine ? mine : infos, fence); + if (mine && r != VK_SUCCESS) gpuload_ring_unwind(&g_ring, used); + pthread_mutex_unlock(&g_lock); + free(mine); + return r; +} + +void vkp_gpu_busy_device(VkPhysicalDevice pd, VkDevice dev, uint32_t family) { + pthread_mutex_lock(&g_lock); + memset(&g_ring, 0, sizeof(g_ring)); + g_was_on = 0; + atomic_store(&g_timing, 0); + atomic_store(&g_lost, 0); + PFN_vkGetDeviceProcAddr gdpa = g_vk.GetDeviceProcAddr; + g_v = (struct gpuload_vk){ + .device = dev, + .create_pool = g_vk.CreateCommandPool, + .destroy_pool = (PFN_vkDestroyCommandPool)gdpa(dev, "vkDestroyCommandPool"), + .alloc_cmds = g_vk.AllocateCommandBuffers, + .begin = g_vk.BeginCommandBuffer, + .end = g_vk.EndCommandBuffer, + .cmd_reset = (PFN_vkCmdResetQueryPool)gdpa(dev, "vkCmdResetQueryPool"), + .cmd_timestamp = (PFN_vkCmdWriteTimestamp)gdpa(dev, "vkCmdWriteTimestamp"), + .create_queries = (PFN_vkCreateQueryPool)gdpa(dev, "vkCreateQueryPool"), + .destroy_queries = (PFN_vkDestroyQueryPool)gdpa(dev, "vkDestroyQueryPool"), + .results = (PFN_vkGetQueryPoolResults)gdpa(dev, "vkGetQueryPoolResults"), + }; + VkPhysicalDeviceProperties props; + g_vk.GetPhysicalDeviceProperties(pd, &props); + gpuload_vk_period(&g_v, props.limits.timestampPeriod); + VkQueueFamilyProperties families[16]; + uint32_t n = 16; + g_vk.GetPhysicalDeviceQueueFamilyProperties(pd, &n, families); + g_family = family; + g_bits = family < n ? families[family].timestampValidBits : 0; + // vk_loader_load_device has just put the driver's function back. + if (g_vk.QueueSubmit != timed_submit) g_submit = g_vk.QueueSubmit; + if (g_submit) g_vk.QueueSubmit = timed_submit; + pthread_mutex_unlock(&g_lock); +} + +void vkp_gpu_busy_device_lost(void) { + atomic_store(&g_lost, 1); + atomic_store(&g_timing, 0); +} + +JNIEXPORT jlong JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeGpuBusyNs(JNIEnv *env, jclass cls) { + return atomic_load(&g_timing) ? (jlong)atomic_load(&g_busy_ns) : -1; +} + +JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetGpuTiming(JNIEnv *env, jclass cls, jboolean on) { + pthread_mutex_lock(&g_lock); + if (!on) g_was_on = 0; + atomic_store(&g_on, on ? 1 : 0); + pthread_mutex_unlock(&g_lock); +} diff --git a/app/src/main/cpp/waylandcomp/src/gpu_busy.h b/app/src/main/cpp/waylandcomp/src/gpu_busy.h new file mode 100644 index 000000000..a477d5236 --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/gpu_busy.h @@ -0,0 +1,9 @@ +#ifndef GPU_BUSY_H +#define GPU_BUSY_H +#include "vk_loader.h" + +// Times the compositor's own GPU work for the HUD (tools/gpuload/src/gpuload_ring.h); call after the device and its +// functions are loaded. Off until WaylandCompositor.nativeSetGpuTiming(true). +void vkp_gpu_busy_device(VkPhysicalDevice pd, VkDevice dev, uint32_t family); +void vkp_gpu_busy_device_lost(void); +#endif diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.c b/app/src/main/cpp/waylandcomp/src/vk_present.c index 8d6b3480a..ffdc5cb1d 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.c +++ b/app/src/main/cpp/waylandcomp/src/vk_present.c @@ -7,6 +7,7 @@ #include "framegen_bridge.h" #include "hdr_compose.h" #include "blend_pass.h" +#include "gpu_busy.h" #include "droiddeck_color.h" #include "quest_device.h" #include @@ -517,6 +518,7 @@ static int dev_init(void) { } vk_loader_load_device(g_dev); g_vk.GetDeviceQueue(g_dev, g_qfam, 0, &g_queue); + vkp_gpu_busy_device(g_pd, g_dev, g_qfam); if (want_sem_fd) { g_import_sem_fd = (PFN_vkImportSemaphoreFdKHR)g_vk.GetDeviceProcAddr(g_dev, "vkImportSemaphoreFdKHR"); VkSemaphoreCreateInfo wsci = {.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO}; @@ -1311,6 +1313,7 @@ static void device_lost(const char *where) { g_dev_state = -2; g_base_black = 0; vkp_framegen_device_lost(); + vkp_gpu_busy_device_lost(); droiddeck_log("error", "GPU device lost (VK_ERROR_DEVICE_LOST in %s): the compositor has stopped presenting; " "restart the session", where); for (uint32_t i = 0; i < g_nimg; i++) blendp_forget_image(g_images[i]); diff --git a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt index 2784f4fa8..296e0487f 100644 --- a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt @@ -213,6 +213,7 @@ class DroidDeckHudHost(context: Context) { "CPU ${mango.cpuPercent}% ${mango.cpuTempC}°C, GPU ${mango.gpuPercent}% ${mango.gpuClockMhz}MHz ${mango.gpuTempC}°C, " + "RAM ${mango.ramUsedText()}/${mango.ramTotalText()}, VRAM ${mango.vramText()}, battery ${mango.battery.percent}% " + "${mango.battery.watts}W charging=${mango.battery.charging}\n" + + "GPU feeds: ${com.droiddeck.launcher.session.GpuStatsComponent.sources.ifEmpty { "none (no session, or kgsl absent)" }}\n" + "CPU temperature zones: ${LinuxRuntime.cpuTempZones().joinToString { it.name }}\n" + "Frames: ${if (feed.engine.isNotEmpty() || feed.pid > 0) "gamescope (pid ${feed.pid}, ${feed.engine})" else "compositor"}, " + "fps ${feed.stats.fps}\n") diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt index 006350916..8e10024fc 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt @@ -7,10 +7,16 @@ import com.droiddeck.launcher.runtime.LinuxRuntime import java.io.File import java.io.RandomAccessFile -class GpuStatsComponent(val dir: File) : SessionPart() { +class GpuStatsComponent(val dir: File, private val root: File) : SessionPart() { @Volatile private var running = false private var thread: Thread? = null private val feeds = ArrayList() + private var layerLoad: LayerLoad? = null + private var memstore: KgslBusy.Reader? = null + private var memstoreLoad: MemstoreLoad? = null + private var memstoreState: String? = null + private var cycleClock: KgslClock.Sampler? = null + @Volatile private var shown = false private class Feed( val file: File, val source: String, val read: () -> Long?, val format: String, val percent: Boolean = false, @@ -24,9 +30,11 @@ class GpuStatsComponent(val dir: File) : SessionPart() { } fun prepare(): Boolean { + sources = "" if (!File(KGSL).exists()) return false val unreadable = MANGOHUD_FILES.any { File(KGSL, it).let { f -> f.exists() && !f.canRead() } } - if (!unreadable && !File(GPUBUSY).canRead()) return false + // Where SELinux hides the kgsl nodes completely (exists() is false too), the layer and cycle-counter feeds stand in. + if (!unreadable && !File(GPUBUSY).canRead() && !loadBlocked()) return false return try { dir.mkdirs() dir.listFiles()?.forEach { java.nio.file.Files.deleteIfExists(it.toPath()) } @@ -34,19 +42,18 @@ class GpuStatsComponent(val dir: File) : SessionPart() { PASSTHROUGH_FILES.forEach { name -> runCatching { File(dir, name).writeText(File(KGSL, name).readText()) } } - loadFeed()?.let { load -> + (loadFeed() ?: blockedLoadFeed())?.let { load -> feeds.add(load) feeds.add(Feed(File(dir, "perf_now"), load.source, load.read, "%-6s\n", percent = true)) } - pick("clock_mhz", clockSources(), "%5d\n") { mhz(it) }?.let { clock -> + (pick("clock_mhz", clockSources(), "%5d\n") { mhz(it) } ?: cycleClockFeed())?.let { clock -> feeds.add(clock) feeds.add(Feed(File(dir, "cur_freq"), clock.source, { clock.read()?.times(1_000_000) }, "%-10d\n")) } pick("temp", tempSources(), "%6d\n") { milliCelsius(it) }?.let { feeds.add(it) } refresh() - Log.i(TAG, "hud: kgsl stats from the session's own: " + MANGOHUD_FILES.joinToString { name -> - "$name " + (feeds.firstOrNull { it.file.name == name }?.source ?: "none") - }) + describeSources() + Log.i(TAG, "hud: kgsl stats from the session's own: $sources") true } catch (e: Exception) { Log.w(TAG, "hud: could not stand in for kgsl stats: $e") @@ -68,17 +75,212 @@ class GpuStatsComponent(val dir: File) : SessionPart() { // kgsl's gpubusy can read 0 0 straight after a read, so feeds that share a reader share one read. private fun refresh() { + if (layerLoad != null || cycleClock != null || memstore != null) { + shown = hudShown() + memstore?.run(shown) + if (!shown) memstoreLoad?.pause() + layerLoad?.wanted(shown && memstoreLoad?.working != true) + } val values = HashMap<() -> Long?, Long?>() for (feed in feeds) { if (feed.read !in values) values[feed.read] = feed.read() values[feed.read]?.let { write(feed, it) } } + memstoreLoad?.let { load -> + val state = when { + load.working -> "working" + load.failed -> "failed" + else -> null + } + if (state != memstoreState) { + memstoreState = state + if (load.failed) { + memstore?.close() + memstore = null + Log.i(TAG, "hud: kgsl memstore timestamps never moved; GPU load from Vulkan timing") + } else if (load.working) { + Log.i(TAG, "hud: GPU load from the kgsl memstore (all GPU work)") + } + describeSources() + } + } + } + + private fun describeSources() { + sources = MANGOHUD_FILES.joinToString { name -> + val source = if (name == "gpu_busy_percentage" && memstoreLoad != null) memstoreSource() + else feeds.firstOrNull { it.file.name == name }?.source + "$name " + (source ?: "none") + } + } + + private fun memstoreSource(): String = when { + memstoreLoad?.working == true -> "kgsl memstore ringbuffer timestamps (all GPU work)" + memstoreLoad?.failed == true -> LAYER_SOURCE + else -> "kgsl memstore once its timestamps move; until then $LAYER_SOURCE" } override fun stop() { running = false thread?.interrupt() thread = null + memstore?.close() + memstore = null + memstoreLoad = null + memstoreState = null + layerLoad?.clear() + cycleClock?.close() + cycleClock = null + sources = "" + } + + private fun hudShown(): Boolean = OverlayShown.check(root) || com.droiddeck.launcher.hud.DroidDeckHudHost.showing + + // Android hides the load (kgsl sysfs blocked): the kgsl memstore gives the whole GPU's busy share. The in-game + // Vulkan layer and the compositor time their own GPU work until the memstore timestamps are seen moving, and + // for the whole session where they never move. + private fun blockedLoadFeed(): Feed? { + if (!loadBlocked()) return null + val load = LayerLoad(guestLoadDir(root), extra = ::compositorBusyNs, onWanted = ::compositorTiming) + .also { it.clear(); layerLoad = it } + val sampled = KgslBusy.open()?.let { reader -> + memstore = reader + MemstoreLoad(reader::counts).also { memstoreLoad = it } + } + val read = read@{ + if (!shown) return@read null + val whole = sampled?.percent() + if (sampled?.working == true) whole else load.percent() + } + return Feed(File(dir, "gpu_busy_percentage"), LAYER_SOURCE, read, "%3d %%\n") + } + + // The clock nodes are blocked too: read the GPU's cycle counter from a small GPU job of our own. + private fun cycleClockFeed(): Feed? { + val sampler = KgslClock.open() ?: return null + cycleClock = sampler + // clock_mhz and cur_freq both read this; one GPU job per refresh serves both. + var at = 0L + var last: Long? = null + val read = { + val now = SystemClock.elapsedRealtime() + if (now - at > PERIOD_MS / 2) { at = now; last = if (shown) sampler.mhz()?.toLong() else null } + last + } + return Feed(File(dir, "clock_mhz"), "GPU cycle counter (own GPU job)", read, "%5d\n") + } + + private fun compositorBusyNs(): Long? = + runCatching { com.droiddeck.launcher.wayland.WaylandCompositor.nativeGpuBusyNs() }.getOrNull()?.takeIf { it >= 0 } + + private fun compositorTiming(on: Boolean) { + runCatching { com.droiddeck.launcher.wayland.WaylandCompositor.nativeSetGpuTiming(on) } + } + + /** + * Busy time the layer (tools/gpuload) writes per process, plus [extra] (the compositor's own GPU time), as a share + * of wall time between two reads. + */ + internal class LayerLoad( + private val dir: File, + private val clock: () -> Long = System::nanoTime, + private val extra: () -> Long? = { null }, + private val onWanted: (Boolean) -> Unit = {}, + ) { + private val last = HashMap() + private var lastAt = 0L + private var on = false + + fun wanted(on: Boolean) { + val flag = File(dir, "on") + if (on && !flag.exists()) runCatching { dir.mkdirs(); flag.createNewFile() } + if (!on && flag.exists()) { flag.delete(); last.clear(); lastAt = 0L } + if (on != this.on) onWanted(on) + this.on = on + } + + fun percent(): Long? { + val now = clock() + val files = dir.listFiles { f -> f.name.isNotEmpty() && f.name.all(Char::isDigit) }.orEmpty() + var busy = 0L + val seen = HashSet() + for (file in files) { + val total = runCatching { file.readText().trim().substringBefore(' ').toLong() }.getOrNull() ?: continue + seen += file.name + last[file.name]?.let { if (total >= it) busy += total - it } + last[file.name] = total + if (System.currentTimeMillis() - file.lastModified() > STALE_MS) file.delete() + } + extra()?.let { total -> + seen += EXTRA + last[EXTRA]?.let { if (total >= it) busy += total - it } + last[EXTRA] = total + } + last.keys.retainAll(seen) + val wall = now - lastAt + val first = lastAt == 0L + lastAt = now + if (first || wall <= 0) return null + return (busy * 100 / wall).coerceIn(0, 100) + } + + fun clear() { + dir.listFiles()?.forEach { it.delete() } + last.clear() + lastAt = 0L + if (on) onWanted(false) + on = false + } + + private companion object { + const val EXTRA = "compositor" + } + } + + /** + * Busy share over the last [WINDOW] reads from [KgslBusy] running totals (busy samples, samples, samples with + * new ringbuffer work). Not [working] until new work has been seen; [failed] once [GIVE_UP_SAMPLES] samples + * passed without any. + */ + internal class MemstoreLoad(private val counts: () -> LongArray?) { + var working = false + private set + var failed = false + private set + private val window = ArrayDeque() + private var waited = 0L + + fun percent(): Long? { + if (failed) return null + val now = counts() ?: return null + val previous = window.lastOrNull() + window.addLast(now) + while (window.size > WINDOW + 1) window.removeFirst() + if (previous == null) return null + val fresh = now[1] - previous[1] + if (fresh <= 0) return null + if (!working) { + if (now[2] > previous[2]) { + working = true + } else { + waited += fresh + if (waited >= GIVE_UP_SAMPLES) failed = true + return null + } + } + val base = window.first() + return ((now[0] - base[0]) * 100 / (now[1] - base[1])).coerceIn(0, 100) + } + + /** The totals stop while sampling is off; the next reading starts a new window. */ + fun pause() { + window.clear() + } + + companion object { + const val GIVE_UP_SAMPLES = 5_000L + const val WINDOW = 2 + } } private fun loadFeed(): Feed? { @@ -134,6 +336,24 @@ class GpuStatsComponent(val dir: File) : SessionPart() { private const val LOAD_HOLD_MS = 5_000L private val MANGOHUD_FILES = listOf("gpu_busy_percentage", "clock_mhz", "temp") private val PASSTHROUGH_FILES = listOf("gpu_model", "gpu_chipid") + const val GUEST_LOAD_DIR = "/run/droiddeck-hud/gpu-load" + private const val LAYER_SOURCE = "Vulkan timing: games ($GUEST_LOAD_DIR) + DroidDeck compositor" + + /** Where the running session's GPU feeds come from, for the HUD diagnostics export. */ + @Volatile var sources = "" + private const val STALE_MS = 30_000L + + fun guestLoadDir(root: File) = File(root, GUEST_LOAD_DIR.removePrefix("/")) + + /** True when this app can see kgsl but none of its GPU load nodes (SELinux-blocked, e.g. the Retroid Pocket Duo). */ + fun loadBlocked(): Boolean = File(KGSL).exists() && !File(GPUBUSY).canRead() && LOAD_SOURCES.none { File(it).canRead() } + + /** Turns on the in-game layer (tools/gpuload) where the load is blocked; empty elsewhere. */ + fun guestEnv(root: File): List { + if (!loadBlocked()) return emptyList() + guestLoadDir(root).mkdirs() + return listOf("DROIDDECK_GPU_LOAD=1", "DROIDDECK_GPU_LOAD_DIR=$GUEST_LOAD_DIR") + } private const val QCOM_GPU = "/sys/kernel/gpu" private val LOAD_SOURCES = listOf( "$KGSL/gpu_busy_percentage", "$KGSL/devfreq/gpu_load", "$KGSL/gpuload", "$QCOM_GPU/gpu_busy", diff --git a/app/src/main/java/com/droiddeck/launcher/session/KgslBusy.kt b/app/src/main/java/com/droiddeck/launcher/session/KgslBusy.kt new file mode 100644 index 000000000..dad62121a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/KgslBusy.kt @@ -0,0 +1,50 @@ +package com.droiddeck.launcher.session + +import android.util.Log + +/** + * Whole-GPU busy share for Adreno devices whose kgsl load nodes are SELinux-blocked for apps + * (app/src/main/cpp/gpuclock/kgsl_busy.c). It covers every process's GPU work, Android's own composition included. + */ +object KgslBusy { + private const val TAG = "SessionService" + + @JvmStatic private external fun nativeOpen(error: IntArray): Long + @JvmStatic private external fun nativeRun(handle: Long, on: Boolean) + @JvmStatic private external fun nativeRead(handle: Long, out: LongArray): Boolean + @JvmStatic private external fun nativeClose(handle: Long) + + private val loaded: Boolean by lazy { + runCatching { System.loadLibrary("gpuclock") }.onFailure { Log.w(TAG, "hud: gpuclock library: $it") }.isSuccess + } + + class Reader internal constructor(private var handle: Long) : AutoCloseable { + /** Samples once a millisecond while on. */ + @Synchronized fun run(on: Boolean) { + if (handle != 0L) nativeRun(handle, on) + } + + /** Running totals: busy samples, samples, samples in which a ringbuffer started new work. */ + @Synchronized fun counts(): LongArray? { + if (handle == 0L) return null + val out = LongArray(3) + return if (nativeRead(handle, out)) out else null + } + + @Synchronized override fun close() { + if (handle != 0L) nativeClose(handle) + handle = 0L + } + } + + fun open(): Reader? { + if (!loaded) return null + val error = IntArray(1) + val handle = runCatching { nativeOpen(error) }.getOrDefault(0L) + if (handle == 0L) { + Log.i(TAG, "hud: no GPU load from the kgsl memstore (open error ${error[0]})") + return null + } + return Reader(handle) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/KgslClock.kt b/app/src/main/java/com/droiddeck/launcher/session/KgslClock.kt new file mode 100644 index 000000000..11c7367ef --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/KgslClock.kt @@ -0,0 +1,50 @@ +package com.droiddeck.launcher.session + +import android.util.Log + +/** + * GPU clock from the GPU's own cycle counter, for Adreno devices whose kgsl clock nodes are SELinux-blocked for + * apps (app/src/main/cpp/gpuclock/kgsl_clock.c). Each [mhz] call runs one tiny GPU job, so callers sample only + * while a HUD is shown. + */ +object KgslClock { + private const val TAG = "SessionService" + + @JvmStatic private external fun nativeOpen(error: IntArray): Long + @JvmStatic private external fun nativeSample(handle: Long): Int + @JvmStatic private external fun nativeClose(handle: Long) + + private val loaded: Boolean by lazy { + runCatching { System.loadLibrary("gpuclock") }.onFailure { Log.w(TAG, "hud: gpuclock library: $it") }.isSuccess + } + + class Sampler internal constructor(private var handle: Long) : AutoCloseable { + /** MHz, or null when this sample was refused or failed its checks. */ + @Synchronized fun mhz(): Int? = if (handle == 0L) null else nativeSample(handle).takeIf { it > 0 } + + @Synchronized override fun close() { + if (handle != 0L) nativeClose(handle) + handle = 0L + } + } + + /** A sampler that already produced one good reading, or null (the reason goes to the log). */ + fun open(): Sampler? { + if (!loaded) return null + val error = IntArray(1) + val handle = runCatching { nativeOpen(error) }.getOrDefault(0L) + if (handle == 0L) { + Log.i(TAG, "hud: no GPU clock from the cycle counter (open error ${error[0]})") + return null + } + val sampler = Sampler(handle) + val first = (0 until 3).firstNotNullOfOrNull { sampler.mhz() } + if (first == null) { + Log.i(TAG, "hud: GPU cycle counter gave no usable reading") + sampler.close() + return null + } + Log.i(TAG, "hud: GPU clock from the cycle counter ($first MHz now)") + return sampler + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 9bc5e59fe..bcb13d14f 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -160,6 +160,11 @@ object SessionFiles { ) + handModels).map { "usr/local/lib/droiddeck-vr/$it" to "usr/local/lib/droiddeck-vr/$it" } + listOf("usr/local/lib/droiddeck-vr/layer/VK_LAYER_DROIDDECK_vr_implicit.json" to "usr/share/vulkan/implicit_layer.d/droiddeck_vr.json") + // HUD GPU load where Android hides it (GpuStatsComponent.guestEnv turns the layer on). + val gpuLoad = listOf( + "usr/local/lib/droiddeck-gpuload/libVkLayer_droiddeck_gpuload.so" to "usr/local/lib/droiddeck-gpuload/libVkLayer_droiddeck_gpuload.so", + "usr/local/lib/droiddeck-gpuload/VK_LAYER_DROIDDECK_gpu_load.json" to "usr/share/vulkan/implicit_layer.d/droiddeck_gpu_load.json", + ) val fexPreloads = listOf("x86_64", "i386").flatMap { arch -> listOf("libblsession.so", "libfakeinput.so", "libfaultreport.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" } } + listOf("libvulkan-thunk.so", "libwebkitinput.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" } @@ -171,7 +176,7 @@ object SessionFiles { "usr/local/lib/droiddeck-pipewire.tzst" to "usr/local/lib/droiddeck-pipewire.tzst", "usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE", "usr/local/lib/droiddeck-dirt/ws2_32.dll" to "usr/local/lib/droiddeck-dirt/ws2_32.dll", - ) + mangoapp + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) -> + ) + mangoapp + msitools.map { it to it } + vr + gpuLoad + fexPreloads).filter { (asset, _) -> val dir = asset.substringBeforeLast('/') runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false) } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 62314d3cb..09bedc4e3 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -808,6 +808,7 @@ class SessionService : Service() { if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true") if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1") guest.add("MESA_DISK_CACHE_DATABASE=1") + guest.addAll(GpuStatsComponent.guestEnv(LinuxRuntime.rootDir(this))) guest.add("GLIBC_TUNABLES=glibc.pthread.rseq=0:glibc.malloc.top_pad=16777216") if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0")) SessionState.steamClientArch = if (steamHere) SessionPrefs.steamClientArch(this) else null @@ -994,7 +995,7 @@ class SessionService : Service() { gpuMem.attach(this) components.add(gpuMem) } - val gpuStats = GpuStatsComponent(File(filesDir, "session/sys/kgsl-3d0")) + val gpuStats = GpuStatsComponent(File(filesDir, "session/sys/kgsl-3d0"), LinuxRuntime.rootDir(this)) if (gpuStats.prepare()) { gpuStats.attach(this) components.add(gpuStats) diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java index 073ed60ff..65e398eec 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -148,6 +148,10 @@ public final class WaylandCompositor { public static native void nativeStart(String xdgRuntimeDir); + /** Nanoseconds of the compositor's own GPU work while timing is on, or -1 when it is not timed. */ + public static native long nativeGpuBusyNs(); + public static native void nativeSetGpuTiming(boolean on); + public static native void nativeStartWithSurface(Surface surface, String xdgRuntimeDir, String driverPath, String libraryName, String nativeLibDir); diff --git a/app/src/test/java/com/droiddeck/launcher/session/GpuLayerLoadTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GpuLayerLoadTest.kt new file mode 100644 index 000000000..996035e31 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/GpuLayerLoadTest.kt @@ -0,0 +1,114 @@ +package com.droiddeck.launcher.session + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class GpuLayerLoadTest { + @get:Rule val tmp = TemporaryFolder() + private var now = 0L + private fun load(dir: File) = GpuStatsComponent.LayerLoad(dir, clock = { now }) + private fun write(dir: File, pid: Int, busyNs: Long) = File(dir, "$pid").writeText("%20d %-32s\n".format(busyNs, "game")) + + @Test fun busyTimeOfAllProcessesOverWallTime() { + val dir = tmp.newFolder("gpu-load") + val load = load(dir) + write(dir, 100, 1_000_000_000L) + write(dir, 200, 5_000_000_000L) + now = 10_000_000_000L + assertNull(load.percent()) + write(dir, 100, 1_300_000_000L) + write(dir, 200, 5_100_000_000L) + now += 1_000_000_000L + assertEquals(40L, load.percent()) + } + + @Test fun aNewProcessStartsFromItsOwnTotalAndAGoneOneStopsCounting() { + val dir = tmp.newFolder("gpu-load") + val load = load(dir) + write(dir, 100, 2_000_000_000L) + now = 1L + load.percent() + File(dir, "100").delete() + write(dir, 300, 9_000_000_000L) + now += 500_000_000L + assertEquals(0L, load.percent()) + write(dir, 300, 9_250_000_000L) + now += 500_000_000L + assertEquals(50L, load.percent()) + } + + @Test fun capsAtFullAndIgnoresOtherFiles() { + val dir = tmp.newFolder("gpu-load") + val load = load(dir) + File(dir, "on").createNewFile() + write(dir, 1, 0L) + write(dir, 2, 0L) + now = 1L + load.percent() + write(dir, 1, 900_000_000L) + write(dir, 2, 900_000_000L) + now += 1_000_000_000L + assertEquals(100L, load.percent()) + } + + @Test fun theOnFlagFollowsTheHudAndHidingDropsTheBaseline() { + val dir = tmp.newFolder("gpu-load") + val load = load(dir) + load.wanted(true) + assertTrue(File(dir, "on").exists()) + write(dir, 1, 100L) + now = 1L + load.percent() + load.wanted(false) + assertFalse(File(dir, "on").exists()) + load.wanted(true) + now += 1_000L + assertNull(load.percent()) + } + + @Test fun theCompositorsOwnGpuTimeAddsToTheGames() { + val dir = tmp.newFolder("gpu-load") + var compositor: Long? = 7_000_000_000L + val load = GpuStatsComponent.LayerLoad(dir, clock = { now }, extra = { compositor }) + write(dir, 100, 1_000_000_000L) + now = 1L + assertNull(load.percent()) + write(dir, 100, 1_600_000_000L) + compositor = 7_150_000_000L + now += 1_000_000_000L + assertEquals(75L, load.percent()) + compositor = null + write(dir, 100, 1_900_000_000L) + now += 1_000_000_000L + assertEquals(30L, load.percent()) + } + + @Test fun onlyTheCompositorCountsWhenNoGameReportsYet() { + val dir = File(tmp.root, "never-created") + var compositor = 0L + val load = GpuStatsComponent.LayerLoad(dir, clock = { now }, extra = { compositor }) + now = 1L + load.percent() + compositor = 200_000_000L + now += 1_000_000_000L + assertEquals(20L, load.percent()) + } + + @Test fun compositorTimingFollowsTheHudOnlyOnChanges() { + val dir = tmp.newFolder("gpu-load") + val calls = mutableListOf() + val load = GpuStatsComponent.LayerLoad(dir, clock = { now }, onWanted = { calls += it }) + load.wanted(true) + load.wanted(true) + load.wanted(false) + load.wanted(true) + load.clear() + assertEquals(listOf(true, false, true, false), calls) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/GpuMemstoreLoadTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GpuMemstoreLoadTest.kt new file mode 100644 index 000000000..f0d7478d8 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/GpuMemstoreLoadTest.kt @@ -0,0 +1,74 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GpuMemstoreLoadTest { + private var busy = 0L + private var samples = 0L + private var moves = 0L + private var available = true + private val load = GpuStatsComponent.MemstoreLoad { if (available) longArrayOf(busy, samples, moves) else null } + + private fun tick(newSamples: Long, newBusy: Long, newMoves: Long = 1) { + samples += newSamples + busy += newBusy + moves += newMoves + } + + @Test fun notTrustedUntilNewWorkIsSeen() { + assertNull(load.percent()) + tick(500, 400, newMoves = 0) + assertNull(load.percent()) + assertFalse(load.working) + tick(500, 300) + assertEquals(70L, load.percent()) + assertTrue(load.working) + } + + @Test fun givesUpWhenTheTimestampsNeverMove() { + load.percent() + repeat(10) { + tick(500, 250, newMoves = 0) + assertNull(load.percent()) + } + assertTrue(load.failed) + tick(500, 250) + assertNull(load.percent()) + assertFalse(load.working) + } + + @Test fun averagesOverTheLastTwoReads() { + load.percent() + tick(500, 500) + assertEquals(100L, load.percent()) + tick(500, 0) + assertEquals(50L, load.percent()) + tick(500, 0, newMoves = 0) + assertEquals(0L, load.percent()) + } + + @Test fun pausingStartsANewWindow() { + load.percent() + tick(500, 500) + load.percent() + load.pause() + tick(0, 0, newMoves = 0) + assertNull(load.percent()) + tick(500, 100) + assertEquals(20L, load.percent()) + } + + @Test fun noReadingWithoutNewSamplesOrCounts() { + load.percent() + tick(500, 100) + load.percent() + assertNull(load.percent()) + available = false + tick(500, 100) + assertNull(load.percent()) + } +} diff --git a/tools/build_local.sh b/tools/build_local.sh index 8caeda49e..222988773 100755 --- a/tools/build_local.sh +++ b/tools/build_local.sh @@ -181,6 +181,13 @@ if ! restore_step vr "${vr_key}" "${vr_out}"; then save_step vr "${vr_key}" "${vr_out}" fi +gpuload_out="${linuxfs_dir}/usr/local/lib/droiddeck-gpuload" +gpuload_key=$(inputs_hash "${image_id} ${ndk_version} ${VK_INCLUDE:-}" tools/gpuload) +if ! restore_step gpuload "${gpuload_key}" "${gpuload_out}"; then + DROIDDECK_BUILD_IMAGE="${image_name}" "${repo_root}/tools/gpuload/build.sh" "${gpuload_out}" + save_step gpuload "${gpuload_key}" "${gpuload_out}" +fi + github_repo=${DROIDDECK_GITHUB_REPOSITORY:-} if [[ -z "${github_repo}" ]]; then origin_url=$(git -C "${repo_root}" remote get-url origin) diff --git a/tools/components/recipes.json b/tools/components/recipes.json index 0dfb8e426..ab7d40b4a 100644 --- a/tools/components/recipes.json +++ b/tools/components/recipes.json @@ -14,6 +14,7 @@ "tools/msitools", "tools/pipewire", "tools/vr", + "tools/gpuload", "app/src/main/cpp/waylandcomp/generated", "app/src/main/cpp/waylandcomp/src/headpose_shm.h", "app/src/main/cpp/waylandcomp/src/spatialpose_shm.h", @@ -21,7 +22,8 @@ ], "outputs": [ "app/src/main/assets/linuxfs/usr/local/bin/gamescope", - "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-vr/droiddeck/bin/linuxarm64/driver_droiddeck.so" + "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-vr/droiddeck/bin/linuxarm64/driver_droiddeck.so", + "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-gpuload/libVkLayer_droiddeck_gpuload.so" ], "ndk": true, "packages": [ diff --git a/tools/components/recipes/runtime.sh b/tools/components/recipes/runtime.sh index 92bcaad6c..aa5273d27 100644 --- a/tools/components/recipes/runtime.sh +++ b/tools/components/recipes/runtime.sh @@ -4,6 +4,8 @@ D=app/src/main/assets/linuxfs mkdir -p "$D" VK_INCLUDE=$(ls -d "${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1) \ tools/vr/build.sh --native "$D/usr/local/lib/droiddeck-vr" +VK_INCLUDE=$(ls -d "${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1) \ + tools/gpuload/build.sh --native "$D/usr/local/lib/droiddeck-gpuload" for script in tools/linuxfs/overlay/usr/local/bin/droiddeck-* tools/linuxfs/overlay/usr/local/bin/steam-compatibility; do install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")" done diff --git a/tools/gpuload/build.sh b/tools/gpuload/build.sh new file mode 100755 index 000000000..b2729de7f --- /dev/null +++ b/tools/gpuload/build.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Builds VK_LAYER_DROIDDECK_gpu_load for the arm64 guest: tools/gpuload/build.sh [--native] OUT +# OUT gets libVkLayer_droiddeck_gpuload.so and its implicit-layer manifest. +set -euo pipefail +here=$(cd "$(dirname "$0")" && pwd) +repo=$(cd "$here/../.." && pwd) +. "$repo/tools/docker_platform.sh" + +native=0 +if [ "${1:-}" = --native ]; then native=1; shift; fi +out=${1:?usage: tools/gpuload/build.sh [--native] OUT} +case "$out" in /*) ;; *) out="$PWD/$out" ;; esac + +vkinc=${VK_INCLUDE:-} +if [ -z "$vkinc" ]; then + ndks=("${NDK:-}" "${ANDROID_NDK_HOME:-}" "${ANDROID_NDK_LATEST_HOME:-}") + sdk_ndks=("${ANDROID_HOME:-$HOME/Library/Android/sdk}"/ndk/*) + for ((i = ${#sdk_ndks[@]} - 1; i >= 0; i--)); do ndks+=("${sdk_ndks[i]}"); done + for ndk in "${ndks[@]}"; do + [ -n "$ndk" ] && [ -d "$ndk" ] || continue + for cand in "$ndk"/toolchains/llvm/prebuilt/*/sysroot/usr/include; do + if [ -f "$cand/vulkan/vk_layer.h" ]; then vkinc=$cand; break 2; fi + done + done +fi +[ -n "$vkinc" ] && [ -f "$vkinc/vulkan/vulkan_core.h" ] || { echo "tools/gpuload/build.sh: no Vulkan headers (set VK_INCLUDE)" >&2; exit 1; } + +work="$repo/build/gpuload" +rm -rf "$work" && mkdir -p "$work/include" "$work/out" +cp -R "$vkinc/vulkan" "$work/include/" +if [ -d "$vkinc/vk_video" ]; then cp -R "$vkinc/vk_video" "$work/include/"; fi + +cmd=(sh -c ' + set -e + cc -std=c11 -O2 -Wall -Wextra -Werror -Itools/gpuload/src -o build/gpuload/test_gpuload_busy tools/gpuload/tests/test_gpuload_busy.c + build/gpuload/test_gpuload_busy + cc -std=c11 -O2 -Wall -Wextra -Werror -Ibuild/gpuload/include -Itools/gpuload/src -o build/gpuload/test_gpuload_ring tools/gpuload/tests/test_gpuload_ring.c + build/gpuload/test_gpuload_ring + aarch64-linux-gnu-gcc -std=c11 -shared -fPIC -O2 -Wall -Wextra -Werror -Wno-unused-parameter -Wno-missing-field-initializers \ + -fvisibility=hidden -pthread -Wl,-z,defs -Ibuild/gpuload/include -Itools/gpuload/src \ + -o build/gpuload/out/libVkLayer_droiddeck_gpuload.so tools/gpuload/src/vk_layer_droiddeck_gpuload.c + aarch64-linux-gnu-strip --strip-unneeded build/gpuload/out/libVkLayer_droiddeck_gpuload.so') +if [ "$native" = 1 ]; then + (cd "$repo" && "${cmd[@]}") +else + docker run --rm --platform "${DROIDDECK_DOCKER_PLATFORM:-linux/amd64}" --user "$(id -u):$(id -g)" -v "$repo:/src" -w /src \ + "${DROIDDECK_BUILD_IMAGE:-droiddeck-local-cross:24.04-v3}" "${cmd[@]}" +fi + +cp "$here/layer/VK_LAYER_DROIDDECK_gpu_load.json" "$work/out/" +mkdir -p "$out" +cp "$work/out/libVkLayer_droiddeck_gpuload.so" "$work/out/VK_LAYER_DROIDDECK_gpu_load.json" "$out/" +echo "built $out" diff --git a/tools/gpuload/layer/VK_LAYER_DROIDDECK_gpu_load.json b/tools/gpuload/layer/VK_LAYER_DROIDDECK_gpu_load.json new file mode 100644 index 000000000..9fa67bc63 --- /dev/null +++ b/tools/gpuload/layer/VK_LAYER_DROIDDECK_gpu_load.json @@ -0,0 +1,20 @@ +{ + "file_format_version": "1.2.0", + "layer": { + "name": "VK_LAYER_DROIDDECK_gpu_load", + "type": "GLOBAL", + "library_path": "/usr/local/lib/droiddeck-gpuload/libVkLayer_droiddeck_gpuload.so", + "api_version": "1.3.0", + "implementation_version": "1", + "description": "Times this process's GPU work for DroidDeck's HUD where Android hides the GPU load; idle unless a HUD is shown", + "functions": { + "vkNegotiateLoaderLayerInterfaceVersion": "DroidDeckGpuLoad_NegotiateLoaderLayerInterfaceVersion" + }, + "enable_environment": { + "DROIDDECK_GPU_LOAD": "1" + }, + "disable_environment": { + "DROIDDECK_GPU_LOAD_DISABLE": "1" + } + } +} diff --git a/tools/gpuload/src/gpuload_busy.h b/tools/gpuload/src/gpuload_busy.h new file mode 100644 index 000000000..e5a47fb13 --- /dev/null +++ b/tools/gpuload/src/gpuload_busy.h @@ -0,0 +1,22 @@ +// Busy-time bookkeeping shared by VK_LAYER_DROIDDECK_gpu_load and DroidDeck's compositor, kept free of Vulkan so it +// can be tested on its own. +#ifndef DROIDDECK_GPULOAD_BUSY_H +#define DROIDDECK_GPULOAD_BUSY_H + +#include + +// GPU ticks between the timestamp written before a batch and the one written after it. Batches on one queue run one +// after the other, so these add up without overlap. Ticks are never compared across batches: on Adreno the always-on +// counter behind the timestamps restarts when the GPU powers down between batches (seen on the Retroid Pocket Duo). +// Anything backwards or longer than max_ticks is a bogus pair and counts as nothing. +static inline uint64_t gpuload_interval(uint64_t start, uint64_t end, uint64_t max_ticks) { + return end > start && end - start <= max_ticks ? end - start : 0; +} + +// A timestamp query slot is reused once its end is available; until the GPU runs the slot's reset again the +// query still shows the previous end, so a result equal to the last one harvested is the old one. +static inline int gpuload_result_is_new(uint64_t start_avail, uint64_t end_avail, uint64_t end, uint64_t last_end) { + return start_avail && end_avail && end != last_end; +} + +#endif diff --git a/tools/gpuload/src/gpuload_ring.h b/tools/gpuload/src/gpuload_ring.h new file mode 100644 index 000000000..58f6abcb7 --- /dev/null +++ b/tools/gpuload/src/gpuload_ring.h @@ -0,0 +1,215 @@ +// Timestamp ring shared by VK_LAYER_DROIDDECK_gpu_load (guest) and DroidDeck's compositor (app): each submit batch +// that has command buffers gets one of the ring's command buffers in front (resets a query pair, writes a TOP_OF_PIPE +// timestamp) and one behind (BOTTOM_OF_PIPE timestamp). Finished pairs are read back without waiting. +// The start timestamp sits after the batch's semaphore waits only because Turnip and Qualcomm's driver on kgsl hold a +// whole batch until its waits signal; a driver that starts TOP_OF_PIPE work earlier would count the wait as busy. +// Callers serialize access to one ring (a queue's submits are externally synchronized anyway). +#ifndef DROIDDECK_GPULOAD_RING_H +#define DROIDDECK_GPULOAD_RING_H + +#include +#include +#include +#include + +#include "gpuload_busy.h" + +#define GPULOAD_RING 64 + +typedef VkResult(VKAPI_PTR *gpuload_set_data_fn)(VkDevice device, void *object); + +struct gpuload_vk { + VkDevice device; + PFN_vkCreateCommandPool create_pool; + PFN_vkDestroyCommandPool destroy_pool; + PFN_vkAllocateCommandBuffers alloc_cmds; + PFN_vkBeginCommandBuffer begin; + PFN_vkEndCommandBuffer end; + PFN_vkCmdResetQueryPool cmd_reset; + PFN_vkCmdWriteTimestamp cmd_timestamp; + PFN_vkCreateQueryPool create_queries; + PFN_vkDestroyQueryPool destroy_queries; + PFN_vkGetQueryPoolResults results; + gpuload_set_data_fn set_data; // a layer must give the loader every command buffer it allocates; NULL elsewhere + double period_ns; + uint64_t max_ticks; +}; + +struct gpuload_slot { + VkCommandBuffer start, end; + uint64_t last_end; +}; + +struct gpuload_ring { + int state; // 0 not set up, 1 timing, -1 cannot time + VkCommandPool pool; + VkQueryPool queries; + uint64_t mask; + uint32_t next, oldest, inflight; + struct gpuload_slot slot[GPULOAD_RING]; +}; + +#define GPULOAD_LONGEST_BATCH_NS 2000000000.0 + +static inline void gpuload_vk_period(struct gpuload_vk *v, float timestamp_period) { + v->period_ns = timestamp_period; + v->max_ticks = timestamp_period > 0 ? (uint64_t)(GPULOAD_LONGEST_BATCH_NS / timestamp_period) : 0; +} + +static inline int gpuload_vk_complete(const struct gpuload_vk *v) { + return v->device && v->create_pool && v->destroy_pool && v->alloc_cmds && v->begin && v->end && v->cmd_reset && + v->cmd_timestamp && v->create_queries && v->destroy_queries && v->results && v->period_ns > 0; +} + +static inline void gpuload_ring_release(struct gpuload_ring *r, const struct gpuload_vk *v) { + if (r->queries) v->destroy_queries(v->device, r->queries, NULL); + if (r->pool) v->destroy_pool(v->device, r->pool, NULL); + r->queries = VK_NULL_HANDLE; + r->pool = VK_NULL_HANDLE; + r->next = r->oldest = r->inflight = 0; +} + +static inline int gpuload_ring_record(struct gpuload_ring *r, const struct gpuload_vk *v, uint32_t i) { + const VkCommandBufferBeginInfo begin = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO}; + struct gpuload_slot *s = &r->slot[i]; + if (v->begin(s->start, &begin) != VK_SUCCESS) return 0; + v->cmd_reset(s->start, r->queries, 2 * i, 2); + v->cmd_timestamp(s->start, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, r->queries, 2 * i); + if (v->end(s->start) != VK_SUCCESS) return 0; + if (v->begin(s->end, &begin) != VK_SUCCESS) return 0; + v->cmd_timestamp(s->end, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, r->queries, 2 * i + 1); + return v->end(s->end) == VK_SUCCESS; +} + +// Sets the ring up for a queue of this family; returns 1 when it can time it (r->state says the same afterwards). +static inline int gpuload_ring_setup(struct gpuload_ring *r, const struct gpuload_vk *v, uint32_t family, + uint32_t timestamp_bits) { + r->state = -1; + if (!timestamp_bits || !gpuload_vk_complete(v)) return 0; + r->mask = timestamp_bits >= 64 ? ~0ull : (1ull << timestamp_bits) - 1; + const VkCommandPoolCreateInfo pool = {.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO, .queueFamilyIndex = family}; + const VkQueryPoolCreateInfo queries = {.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO, + .queryType = VK_QUERY_TYPE_TIMESTAMP, .queryCount = 2 * GPULOAD_RING}; + VkCommandBuffer cmds[2 * GPULOAD_RING]; + if (v->create_pool(v->device, &pool, NULL, &r->pool) != VK_SUCCESS) { r->pool = VK_NULL_HANDLE; goto fail; } + if (v->create_queries(v->device, &queries, NULL, &r->queries) != VK_SUCCESS) { r->queries = VK_NULL_HANDLE; goto fail; } + const VkCommandBufferAllocateInfo alloc = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, .commandPool = r->pool, + .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, .commandBufferCount = 2 * GPULOAD_RING}; + if (v->alloc_cmds(v->device, &alloc, cmds) != VK_SUCCESS) goto fail; + for (uint32_t i = 0; i < 2 * GPULOAD_RING; i++) + if (v->set_data && v->set_data(v->device, cmds[i]) != VK_SUCCESS) goto fail; + for (uint32_t i = 0; i < GPULOAD_RING; i++) { + r->slot[i].start = cmds[2 * i]; + r->slot[i].end = cmds[2 * i + 1]; + r->slot[i].last_end = 0; + if (!gpuload_ring_record(r, v, i)) goto fail; + } + r->next = r->oldest = r->inflight = 0; + r->state = 1; + return 1; +fail: + gpuload_ring_release(r, v); + r->state = -1; + return 0; +} + +// Nanoseconds of GPU time in the batches that finished since the last call. Slots are taken in ring order and a +// queue runs its batches in order, so the oldest slot finishes first. +static inline uint64_t gpuload_ring_harvest(struct gpuload_ring *r, const struct gpuload_vk *v) { + uint64_t ticks = 0; + while (r->inflight) { + struct gpuload_slot *s = &r->slot[r->oldest]; + uint64_t q[4] = {0, 0, 0, 0}; + VkResult res = v->results(v->device, r->queries, 2 * r->oldest, 2, sizeof(q), q, 2 * sizeof(uint64_t), + VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WITH_AVAILABILITY_BIT); + if (res != VK_SUCCESS && res != VK_NOT_READY) break; + uint64_t start = q[0] & r->mask, end = q[2] & r->mask; + if (!gpuload_result_is_new(q[1], q[3], end, s->last_end)) break; + s->last_end = end; + r->oldest = (r->oldest + 1) % GPULOAD_RING; + r->inflight--; + ticks += gpuload_interval(start, end, v->max_ticks); + } + return (uint64_t)((double)ticks * v->period_ns); +} + +static inline int gpuload_plain_batch(const void *next) { + for (const VkBaseInStructure *s = (const VkBaseInStructure *)next; s; s = s->pNext) { + if (s->sType == VK_STRUCTURE_TYPE_DEVICE_GROUP_SUBMIT_INFO) return 0; + if (s->sType == VK_STRUCTURE_TYPE_PROTECTED_SUBMIT_INFO && ((const VkProtectedSubmitInfo *)s)->protectedSubmit) + return 0; + } + return 1; +} + +static inline struct gpuload_slot *gpuload_ring_take(struct gpuload_ring *r) { + if (r->inflight >= GPULOAD_RING) return NULL; + struct gpuload_slot *s = &r->slot[r->next]; + r->next = (r->next + 1) % GPULOAD_RING; + r->inflight++; + return s; +} + +// Gives back the slots of a submit that failed. +static inline void gpuload_ring_unwind(struct gpuload_ring *r, uint32_t used) { + r->next = (r->next + GPULOAD_RING - used) % GPULOAD_RING; + r->inflight -= used; +} + +// A copy of the batches with the ring's command buffers around each one that has command buffers (free() it after +// the submit), or NULL to submit the originals. *used counts the slots taken, for gpuload_ring_unwind. +static inline VkSubmitInfo *gpuload_ring_wrap(struct gpuload_ring *r, uint32_t count, const VkSubmitInfo *infos, + uint32_t *used) { + *used = 0; + size_t cmds = 0; + for (uint32_t i = 0; i < count; i++) cmds += infos[i].commandBufferCount + 2; + VkSubmitInfo *mine = (VkSubmitInfo *)malloc(count * sizeof(*mine) + cmds * sizeof(VkCommandBuffer)); + if (!mine) return NULL; + VkCommandBuffer *list = (VkCommandBuffer *)(mine + count); + for (uint32_t i = 0; i < count; i++) { + uint32_t n = infos[i].commandBufferCount; + mine[i] = infos[i]; + if (!n || !gpuload_plain_batch(infos[i].pNext)) continue; + struct gpuload_slot *s = gpuload_ring_take(r); + if (!s) continue; + list[0] = s->start; + memcpy(list + 1, infos[i].pCommandBuffers, n * sizeof(VkCommandBuffer)); + list[n + 1] = s->end; + mine[i].pCommandBuffers = list; + mine[i].commandBufferCount = n + 2; + list += n + 2; + (*used)++; + } + return mine; +} + +static inline VkSubmitInfo2 *gpuload_ring_wrap2(struct gpuload_ring *r, uint32_t count, const VkSubmitInfo2 *infos, + uint32_t *used) { + *used = 0; + size_t cmds = 0; + for (uint32_t i = 0; i < count; i++) cmds += infos[i].commandBufferInfoCount + 2; + VkSubmitInfo2 *mine = (VkSubmitInfo2 *)malloc(count * sizeof(*mine) + cmds * sizeof(VkCommandBufferSubmitInfo)); + if (!mine) return NULL; + VkCommandBufferSubmitInfo *list = (VkCommandBufferSubmitInfo *)(mine + count); + for (uint32_t i = 0; i < count; i++) { + uint32_t n = infos[i].commandBufferInfoCount; + mine[i] = infos[i]; + if (!n || (infos[i].flags & VK_SUBMIT_PROTECTED_BIT) || !gpuload_plain_batch(infos[i].pNext)) continue; + struct gpuload_slot *s = gpuload_ring_take(r); + if (!s) continue; + memset(&list[0], 0, sizeof(list[0])); + list[0].sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO; + list[0].commandBuffer = s->start; + memcpy(list + 1, infos[i].pCommandBufferInfos, n * sizeof(VkCommandBufferSubmitInfo)); + memset(&list[n + 1], 0, sizeof(list[n + 1])); + list[n + 1].sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO; + list[n + 1].commandBuffer = s->end; + mine[i].pCommandBufferInfos = list; + mine[i].commandBufferInfoCount = n + 2; + list += n + 2; + (*used)++; + } + return mine; +} + +#endif diff --git a/tools/gpuload/src/vk_layer_droiddeck_gpuload.c b/tools/gpuload/src/vk_layer_droiddeck_gpuload.c new file mode 100644 index 000000000..ef58bef6d --- /dev/null +++ b/tools/gpuload/src/vk_layer_droiddeck_gpuload.c @@ -0,0 +1,410 @@ +// VK_LAYER_DROIDDECK_gpu_load: this process's GPU busy time for the HUD where Android keeps the GPU load from apps +// (kgsl sysfs SELinux-blocked, perfcounter reads refused while /sys/class/kgsl/kgsl-3d0/perfcounter is 0). +// The process total in nanoseconds goes to $DROIDDECK_GPU_LOAD_DIR/; nothing is timed while +// $DROIDDECK_GPU_LOAD_DIR/on is missing (the app removes it while no HUD is shown). +#define _GNU_SOURCE +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gpuload_ring.h" + +#define EXPORT __attribute__((visibility("default"))) +#define MAX_FAMILIES 32 +#define WANTED_CHECK_NS 500000000ull +#define WRITE_EVERY_NS 100000000ull + +static const char *g_dir; + +static void say(const char *what) { + // FEX leaves program_invocation_short_name empty for x86 programs. + fprintf(stderr, "[droiddeck-gpu-load] %s (pid %d): %s\n", program_invocation_short_name, (int)getpid(), what); +} + +__attribute__((constructor)) static void init(void) { + const char *dir = getenv("DROIDDECK_GPU_LOAD_DIR"); + g_dir = dir && *dir ? dir : NULL; +} + +static uint64_t now_ns(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec; +} + +struct inst { + struct inst *next_in_list; + VkInstance handle; + PFN_vkGetInstanceProcAddr gipa; + PFN_vkDestroyInstance destroy; + PFN_vkGetPhysicalDeviceProperties props; + PFN_vkGetPhysicalDeviceQueueFamilyProperties families; +}; + +struct dev { + struct dev *next_in_list; + VkDevice handle; + PFN_vkGetDeviceProcAddr gdpa; + PFN_vkDestroyDevice destroy; + PFN_vkGetDeviceQueue get_queue; + PFN_vkGetDeviceQueue2 get_queue2; + PFN_vkQueueSubmit submit; + PFN_vkQueueSubmit2 submit2; + PFN_vkQueueSubmit2 submit2_khr; + struct gpuload_vk vk; + uint32_t family_count; + uint32_t timestamp_bits[MAX_FAMILIES]; +}; + +struct queue { + struct queue *next_in_list; + VkQueue handle; + struct dev *dev; + uint32_t family; + uint32_t epoch; + struct gpuload_ring ring; +}; + +static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; +static struct inst *g_insts; +static struct dev *g_devs; +static struct queue *g_queues; + +static void *key(const void *obj) { return *(void *const *)obj; } + +static struct inst *inst_for(const void *obj) { + struct inst *r; + pthread_mutex_lock(&g_lock); + for (r = g_insts; r && key(r->handle) != key(obj); r = r->next_in_list) {} + pthread_mutex_unlock(&g_lock); + return r; +} + +static struct dev *dev_for(const void *obj) { + struct dev *r; + pthread_mutex_lock(&g_lock); + for (r = g_devs; r && key(r->handle) != key(obj); r = r->next_in_list) {} + pthread_mutex_unlock(&g_lock); + return r; +} + +static struct queue *queue_for(VkQueue handle) { + struct queue *r; + pthread_mutex_lock(&g_lock); + for (r = g_queues; r && r->handle != handle; r = r->next_in_list) {} + pthread_mutex_unlock(&g_lock); + return r; +} + +static void note_queue(struct dev *d, VkQueue handle, uint32_t family) { + if (!handle || queue_for(handle)) return; + struct queue *q = calloc(1, sizeof(*q)); + if (!q) return; + q->handle = handle; + q->dev = d; + q->family = family; + pthread_mutex_lock(&g_lock); + q->next_in_list = g_queues; + g_queues = q; + pthread_mutex_unlock(&g_lock); +} + +// ---- process total --------------------------------------------------------------------------------------------- + +static _Atomic uint64_t g_busy_ns; +static _Atomic uint64_t g_wanted_at; +static _Atomic int g_wanted; +static _Atomic uint32_t g_epoch = 1; // bumped each time the HUD comes back +static pthread_mutex_t g_write_lock = PTHREAD_MUTEX_INITIALIZER; +static uint64_t g_written_at; +static int g_fd = -1; +static pid_t g_fd_pid; +static int g_announced; + +static int wanted(uint64_t now) { + uint64_t at = atomic_load(&g_wanted_at); + if (at && now - at < WANTED_CHECK_NS) return atomic_load(&g_wanted); + atomic_store(&g_wanted_at, now); + char path[512]; + snprintf(path, sizeof(path), "%s/on", g_dir); + int on = access(path, F_OK) == 0; + if (on && !atomic_exchange(&g_wanted, on)) atomic_fetch_add(&g_epoch, 1); + if (!on) atomic_store(&g_wanted, 0); + return on; +} + +static void publish(uint64_t now) { + if (pthread_mutex_trylock(&g_write_lock)) return; + if (now - g_written_at >= WRITE_EVERY_NS) { + g_written_at = now; + pid_t pid = getpid(); + struct stat st; + // The app deletes files that stopped changing; a forked child needs its own. + if (g_fd >= 0 && (g_fd_pid != pid || fstat(g_fd, &st) || st.st_nlink == 0)) { close(g_fd); g_fd = -1; } + if (g_fd < 0) { + char path[512]; + snprintf(path, sizeof(path), "%s/%d", g_dir, (int)pid); + g_fd = open(path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); + g_fd_pid = pid; + } + if (g_fd >= 0) { + char line[80]; + int n = snprintf(line, sizeof(line), "%20llu %-32.32s\n", (unsigned long long)atomic_load(&g_busy_ns), + program_invocation_short_name); + if (pwrite(g_fd, line, (size_t)n, 0) != n) { close(g_fd); g_fd = -1; } + } + } + pthread_mutex_unlock(&g_write_lock); +} + +// The queue to time this submit on, or NULL to pass it through. +static struct queue *measuring(VkQueue handle) { + if (!g_dir) return NULL; + struct queue *q = queue_for(handle); + if (!q) return NULL; + uint64_t now = now_ns(); + if (!wanted(now)) return NULL; + struct dev *d = q->dev; + if (q->ring.state == 0) { + uint32_t bits = q->family < d->family_count ? d->timestamp_bits[q->family] : 0; + if (gpuload_ring_setup(&q->ring, &d->vk, q->family, bits)) { + if (!__atomic_exchange_n(&g_announced, 1, __ATOMIC_RELAXED)) say("timing this process's GPU work for the HUD"); + } else { + say(bits ? "cannot set up GPU timing on this queue" : "this queue family has no timestamps"); + } + q->epoch = atomic_load(&g_epoch); + } + if (q->ring.state != 1) return NULL; + uint64_t ns = gpuload_ring_harvest(&q->ring, &d->vk); + uint32_t epoch = atomic_load(&g_epoch); + if (q->epoch == epoch) atomic_fetch_add(&g_busy_ns, ns); + else q->epoch = epoch; // timed before the HUD went away + publish(now); + return q; +} + +static VkResult VKAPI_CALL QueueSubmit(VkQueue queue, uint32_t count, const VkSubmitInfo *infos, VkFence fence) { + struct dev *d = dev_for(queue); + if (!d) return VK_ERROR_DEVICE_LOST; + struct queue *q = count ? measuring(queue) : NULL; + uint32_t used = 0; + VkSubmitInfo *mine = q ? gpuload_ring_wrap(&q->ring, count, infos, &used) : NULL; + VkResult r = d->submit(queue, count, mine ? mine : infos, fence); + if (mine && r != VK_SUCCESS) gpuload_ring_unwind(&q->ring, used); + free(mine); + return r; +} + +static VkResult submit2(PFN_vkQueueSubmit2 next, VkQueue queue, uint32_t count, const VkSubmitInfo2 *infos, VkFence fence) { + struct queue *q = count ? measuring(queue) : NULL; + uint32_t used = 0; + VkSubmitInfo2 *mine = q ? gpuload_ring_wrap2(&q->ring, count, infos, &used) : NULL; + VkResult r = next(queue, count, mine ? mine : infos, fence); + if (mine && r != VK_SUCCESS) gpuload_ring_unwind(&q->ring, used); + free(mine); + return r; +} + +static VkResult VKAPI_CALL QueueSubmit2(VkQueue queue, uint32_t count, const VkSubmitInfo2 *infos, VkFence fence) { + struct dev *d = dev_for(queue); + return d ? submit2(d->submit2, queue, count, infos, fence) : VK_ERROR_DEVICE_LOST; +} + +static VkResult VKAPI_CALL QueueSubmit2KHR(VkQueue queue, uint32_t count, const VkSubmitInfo2 *infos, VkFence fence) { + struct dev *d = dev_for(queue); + return d ? submit2(d->submit2_khr, queue, count, infos, fence) : VK_ERROR_DEVICE_LOST; +} + +static void VKAPI_CALL GetDeviceQueue(VkDevice device, uint32_t family, uint32_t index, VkQueue *out) { + struct dev *d = dev_for(device); + if (!d) return; + d->get_queue(device, family, index, out); + if (out && *out) note_queue(d, *out, family); +} + +static void VKAPI_CALL GetDeviceQueue2(VkDevice device, const VkDeviceQueueInfo2 *info, VkQueue *out) { + struct dev *d = dev_for(device); + if (!d) return; + d->get_queue2(device, info, out); + if (out && *out) note_queue(d, *out, info->queueFamilyIndex); +} + +// ---- instance and device --------------------------------------------------------------------------------------- + +static VkResult VKAPI_CALL CreateInstance(const VkInstanceCreateInfo *ci, const VkAllocationCallbacks *alloc, VkInstance *out) { + VkLayerInstanceCreateInfo *link = (VkLayerInstanceCreateInfo *)ci->pNext; + while (link && !(link->sType == VK_STRUCTURE_TYPE_LOADER_INSTANCE_CREATE_INFO && link->function == VK_LAYER_LINK_INFO)) + link = (VkLayerInstanceCreateInfo *)link->pNext; + if (!link) return VK_ERROR_INITIALIZATION_FAILED; + PFN_vkGetInstanceProcAddr next_gipa = link->u.pLayerInfo->pfnNextGetInstanceProcAddr; + link->u.pLayerInfo = link->u.pLayerInfo->pNext; + PFN_vkCreateInstance create = (PFN_vkCreateInstance)next_gipa(VK_NULL_HANDLE, "vkCreateInstance"); + if (!create) return VK_ERROR_INITIALIZATION_FAILED; + struct inst *in = calloc(1, sizeof(*in)); + if (!in) return VK_ERROR_OUT_OF_HOST_MEMORY; + VkResult r = create(ci, alloc, out); + if (r != VK_SUCCESS) { free(in); return r; } + in->handle = *out; + in->gipa = next_gipa; + in->destroy = (PFN_vkDestroyInstance)next_gipa(*out, "vkDestroyInstance"); + in->props = (PFN_vkGetPhysicalDeviceProperties)next_gipa(*out, "vkGetPhysicalDeviceProperties"); + in->families = (PFN_vkGetPhysicalDeviceQueueFamilyProperties)next_gipa(*out, "vkGetPhysicalDeviceQueueFamilyProperties"); + pthread_mutex_lock(&g_lock); + in->next_in_list = g_insts; + g_insts = in; + pthread_mutex_unlock(&g_lock); + return VK_SUCCESS; +} + +static void VKAPI_CALL DestroyInstance(VkInstance instance, const VkAllocationCallbacks *alloc) { + struct inst *in = inst_for(instance); + if (!in) return; + PFN_vkDestroyInstance destroy = in->destroy; + pthread_mutex_lock(&g_lock); + for (struct inst **p = &g_insts; *p; p = &(*p)->next_in_list) + if (*p == in) { *p = in->next_in_list; break; } + pthread_mutex_unlock(&g_lock); + free(in); + if (destroy) destroy(instance, alloc); +} + +static VkLayerDeviceCreateInfo *device_chain(const VkDeviceCreateInfo *ci, VkLayerFunction function) { + VkLayerDeviceCreateInfo *c = (VkLayerDeviceCreateInfo *)ci->pNext; + while (c && !(c->sType == VK_STRUCTURE_TYPE_LOADER_DEVICE_CREATE_INFO && c->function == function)) + c = (VkLayerDeviceCreateInfo *)c->pNext; + return c; +} + +static VkResult VKAPI_CALL CreateDevice(VkPhysicalDevice pd, const VkDeviceCreateInfo *ci, const VkAllocationCallbacks *alloc, + VkDevice *out) { + VkLayerDeviceCreateInfo *link = device_chain(ci, VK_LAYER_LINK_INFO); + if (!link) return VK_ERROR_INITIALIZATION_FAILED; + VkLayerDeviceCreateInfo *callback = device_chain(ci, VK_LOADER_DATA_CALLBACK); + PFN_vkGetInstanceProcAddr next_gipa = link->u.pLayerInfo->pfnNextGetInstanceProcAddr; + PFN_vkGetDeviceProcAddr next_gdpa = link->u.pLayerInfo->pfnNextGetDeviceProcAddr; + link->u.pLayerInfo = link->u.pLayerInfo->pNext; + struct inst *in = inst_for(pd); + PFN_vkCreateDevice create = (PFN_vkCreateDevice)next_gipa(in ? in->handle : VK_NULL_HANDLE, "vkCreateDevice"); + if (!create) return VK_ERROR_INITIALIZATION_FAILED; + struct dev *d = calloc(1, sizeof(*d)); + if (!d) return VK_ERROR_OUT_OF_HOST_MEMORY; + VkResult r = create(pd, ci, alloc, out); + if (r != VK_SUCCESS) { free(d); return r; } + d->handle = *out; + d->gdpa = next_gdpa; +#define LOAD(field, type, name) d->field = (type)next_gdpa(*out, name) + LOAD(destroy, PFN_vkDestroyDevice, "vkDestroyDevice"); + LOAD(get_queue, PFN_vkGetDeviceQueue, "vkGetDeviceQueue"); + LOAD(get_queue2, PFN_vkGetDeviceQueue2, "vkGetDeviceQueue2"); + LOAD(submit, PFN_vkQueueSubmit, "vkQueueSubmit"); + LOAD(submit2, PFN_vkQueueSubmit2, "vkQueueSubmit2"); + LOAD(submit2_khr, PFN_vkQueueSubmit2, "vkQueueSubmit2KHR"); + LOAD(vk.create_pool, PFN_vkCreateCommandPool, "vkCreateCommandPool"); + LOAD(vk.destroy_pool, PFN_vkDestroyCommandPool, "vkDestroyCommandPool"); + LOAD(vk.alloc_cmds, PFN_vkAllocateCommandBuffers, "vkAllocateCommandBuffers"); + LOAD(vk.begin, PFN_vkBeginCommandBuffer, "vkBeginCommandBuffer"); + LOAD(vk.end, PFN_vkEndCommandBuffer, "vkEndCommandBuffer"); + LOAD(vk.cmd_reset, PFN_vkCmdResetQueryPool, "vkCmdResetQueryPool"); + LOAD(vk.cmd_timestamp, PFN_vkCmdWriteTimestamp, "vkCmdWriteTimestamp"); + LOAD(vk.create_queries, PFN_vkCreateQueryPool, "vkCreateQueryPool"); + LOAD(vk.destroy_queries, PFN_vkDestroyQueryPool, "vkDestroyQueryPool"); + LOAD(vk.results, PFN_vkGetQueryPoolResults, "vkGetQueryPoolResults"); +#undef LOAD + d->vk.device = *out; + d->vk.set_data = callback ? (gpuload_set_data_fn)callback->u.pfnSetDeviceLoaderData : NULL; + if (in && in->props && in->families) { + VkPhysicalDeviceProperties props; + in->props(pd, &props); + gpuload_vk_period(&d->vk, props.limits.timestampPeriod); + uint32_t n = 0; + in->families(pd, &n, NULL); + VkQueueFamilyProperties families[MAX_FAMILIES]; + if (n > MAX_FAMILIES) n = MAX_FAMILIES; + in->families(pd, &n, families); + d->family_count = n; + for (uint32_t i = 0; i < n; i++) d->timestamp_bits[i] = families[i].timestampValidBits; + } + // Without the loader callback our command buffers would lack the loader's dispatch pointer. + if (!d->vk.set_data) d->family_count = 0; + pthread_mutex_lock(&g_lock); + d->next_in_list = g_devs; + g_devs = d; + pthread_mutex_unlock(&g_lock); + return VK_SUCCESS; +} + +static void VKAPI_CALL DestroyDevice(VkDevice device, const VkAllocationCallbacks *alloc) { + struct dev *d = dev_for(device); + if (!d) return; + struct queue *mine = NULL; + pthread_mutex_lock(&g_lock); + for (struct queue **p = &g_queues; *p;) { + struct queue *q = *p; + if (q->dev == d) { *p = q->next_in_list; q->next_in_list = mine; mine = q; } else p = &q->next_in_list; + } + for (struct dev **p = &g_devs; *p; p = &(*p)->next_in_list) + if (*p == d) { *p = d->next_in_list; break; } + pthread_mutex_unlock(&g_lock); + while (mine) { + struct queue *q = mine; + mine = q->next_in_list; + gpuload_ring_release(&q->ring, &d->vk); + free(q); + } + PFN_vkDestroyDevice destroy = d->destroy; + free(d); + if (destroy) destroy(device, alloc); +} + +// ---- dispatch -------------------------------------------------------------------------------------------------- + +EXPORT VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL DroidDeckGpuLoad_GetDeviceProcAddr(VkDevice device, const char *name); + +static PFN_vkVoidFunction device_hook(struct dev *d, const char *name) { + if (!g_dir) return NULL; + if (!strcmp(name, "vkGetDeviceQueue") && d->get_queue) return (PFN_vkVoidFunction)GetDeviceQueue; + if (!strcmp(name, "vkGetDeviceQueue2") && d->get_queue2) return (PFN_vkVoidFunction)GetDeviceQueue2; + if (!strcmp(name, "vkQueueSubmit") && d->submit) return (PFN_vkVoidFunction)QueueSubmit; + if (!strcmp(name, "vkQueueSubmit2") && d->submit2) return (PFN_vkVoidFunction)QueueSubmit2; + if (!strcmp(name, "vkQueueSubmit2KHR") && d->submit2_khr) return (PFN_vkVoidFunction)QueueSubmit2KHR; + return NULL; +} + +EXPORT VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL DroidDeckGpuLoad_GetInstanceProcAddr(VkInstance instance, const char *name) { + if (!strcmp(name, "vkCreateInstance")) return (PFN_vkVoidFunction)CreateInstance; + if (!strcmp(name, "vkDestroyInstance")) return (PFN_vkVoidFunction)DestroyInstance; + if (!strcmp(name, "vkCreateDevice")) return (PFN_vkVoidFunction)CreateDevice; + if (!strcmp(name, "vkGetInstanceProcAddr")) return (PFN_vkVoidFunction)DroidDeckGpuLoad_GetInstanceProcAddr; + if (!strcmp(name, "vkGetDeviceProcAddr")) return (PFN_vkVoidFunction)DroidDeckGpuLoad_GetDeviceProcAddr; + if (!instance) return NULL; + struct inst *in = inst_for(instance); + return in ? in->gipa(instance, name) : NULL; +} + +EXPORT VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL DroidDeckGpuLoad_GetDeviceProcAddr(VkDevice device, const char *name) { + if (!strcmp(name, "vkGetDeviceProcAddr")) return (PFN_vkVoidFunction)DroidDeckGpuLoad_GetDeviceProcAddr; + if (!strcmp(name, "vkDestroyDevice")) return (PFN_vkVoidFunction)DestroyDevice; + struct dev *d = dev_for(device); + if (!d) return NULL; + PFN_vkVoidFunction mine = device_hook(d, name); + return mine ? mine : d->gdpa(device, name); +} + +EXPORT VKAPI_ATTR VkResult VKAPI_CALL DroidDeckGpuLoad_NegotiateLoaderLayerInterfaceVersion(VkNegotiateLayerInterface *v) { + if (v->loaderLayerInterfaceVersion > 2) v->loaderLayerInterfaceVersion = 2; + v->pfnGetInstanceProcAddr = DroidDeckGpuLoad_GetInstanceProcAddr; + v->pfnGetDeviceProcAddr = DroidDeckGpuLoad_GetDeviceProcAddr; + v->pfnGetPhysicalDeviceProcAddr = NULL; + return VK_SUCCESS; +} diff --git a/tools/gpuload/tests/test_gpuload_busy.c b/tools/gpuload/tests/test_gpuload_busy.c new file mode 100644 index 000000000..706580e90 --- /dev/null +++ b/tools/gpuload/tests/test_gpuload_busy.c @@ -0,0 +1,24 @@ +#include +#include + +#include "gpuload_busy.h" + +int main(void) { + const uint64_t max = 1000; + assert(gpuload_interval(10, 20, max) == 10); + // Each batch counts on its own, even when the counter restarted in between (later batch has smaller ticks). + assert(gpuload_interval(900000, 900400, max) == 400); + assert(gpuload_interval(50, 350, max) == 300); + // Backwards, empty or too long: nothing. + assert(gpuload_interval(60, 50, max) == 0); + assert(gpuload_interval(70, 70, max) == 0); + assert(gpuload_interval(100, 100 + max + 1, max) == 0); + assert(gpuload_interval(100, 100 + max, max) == max); + // Query results: both halves available and an end that differs from the last harvested one. + assert(gpuload_result_is_new(1, 1, 500, 400)); + assert(!gpuload_result_is_new(1, 1, 400, 400)); + assert(!gpuload_result_is_new(1, 0, 500, 400)); + assert(!gpuload_result_is_new(0, 1, 500, 400)); + puts("test_gpuload_busy: ok"); + return 0; +} diff --git a/tools/gpuload/tests/test_gpuload_ring.c b/tools/gpuload/tests/test_gpuload_ring.c new file mode 100644 index 000000000..4c5bc1395 --- /dev/null +++ b/tools/gpuload/tests/test_gpuload_ring.c @@ -0,0 +1,96 @@ +#include +#include +#include + +#include "gpuload_ring.h" + +// A fake device: query results come from this table, [start, start available, end, end available] per slot. +static uint64_t g_result[GPULOAD_RING][4]; +static uintptr_t g_next_handle = 0x1000; + +static VkResult VKAPI_CALL create_pool(VkDevice d, const VkCommandPoolCreateInfo *ci, const VkAllocationCallbacks *a, VkCommandPool *out) { + (void)d; (void)ci; (void)a; *out = (VkCommandPool)(g_next_handle++); return VK_SUCCESS; +} +static void VKAPI_CALL destroy_pool(VkDevice d, VkCommandPool p, const VkAllocationCallbacks *a) { (void)d; (void)p; (void)a; } +static VkResult VKAPI_CALL alloc_cmds(VkDevice d, const VkCommandBufferAllocateInfo *ai, VkCommandBuffer *out) { + (void)d; + for (uint32_t i = 0; i < ai->commandBufferCount; i++) out[i] = (VkCommandBuffer)(g_next_handle++); + return VK_SUCCESS; +} +static VkResult VKAPI_CALL begin(VkCommandBuffer c, const VkCommandBufferBeginInfo *bi) { (void)c; (void)bi; return VK_SUCCESS; } +static VkResult VKAPI_CALL end(VkCommandBuffer c) { (void)c; return VK_SUCCESS; } +static void VKAPI_CALL cmd_reset(VkCommandBuffer c, VkQueryPool q, uint32_t first, uint32_t n) { (void)c; (void)q; (void)first; (void)n; } +static void VKAPI_CALL cmd_timestamp(VkCommandBuffer c, VkPipelineStageFlagBits s, VkQueryPool q, uint32_t i) { (void)c; (void)s; (void)q; (void)i; } +static VkResult VKAPI_CALL create_queries(VkDevice d, const VkQueryPoolCreateInfo *ci, const VkAllocationCallbacks *a, VkQueryPool *out) { + (void)d; (void)a; assert(ci->queryCount == 2 * GPULOAD_RING); *out = (VkQueryPool)(g_next_handle++); return VK_SUCCESS; +} +static void VKAPI_CALL destroy_queries(VkDevice d, VkQueryPool q, const VkAllocationCallbacks *a) { (void)d; (void)q; (void)a; } +static VkResult VKAPI_CALL results(VkDevice d, VkQueryPool q, uint32_t first, uint32_t n, size_t size, void *data, + VkDeviceSize stride, VkQueryResultFlags flags) { + (void)d; (void)q; (void)size; + assert(n == 2 && stride == 2 * sizeof(uint64_t) && (flags & VK_QUERY_RESULT_WITH_AVAILABILITY_BIT)); + uint64_t *out = data; + for (int k = 0; k < 4; k++) out[k] = g_result[first / 2][k]; + return out[1] && out[3] ? VK_SUCCESS : VK_NOT_READY; +} + +static void finish(uint32_t slot, uint64_t start, uint64_t end) { + g_result[slot][0] = start; g_result[slot][1] = 1; g_result[slot][2] = end; g_result[slot][3] = 1; +} + +int main(void) { + struct gpuload_vk v = { + .device = (VkDevice)(uintptr_t)1, .create_pool = create_pool, .destroy_pool = destroy_pool, .alloc_cmds = alloc_cmds, + .begin = begin, .end = end, .cmd_reset = cmd_reset, .cmd_timestamp = cmd_timestamp, + .create_queries = create_queries, .destroy_queries = destroy_queries, .results = results, + }; + struct gpuload_ring r = {0}; + assert(!gpuload_ring_setup(&r, &v, 0, 64) && r.state == -1); // no timestamp period yet + gpuload_vk_period(&v, 52.083f); + assert(!gpuload_ring_setup(&r, &v, 0, 0) && r.state == -1); // family without timestamps + assert(gpuload_ring_setup(&r, &v, 0, 64) && r.state == 1); + + VkCommandBuffer app[2] = {(VkCommandBuffer)(uintptr_t)0xa1, (VkCommandBuffer)(uintptr_t)0xa2}; + VkProtectedSubmitInfo prot = {.sType = VK_STRUCTURE_TYPE_PROTECTED_SUBMIT_INFO, .protectedSubmit = VK_TRUE}; + VkSubmitInfo batches[3] = { + {.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, .commandBufferCount = 2, .pCommandBuffers = app}, + {.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO}, // semaphores only + {.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, .pNext = &prot, .commandBufferCount = 1, .pCommandBuffers = app}, + }; + uint32_t used = 0; + VkSubmitInfo *mine = gpuload_ring_wrap(&r, 3, batches, &used); + assert(mine && used == 1 && r.inflight == 1); + assert(mine[0].commandBufferCount == 4 && mine[0].pCommandBuffers[0] == r.slot[0].start && + mine[0].pCommandBuffers[1] == app[0] && mine[0].pCommandBuffers[2] == app[1] && + mine[0].pCommandBuffers[3] == r.slot[0].end); + assert(mine[1].commandBufferCount == 0 && mine[2].commandBufferCount == 1 && mine[2].pCommandBuffers == app); + free(mine); + + assert(gpuload_ring_harvest(&r, &v) == 0 && r.inflight == 1); // not finished yet + finish(0, 1000, 1192); // 192 ticks = 10 us + assert(gpuload_ring_harvest(&r, &v) == 9999 && r.inflight == 0); + + // A failed submit gives its slot back. + mine = gpuload_ring_wrap(&r, 1, batches, &used); + assert(used == 1 && r.inflight == 1); + gpuload_ring_unwind(&r, used); + assert(r.inflight == 0 && r.next == 1); + free(mine); + + // Fill the ring: the 65th batch goes out untimed. + for (int i = 0; i < GPULOAD_RING; i++) { mine = gpuload_ring_wrap(&r, 1, batches, &used); assert(used == 1); free(mine); } + mine = gpuload_ring_wrap(&r, 1, batches, &used); + assert(used == 0 && mine[0].commandBufferCount == 2); + free(mine); + + // Slot 0 comes round again: until the GPU resets it, it still shows the old pair, which must not count twice. + // Slots 1..63 finish after a counter restart (smaller ticks than slot 0's old pair); each still counts. + for (uint32_t s = 1; s < GPULOAD_RING; s++) finish(s, 10 * s, 10 * s + 4); + assert(gpuload_ring_harvest(&r, &v) == (uint64_t)(63 * 4 * 52.083f) && r.inflight == 1 && r.oldest == 0); + finish(0, 5, 9); + assert(gpuload_ring_harvest(&r, &v) == (uint64_t)(4 * 52.083f) && r.inflight == 0); + + gpuload_ring_release(&r, &v); + puts("test_gpuload_ring: ok"); + return 0; +} diff --git a/tools/release/runtime_inputs.py b/tools/release/runtime_inputs.py index 4aefaa3da..9122707be 100644 --- a/tools/release/runtime_inputs.py +++ b/tools/release/runtime_inputs.py @@ -26,6 +26,7 @@ SOURCE_PATHS = ['app/build.gradle', 'app/src/main/cpp/fakeinput_steam.cpp', 'app 'tools/mangoapp', 'tools/msitools', 'tools/pipewire', 'tools/comet', 'tools/xbox', 'tools/dirt-ws2', 'tools/components', 'tools/ci', 'tools/build_local.sh', 'tools/release/runtime_inputs.py', 'app/src/main/assets/desktop-removal', 'tools/quest', 'tools/vr/src', 'tools/vr/driver_droiddeck', 'tools/vr/layer', 'tools/vr/build.sh', + 'tools/gpuload', 'app/src/main/cpp/waylandcomp/src/headpose_shm.h', 'app/src/main/cpp/venc/venc_proto.h', 'app/src/main/cpp/waylandcomp/src/spatialpose_shm.h', @@ -92,6 +93,8 @@ def required(): 'droiddeck/resources/settings/default.vrsettings', 'layer/libVkLayer_droiddeck_vr.so', 'layer/VK_LAYER_DROIDDECK_vr.json', 'layer/VK_LAYER_DROIDDECK_vr_implicit.json', 'layer/libdroiddeck_vr_scheduling.so']] + paths += ['assets/linuxfs/usr/local/lib/droiddeck-gpuload/' + name for name in + ['libVkLayer_droiddeck_gpuload.so', 'VK_LAYER_DROIDDECK_gpu_load.json']] for side in ['left', 'right']: paths += [f'assets/linuxfs/usr/local/lib/droiddeck-vr/droiddeck/resources/rendermodels/droiddeck_hand_{side}/{name}' for name in [f'droiddeck_hand_{side}.json', 'hand.mtl', 'hand.png', 'hand_open.obj', 'hand_fist.obj', diff --git a/tools/tests/kgsl_busy_harness.c b/tools/tests/kgsl_busy_harness.c new file mode 100644 index 000000000..14e96bf64 --- /dev/null +++ b/tools/tests/kgsl_busy_harness.c @@ -0,0 +1,56 @@ +#include +#include +#include + +#include "kgsl_busy.h" + +#define ENTRIES 819 + +static uint32_t memstore[ENTRIES * KGSL_BUSY_ENTRY_WORDS]; + +static void stamp(int entry, uint32_t sop, uint32_t eop) { + memstore[entry * KGSL_BUSY_ENTRY_WORDS + KGSL_BUSY_SOP] = sop; + memstore[entry * KGSL_BUSY_ENTRY_WORDS + KGSL_BUSY_EOP] = eop; +} + +int main(void) { + assert(kgsl_busy_first_slot(8 * 4096) == 813); + assert(kgsl_busy_first_slot(8 * 16384) == 3270); + assert(kgsl_busy_first_slot(7 * 40) == -1); + + int first = kgsl_busy_first_slot(sizeof(memstore)); + uint32_t last[KGSL_BUSY_SLOTS] = {0}; + int moved = 0; + + stamp(first, 100, 100); + stamp(first + 2, 7, 7); + assert(kgsl_busy_sample(memstore, first, last, &moved) == 0); + assert(moved == 1 && last[0] == 100 && last[2] == 7); + + moved = 0; + assert(kgsl_busy_sample(memstore, first, last, &moved) == 0); + assert(moved == 0); + + stamp(first + 2, 8, 7); + assert(kgsl_busy_sample(memstore, first, last, &moved) == 1); + assert(moved == 1); + + moved = 0; + stamp(first + 2, 8, 8); + assert(kgsl_busy_sample(memstore, first, last, &moved) == 0); + assert(moved == 0); + + stamp(first + 4, 3, 2); + assert(kgsl_busy_sample(memstore, first, last, &moved) == 1); + + memset(memstore, 0, sizeof(memstore)); + stamp(first - 1, 5, 4); + stamp(first + KGSL_BUSY_SLOTS, 9, 8); + memset(last, 0, sizeof(last)); + moved = 0; + assert(kgsl_busy_sample(memstore, first, last, &moved) == 0); + assert(moved == 0); + + puts("PASS: kgsl memstore busy"); + return 0; +} diff --git a/tools/tests/test_kgsl_busy.py b/tools/tests/test_kgsl_busy.py new file mode 100644 index 000000000..7655b9873 --- /dev/null +++ b/tools/tests/test_kgsl_busy.py @@ -0,0 +1,33 @@ +"""Exercise the HUD's kgsl memstore busy sampling (app/src/main/cpp/gpuclock/kgsl_busy.h).""" +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +REPO = Path(__file__).resolve().parents[2] + + +class KgslBusyTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = shutil.which(os.environ.get('CC', 'cc')) + if compiler is None: + raise unittest.SkipTest('A host C compiler is required') + cls.temp = tempfile.TemporaryDirectory(prefix='droiddeck-kgsl-busy-') + cls.addClassCleanup(cls.temp.cleanup) + cls.binary = Path(cls.temp.name) / 'kgsl-busy-test' + subprocess.run([compiler, '-std=c11', '-Wall', '-Wextra', '-Werror', + '-I', str(REPO / 'app/src/main/cpp/gpuclock'), + str(REPO / 'tools/tests/kgsl_busy_harness.c'), '-o', str(cls.binary)], + check=True, capture_output=True, text=True) + + def test_ringbuffer_slots_and_busy_rule(self): + result = subprocess.run([str(self.binary)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('PASS: kgsl memstore busy', result.stdout) + + +if __name__ == '__main__': + unittest.main()