diff --git a/runtime/src/host_sampler.c b/runtime/src/host_sampler.c index 8ce95485..e91919b2 100644 --- a/runtime/src/host_sampler.c +++ b/runtime/src/host_sampler.c @@ -12,9 +12,8 @@ * 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 - + * Other hosts, and builds without debug tools, report the sampler as + * unsupported. */ #if defined(__linux__) && !defined(_GNU_SOURCE) #define _GNU_SOURCE 1 /* REG_RIP, dladdr */ #endif @@ -31,6 +30,7 @@ static volatile uint64_t s_seq; static uintptr_t s_image_base; static int s_supported; +#ifndef PSX_NO_DEBUG_TOOLS static void record(uintptr_t pc) { const uint64_t n = s_seq; HostSample *e = &s_ring[n % HOST_SAMPLER_CAP]; @@ -39,8 +39,13 @@ static void record(uintptr_t pc) { e->in_pass = g_psx_render_pass_active ? 1u : 0u; s_seq = n + 1; } +#endif -#if defined(_WIN32) +#if defined(PSX_NO_DEBUG_TOOLS) +/* Production builds carry no sampler: it reports unsupported. */ +void host_sampler_start(void) {} + +#elif defined(_WIN32) #include static HANDLE s_target; @@ -161,5 +166,3 @@ int host_sampler_get(uint64_t seq, HostSample *out) { *out = s_ring[seq % HOST_SAMPLER_CAP]; return 1; } - -#endif /* !PSX_NO_DEBUG_TOOLS */ diff --git a/tools/host_profile.py b/tools/host_profile.py new file mode 100644 index 00000000..b9c1475a --- /dev/null +++ b/tools/host_profile.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Name the hot host functions of a running psxrecomp game. + +Reads the always-on host CPU sampler (TCP host_profile, runtime/src/ +host_sampler.c) over a window of the ring and maps each executable-relative +address to the function containing it, using the image's own symbol table +(nm), so a Release build without debug info profiles too. + + host_profile.py --port 4531 --exe game.exe --frames 300 [--pass 1] [--top 40] + +--frames N: the last N guest frames. --pass 1 / 0: only samples inside / +outside render passes. Prints per-function sample counts and shares. +""" +import argparse +import bisect +import json +import os +import shutil +import socket +import subprocess +import sys + + +def query(port, **kw): + with socket.create_connection(("127.0.0.1", port), 5) as s: + s.settimeout(30) + s.sendall((json.dumps(dict(id=1, cmd="host_profile", **kw)) + "\n").encode()) + with s.makefile("rb") as f: + return json.loads(f.readline()) + + +def find_nm(explicit): + if explicit: + return explicit + for name in ("llvm-nm", "nm"): + p = shutil.which(name) + if p: + return p + sys.exit("no nm on PATH; pass --nm") + + +def preferred_base(exe, out): + """The address nm's symbol values are relative to: a PE image's + ImageBase (optional header), a Mach-O executable's __mh_execute_header, + 0 for a position-independent ELF.""" + with open(exe, "rb") as f: + head = f.read(4096) + if head[:2] == b"MZ": + pe = int.from_bytes(head[0x3C:0x40], "little") + magic = int.from_bytes(head[pe + 24:pe + 26], "little") + if magic == 0x20B: # PE32+ + return int.from_bytes(head[pe + 24 + 24:pe + 24 + 32], "little") + return int.from_bytes(head[pe + 24 + 28:pe + 24 + 32], "little") + for line in out.splitlines(): + parts = line.split(None, 2) + if len(parts) == 3 and parts[2] in ("__mh_execute_header", "_mh_execute_header"): + return int(parts[0], 16) + return 0 + + +def image_size(exe): + """SizeOfImage of a PE executable, or None.""" + with open(exe, "rb") as f: + head = f.read(4096) + if head[:2] != b"MZ": + return None + pe = int.from_bytes(head[0x3C:0x40], "little") + return int.from_bytes(head[pe + 24 + 56:pe + 24 + 60], "little") + + +def symbols(nm, exe): + """(rva, name) sorted, from the image's text symbols.""" + out = subprocess.run([nm, "--defined-only", exe], capture_output=True, + text=True, check=True).stdout + base = preferred_base(exe, out) + table = [] + for line in out.splitlines(): + parts = line.split(None, 2) + if len(parts) == 3 and parts[1] in ("T", "t"): + table.append((int(parts[0], 16) - base, parts[2])) + table.sort() + return table + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--port", type=int, required=True) + ap.add_argument("--exe", required=True) + ap.add_argument("--frames", type=int, default=0) + ap.add_argument("--since", type=int, default=-1) + ap.add_argument("--pass", dest="in_pass", type=int, default=-1) + ap.add_argument("--top", type=int, default=40) + ap.add_argument("--nm") + a = ap.parse_args() + kw = {"top": 400} + if a.frames > 0: + kw["frames"] = a.frames + if a.since >= 0: + kw["since"] = a.since + if a.in_pass >= 0: + kw["pass"] = a.in_pass + r = query(a.port, **kw) + if not r.get("ok") or not r.get("supported"): + sys.exit("sampler unavailable: %s" % r) + table = symbols(find_nm(a.nm), a.exe) + keys = [t[0] for t in table] + size = image_size(a.exe) + per = {} + for rva_s, n in r["top"]: + rva = int(rva_s, 16) + i = bisect.bisect_right(keys, rva) - 1 + if (size is not None and rva >= size) or i < 0: + name = "(outside the executable: system code, waits)" + else: + name = table[i][1] + per[name] = per.get(name, 0) + n + total = r["samples"] or 1 + print("samples %d (binned top 400 addresses; %d not binned) seq %d" % + (r["samples"], r["unbinned"], r["seq"])) + for name, n in sorted(per.items(), key=lambda kv: -kv[1])[:a.top]: + print("%6d %5.1f%% %s" % (n, 100.0 * n / total, name)) + + +if __name__ == "__main__": + main()