From 0901bb8b1e47b8df19be5ab4feb80539e00f05fc Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 2 Oct 2026 16:43:49 -0700 Subject: [PATCH] Always-on host CPU sampler ring (TCP host_profile) Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/include/host_sampler.h | 31 +++++++ runtime/runtime.cmake | 1 + runtime/src/debug_server.c | 64 +++++++++++++ runtime/src/host_sampler.c | 165 +++++++++++++++++++++++++++++++++ runtime/src/main.cpp | 2 + 5 files changed, 263 insertions(+) create mode 100644 runtime/include/host_sampler.h create mode 100644 runtime/src/host_sampler.c diff --git a/runtime/include/host_sampler.h b/runtime/include/host_sampler.h new file mode 100644 index 00000000..069ff1b5 --- /dev/null +++ b/runtime/include/host_sampler.h @@ -0,0 +1,31 @@ +#ifndef PSX_HOST_SAMPLER_H +#define PSX_HOST_SAMPLER_H +/* Always-on host CPU sampler for the emulation thread (debug-tools builds). + * See host_sampler.c; TCP host_profile; tools/host_profile.py. */ +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define HOST_SAMPLER_CAP 65536u /* ~65 s at 1 kHz */ + +typedef struct HostSample { + uint64_t rva; /* instruction pointer minus the executable's base */ + uint32_t frame; /* guest frame (s_frame_count) */ + uint32_t in_pass; /* 1 while a render pass ran */ +} HostSample; + +/* Start sampling the calling thread (the emulation thread). Idempotent. */ +void host_sampler_start(void); +int host_sampler_supported(void); +/* Total samples recorded; the ring holds the newest HOST_SAMPLER_CAP. */ +uint64_t host_sampler_seq(void); +uint64_t host_sampler_image_base(void); +/* Sample `seq`, if it is still in the ring. */ +int host_sampler_get(uint64_t seq, HostSample *out); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index f6b0b9f1..46a6c846 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -369,6 +369,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/sio.c ${PSXRECOMP_ROOT}/runtime/src/memcard.c ${PSXRECOMP_ROOT}/runtime/src/debug_server.c + ${PSXRECOMP_ROOT}/runtime/src/host_sampler.c ${PSXRECOMP_ROOT}/runtime/src/debug_trace_ranges.c ${PSXRECOMP_ROOT}/runtime/src/dirty_ram_interp.c ${PSXRECOMP_ROOT}/runtime/src/game_dispatch_compat.c diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index ae16a31e..1e759c57 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -17,6 +17,7 @@ #endif #include #include "debug_server.h" +#include "host_sampler.h" #include "psx_video_timing.h" #include "psx_bss.h" #include "nd_intro_ot.h" @@ -5323,6 +5324,68 @@ static void handle_geom_correction(int id, const char *json) (unsigned long long)ps.word_no_z); } +/* host_profile — histogram of the always-on host CPU sampler (host_sampler.c) + * over a window of the ring: {"cmd":"host_profile","since":SEQ} or + * {"cmd":"host_profile","frames":N} (the last N guest frames), optional + * "pass":1 (only samples inside render passes) / "pass":0 (outside), "top":K. + * Addresses are executable-relative; tools/host_profile.py names them. Take + * "seq" from one reply as "since" of the next to profile what ran between. */ +static void handle_host_profile(int id, const char *json) +{ + enum { MAXB = 4096 }; + static uint64_t rvas[MAXB]; + static uint32_t counts[MAXB]; + const uint64_t seq = host_sampler_seq(); + const uint64_t oldest = seq > HOST_SAMPLER_CAP ? seq - HOST_SAMPLER_CAP : 0; + int since = json_get_int(json, "since", -1); + const int frames = json_get_int(json, "frames", -1); + const int pass = json_get_int(json, "pass", -1); + int top = json_get_int(json, "top", 60); + if (top < 1) top = 1; + if (top > 400) top = 400; + uint64_t from = since >= 0 ? (uint64_t)since : oldest; + if (from < oldest) from = oldest; + uint32_t frame_lo = 0; + if (frames > 0) frame_lo = (uint32_t)s_frame_count - (uint32_t)frames; + int nb = 0; + uint64_t total = 0, dropped = 0; + for (uint64_t i = from; i < seq; i++) { + HostSample smp; + if (!host_sampler_get(i, &smp)) continue; + if (frames > 0 && (int32_t)(smp.frame - frame_lo) < 0) continue; + if (pass >= 0 && (int)smp.in_pass != pass) continue; + total++; + int b = 0; + while (b < nb && rvas[b] != smp.rva) b++; + if (b == nb) { + if (nb == MAXB) { dropped++; continue; } + rvas[nb] = smp.rva; counts[nb] = 0; nb++; + } + counts[b]++; + } + size_t cap = 256 + (size_t)top * 48; + char *buf = (char *)malloc(cap); + if (!buf) { send_err(id, "oom"); return; } + size_t n = (size_t)snprintf(buf, cap, + "{\"id\":%d,\"ok\":true,\"supported\":%d,\"seq\":%llu," + "\"image_base\":\"0x%llX\",\"samples\":%llu,\"unbinned\":%llu,\"top\":[", + id, host_sampler_supported(), (unsigned long long)seq, + (unsigned long long)host_sampler_image_base(), + (unsigned long long)total, (unsigned long long)dropped); + for (int k = 0; k < top; k++) { + int best = -1; + for (int b = 0; b < nb; b++) + if (counts[b] && (best < 0 || counts[b] > counts[best])) best = b; + if (best < 0) break; + n += (size_t)snprintf(buf + n, cap - n, "%s[\"0x%llX\",%u]", k ? "," : "", + (unsigned long long)rvas[best], counts[best]); + counts[best] = 0; + } + snprintf(buf + n, cap - n, "]}"); + send_fmt("%s", buf); + free(buf); +} + /* pgxp_shadow — read PGXP shadow slots: {"cmd":"pgxp_shadow","addr":A, * "count":N} walks N guest words from A (RAM / scratchpad); "space":"gpr" or * "gte" with "index"/"count" reads register shadows instead. Each slot: the @@ -14459,6 +14522,7 @@ static const CmdEntry s_commands[] = { { "geom_correction", handle_geom_correction }, { "pgxp", handle_pgxp }, { "pgxp_shadow", handle_pgxp_shadow }, + { "host_profile", handle_host_profile }, { "pgxp_miss_ring", handle_pgxp_miss_ring }, { "ws_aspect_cone_site", handle_ws_aspect_cone_site }, { "ws_margin", handle_ws_margin }, diff --git a/runtime/src/host_sampler.c b/runtime/src/host_sampler.c new file mode 100644 index 00000000..8ce95485 --- /dev/null +++ b/runtime/src/host_sampler.c @@ -0,0 +1,165 @@ +/* host_sampler.c - always-on host CPU sampler for the emulation thread + * (debug-tools builds; TCP host_profile, tools/host_profile.py). + * + * A background thread samples the emulation thread's instruction pointer + * every millisecond into a ring, tagged with the guest frame and whether a + * render pass was running. The ring covers the last ~65 seconds, so a probe + * asks for a window that already happened instead of arming a profiler + * before a workload. Addresses are stored relative to the executable's load + * base; tools/host_profile.py maps them to functions with the image's own + * symbol table (nm), so no debug info is needed. + * + * Windows: SuspendThread / GetThreadContext / ResumeThread from a sampler + * thread. POSIX: the process CPU-time timer (SIGPROF) delivered to the + * emulation thread, which reads its own interrupted PC from the ucontext. + * Other hosts report the sampler as unsupported. */ +#ifndef PSX_NO_DEBUG_TOOLS + +#if defined(__linux__) && !defined(_GNU_SOURCE) +#define _GNU_SOURCE 1 /* REG_RIP, dladdr */ +#endif +#include "host_sampler.h" + +#include +#include + +extern int g_psx_render_pass_active; +extern uint64_t s_frame_count; + +static HostSample s_ring[HOST_SAMPLER_CAP]; +static volatile uint64_t s_seq; +static uintptr_t s_image_base; +static int s_supported; + +static void record(uintptr_t pc) { + const uint64_t n = s_seq; + HostSample *e = &s_ring[n % HOST_SAMPLER_CAP]; + e->rva = (pc >= s_image_base) ? (uint64_t)(pc - s_image_base) : 0; + e->frame = (uint32_t)s_frame_count; + e->in_pass = g_psx_render_pass_active ? 1u : 0u; + s_seq = n + 1; +} + +#if defined(_WIN32) +#include + +static HANDLE s_target; + +#ifndef CREATE_WAITABLE_TIMER_HIGH_RESOLUTION +#define CREATE_WAITABLE_TIMER_HIGH_RESOLUTION 0x00000002 +#endif + +static DWORD WINAPI sampler_main(LPVOID arg) { + (void)arg; + /* A 1 ms period needs a high-resolution timer; Sleep(1) would follow + * whatever timer resolution the process last asked for. */ + HANDLE timer = CreateWaitableTimerExW(NULL, NULL, + CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, + TIMER_ALL_ACCESS); + if (timer) { + LARGE_INTEGER due; + due.QuadPart = -10000; /* 1 ms, relative */ + if (!SetWaitableTimer(timer, &due, 1, NULL, NULL, FALSE)) { + CloseHandle(timer); + timer = NULL; + } + } + for (;;) { + if (timer) WaitForSingleObject(timer, INFINITE); + else Sleep(1); + if (SuspendThread(s_target) == (DWORD)-1) continue; + CONTEXT ctx; + memset(&ctx, 0, sizeof ctx); + ctx.ContextFlags = CONTEXT_CONTROL; + if (GetThreadContext(s_target, &ctx)) { +#if defined(_M_X64) || defined(__x86_64__) + record((uintptr_t)ctx.Rip); +#elif defined(_M_ARM64) || defined(__aarch64__) + record((uintptr_t)ctx.Pc); +#endif + } + ResumeThread(s_target); + } + return 0; +} + +void host_sampler_start(void) { + if (s_supported) return; + s_image_base = (uintptr_t)GetModuleHandleW(NULL); + if (!DuplicateHandle(GetCurrentProcess(), GetCurrentThread(), + GetCurrentProcess(), &s_target, + THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | + THREAD_QUERY_INFORMATION, + FALSE, 0)) + return; + HANDLE t = CreateThread(NULL, 0, sampler_main, NULL, 0, NULL); + if (!t) return; + SetThreadPriority(t, THREAD_PRIORITY_TIME_CRITICAL); + CloseHandle(t); + s_supported = 1; +} + +#elif defined(__linux__) || defined(__APPLE__) +#include +#include +#include +#include +#include + +static pthread_t s_thread; + +static void on_prof(int sig, siginfo_t *si, void *uc_) { + (void)sig; (void)si; + if (!pthread_equal(pthread_self(), s_thread)) return; + ucontext_t *uc = (ucontext_t *)uc_; +#if defined(__linux__) && defined(__x86_64__) + record((uintptr_t)uc->uc_mcontext.gregs[REG_RIP]); +#elif defined(__linux__) && defined(__aarch64__) + record((uintptr_t)uc->uc_mcontext.pc); +#elif defined(__APPLE__) && defined(__x86_64__) + record((uintptr_t)uc->uc_mcontext->__ss.__rip); +#elif defined(__APPLE__) && defined(__aarch64__) + record((uintptr_t)uc->uc_mcontext->__ss.__pc); +#else + (void)uc; +#endif +} + +void host_sampler_start(void) { + if (s_supported) return; + Dl_info info; + if (dladdr((void *)&host_sampler_start, &info) && info.dli_fbase) + s_image_base = (uintptr_t)info.dli_fbase; + s_thread = pthread_self(); + struct sigaction sa; + memset(&sa, 0, sizeof sa); + sa.sa_sigaction = on_prof; + sa.sa_flags = SA_SIGINFO | SA_RESTART; + sigemptyset(&sa.sa_mask); + if (sigaction(SIGPROF, &sa, NULL) != 0) return; + struct itimerval it; + it.it_interval.tv_sec = 0; + it.it_interval.tv_usec = 1000; + it.it_value = it.it_interval; + if (setitimer(ITIMER_PROF, &it, NULL) != 0) return; + s_supported = 1; +} + +#else +void host_sampler_start(void) {} +#endif + +int host_sampler_supported(void) { return s_supported; } + +uint64_t host_sampler_seq(void) { return s_seq; } + +uint64_t host_sampler_image_base(void) { return (uint64_t)s_image_base; } + +int host_sampler_get(uint64_t seq, HostSample *out) { + const uint64_t now = s_seq; + if (seq >= now || now - seq > HOST_SAMPLER_CAP) return 0; + *out = s_ring[seq % HOST_SAMPLER_CAP]; + return 1; +} + +#endif /* !PSX_NO_DEBUG_TOOLS */ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 271e8213..d9477181 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -85,6 +85,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "recomp_audio_drc.h" #include "memcard.h" #include "debug_server.h" +#include "host_sampler.h" #include "crash_trace.h" #include "freeze_heartbeat.h" #include "config_loader.h" @@ -16210,6 +16211,7 @@ session_reboot: std::atexit(game_options_save_now); #ifndef PSX_NO_DEBUG_TOOLS debug_server_init(debug_port); + host_sampler_start(); /* this is the emulation thread */ #else (void)debug_port; #endif