291 lines
11 KiB
Bash
Executable file
291 lines
11 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# profile-x68000.sh - PC-sampling profiler for a running X68000 example.
|
|
#
|
|
# Human68k relocates a .X anywhere it likes and the ELF beside it is linked at
|
|
# zero, so the first job is finding where the program actually landed. Every
|
|
# xdev68k binary opens with the same "#NEWLIB" marker two bytes into .text, so
|
|
# the script scans RAM for that -- but the marker appears TWICE, because the
|
|
# loader leaves a copy of the file in a disk buffer, and picking the wrong one
|
|
# silently attributes every sample to whatever symbol happens to sit at that
|
|
# offset. So it collects every candidate and then lets the program counter
|
|
# choose -- except the two ranges OVERLAP, so containment is not enough. What
|
|
# separates them is that Human68k RELOCATES the copy it runs: at a known
|
|
# relocation site the running image holds fileValue + base, while the disk
|
|
# buffer still holds fileValue. From then on
|
|
# it samples the PC once per emulated frame, buckets by offset into .text, and
|
|
# attributes the buckets to functions with nm.
|
|
#
|
|
# Sampling once per frame is coarse (55 Hz), but this port runs a game frame in
|
|
# 150 ms or more, so each frame still gets several samples and the shape of the
|
|
# profile is clear. Read it as "which functions dominate", never as a timing.
|
|
#
|
|
# Usage: scripts/profile-x68000.sh [example] [seconds] [skipSeconds]
|
|
# example .X base name in build/x68000/bin, default staxi
|
|
# seconds emulated seconds to run, default 240
|
|
# skipSeconds ignore samples before this, default 130 (boot + menu)
|
|
# Env: X68K_DIR dir holding HUMAN302.XDF + roms/ (default: the installer's toolchains/firmware/x68000)
|
|
# Requires: the example built WITH symbols (build/x68000/bin/<NAME>.X.elf).
|
|
set -uo pipefail
|
|
|
|
prog=${1:-staxi}
|
|
seconds=${2:-240}
|
|
skip=${3:-130}
|
|
PROG=$(echo "$prog" | tr '[:lower:]' '[:upper:]')
|
|
|
|
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
source "$repo/scripts/lib/joeyEnv.sh"
|
|
joeyNeed MAME mame
|
|
joeyNeed X68K_DIR x68kFirmware
|
|
joeyNeedFile "$X68K_DIR/roms/x68000/cgrom.dat" x68kCgrom
|
|
source "$repo/scripts/lib/x68kDisk.sh"
|
|
bin=$(joeyExampleProgram "$prog" x68000) || exit 2
|
|
elf=$bin.elf
|
|
joeyNeed X68K_ROOT toolchain:x68000
|
|
export PATH="$X68K_ROOT/bin:$PATH"
|
|
|
|
[[ -f $bin ]] || { echo "profile-x68000: $bin not built" >&2; exit 2; }
|
|
[[ -f $elf ]] || { echo "profile-x68000: $elf missing (need symbols)" >&2; exit 2; }
|
|
|
|
# .text size from the ELF, so a candidate base can be tested by containment.
|
|
textSize=$(m68k-xelf-readelf -S "$elf" | awk '/\.text/ { print strtonum("0x" $7); exit }')
|
|
[[ -n ${textSize:-} && $textSize -gt 0 ]] || { echo "profile-x68000: cannot read .text size" >&2; exit 2; }
|
|
|
|
# A 32-bit relocation site in .text, and the value the .X file carries there.
|
|
# The .X header is 64 bytes, so the file offset is 64 + the .text offset.
|
|
relocSite=$(m68k-xelf-readelf -r "$elf" | awk '$2 ~ /01$/ && $3 == "R_68K_32" { print strtonum("0x" $1); exit }')
|
|
[[ -n ${relocSite:-} ]] || { echo "profile-x68000: no R_68K_32 relocation to fingerprint with" >&2; exit 2; }
|
|
relocValue=$(python3 -c "
|
|
import sys
|
|
d = open('$bin','rb').read()
|
|
o = 64 + $relocSite
|
|
print(int.from_bytes(d[o:o+4],'big'))
|
|
")
|
|
|
|
# gMouseX is written once per input poll, i.e. once per game frame.
|
|
mouseXOff=$(m68k-xelf-nm "$elf" | awk '$3 == "gMouseX" { print strtonum("0x" $1); exit }')
|
|
[[ -n ${mouseXOff:-} ]] || { echo "profile-x68000: gMouseX not found; cannot count frames" >&2; exit 2; }
|
|
|
|
work=$(mktemp -d -t joey-x68prof.XXXXXX)
|
|
trap 'rm -rf "$work"' EXIT
|
|
|
|
x68kBootDisk "$work/boot.xdf" 'B:' "B:\\$PROG.X" || exit 1
|
|
x68kDataDisk "$work/data.xdf" || exit 1
|
|
x68kXdf add "$work/data.xdf" "$bin" "$PROG.X" >/dev/null || exit 1
|
|
# The program's DATA tree sits next to it: its own folder's for Space Taxi,
|
|
# the shared bin/DATA for the rest.
|
|
if [[ -d $(dirname "$bin")/DATA ]]; then
|
|
x68kAddTree "$work/data.xdf" "$(dirname "$bin")/DATA" DATA || exit 1
|
|
fi
|
|
|
|
cat > "$work/prof.lua" <<LUA
|
|
local cpu = manager.machine.devices[":maincpu"]
|
|
local mem = cpu.spaces["program"]
|
|
local out = io.open("prof.txt", "w")
|
|
-- MAME's 68000 core does NOT expose a register called "PC": it has CURPC
|
|
-- (where the CPU is now) and rPC. Asking for the wrong one throws inside the
|
|
-- frame callback, once per frame, forever -- the run just gets slower and
|
|
-- writes no samples. Resolve it once, loudly.
|
|
local pcReg = nil
|
|
for _, name in ipairs({ "CURPC", "rPC", "PC" }) do
|
|
if cpu.state[name] ~= nil then
|
|
pcReg = name
|
|
break
|
|
end
|
|
end
|
|
if pcReg == nil then
|
|
out:write("no program-counter register on this CPU\n")
|
|
out:close()
|
|
manager.machine:exit()
|
|
end
|
|
|
|
local base = nil
|
|
local cands = {}
|
|
local textSize = $textSize
|
|
local mouseXOff = $mouseXOff
|
|
local frames = 0
|
|
local fpsStart = 0
|
|
local relocSite = $relocSite
|
|
local relocValue = $relocValue
|
|
local scanAt = 0x1000
|
|
local buckets = {}
|
|
local total = 0
|
|
local skipUntil = $skip
|
|
local stopAt = $seconds
|
|
|
|
-- "#NEWLIB" sits two bytes into every xdev68k .text. 0x234E4557 is the long at
|
|
-- offset 2, and 0x4C494200 follows it.
|
|
-- A slice per frame so MAME keeps running, and it WRAPS: Human68k does not
|
|
-- load the program until the boot finishes, which is a couple of minutes in,
|
|
-- so a single pass over RAM finds nothing and must start again.
|
|
local scanDone = false
|
|
local function scanSlice()
|
|
local limit = scanAt + 0x40000
|
|
while scanAt < limit do
|
|
if scanAt >= 0x400000 then
|
|
scanDone = true
|
|
return
|
|
end
|
|
if mem:read_u32(scanAt) == 0x234E4557 and mem:read_u32(scanAt + 4) == 0x4C494200 then
|
|
cands[#cands + 1] = scanAt - 2
|
|
end
|
|
scanAt = scanAt + 2
|
|
end
|
|
end
|
|
|
|
-- Which candidate is the copy Human68k is RUNNING? At relocSite the .X file
|
|
-- holds relocValue; the loaded image holds relocValue + its own base, because
|
|
-- the loader patched it. The untouched disk buffer still matches the file.
|
|
-- The PC must also be inside it, which rules out a stale image of an older
|
|
-- build sitting in memory.
|
|
local function pickBase()
|
|
local pc = cpu.state[pcReg].value
|
|
for _, c in ipairs(cands) do
|
|
if pc >= c and pc < c + textSize then
|
|
local got = mem:read_u32(c + relocSite)
|
|
if got == ((relocValue + c) & 0xFFFFFFFF) then
|
|
return c
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
emu.register_frame_done(function()
|
|
local t = manager.machine.time:as_double()
|
|
if base == nil then
|
|
if not scanDone then
|
|
scanSlice()
|
|
return
|
|
end
|
|
base = pickBase()
|
|
if base ~= nil then
|
|
out:write(string.format("base %08X at %.1fs (of %d candidates)\n", base, t, #cands))
|
|
out:flush()
|
|
-- gMouseX is written once per jlpInputPoll, which the game calls
|
|
-- once a frame, so counting writes to it counts game frames. That
|
|
-- turns "how much of the frame is X" into "and how long is a
|
|
-- frame", which is the difference between a real speedup and one
|
|
-- that only buys more idle time.
|
|
fpsTap = mem:install_write_tap(base + mouseXOff, base + mouseXOff + 1, "fps", function(offset, data, mask)
|
|
frames = frames + 1
|
|
return data
|
|
end)
|
|
fpsStart = t
|
|
return
|
|
end
|
|
-- Nothing the PC is inside yet: the program is not loaded, or it is
|
|
-- and the sweep predates it. Sweep again. The boot takes a couple of
|
|
-- minutes, so the first few passes finding nothing is the normal case.
|
|
cands = {}
|
|
scanAt = 0x1000
|
|
scanDone = false
|
|
return
|
|
end
|
|
if t < skipUntil then return end
|
|
local pc = cpu.state[pcReg].value
|
|
local key
|
|
if pc >= base and pc < base + textSize then
|
|
-- The EXACT offset, not a 64-byte bucket. Buckets straddle function
|
|
-- boundaries, and a bucket credited to the symbol at its start hands
|
|
-- one function's samples to its neighbour: that is how a vblank wait
|
|
-- came out looking like an expensive input poll.
|
|
key = pc - base
|
|
else
|
|
key = -1 -- ROM, IOCS, anything not ours
|
|
end
|
|
buckets[key] = (buckets[key] or 0) + 1
|
|
total = total + 1
|
|
if t >= stopAt then
|
|
out:write(string.format("fps %.2f frames %d window %.1f tapAt %08X\n",
|
|
(t > fpsStart) and (frames / (t - fpsStart)) or 0.0, frames, t - fpsStart, base + mouseXOff))
|
|
out:write(string.format("total %d\n", total))
|
|
for k, v in pairs(buckets) do
|
|
out:write(string.format("%d %d\n", k, v))
|
|
end
|
|
out:write("DONE\n")
|
|
out:close()
|
|
manager.machine:exit()
|
|
end
|
|
end)
|
|
LUA
|
|
|
|
( cd "$work" && SDL_AUDIODRIVER=dummy timeout 3600 "$MAME" x68000 \
|
|
-bios ipl10 -rompath "$X68K_DIR/roms" \
|
|
-flop1 boot.xdf -flop2 data.xdf \
|
|
-video none -sound none -nothrottle -skip_gameinfo \
|
|
-autoboot_script prof.lua </dev/null >mame.log 2>&1 ) || true
|
|
|
|
[[ -s $work/prof.txt ]] || { echo "profile-x68000: FAIL (no samples)" >&2; exit 1; }
|
|
grep -q '^base ' "$work/prof.txt" || { echo "profile-x68000: FAIL (never found the load base)" >&2; exit 1; }
|
|
|
|
m68k-xelf-nm -n "$elf" > "$work/syms.txt"
|
|
python3 - "$work/prof.txt" "$work/syms.txt" <<'PY'
|
|
import sys
|
|
prof, symfile = sys.argv[1], sys.argv[2]
|
|
syms = []
|
|
for line in open(symfile):
|
|
p = line.split()
|
|
if len(p) == 3 and p[1] in ('t', 'T'):
|
|
syms.append((int(p[0], 16), p[2]))
|
|
syms.sort()
|
|
|
|
def owner(off):
|
|
lo, hi = 0, len(syms) - 1
|
|
best = None
|
|
while lo <= hi:
|
|
mid = (lo + hi) // 2
|
|
if syms[mid][0] <= off:
|
|
best = syms[mid][1]
|
|
lo = mid + 1
|
|
else:
|
|
hi = mid - 1
|
|
return best or "?"
|
|
|
|
total = 0
|
|
fps = None
|
|
hits = {}
|
|
buckets = {}
|
|
outside = 0
|
|
for line in open(prof):
|
|
p = line.split()
|
|
if p and p[0] == 'total':
|
|
total = int(p[1])
|
|
elif len(p) == 2 and p[0].lstrip('-').isdigit():
|
|
off, n = int(p[0]), int(p[1])
|
|
buckets[off] = buckets.get(off, 0) + n
|
|
if off < 0:
|
|
outside += n
|
|
else:
|
|
hits[owner(off)] = hits.get(owner(off), 0) + n
|
|
if total == 0:
|
|
sys.exit("profile-x68000: no samples recorded")
|
|
rows = sorted(hits.items(), key=lambda kv: -kv[1])
|
|
fpsText = f", game running at {fps:.1f} fps" if fps is not None and fps > 0.0 else " (frame count unavailable)"
|
|
print(f"profile-x68000: {total} samples, {outside} ({outside*100.0/total:.1f}%) outside the program (ROM/IOCS){fpsText}")
|
|
shown = 0
|
|
for name, n in rows[:25]:
|
|
print(f" {n*100.0/total:5.1f}% {n:6d} {name}")
|
|
shown += n
|
|
print(f" {(total-outside-shown)*100.0/total:5.1f}% {total-outside-shown:6d} (everything else)")
|
|
|
|
# The hottest 64-byte buckets, named as symbol+offset. Function totals say
|
|
# which routine to look at; these say which LINE of it, which is the
|
|
# difference between rewriting a loop and rewriting the wrong loop.
|
|
print("\nhottest addresses (symbol + offset into it):")
|
|
def ownerOff(off):
|
|
lo, hi = 0, len(syms) - 1
|
|
best = None
|
|
while lo <= hi:
|
|
mid = (lo + hi) // 2
|
|
if syms[mid][0] <= off:
|
|
best = syms[mid]
|
|
lo = mid + 1
|
|
else:
|
|
hi = mid - 1
|
|
return (best[1], off - best[0]) if best else ("?", 0)
|
|
for off, n in sorted(buckets.items(), key=lambda kv: -kv[1])[:20]:
|
|
if off < 0:
|
|
continue
|
|
name, delta = ownerOff(off)
|
|
print(f" {n*100.0/total:5.1f}% {n:6d} {name}+0x{delta:x}")
|
|
PY
|