#509's exit criterion is a measurement, not code: "a per-subsystem
millisecond breakdown captured on the A10X, not inferred from a Mac". The
counters to produce it shipped in #510/PR #518; what was missing was any way
to actually run the capture without a long manual ritual per attempt.
Two drivers, one shared report parser, so the iOS/tvOS and Raspberry Pi
paths print the same table.
test/tools/device_perf.sh -- iOS/tvOS via RetroArch
---------------------------------------------------
doctor / build / install / cfg / capture / pull / report.
`install` injects the core into a RetroArch checkout's modules dir, runs
code-sign-cores.sh (tvOS will not dlopen an unsigned dylib -- it launches
fine and simply cannot load the core, which reads as "our core is broken"),
and deploys via xcodebuild to a named device.
`build` asserts build identity: the core stamps "vX.Y.Z <gitrev>" into the
binary and the script fails if it does not match scripts/build-id.sh. A
RetroArch install may already carry a nightly of this same dylib, and a
device round-trip that profiles someone else's binary is worse than no
measurement at all.
Two RetroArch traps, both silent, both found in runloop.c
---------------------------------------------------------
1. `perfcnt_enable` is mandatory and off means ZERO, not "off":
registration is never gated, only start/stop and perf_log are. So the
core sets vjPerfActive=1, every probe fires, and every counter stays at
zero -- indistinguishable from broken instrumentation. `cfg` writes the
setting; `report` exits 2 on an all-zero capture rather than printing a
table of noughts.
Corollary now documented in perf_iface.h: under RetroArch the fast path
is NOT the never-taken branch that header described. RetroArch accepts
env 28 unconditionally, so ~2,000 indirect calls per frame happen during
ordinary play. Negligible at slice granularity, but not zero, and only
zero on frontends that actually decline.
2. The log prints an AVERAGE, not a total:
#define PERF_LOG_FMT "[PERF] Avg (%s): %llu ticks, %llu runs."
Total = avg * runs. This inverts the exact comparison the counters exist
to make: on yarc.j64 the blitter's average is ~32x the GPU's while its
total is ~4x smaller. The parser multiplies back.
The report also decomposes the deliberate overlap (a 68K bus access runs
RISC inline; the OP runs the GPU inline from a halfline callback) instead of
printing a pie chart that does not sum to frame time.
test/tools/rpi_perf.sh -- Raspberry Pi over SSH
------------------------------------------------
build / doctor / deploy / bench / profile. Runs perf_iface_witness on the Pi
rather than RetroArch: it is a complete env-28 frontend in one file, so it
needs no frontend, no GUI, no config, and trap 1 cannot apply to it.
It REFUSES to report timings from emulated hardware. The obvious way to
"test the RPi build" without a Pi is an aarch64 container on the Mac; it
runs, and every number is meaningless. So `bench`/`profile` gate on
`doctor`, which requires a Raspberry Pi /proc/device-tree/model and a
non-virtualised systemd-detect-virt, and warns on a non-performance cpu
governor or non-zero get_throttled. The dangerous outcome here is not a
wrong number, it is a confident one from a machine that was never a Pi.
perf_iface_witness: optional frame count
-----------------------------------------
argv[3], defaulting to the existing 120. 120 frames is enough to ASSERT the
plumbing (its job in `make test`) but far too short to RANK subsystems on
real content, which is what rpi_perf.sh needs it for. A non-numeric value is
rejected rather than clamped -- a typo'd count silently running 120 frames
would be reported as a full-length profiling run.
Verified
--------
- report parser reproduces the witness totals exactly from a synthetic
RetroArch-format log (431,643,615 vs the witness's 431,703,000; the delta
is RetroArch's own integer-division of the average)
- empty/absent counters exit 2 with the two likely causes named
- frames arg scales exactly: 120 -> 201,045 GPU calls, 600 -> 1,005,488
- bad frames arg rejected, exit 1
- doctor lists the A10X: filters OUT simulators rather than requiring
"physical", because an unavailable device prints a blank Reality column
and would otherwise hide the exact box we are waiting on
- `make test` exit 0, `make lint` clean, bash -n on all scripts
Not yet done: the A10X ("Bedroom", AppleTV6,2) is currently unavailable, so
#509's exit criterion remains formally unmet. This is the tooling that makes
that run a single command once the device is up.
yarc.j64 was the default and is a poor one. Profiled with ir_ab.sh
--profile (#534): 73.3% of its GPU time is `jr`, a spin loop, and its
DSP share is ~0.08% -- so a DSP or blitter change measures as "no
effect" on it. It is an amplifier for GPU-loop work, not a benchmark:
the #532 hoist scored -4.33% on yarc against -3.77% on jagniccc for the
same change, and that gap is the bias.
jagniccc.j64 (the NICCC 2000 port -- a GPU program rasterising 75,340
precalculated polygons while the 68K sits in `stop #$2000`) is the only
public ROM exercising GPU, DSP, blitter and 68K in real proportion:
GPU 40.2% / DSP 23.7% / blitter 10.3% at 600 frames.
The warmup moves with it, and that part is load-bearing rather than a
rounding knob: jagniccc executes ZERO GPU-interpreter instructions at 90
frames because it is still in BIOS boot. Keeping the old 60-frame warmup
would have put most of the measured window in boot code and quietly
reported the wrong thing -- the same blind spot this issue is about, one
level up. 300 clears boot; frames raised to 900 for a real window.
yarc stays available (BENCH_ROM=) and is documented as an amplifier.
Numbers recorded before this change (e.g. #515's -O2/-O3 figures) were
taken on yarc @ 600/60 and are NOT comparable to the new default.
Verified: `make benchmark` runs clean on the new defaults (157.97 fps,
6.330 ms/frame, 900 frames after 300 warmup).
Co-Authored-By: Claude Opus 5 <[email protected]>
Four findings, all in the instrument rather than the gpu.c hoist:
1. The job went red on exactly the PRs its own paths filter selects.
ir_ab.sh exits 2 when both arms build a byte-identical library, which
is the EXPECTED result for a PR touching only ir_ab.sh or ir-ab.yml --
both of which are in the filter. Under `set -e` that failed the step,
contradicting the header's "advisory, not a veto". Exit 2 is now a
notice and a pass; every other code still propagates.
2. The macOS container path leaked its work dir permanently. The host set
an EXIT trap and then `exec`d the container, destroying it; inside the
container IR_AB_WORK is set, so the trap-setup branch is skipped there
too. Nothing ever cleaned up, despite IR_KEEP defaulting to 0. Run the
container as a child and propagate its exit code so the trap fires.
3. The job-summary failure fallback was dead code. `sed -n` exits 0 when
the pattern never matches, so `sed ... || cat ...` could never reach
the fallback -- on an aborted run it printed an empty block, which is
when the log tail is most wanted. Test for the pattern explicitly.
4. Docs arithmetic: 159,337,672 is not divisible by 3, so "exactly 3
instructions per GPU opcode" invited a check it fails. Reworded as a
calibration figure and the non-integer noted.
No change to src/tom/gpu.c -- Kimi found the hoist itself clean.
Co-Authored-By: Claude Opus 5 <[email protected]>
Adds the "Load-proof A/B" section: why counting work beats timing it on a
machine that is never quiet, the two validations the instrument had to pass
before any number from it counts (determinism -- Ir identical, not close; and
sensitivity -- a deliberate one-volatile-increment-per-GPU-opcode control
scoring +1.815% with the right sign), and the honest limits (Ir is not wall
time; absolute Ir is arch-specific; the denominator includes the harness).
Also records the #533 finding as a table, because "pick a benchmark ROM" turns
out to be a frame-count question as much as a ROM question. jagniccc.j64 IS
the NICCC-2000 port and IS GPU-rendered -- but only after its boot window: at
90 frames it executes ZERO GPU-interpreter instructions and 38% of its work is
the 68K traceback hook, while at 300 frames it settles at GPU 38.6% / DSP
22.3% / blitter 7.1% / 68K 5.9%. A gate on the 90-frame window would have
reported 0.0% forever and looked healthy.
Sets that 300-frame workload as the default for ir_ab.sh and pins it
explicitly in the workflow (rather than inheriting the script default) so the
CI workload is legible where the paths filter is.
Co-Authored-By: Claude Opus 5 <[email protected]>
That comment was added on a hypothesis, not a measurement, and the
measurement refutes it. The premise was right -- the fast engine really
is scalar (0 NEON instructions in blitter_generic and blitter_blit)
while BlitterMidsummer2 carries 250 -- but the conclusion did not
follow.
On arm64, interleaved A/B/B/A with a Mann-Whitney test: fast is never
slower, and in a blit-heavy Tempest window (81,873 inner iterations per
frame) accurate costs about 2.2x the time, with the two distributions
completely separated across 40 reps. Fast partly wins by doing coarser
work -- which is the documented semantics of the option, not a confound.
Leaving a doc line that tells the next reader to distrust a correct core
option is worse than having no note at all.
Co-Authored-By: Claude Opus 5 <[email protected]>
Phase 1 of #509, which it blocks: we could not profile the core on the
device that is actually slow. Everything in docs/profiling.md is
host-side (Instruments, perf, flame graphs) and none of it reaches a
locked-down tvOS box; src/core/perf_counters.h counts EVENTS, not time,
and only under the non-shipping BENCH_PROFILE build. RetroArch already
solves this and the core simply never asked.
Eight timing counters, registered in retro_init and rendered by the
frontend's own performance-counter UI. Totals are also dumped to the log
at retro_deinit, which is what can actually be retrieved from a device
you cannot attach a profiler to.
## The counters overlap, deliberately
The Jaguar's processors do not run in disjoint phases, so these do not
sum to frame time and are not a pie chart. Two real nestings: a 68K
access into GPU/DSP local RAM runs RISC cycles inline via GPUSyncToM68K()
/ DSPSyncToM68K(), and the Object Processor runs the GPU inline from a
halfline callback.
vj_gpu_sync / vj_dsp_sync exist so the first is MEASURABLE rather than
merely disclosed. Without them vj_m68k_slice is uninterpretable on a
GPU-heavy title -- exactly the workload this is meant to diagnose --
because it silently contains the RISC time. The decomposition is spelled
out in perf_iface.h and docs/profiling.md.
## Re-entrancy is required, not defensive
GPUExec() is genuinely re-entrant: op.c runs the GPU inline from a
halfline callback, and the 68K bus dispatch runs it from GPUSyncToM68K().
perf_start() overwrites the counter's start tick, so an unguarded
re-entry would make the OUTER span measure only the inner one and inflate
call_cnt. Per-slot depth counters time only the outermost span.
## Probe placement
VJP_ENTER goes after a function's top-of-function guards, VJP_LEAVE at
the single exit, and every instrumented function was checked to have no
return between the two. That is correctness -- an early return leaks the
depth and the slot then measures nothing for the rest of the session --
and also accuracy, since the guards are the do-nothing cases (a halted
GPU, an odd halfline with no OP pass). M68KExecuteWithStalls returns from
inside a branch, so its probe sits at the two call sites instead.
## Cost
One never-taken branch per probe site when the frontend declines, the
same short-circuit shape crash_detect uses. A branch-free off state would
need a compile-time switch, which would put the instrumentation outside
shipping builds and defeat the point; every probe brackets a slice, never
an interpreter inner loop. No new BUILD_AXES entry, because no
compile-affecting switch was added.
## Gate
test/tools/perf_iface_witness, wired into `make test`. It supplies
perf_register/perf_start/perf_stop itself -- it IS the frontend -- so it
holds the very counters the core registered and reads them directly
rather than trusting the core's account of itself. Nine checks, two of
which matter most: probe balance (VJPerfLeakedSlots()==0, catching an
early return added to a probe region later), and that the framebuffer
hash is BYTE-IDENTICAL with counters on and off. Instrumentation that
perturbs what it measures is worse than none.
Standalone dlopen loader rather than a harness change, for the same
reason netlink_rebuild_witness.c is one: the shared harness cannot answer
env 28, so under every other test this feature short-circuits at
`if (vjPerfActive)` and a broken registration would leave the suite green.
Verified: make TEST_EXPORTS=1 test exits 0; both audio gates run
explicitly because dac.c is touched (Skyhammer RMS 3083.3 vs 3079.8
documented; Iron Soldier 1 RMS 1174.2 vs ~1175 documented); c89-lint
clean on all eight touched files; export lists mirror; shipped ABI
unchanged at 46 symbols.
Co-Authored-By: Claude Opus 5 <[email protected]>
Confirmed each edit against actual shipped state rather than trusting
prior status headers:
- true-color-shadowfb-design.md said "awaiting spec review"; the feature
(src/tom/shadowfb.c/.h, virtualjaguar_true_color option, titledb default,
savestate coverage) has been shipping since v3.2.0 (PR #341). Two
releases stale.
- netlink-ux-design.md still named the pre-merge branch and pre-release
issue framing; updated to reflect v3.4.0 shipped status and that #494
stays open deliberately (automated netpacket coverage, not correctness
doubt -- correctness was confirmed by maintainer play-testing on iOS).
- netlink-design.md's 2026-07-31 "Known limitations" predates the UX
overhaul and the video-corruption fix (ea42736); added a dated addendum
rather than editing the historical snapshot in place, and pointed
forward to the two docs that are now authoritative for current behaviour.
- netlink-ux-plan.md was a 1057-line SDD implementation plan, all 6 tasks
unchecked, referenced by nothing else in the tree. Every task maps to
shipped code (jlink_discover.c, the auto-mode resolver, OSD narration,
the two docs above). Deleted rather than left as a permanent unstarted-
looking artifact for a feature that's done -- same call as the
spike-jaguar-cd-support.md removal earlier this release.
Also: CLAUDE.md's src/jerry/ pointer only named netlink-design.md and
omitted jlink_discover.c entirely; added the UX/user-guide docs and a
Key-harnesses entry for the discovery test tools, including the per-binary
macOS Local Network permission trap that already cost one CI debugging
session (test/tools/netlink_discover_probe.c's whole reason for living
inside the test binary instead of a script). And linked docs/profiling.md
forward to the new on-device perf work (#509/#510) so it stops reading as
the complete profiling story, plus a caveat on the BENCH_BLITTER=accurate
example line since #511 found the "fast" blitter is scalar-only against
accurate's SIMD -- the opposite of what the benchmark comment implies.
Ruled out as still-accurate after checking against source, not just
re-reading the doc: blit-memo.md (titledb.c:93 confirms no title tagged
yet), docs/design/2026-08-11-acid-output-assertions.md (test/acid/run.c's
audio callback is still the no-op stub the design describes as the
problem). Wiki (Home.md, Game-Status.md) already points at canonical
in-repo sources that still exist and are current -- no change needed.
Co-Authored-By: Claude Opus 5 <[email protected]>
Generalizes the ad-hoc BLITTER_PROFILE pattern into a reusable,
zero-overhead-when-off counter system any subsystem can use.
* src/core/perf_counters.{h,c} - PERF_COUNTER / PERF_INC / PERF_ADD
macros backed by a constructor-registered linked list. When
BENCH_PROFILE is undefined every macro expands to (0) so there is
no runtime, code-size, or symbol cost in shipped builds.
* src/tom/blitter.c - migrate the existing BLITTER_PROFILE counters
in BlitterMidsummer2 onto the new system. Counters are embedded in
existing initializers via the comma operator so the file stays
C89-clean (no statements before declarations).
* Makefile - `make BENCH_PROFILE=1` defines the macro globally.
`make benchmark BENCH_PROFILE=1` re-invokes with TEST_EXPORTS=1 so
test_benchmark can dlsym `perf_counters_dump` and print all
registered counters next to the FPS report.
* test/tools/test_benchmark.c - dlsym the optional dump symbol; if
present (BENCH_PROFILE build), call it before the BENCHMARK RESULTS
block. No effect on default builds.
* exports-test.list / link-test.T - add perf_counters_{dump,reset,
register} so harnesses can reach them under TEST_EXPORTS=1.
* scripts/profile-mac.sh - one-line wrapper around `xctrace record`.
Defaults to Time Profiler; --template "CPU Counters" for PMU
events on Apple Silicon. Builds the core + harness, runs the
benchmark under instrumentation, writes a .trace bundle, and can
auto-open it with --open.
* docs/profiling.md - new sections covering BENCH_PROFILE counters
(when to use vs sampling profilers) and the profile-mac.sh wrapper.
Validated end-to-end with `make benchmark BENCH_PROFILE=1
BENCH_BLITTER=accurate`: counters populate
(blitter_inner=283994 over 120 frames of yarc), and default builds
remain unchanged.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
The first sanitizer run (now correctly linking with -shared-libasan)
caught real UB in src/m68000/readcpu.c:1004 -- "shift exponent -1 is
negative". The UAE 68K core (machine-generated, ~1.8 MB cpuemu.c)
uses negative shifts intentionally; fixing requires patching the
generator upstream and is out of scope. We treat src/m68000/ as
opaque per CLAUDE.md.
Add .ubsan-ignorelist with src:src/m68000/* and pass it via
-fsanitize-ignorelist so the sanitizer job isolates new bugs in OUR
code without burying the signal under upstream noise.
Also fix docs/profiling.md: SIMD is currently wired into the
ACCURATE blitter's pixel kernel (BlitterMidsummer2's DATA() macro),
not the fast blitter as the doc claimed. Caught by the blitter
spike research agent. Cross-references issue #124 for the SIMD
widening plan.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
The harness at test/tools/test_benchmark.c already existed but had no
build wiring -- it just sat there waiting. Now \`make benchmark\` builds
the harness and runs it against a default ROM (test/roms/yarc.j64), with
overrides for ROM, frame count, warmup, and blitter mode:
make benchmark # 600 frames, fast blitter
make benchmark BENCH_FRAMES=3000 # longer = smoother numbers
make benchmark BENCH_BLITTER=accurate # A/B against slow path
make benchmark BENCH_ROM=test/roms/private/X.j64
Local sanity: ./test/tools/test_benchmark on yarc.j64 reports
~240 FPS / 4.2ms per frame on Apple Silicon, fast blitter. Use as a
same-host commit-to-commit delta during the upcoming perf work.
docs/profiling.md is the new reference: macOS Instruments invocation,
Linux perf + flame graph, the SIMD A/B knob, known hot paths
(OPProcessFixedBitmap, BlitterMidsummer2, GPUExec, DSPExec), build
flavors for profiling vs sanitizing vs coverage, and a
regression-triage checklist.
CONTRIBUTING.md and CLAUDE.md cross-reference the new doc.
The harness binary is built inline in the benchmark recipe (not via a
separate rule under the TEST_EXPORTS=1 block), so \`make benchmark\`
works without TEST_EXPORTS=1 -- the harness only uses the production
retro_* exports.
Co-Authored-By: Claude Opus 4.7 <[email protected]>