Host sampler: accessors in every build; tools/host_profile.py names hot functions

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit a51504fd3b)
This commit is contained in:
Matthew Stanley
2026-10-02 16:48:03 -07:00
parent 9af4abb94f
commit e28f7f18ee
2 changed files with 134 additions and 6 deletions
+9 -6
View File
@@ -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 <windows.h>
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 */
+125
View File
@@ -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()