12 Commits
Author SHA1 Message Date
Joseph Mattiello cb3e40c6fc test(perf): automate the on-device capture #509 is blocked on
#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.
2026-08-22 01:13:47 -04:00
Joe Mattiello ce7e6b3681 Merge pull request #541 from libretro/perf/533-benchmark-rom
perf(bench): default `make benchmark` to jagniccc, not yarc (#533)
2026-08-20 18:21:43 -04:00
Joseph MattielloandClaude Opus 5 b5aaf17edc perf(bench): default make benchmark to jagniccc, not yarc (#533)
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]>
2026-08-20 18:06:56 -04:00
Joseph MattielloandClaude Opus 5 55c85f1fc1 fix(ci): address Kimi review on the Ir A/B harness (#534)
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]>
2026-08-20 17:53:52 -04:00
Joseph MattielloandClaude Opus 5 0e185fbaad docs(profiling): document the cachegrind Ir A/B harness (#534, #533)
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]>
2026-08-20 17:22:47 -04:00
Joseph Mattiello e431639d22 Merge remote-tracking branch 'libretro/develop' into feat/510-perf-interface 2026-08-20 04:13:15 -04:00
Joseph MattielloandClaude Opus 5 64aa5f0488 docs(profiling): retract the 'fast may be slower' note (#511)
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]>
2026-08-20 04:13:13 -04:00
Joseph MattielloandClaude Opus 5 a7bc4ead73 feat(perf): wire RETRO_ENVIRONMENT_GET_PERF_INTERFACE for on-device profiling (#510)
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]>
2026-08-20 02:00:56 -04:00
Joseph MattielloandClaude Opus 5 f9dc350530 docs: post-v3.4.0 pass — fix stale status headers, drop shipped SDD plan
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]>
2026-08-19 23:17:04 -04:00
Joseph MattielloandClaude Opus 4.7 28b56ce744 perf: add reusable perf_counters.h instrumentation system
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]>
2026-05-01 23:12:07 -04:00
Joseph MattielloandClaude Opus 4.7 cb385d60c2 ci: UBSAN ignorelist for src/m68000 + fix profiling.md
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]>
2026-05-01 02:55:05 -04:00
Joseph MattielloandClaude Opus 4.7 ee93fe5bde perf: make benchmark target + docs/profiling.md
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]>
2026-05-01 01:39:01 -04:00