mirror of
https://github.com/ARMSX2/ARMSX2.git
synced 2026-08-24 16:50:16 -07:00
Merge yaps2: arm64 JIT transplant + test/perf/libretro infrastructure
Merges yaps2/main (github.com/yaps2/yaps2,c16b88cb7) into ARMSX2, replacing the arm64 recompiler family with the yaps2 JITs and importing the yaps2 testing, perf, and libretro infrastructure. Common ancestor is upstream PCSX2342db5152(2026-06-19); git auto-merged all but 38 files. Replaced (deleted in this merge, recoverable from history): - arm64/aR5900*, aR3000A*, aVU* -> arm64/iR5900*/iR3000A*/microVU*-arm64: EE static-pin register file with lazy dirty tracking, dual-residence allocator, IOP block linking, native COP2 macro ops, inline unaligned fastmem, persisted VU program cache, call-ret shadow ring, VU0 spin fast-forward. - MVU_DIFF shadow-run hooks in shared VU interpreter TUs (superseded by the offline vurunner JIT-vs-interp oracle). Imported from yaps2: - tests/ctest/core/recompilers: ~80 gtest suites (EE/IOP/VU differential harnesses, fuzzers, ABI digest tripwire, capture format pins) plus the gs_vertex_tests kernel oracle. - pcsx2-vurunner / pcsx2-eerunner headless capture-replay runners. - tools/perf counter-based A/B rigs, perf jitdump productionization, PmuCounters, clang-perf/clang-handheld presets. - pcsx2-libretro core (ENABLE_LIBRETRO, default OFF; rename pending). - GS vertex-kick fast path (GV series): TBL-based packed parse, register-resident kick, scalar-outcode cull, fused draw-rect/FindMinMax. - Null renderer, VK_KHR_display direct WSI, swapchain PresentStats. - SPU2 NEON mixer vectorization, EE timer read clamp (NFL 2K5 hang), IOP ioman signed-compare fix, assorted UB fixes. Kept from ARMSX2 in the both-touched files: - iOS dual-map W^X and fastmem-unavailable resilience (Memory, HostSys, vtlb). The split data/code area model is retained; both areas now take fixed VA hints so cached VU JIT code stays deterministic on Linux. - Android thread-affinity model, VMState shutdown early-outs, all platform frontends, branding, CI, RetroAchievements identity/policy. - GSDeviceVK: ARMSX2's push-descriptor decision logic (Mali crash gate, proprietary-vs-turnip Adreno split) merged with yaps2's descriptor-pool exhaustion recovery (flush + render-pass restart instead of dropped binds). Vendor feature policy is the union: Mali fbfetch policy with MediaTek/G57/Xclipse gates from ARMSX2; Adreno stencil/ROV/ test-and-sample-depth hang avoidance and no_ps2_z_quantization from yaps2. Build-system notes: - The Qt debugger is now gated behind ENABLE_QT_DEBUGGER (default off on arm64) so handheld builds drop the KDDockWidgets dependency. - GSDeviceNone and remaining yaps2 GS code were ported to the newer upstream GSTexture Usage-flags API. The replaced backend's interpreter-fallback glue (intExecuteOneInst, AndroidEEOpHist) and the EEDiffVerify runtime differ are retained for now; dead pieces will be removed in a follow-up commit.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,74 @@
|
||||
# tools/perf — PCSX2 ARM64 CPU profiling rig
|
||||
|
||||
Step 0 of the `neither` cherry-pick funnel (`/home/bmd/pcsx2/neither/CLAUDE.md`): get a
|
||||
**current, repeatable, attributable** bottleneck baseline for our own port. The old
|
||||
RK3562 numbers are stale and from the wrong device; we re-profile on **M2 Max / Asahi**
|
||||
first, then SD865.
|
||||
|
||||
## Pieces
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `bucket_perf.py` | parse a `perf report --stdio` dump → subsystem ranking (EE-JIT/VU0/VU1/IOP/VIF-JIT + GS/SPU2/vtlb/dispatcher/sync/kernel) + thread-comm axis; `--json` for aggregation. Pure stdlib. |
|
||||
| `profile_run.sh` | one-command wrapper: precondition gate → `perf record`/`inject --jit`/`report` → bucket → median wallclock + median shares → `summary.md`. Device-parameterized. Answers *where does time go* (attribution). |
|
||||
| `codegen_ab.sh` | deterministic A/B of **two** eerunner binaries differing only in emitted code: `perf stat -e instructions,cycles` median over N runs → Δinsns/Δcycles/IPC → `summary.md`. Answers *did this codegen change help* (comparison). |
|
||||
| `devices/<label>.env` | per-device `BIN_DIR`/`FREQ_DEFAULT`/`CYCLES_EVENT` (`m2max-asahi.env` shipped). |
|
||||
| `scenes/<id>.env` | per-scene `ISO`/`SAVESTATE`/`FRAMES`/`RENDERERS`/`LABEL` (4 scenes; you fill ISO+savestate). |
|
||||
|
||||
## Drivers
|
||||
|
||||
- **eerunner** (whole-system): `pcsx2-eerunner --liverun` runs EE-JIT + MTVU + IOP + VIF + real GS headless for a fixed frame count from a savestate. perf-recorded, JIT-symbolized via the new `--perf-jitdump` flag, bucketed. **`vk` is the representative profile; `null` is a secondary "scalar EE/IOP minus GS-feeding" diagnostic** (Null drops GIF/PATH3). Audio is excluded (SPU2 forced Null) — noted in every summary.
|
||||
- **gsrunner** (GS-only cross-check): `pcsx2-gsrunner -perf` replays a `.gs` dump deterministically and prints `@HWSTAT@` frame time + CPU/GS/GPU thread %. No perf/bucketing.
|
||||
- VU isolation: `pcsx2-vurunner --bench <cap.vucap>` (deterministic PMU cycles) — run directly; cross-checks the `VU1-JIT` bucket.
|
||||
|
||||
## One-time setup (M2)
|
||||
|
||||
1. Build: `cmake --preset clang-perf && cmake --build build-perf --target pcsx2-eerunner pcsx2-gsrunner pcsx2-vurunner`
|
||||
2. **Allow perf sampling:** `sudo sysctl kernel.perf_event_paranoid=1` (currently 2; the wrapper aborts with this hint otherwise).
|
||||
3. Provide assets (copyrighted, not checked in): the 2 ISOs + 4 savestates referenced in `scenes/*.env`. Capture each savestate in pcsx2-qt paused exactly at the scene start.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# whole-system, both renderers, 3 runs, median
|
||||
tools/perf/profile_run.sh --device m2max-asahi --scene uya-gameplay --renderer both --runs 3
|
||||
# → ~/pcsx2-profiles/m2max-asahi/uya-gameplay/{vk,null}/summary.md
|
||||
|
||||
# GS-only cross-check
|
||||
tools/perf/profile_run.sh --device m2max-asahi --driver gsrunner --gs-dump test-dumps/<dump>.gs
|
||||
```
|
||||
|
||||
## Codegen A/B (comparing two binaries) — use `codegen_ab.sh`, NOT wallclock
|
||||
|
||||
`profile_run.sh`'s wallclock is **invalid** for comparing two binaries: perf-record
|
||||
sampling perturbs the async EE↔MTVU↔MTGS sync in a *binary-dependent* way and
|
||||
manufactures phantom wallclock deltas (a reproducible, thermal-controlled **+11%
|
||||
"regression"** that was pure artifact — memory
|
||||
`feedback_sd865_codegen_ab_use_instructions_not_wallclock`). No-perf wallclock is too
|
||||
noisy (±15% session drift) to resolve a <10% codegen effect. The **deterministic**
|
||||
metric is retired instructions via `perf stat` (reproducible to <0.1%):
|
||||
|
||||
```bash
|
||||
# build base + new eerunner, stage both on the device, then:
|
||||
tools/perf/codegen_ab.sh --device sd865 --scene sotc-01 \
|
||||
--base pcsx2-eerunner-base --new pcsx2-eerunner-new --runs 3
|
||||
# → ~/pcsx2-profiles/sd865/codegen-ab/sotc-01/summary.md (Δinsns, Δcycles, IPC)
|
||||
```
|
||||
|
||||
Reading the result: **|Δinsns| > |Δcycles|** ⇒ the change is *pure code density* and
|
||||
the OoO A77 absorbed it via IPC (≈neutral — the EE-density-is-neutral lesson). A
|
||||
change that moves cycles ≈ as much as instructions is a *real compute* delta. Default
|
||||
`--renderer null` (most deterministic); use `vk` only if the change touches
|
||||
GS-feeding codegen (GIF/PATH3/XGKICK). The script enforces the quoted-path / 1e8
|
||||
instruction sanity floor so a word-split fast-fail can't masquerade as a measurement.
|
||||
|
||||
## Go/no-go checklist
|
||||
|
||||
- **#1 (perf works on M2):** a tiny liverun under `perf record -k mono` + `perf inject --jit` + `perf report` must show real `EE_*`/`VU1_*` symbols, **not** raw `[JIT]` addresses. Resolve the Apple-PMU unknowns here (cycles event binding across the two PMUs `apple_avalanche_pmu`/`apple_blizzard_pmu`; max `-F`; `-k mono` honored). Record the working `CYCLES_EVENT` in `devices/m2max-asahi.env`.
|
||||
- **#2 (share stability):** top-bucket median-share MAD across 3 runs is small (a few %) → MTVU nondeterminism doesn't break the ranking.
|
||||
- **#3 (before SD865):** all 4 scenes yield a stable, attributable ranking (`unattributed` < ~5%) with a median wallclock. Then add `devices/sd865.env` + re-captured savestates; no code change.
|
||||
|
||||
## Notes
|
||||
|
||||
- The new `--perf-jitdump` flag lives in `pcsx2-eerunner/Main.cpp` (test harness only; no production gate). jitdump path: `~/.config/PCSX2/cache/pcsx2-perf-<pid>/jit-<pid>.dump`.
|
||||
- Deferred: audio profiling (opt-in real-SPU2 knob), a dedicated EE numeric microbench.
|
||||
@@ -0,0 +1,311 @@
|
||||
# EE branch-mispredict attribution (P2-2 Step 0) — 2026-07-10: GATE FAILED, campaign closed
|
||||
|
||||
Campaign plan: measure where EE-thread branch mispredicts come from before
|
||||
building the MAMBO-X64-style RAS-paired return cache (software RSB + BL/RET
|
||||
pairing; lrps2's direct-mapped shape). Gate G1 for building it: guest-indirect
|
||||
funnel ≥ ~35% of mispredict samples AND ≥ ~1.5% of EE-thread cycles.
|
||||
|
||||
**Verdict (as corrected by the same-day re-verification below): G1 fails on
|
||||
UYA at every attribution bound; on SotC it fails at the honest bound and is
|
||||
ambiguous only under maximally generous attribution. The RSB/RAS return cache
|
||||
stays DISMISSED unbuilt. The original "fails by 3–5×" margin claim is
|
||||
RETRACTED — sampling skid makes the sub-block attribution too coarse to
|
||||
support it; see "Re-verification" below for the honest bounds.**
|
||||
|
||||
## Capture
|
||||
|
||||
Retroid Flip2 SD865 (root@192.168.4.42), Rocknix, real gameplay by bmd
|
||||
2026-07-10 (fan pinned max, EnablePerfDump=true). Per game: alternating 10s
|
||||
EE-thread `perf stat` windows (branch rates + branch mix) and 10s 20kHz
|
||||
`perf record -e r22` bursts (BR_MIS_PRED_RETIRED — supported on the A77;
|
||||
r0d/r0e are NOT implemented, r78/r79/r7a are), plus the run's jitdump.
|
||||
- UYA (SCUS-97353): ~5 min gameplay, 8 bursts, 558k samples.
|
||||
- SotC (SCUS-97472): slowdown save .02 (colossus scene), 6 bursts, 981k samples.
|
||||
Analysis: `ee_code_census.py --brmis` (bc9882c60) — buckets sample IPs into
|
||||
native / dispatcher regions / JIT blocks (instruction class at IP + entry8).
|
||||
|
||||
## Rates (stat windows, summed)
|
||||
|
||||
| | insns | IPC | BrMis(ret)/kinsn | miss rate | pool @14cyc | immed/ret/indir per kinsn |
|
||||
|---|---|---|---|---|---|---|
|
||||
| UYA | 305G | 2.05 | **1.29** | 1.12% | **3.7% of EE cycles** | 101.6 / 11.8 / 18.2 |
|
||||
| SotC | 227G | 1.52 | **2.16** | 1.49% | **4.6% of EE cycles** | 134.4 / 15.9 / 21.8 |
|
||||
|
||||
(Speculative r10 runs ~17–20% above retired r22 — use retired.)
|
||||
|
||||
## Attribution (r22 sample shares)
|
||||
|
||||
| bucket | UYA | SotC |
|
||||
|---|---|---|
|
||||
| **native C code (not JIT)** | **53.2%** | **54.5%** |
|
||||
| EE JIT block bodies (guest b.cond/cbz + skid) | ~24% | ~29% |
|
||||
| EE Dispatcher region (shared `Br x17` funnel) | 3.8% | 6.4% |
|
||||
| EE block entry8 (target-side skid: funnel ∪ taken guest-cond exits) | 5.2% | 4.8% |
|
||||
| IOP Dispatcher + IOP entry8 | 2.3% + 1.7% | 1.1% + 0.3% |
|
||||
| VU0 micro blocks | 5.4% | 0.1% |
|
||||
|
||||
Attribution notes: r22 sampling has skid — most block samples land on
|
||||
non-branch IPs downstream of the mispredicting branch; region-level shares
|
||||
are sound, sub-block position is smeared. ⚠️ The re-verification showed the
|
||||
skid regularly exceeds 64 insns, so the "block bodies" and "entry8" rows
|
||||
above are NOT separable — use the corrected bounds in the re-verification
|
||||
section, not these rows.
|
||||
|
||||
## The funnel (what an RSB/RAS return cache could touch)
|
||||
|
||||
⚠️ SUPERSEDED by the re-verification section below. The entry8 window (8B =
|
||||
2 insns) is far narrower than the real r22 skid, so these bounds were
|
||||
overconfident-low:
|
||||
|
||||
| | share of mispredicts | × total pool = EE-thread cycles |
|
||||
|---|---|---|
|
||||
| UYA | 6.1% (dispatchers alone) – 13.0% (+ all entry8) | ~~0.23–0.48%~~ |
|
||||
| SotC | 7.5% – 12.6% | ~~0.35–0.58%~~ |
|
||||
|
||||
## Re-verification (same day — bmd challenged the result)
|
||||
|
||||
Recheck tool: `tools/perf/brmis_recheck.py` (usage: `brmis_recheck.py
|
||||
<jitdump> <perf.data...>`; the native DSO/function split additionally needs
|
||||
the deployed binary under a `--symfs`-shaped tree — see Repro). Three
|
||||
findings:
|
||||
|
||||
**1. Skid is real and large — sub-block attribution is unreliable.** 76%
|
||||
(UYA) / ~75% (SotC) of EE-block mispredict samples land on NON-branch
|
||||
instructions; only 0.86% land on a b.cond. r22's overflow interrupt records
|
||||
the PC wherever execution parks (the next stall point), tens of insns —
|
||||
sometimes >64 — downstream of the retiring mispredict. So the entry8-vs-body
|
||||
split in the table above is interpretation, not measurement. Any bound built
|
||||
from an 8-byte entry window is wrong.
|
||||
|
||||
**2. Corrected funnel bounds** (nearest-upstream-branch analysis: a sample is
|
||||
*entry-reach* if no branch insn sits between block entry and the IP — the
|
||||
incoming edge is then the only in-block candidate for the retired mispredict):
|
||||
|
||||
| bound | UYA share → cycles | SotC share → cycles |
|
||||
|---|---|---|
|
||||
| floor (dispatcher regions only) | 6.1% → 0.23% | 7.5% → 0.35% |
|
||||
| honest ceiling (+ all EE/IOP entry-reach) | 21.5% → **0.79%** | 28.0% → **1.29%** |
|
||||
| absurd ceiling (+ unattributable deep-straight-line samples) | 28.2% → 1.04% | 36.3% → 1.67% |
|
||||
|
||||
Gates (≥35% share, ≥1.5% cycles): **UYA fails at every bound. SotC fails at
|
||||
the honest ceiling and crosses both gates only at the absurd ceiling** (every
|
||||
unattributable sample assigned to the funnel). Two reasons the truth sits
|
||||
near the floor, not the ceiling: (a) entry-reach shadows include taken AND
|
||||
fall-through-resolved guest-cond edges (the long-form `b.cc +8; B <link>`
|
||||
puts both edge types' continuations at block entries) — and with 110–134
|
||||
immediate branches/kinsn vs 12–16 returns/kinsn, a ~1% cond mispredict rate
|
||||
alone explains most of the total pool; (b) they also include BTB-capacity
|
||||
misses on direct link `B`s (130KB working set) — a density problem, not a
|
||||
prediction problem. Definitive split needs counting, not sampling: JITTELEM
|
||||
dispatch counters (Step 0c) would pin the dispatch rate exactly, and a
|
||||
per-site-Br A/B would measure the shared-`Br` aliasing penalty by
|
||||
construction. Neither run — the honest ceiling still fails G1.
|
||||
|
||||
**3. The native ~54% is genuine and now symbolized** (device binary pulled,
|
||||
function-level): body-spread across the emulator's own C, not a boundary-skid
|
||||
artifact. Top offenders — UYA: `isa_native::spu2Mix` (11% of native),
|
||||
`recClearIOP`, `mVUlookupProg_VU0`, `__aarch64_ldadd4_rel` (outline atomics =
|
||||
MTVU/MTGS rings), `_cpuEventTest_Shared`, `vifTransferLoop<1>`,
|
||||
`iopMemRead32`. SotC: `__aarch64_ldadd4_rel` (#1, 10% of native), `spu2Mix`,
|
||||
`vifTransferLoop<1>`, `Gif_Unit::Execute`, `_cpuEventTest_Shared`,
|
||||
`VU_Thread::*` (MTVU coordination). DSO split: 82% (UYA) / 94% (SotC) of
|
||||
native samples are yaps2-qt's own text; libc 8%/4%; kernel 3%/1%. Oddity
|
||||
worth a look someday: 6% (UYA) of "native" is unregistered JIT code (in the
|
||||
perf map but not the jitdump — likely the VIF dynarec, which doesn't emit
|
||||
jitdump records).
|
||||
|
||||
Even a PERFECT return predictor (100% RSB hit rate, zero added cost) recovers
|
||||
at most ~0.8% (UYA) / ~1.3% (SotC) of the EE thread under the honest ceiling
|
||||
— and the RSB-addressable subset (JR-$ra hits only) is strictly smaller: the
|
||||
funnel ceiling also contains JALR/jump-table targets, guest-cond edge
|
||||
shadows, and direct-B BTB misses, while the RSB adds ~6 insns/call +
|
||||
~8 insns/return of hot bytes against a 92%-utilized icache working set.
|
||||
|
||||
## Why MAMBO-X64's result doesn't transfer
|
||||
|
||||
Their §6.3 win (returns alone: DBT overhead 11.1%→4.1% on SPEC) comes from
|
||||
call/return-dense compiled userspace where the translated code IS the
|
||||
workload. On yaps2's EE thread, **~54% of mispredicts are in the emulator's
|
||||
own native C** (SPU2 mix, VIF unpack, MTVU coordination, GS dispatch — same
|
||||
thread), and the JIT-side remainder is dominated by data-dependent guest
|
||||
conditionals. PS2 game EE code (flat hand-scheduled loops, low call density:
|
||||
returns are only ~12-16/kinsn vs ~110-134 branches/kinsn) plus the A77's
|
||||
indirect predictor already coping with the shared `Br` leaves the return
|
||||
funnel a rounding-error bucket.
|
||||
|
||||
## What the data says the real mispredict levers are
|
||||
|
||||
1. **Native-code mispredicts (54%)** — not fixable in the JIT. The lever is
|
||||
the existing roadmap item: move native services OFF the wall thread
|
||||
(SPU2-offload first; idle cores available). That doesn't reduce
|
||||
mispredicts, it moves them off the critical path.
|
||||
2. **Guest conditionals (~24-29%)** — irreducible by construction.
|
||||
3. **Residual shelf item (latency, not prediction):** the EE dispatcher
|
||||
region costs ~2.1% of EE-thread CYCLES (session-2 cycles census) of which
|
||||
the 2-dependent-load LUT walk is part; P2-3 (flat phys→block LUT) shaves
|
||||
1 load ≈ sub-0.5% ceiling. Below the ship bar on its own; only worth
|
||||
folding into some future dispatcher rework. NOT built now.
|
||||
|
||||
## Gate ledger
|
||||
|
||||
| Gate | Threshold | Measured (post-re-verification bounds) | Verdict |
|
||||
|---|---|---|---|
|
||||
| G1 share | ≥35% of mispredict samples | UYA ≤28% at absurd ceiling; SotC ≤28% honest, 36% absurd | **FAIL** (SotC ambiguous only at absurd bound) |
|
||||
| G1 pool | ≥1.5% of EE-thread cycles | UYA ≤1.04%; SotC ≤1.29% honest, 1.67% absurd | **FAIL** (same caveat) |
|
||||
| Step 1 (RSB build) | G1 | — | **DISMISSED, not built** |
|
||||
| Step 3 (flat LUT) | G2 ≥1% cycles post-Step-1 | latency item ~0.5% ceiling | shelved |
|
||||
|
||||
If the SotC ambiguity ever needs settling: (1) JITTELEM dispatch-class
|
||||
counters (Step 0c — exact, skid-free; one build + one gameplay evening); if
|
||||
the dispatch rate is high, (2) per-site inline LUT+`Br` A/B (de-aliases the
|
||||
shared `Br`; the r22 delta measures the funnel by construction, and it IS
|
||||
the candidate fix).
|
||||
|
||||
## Appendix: how FEX does it (source read 2026-07-10)
|
||||
|
||||
FEX-Emu (x86→AArch64 usermode DBT, MIT; cloned at `/home/bmd/pcsx2/FEX`,
|
||||
shallow) ships a production MAMBO-X64-style **call-ret shadow stack**,
|
||||
unconditional, plus per-site inline lookup for all other indirects. Their
|
||||
workload (call-dense x86 userspace) is MAMBO's world, so for them the prize
|
||||
is real. Key files: `FEXCore/Source/Interface/Core/JIT/BranchOps.cpp`
|
||||
(~line 100 has the full pattern catalog as a comment),
|
||||
`Dispatcher/Dispatcher.cpp`, `LookupCache.h`,
|
||||
`Source/Tools/LinuxEmulation/LinuxSyscalls/ThreadManager.cpp:185` (alloc),
|
||||
`SyscallsSMCTracking.cpp:48` (fault recovery). ⚠️ Their CLAUDE.md forbids
|
||||
AI-generated contributions **to FEX** — read-as-reference only, never send
|
||||
them a PR from here.
|
||||
|
||||
Design elements:
|
||||
|
||||
1. **True shadow stack, not a direct-mapped cache.** Per-thread 4MB
|
||||
(`CALLRET_STACK_SIZE`), dedicated pinned reg **x25 = REG_CALLRET_SP**,
|
||||
spilled/reloaded to `State.callret_sp` at JIT-loop boundaries.
|
||||
2. **Call site:** `adr TMP1, <landing>; stp {guestRA, landing},
|
||||
[x25, -0x10]!; bl <target>` — the `bl` pushes the hardware RAS. With
|
||||
Multiblock on, the frontend translates ACROSS the call so the return
|
||||
continuation is usually the fall-through block right after the `bl`
|
||||
(zero-cost landing); when block layout doesn't cooperate the landing is a
|
||||
direct `b <return-block>`. A call whose continuation isn't translated
|
||||
pushes **{xzr, xzr}** — a never-matching frame that keeps the stack
|
||||
depth aligned with guest call depth so grandparent returns still hit.
|
||||
3. **Return site:** `ldp {ra, landing}, [x25], 0x10; sub; cbz -> hit` —
|
||||
on hit `ret landing` pairs with the caller's `bl` and the RAS predicts
|
||||
it. On mismatch, fall into the inline L1 probe, and **every exit path
|
||||
still ends in `ret`** (even the dispatcher fallback): "follow the normal
|
||||
path (but ending in a ret)". That keeps the hardware RAS balanced —
|
||||
a miss-path `b` would leave the paired RAS entry unpopped and desync
|
||||
every subsequent return. (Our Step-1 sketch had `B DispatcherReg` on
|
||||
miss — FEX's detail is correct, ours was wrong.)
|
||||
4. **Non-return indirects: NO shared mega-`Br`.** Every indirect site
|
||||
emits its own inline L1 probe — flat direct-mapped `{HostCode, GuestRIP}`
|
||||
16B-entry array (8k–1M entries, auto-resizing, base+pre-shifted mask
|
||||
loaded from STATE in one `ldp`), ~6 insns, one dependent-load level —
|
||||
ending in a per-site `br` (or `blr` for indirect calls, which also
|
||||
pushes the RAS). The central dispatcher is only the L1-miss slow path.
|
||||
This is exactly the P2-3 flat-LUT + per-site-Br combination.
|
||||
5. **No bounds checks in the hot path.** Guard pages both sides; the
|
||||
SIGSEGV handler recenters the SP to Base+SIZE/4 on any fault in the
|
||||
region (1MB overflow runway, 3MB underflow runway — underflow is the
|
||||
common imbalance). Thread switches / longjmp-style guest behavior
|
||||
degrade to compare-misses, never to wrong execution.
|
||||
6. **Invalidation = `madvise(DONTNEED)` the whole stack** on any SMC /
|
||||
code-cache invalidation: pages lazily refill as zeros, and a zero frame
|
||||
is a never-matching sentinel. (yaps2's Remove()-redirect-stub property
|
||||
is stronger — our landings stay VALID across recClear without any wipe;
|
||||
only recResetRaw would need one.)
|
||||
|
||||
Verdict interplay: FEX is the blueprint to copy if this campaign ever
|
||||
reopens (it validates the Step-1 design and corrects the miss-path detail),
|
||||
but it doesn't change the gate arithmetic — they built it for call-dense
|
||||
x86; our measured guest-indirect pool stays ≤0.8–1.3% of EE-thread cycles.
|
||||
The transferable-today idea is #4: per-site probe + per-site `br` needs no
|
||||
shadow stack, no reserved register, and doubles as the decisive
|
||||
funnel-measurement A/B.
|
||||
|
||||
## Step 1 built after all (2026-07-10, user decision)
|
||||
|
||||
bmd overrode the gate: "reproduce FEX's solution anyways… keep an old build
|
||||
without it sitting around, and we'll A/B test with a better harness and
|
||||
multiple games to really see." The attribution above stands as the PRIOR
|
||||
(expect ≤~1% EE-thread upside, less the added tail bytes); the A/B is the
|
||||
measurement that can't argue back.
|
||||
|
||||
Landed (all tagged P2-2):
|
||||
- `c38d90eb8` BL-form link sites (bit-0 tag in the Arm64BaseBlocks linkmap).
|
||||
- `27a730e1b` the call-ret shadow-stack ring: JAL/JALR-rd31 push
|
||||
{returnPC, landing} + BL, JR-$ra pop + compare + RET on every path
|
||||
(miss RETs into DispatcherReg — FEX's RAS-balance detail). Adaptations:
|
||||
4096-frame masked ring instead of guard pages (PageFaultHandler has no
|
||||
ucontext access; wrap needs no recovery), ring base/offset in
|
||||
cpuRegistersPack instead of a burned register (x25=RECCYCLE, x28=COP2
|
||||
macro VI pool). Landing = linked B (we don't translate through calls).
|
||||
Goemon emit-gated off. `EE_CALLRET_STACK=0` builds the baseline arm.
|
||||
- `3b2abac10` hit/miss telemetry behind `EE_CALLRET_TELEM` (default OFF —
|
||||
emitted-instruction cost; validation bar ≥80% hit rate).
|
||||
- Tests: `arm64_baseblocks_link_tests.cpp` (encodings),
|
||||
`ee_rec_callret_tests.cpp` (roundtrips, nesting, clobbered-$ra miss,
|
||||
empty-ring sentinel miss, ring wrap + balance, frame contents).
|
||||
|
||||
A/B protocol (`tools/perf/callret_ab.sh` + `callret_ab_report.py`): two
|
||||
`pcsx2-eerunner` binaries from the SAME tip (`-DEE_CALLRET_STACK=0` vs
|
||||
default), savestate-anchored `--liverun --renderer null` (headless,
|
||||
unlimited limiter, EERUNNER_SYNCMTGS=0), N frames per run, ABBA-interleaved
|
||||
rounds, `perf stat -e cycles,instructions,r21,r22,r78,r79` around each run.
|
||||
Decision metric: wall s/kframe (throughput truth); mechanism metric:
|
||||
r22/kinsn delta; fan pinned + auto-restored, A77-cluster taskset on device.
|
||||
|
||||
Gate-run note (2026-07-10): `--stepdiff` on UYA+SotC shows an
|
||||
identical-signature pre-existing DATA divergence on main WITH AND WITHOUT
|
||||
the call-ret arm (UYA offending block 0x00122a1c, SotC 0x00124c60 —
|
||||
s0/v0 lo words; possibly benign timer-laundering the zoom can't classify,
|
||||
possibly real). Separate triage item; not introduced by P2-2.
|
||||
|
||||
## A/B RESULTS — SD865, 2026-07-10 (same night)
|
||||
|
||||
Retroid Flip2 SD865, A77 cluster (taskset 4-7), fan pinned, Devel arms from
|
||||
tip `8b0a04c3b` ± `-DEE_CALLRET_STACK=0`. 4 games × 2 arms × 4 ABBA rounds
|
||||
× 3000 frames, savestate-anchored `--liverun --renderer null`. Medians:
|
||||
|
||||
| game (scene) | cycles | insns | IPC | BrMis/kinsn | wall s/kframe |
|
||||
|---|---|---|---|---|---|
|
||||
| SotC (slowdown .02) | **−0.95%** | +0.53% | +1.50% | −1.42% | −0.49% |
|
||||
| Katamari (.03) | **−0.65%** | +0.15% | +0.73% | −2.61% | ±0.00% |
|
||||
| OutRun (.03) | **−0.42%** | +0.36% | +0.84% | −0.64% | −0.52% |
|
||||
| UYA (.01) | +0.03% | +0.56% | +0.56% | −1.73% | (+1.28% = PerfLog 0.1s-quantization artifact; elapsed +0.44%, cycles flat) |
|
||||
|
||||
**Mechanism: validated.** Device telem run (UYA, 600 frames): 14.9M pushes,
|
||||
13.4M pops, **98.6% hit rate** (bar was ≥80%). BrMis/kinsn drops in all
|
||||
four games; IPC rises in all four; instructions rise ~+0.15–0.56% (the tail
|
||||
bytes) yet cycles still fall in 3 of 4.
|
||||
|
||||
**Verdict: small, consistent, real win on the A77 — no regression
|
||||
anywhere.** Median ~−0.5% whole-PROCESS cycles (−0.95% on the SotC
|
||||
EE-bound benchmark scene; EE-thread share is larger than the process
|
||||
number). The Step-0 attribution called the shape correctly: the prize is
|
||||
small because the funnel is small — but the hit path also deletes the
|
||||
2-dependent-load dispatcher walk per return (the P2-3 latency item, folded
|
||||
in free), which is why SotC lands near/above the funnel-prediction ceiling.
|
||||
Run-to-run spread ±1 wall quantum; raw data
|
||||
`scratchpad/brmis-2026-07-10/callret-ab-run1/`.
|
||||
|
||||
Open before calling it SHIPPED (feature is default-ON on main):
|
||||
- **RK3562 wallclock** (plan Step-2 land rule: "RK neutral-or-better") —
|
||||
in-order A53 pays the +insns for real, but also has an 8-entry RAS.
|
||||
Needs a build-rk3562 pair + RG43H run.
|
||||
- Interactive validation on the deployed qt build (`yaps2-qt` = callret;
|
||||
`yaps2-qt.pre-callret` kept alongside, per the keep-an-old-build ask).
|
||||
|
||||
## Repro
|
||||
|
||||
```
|
||||
# device: /storage/pcsx2/gameplay-capture-brmis.sh (scratchpad/icache/), then:
|
||||
python3 tools/perf/ee_code_census.py <session>/jit-*.dump <session>/hf-*.perf.data --brmis
|
||||
# skid-honest bounds + native split (re-verification):
|
||||
python3 tools/perf/brmis_recheck.py <session>/jit-*.dump <session>/hf-*.perf.data
|
||||
# native symbolization: pull the DEPLOYED binary into a symfs tree, then
|
||||
# perf script -i <hf> --symfs <tree> -F ip,sym,dso | grep yaps2-qt | ...
|
||||
# rates:
|
||||
awk '/cycles/{c+=$1} /instructions/{i+=$1} / r22 /{m+=$1} END{print 1000*m/i, 100*m*14/c}' stat-*-br.txt
|
||||
```
|
||||
Data: `/home/bmd/pcsx2/scratchpad/brmis-2026-07-10/session3-{uya,sotc}/`
|
||||
(durable copy; originals in the b79df7f1 session scratchpad under /tmp).
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Re-verify the brmis attribution: quantify r22 sampling skid and symbolize
|
||||
the native bucket. Challenges tested:
|
||||
1. entry8 (8B) is far narrower than real skid -> funnel underestimated?
|
||||
2. "native 53%" bucket never symbolized -> what is actually in it?
|
||||
Usage: brmis_recheck.py <jitdump> <perf.data...>
|
||||
"""
|
||||
import os, sys, struct, bisect, subprocess
|
||||
from collections import Counter
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from ee_code_census import parse_jitdump, _dedupe_blocks, _iter_samples, _insn_class
|
||||
|
||||
jd, pds = sys.argv[1], sys.argv[2:]
|
||||
blocks = _dedupe_blocks(parse_jitdump(jd))
|
||||
meta = []
|
||||
for addr, size, name, code in sorted(blocks):
|
||||
n = len(code) // 4
|
||||
ins = list(struct.unpack_from(f"<{n}I", code)) if n else []
|
||||
group = name.split("_")[0] if " " not in name else f"[{name}]"
|
||||
meta.append((addr, addr + size, name, group, ins))
|
||||
lows = [m[0] for m in meta]
|
||||
|
||||
def locate(ip):
|
||||
j = bisect.bisect_right(lows, ip) - 1
|
||||
return j if (j >= 0 and ip < meta[j][1]) else -1
|
||||
|
||||
# ---- pass 1: in-block sample geometry ---------------------------------------
|
||||
OFFB = [(0, 2), (2, 4), (4, 8), (8, 16), (16, 32), (32, 64), (64, 128),
|
||||
(128, 1 << 30)] # insn-offset buckets
|
||||
off_h = {} # group -> Counter(bucket)
|
||||
updist = {} # group -> Counter(dist class)
|
||||
n_tot = 0
|
||||
n_native = 0
|
||||
grp_tot = Counter()
|
||||
region_tot = Counter()
|
||||
entry_sweep = {} # group -> Counter(W) for W in bytes
|
||||
SWEEP = (8, 16, 32, 64, 128, 256)
|
||||
|
||||
for pd in pds:
|
||||
for _t, ip in _iter_samples(pd):
|
||||
n_tot += 1
|
||||
j = locate(ip)
|
||||
if j < 0:
|
||||
n_native += 1
|
||||
continue
|
||||
lo, hi, name, group, ins = meta[j]
|
||||
if group.startswith("["):
|
||||
region_tot[name] += 1
|
||||
continue
|
||||
off = ip - lo
|
||||
k = off // 4
|
||||
grp_tot[group] += 1
|
||||
for a, b in OFFB:
|
||||
if a <= k < b:
|
||||
off_h.setdefault(group, Counter())[(a, b)] += 1
|
||||
break
|
||||
for W in SWEEP:
|
||||
if off < W:
|
||||
entry_sweep.setdefault(group, Counter())[W] += 1
|
||||
# nearest upstream branch insn (address order == execution order in a
|
||||
# straight-line run; a taken upstream branch would not flow here)
|
||||
d = None
|
||||
for back in range(1, min(k, 64) + 1):
|
||||
if _insn_class(ins[k - back]) != "non-branch":
|
||||
d = back
|
||||
break
|
||||
if d is None:
|
||||
cls = f"entry@{min(k,64)}" if k <= 64 else "nobranch>64up"
|
||||
elif d <= 4:
|
||||
cls = "br<=4"
|
||||
elif d <= 16:
|
||||
cls = "br5-16"
|
||||
else:
|
||||
cls = "br17-64"
|
||||
updist.setdefault(group, Counter())[cls] += 1
|
||||
|
||||
print(f"== {n_tot} samples | native {n_native} ({100*n_native/n_tot:.1f}%) ==")
|
||||
for name, v in region_tot.most_common():
|
||||
print(f" region {name}: {v} ({100*v/n_tot:.2f}%)")
|
||||
|
||||
print("\n== in-block insn-offset-from-entry histogram (% of group) ==")
|
||||
hdr = " ".join(f"{a}-{b if b < 1<<29 else ''}".rstrip("-").ljust(7)
|
||||
for a, b in OFFB)
|
||||
print(f"{'group':6s} {'n':>7s} " + hdr)
|
||||
for g in sorted(grp_tot, key=lambda g: -grp_tot[g]):
|
||||
c = off_h.get(g, Counter())
|
||||
t = grp_tot[g]
|
||||
print(f"{g:6s} {t:7d} " + " ".join(
|
||||
f"{100*c.get((a,b),0)/t:6.2f}%" for a, b in OFFB))
|
||||
|
||||
print("\n== nearest-UPSTREAM-branch distance (execution-order candidates for "
|
||||
"the retired mispredict) ==")
|
||||
cats = ["entry@0", "br<=4", "br5-16", "br17-64", "nobranch>64up"]
|
||||
# merge entry@k into one bucket
|
||||
for g in sorted(grp_tot, key=lambda g: -grp_tot[g]):
|
||||
c = updist.get(g, Counter())
|
||||
t = grp_tot[g]
|
||||
entry = sum(v for k, v in c.items() if k.startswith("entry@"))
|
||||
row = {"entry-reach": entry, "br<=4": c.get("br<=4", 0),
|
||||
"br5-16": c.get("br5-16", 0), "br17-64": c.get("br17-64", 0),
|
||||
"nobranch>64up": c.get("nobranch>64up", 0)}
|
||||
print(f"{g:6s} " + " ".join(f"{k}:{100*v/t:.1f}%" for k, v in row.items()))
|
||||
|
||||
print("\n== entry-window sweep: samples within W bytes of a block entry ==")
|
||||
print("(upper bound on incoming-edge shadow: dispatcher Br + link B + taken "
|
||||
"cond edges all land at entries)")
|
||||
print(f"{'group':6s} " + " ".join(f"W={w:<4d}" for w in SWEEP))
|
||||
for g in sorted(grp_tot, key=lambda g: -grp_tot[g]):
|
||||
c = entry_sweep.get(g, Counter())
|
||||
print(f"{g:6s} " + " ".join(f"{100*c.get(w,0)/n_tot:5.2f}%" for w in SWEEP))
|
||||
disp = sum(v for k, v in region_tot.items() if "Dispatcher" in k)
|
||||
print(f"\nfunnel(W) = dispatcher regions ({100*disp/n_tot:.2f}%) + EE/IOP entryW:")
|
||||
for w in SWEEP:
|
||||
ee = entry_sweep.get("EE", Counter()).get(w, 0)
|
||||
iop = entry_sweep.get("IOP", Counter()).get(w, 0)
|
||||
print(f" W={w:<4d} funnel = {100*(disp+ee+iop)/n_tot:5.2f}% of all samples")
|
||||
|
||||
# ---- pass 2: DSO split of the native bucket ---------------------------------
|
||||
print("\n== native-bucket DSO split (perf script ip,dso) ==")
|
||||
dso = Counter()
|
||||
for pd in pds:
|
||||
proc = subprocess.Popen(["perf", "script", "-i", pd, "-F", "ip,dso"],
|
||||
stdout=subprocess.PIPE, text=True)
|
||||
for line in proc.stdout:
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
try:
|
||||
ip = int(parts[0], 16)
|
||||
except ValueError:
|
||||
continue
|
||||
if locate(ip) >= 0:
|
||||
continue # JIT — already attributed
|
||||
d = parts[1].strip("()")
|
||||
dso[d] += 1
|
||||
tot = sum(dso.values())
|
||||
print(f"native total via dso pass: {tot}")
|
||||
for d, v in dso.most_common(15):
|
||||
print(f" {100*v/tot:6.2f}% {d}")
|
||||
Executable
+239
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0+
|
||||
#
|
||||
# bucket_perf.py — turn a `perf report --stdio` dump into a PCSX2 subsystem
|
||||
# bottleneck ranking. Part of the M2-first profiling rig (plan: Step 0 of the
|
||||
# neither cherry-pick funnel). Pure stdlib; runs identically on M2 Asahi and SD865.
|
||||
#
|
||||
# Input is the ALREADY-DUMPED text report (the wrapper runs `perf report` once and
|
||||
# redirects to a file — see tools/perf/profile_run.sh — because a raw 500-700 MB
|
||||
# perf.data is expensive to traverse). This script never invokes perf and never
|
||||
# holds the .data; it only parses text.
|
||||
#
|
||||
# Bucketing has two axes:
|
||||
# 1. SUBSYSTEM — JIT blocks by their Perf::Group symbol prefix
|
||||
# (EE_/VU0_/VU1_/IOP_/VIF_, from common/Perf.cpp) + native code by symbol regex.
|
||||
# 2. THREAD comm (CPU=EE thread, MTVU, GS, Audio) — a secondary breakdown so the
|
||||
# EE-vs-MTVU split is visible and bucket attribution can be sanity-checked
|
||||
# (e.g. VU1-JIT should land mostly on MTVU under async MTGS).
|
||||
#
|
||||
# Anything unmatched falls into the visible `unattributed` bucket — never silently
|
||||
# dropped, so a large value flags a missing rule rather than a clean-looking lie.
|
||||
#
|
||||
# Usage:
|
||||
# perf report -i perf.jit.data --stdio --percent-limit 0 -g none > report.txt
|
||||
# bucket_perf.py report.txt # human table + @BUCKET@ grep lines
|
||||
# bucket_perf.py --json report.txt # machine JSON (the wrapper medians these)
|
||||
# cat report.txt | bucket_perf.py # stdin also works
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
# --- Subsystem bucketing -----------------------------------------------------
|
||||
# JIT symbol prefixes emitted by Perf::Group (common/Perf.cpp). Checked first.
|
||||
JIT_PREFIX = re.compile(r"^(EE|VU0|VU1|IOP|VIF)_")
|
||||
JIT_BUCKET = {"EE": "EE-JIT", "VU0": "VU0-JIT", "VU1": "VU1-JIT",
|
||||
"IOP": "IOP-JIT", "VIF": "VIF"}
|
||||
|
||||
# --- GPU-driver quarantine (checked BEFORE kernel) ---------------------------
|
||||
# The host GPU stack (userspace Vulkan driver + DRM kernel + GPU memory manager).
|
||||
# On the M2/Asahi box this dominates the GS thread (libvulkan_asahi + [asahi] Rust
|
||||
# driver + drm_mm allocator: ~38% kernel + ~2% userspace at go/no-go #1). It is
|
||||
# entirely HOST-SPECIFIC — it tells you nothing about SD865 (Adreno) CPU cost, so it
|
||||
# gets its own visible bucket instead of polluting kernel/other and unattributed.
|
||||
# Matches both the userspace driver dso AND the explicit DRM/GPU-MM kernel symbols;
|
||||
# generic kernel routines (memset/mutex/malloc) stay in kernel/other on purpose
|
||||
# (can't honestly attribute a generic memcpy to the GPU by symbol alone — the
|
||||
# --renderer null run is the clean way to drop the whole GS/GPU path).
|
||||
GPU_DRIVER_DSO = re.compile(r"asahi|libvulkan|libVkLayer|_mesa|libdrm|radeonsi|"
|
||||
r"libGLX|libEGL|nvidia|anv_|\btu\b|panfrost|libmali")
|
||||
GPU_DRIVER_SYM = re.compile(r"^drm_|drm_mm|^add_hole|^rm_hole|^hk_|_mesa_|^vk_|"
|
||||
r"gpu_|RunVertex|RunFragment|FwCtlChannel|HeapAllocator")
|
||||
|
||||
# Native code, ordered — first match wins. Tuned against real demangled symbol names
|
||||
# from R&C UYA captures (2026-06-24). Many hot natives are under the `isa_native::`
|
||||
# MultiISA-dispatch namespace, so anchors match substrings, not leading symbols.
|
||||
# Kept broad on purpose — re-tighten if a bucket starts swallowing unrelated symbols.
|
||||
NATIVE_RULES = [
|
||||
# One-time costs (savestate/ISO decompress, shader compile, gamedb parse). Kept
|
||||
# separate so the steady-state emulation ranking isn't inflated by startup. Note:
|
||||
# over a bounded liverun this is a fixed cost — its share shrinks as FRAMES grows.
|
||||
("startup/io", re.compile(r"shaderc|glslang|spirv|SPIRV|libzstd|ZSTD|HUF_|"
|
||||
r"c4::yml|ryml|ParseEngine|GameDatabase|"
|
||||
r"Decompress|inflate|lzma|LZ4")),
|
||||
("IPU/video", re.compile(r"yuv2rgb|_DCT|IDCT|idct|IPU|ipu|getBits|[Mm]dec")),
|
||||
# VU dispatch envelope (program lookup/search, dispatcher entry/exit, sync-ahead,
|
||||
# block clear/compile) — distinct from the jitted VU bodies (VU0_/VU1_). This is
|
||||
# the "VU cache duplication / lookup" cost the stale RK3562 notes flagged.
|
||||
("VU-glue", re.compile(r"mVUlookupProg|mVUsearchProg|mVUcompile|mVUdispatch|"
|
||||
r"VU0StartFunc|VU1StartFunc|vu0SyncRunAhead|"
|
||||
r"vu1SyncRunAhead|recMicroVU|BaseVUmicroCPU|"
|
||||
r"mVUexecute|mVUreset|mVUcleanUp|microVU.*[Dd]ispatch|"
|
||||
r"mVUEBit|mVU_XGKICK|mVUcomputeProgramHash|mVU.?clearlpState|"
|
||||
r"vu0ExecMicro|vu1ExecMicro|vuExecMicro")),
|
||||
# VIF (unpack dynarec front-end + native transfer/interrupt). Jitted VIF_ blocks
|
||||
# land in the VIF bucket via JIT_PREFIX; these are the native halves.
|
||||
("VIF", re.compile(r"vifTransfer|VIF0transfer|VIF1transfer|vif0Interrupt|"
|
||||
r"vif1Interrupt|dVifUnpack|vifUnpack|VifUnpack|Vif_|"
|
||||
r"VIFunpack|vifCode|dVifsetVUptr|vifExecQueue|"
|
||||
r"_VIF[01]chain|VIF[01]chain")),
|
||||
("GS", re.compile(r"GSXXH|XXH_INLINE|XXH3|GSLocalMemory|GSRenderer|GSState|"
|
||||
r"GSDevice|GSDraw|GSRasteriz|GSVertex|GSTextureCache|"
|
||||
r"GSClut|GSGet|GSLookup|GSVector|GSBlock|GSClip|::GS|"
|
||||
r"GS[A-Z][a-z]|Gif_Unit|Gif_|GIFTag|GIFPath|GIFPackedReg")),
|
||||
("SPU2/audio", re.compile(r"[Ss][Pp][Uu]2|SndOut|[Ss]oundtouch|cubeb|TimeStretch|"
|
||||
r"ReverbDo|V_Volume|V_Core|VolumeSlide|V_ADSR|ADSR|WaveDump")),
|
||||
("vtlb/mem", re.compile(r"vtlb|[Mm]em[RW]rite|[Mm]em[Rr]ead|GetMemPtr|iopMem|"
|
||||
r"eeMem|recMemory|RecMemcheck|GoemonUnloadTlb")),
|
||||
("EE/IOP-glue", re.compile(r"cpuEventTest|iopEventTest|CPU_INT|recClear|"
|
||||
r"Arm64BaseBlocks|ExecuteBlock|"
|
||||
r"psxBranchTest|intcInterrupt|dmacInterrupt|hwIntc|"
|
||||
r"hwDmac|cpuException|psxException|eeloadHook|_cpuTest|"
|
||||
r"psxRcnt|psxCounter|rcntUpdate|EEcnt|hwRead|hwWrite|"
|
||||
r"dmaExec|dmacWrite|dmacRead|dmaGetAddr|eeHw|DMAVerbose|"
|
||||
r"DMACh|_rcntSet|rcntSet|recEventTest|recReset|"
|
||||
r"R5900::Interpreter|OpcodeImpl::COP0|OpcodeImpl::SYSCALL|"
|
||||
r"OpcodeImpl::ERET|OpcodeImpl::MTC0|"
|
||||
r"_dmaGIF|_dmaVIF|sif[01]|EEsif")),
|
||||
("dispatcher/glue", re.compile(r"Dispatcher|recExecute|recRecompile|iopRecRecompile|"
|
||||
r"JITCompile|recompileNextInstruction|recCall|dyna_|"
|
||||
r"sync_cache_range|__clear_cache|FlushInstructionCache")),
|
||||
("memops", re.compile(r"__memcpy|__memset|__memmove|__pi_mem|memcpy_|memset_|"
|
||||
r"memcpy@|memset@|memmove|crc32|"
|
||||
r"_int_malloc|_int_free|\bmalloc\b|\bfree\b|cfree|"
|
||||
r"malloc_consolidate|operator new|operator delete")),
|
||||
("sync/mtgs/mtvu", re.compile(r"pthread_mutex|pthread_cond|futex|__lll_|"
|
||||
r"condition_variable|Semaphore|WaitForBits|"
|
||||
r"std::.*mutex|Threading::|sem_post|sem_wait|"
|
||||
r"spin_on_owner|raw_spin|MTGS|MTVU|VU_Thread|"
|
||||
r"ThreadEntryPoint|Get_MTVUChanges|mtvu|GIFPath_|"
|
||||
r"ExecuteRingBuffer|ExecuteGSPacket")),
|
||||
]
|
||||
|
||||
# Buckets we always print even at 0% (so the ranking shape is stable run-to-run).
|
||||
ALL_BUCKETS = ["EE-JIT", "VU0-JIT", "VU1-JIT", "IOP-JIT", "VIF",
|
||||
"VU-glue", "GS", "IPU/video", "SPU2/audio", "vtlb/mem", "EE/IOP-glue",
|
||||
"dispatcher/glue", "memops", "sync/mtgs/mtvu", "startup/io",
|
||||
"JIT-other", "GPU-driver", "libc/uncat", "kernel/other", "unattributed"]
|
||||
|
||||
# A leading percent column, e.g. " 41.23%". `perf report -g none` emits one
|
||||
# Overhead column; if a Children column sneaks in there are two — we take the LAST
|
||||
# leading percent as self%.
|
||||
PCT = re.compile(r"^\s*((?:\d+\.\d+%\s+)+)(.*)$")
|
||||
# The symbol-type marker splits "comm dso" from "symbol": [.] user, [k] kernel, etc.
|
||||
SYMMARK = re.compile(r"\s\[[.kguHh]\]\s")
|
||||
|
||||
|
||||
def classify(dso, symbol, is_kernel):
|
||||
"""Return the subsystem bucket for one report row. Order: JIT prefix → GPU-driver
|
||||
(host-specific, before kernel so the GPU stack's kernel symbols are quarantined) →
|
||||
generic kernel → native rules → unattributed (raw 0x addrs fall through)."""
|
||||
m = JIT_PREFIX.match(symbol)
|
||||
if m:
|
||||
return JIT_BUCKET[m.group(1)]
|
||||
# Unsymbolized JIT continuation blocks: perf inject names only the program-entry
|
||||
# block (per a41d849f4), so sub-blocks show up as `[JIT] tid N 0x...` raw addrs.
|
||||
# Label them JIT-other rather than letting them sink into unattributed — they ARE
|
||||
# guest JIT execution, just unnamed (which JIT engine is unknowable from the addr).
|
||||
if dso.startswith("[JIT]"):
|
||||
return "JIT-other"
|
||||
if GPU_DRIVER_DSO.search(dso) or GPU_DRIVER_SYM.search(symbol):
|
||||
return "GPU-driver"
|
||||
if is_kernel or dso == "[kernel.kallsyms]":
|
||||
return "kernel/other"
|
||||
# Unsymbolized C-runtime: ROCKNIX/handheld libc & loader ship stripped, so hot libc
|
||||
# routines (memcpy/string/malloc internals) show as raw `0x...` offsets. They're real
|
||||
# CPU cost but un-attributable by name — give them an honest visible bucket instead of
|
||||
# sinking into unattributed (which we reserve for genuinely unidentified rows).
|
||||
if symbol.startswith("0x") and re.search(r"libc[.-]|ld-linux|libm\.so|libpthread", dso):
|
||||
return "libc/uncat"
|
||||
for name, rx in NATIVE_RULES:
|
||||
if rx.search(symbol):
|
||||
return name
|
||||
return "unattributed"
|
||||
|
||||
|
||||
def parse(lines):
|
||||
"""Parse a perf report --stdio dump -> (bucket->pct, comm->pct, total_pct)."""
|
||||
buckets = {b: 0.0 for b in ALL_BUCKETS}
|
||||
comms = {}
|
||||
total = 0.0
|
||||
for line in lines:
|
||||
if not line.strip() or line.lstrip().startswith("#"):
|
||||
continue
|
||||
m = PCT.match(line)
|
||||
if not m:
|
||||
continue
|
||||
# last leading percent = self%
|
||||
pct = float(m.group(1).split("%")[-2].split()[-1])
|
||||
rest = m.group(2)
|
||||
sm = SYMMARK.search(rest)
|
||||
if sm:
|
||||
left = rest[:sm.start()]
|
||||
symbol = rest[sm.end():].strip()
|
||||
mark = rest[sm.start():sm.end()].strip()
|
||||
else:
|
||||
# no marker (rare) — treat whole remainder as "comm ... symbol"
|
||||
left, symbol, mark = rest, rest.split()[-1] if rest.split() else "", ""
|
||||
# Columns are separated by 2+ spaces; comm itself may contain a single
|
||||
# space (e.g. "CPU Thread"), so split on runs of >=2 spaces, not any space.
|
||||
parts = re.split(r"\s{2,}", left.strip())
|
||||
comm = parts[0] if parts else "?"
|
||||
dso = parts[-1] if len(parts) > 1 else ""
|
||||
is_kernel = (mark == "[k]") or (dso == "[kernel.kallsyms]")
|
||||
bucket = classify(dso, symbol, is_kernel)
|
||||
buckets[bucket] += pct
|
||||
comms[comm] = comms.get(comm, 0.0) + pct
|
||||
total += pct
|
||||
return buckets, comms, total
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Bucket a perf report into PCSX2 subsystems.")
|
||||
ap.add_argument("report", nargs="?", help="perf report --stdio dump (default stdin)")
|
||||
ap.add_argument("--json", action="store_true", help="emit JSON instead of a table")
|
||||
args = ap.parse_args()
|
||||
|
||||
src = open(args.report) if args.report else sys.stdin
|
||||
with src:
|
||||
buckets, comms, total = parse(src)
|
||||
|
||||
# Normalize to the parsed total so shares sum to 100% regardless of perf quirks
|
||||
# (e.g. a multi-PMU recording would otherwise sum to ~200%). Shares, not absolute
|
||||
# percentages, are the comparison currency (per the methodology rule).
|
||||
if total > 0:
|
||||
buckets = {k: v / total * 100.0 for k, v in buckets.items()}
|
||||
comms = {k: v / total * 100.0 for k, v in comms.items()}
|
||||
|
||||
ranked = sorted(buckets.items(), key=lambda kv: kv[1], reverse=True)
|
||||
comm_ranked = sorted(comms.items(), key=lambda kv: kv[1], reverse=True)
|
||||
|
||||
if args.json:
|
||||
json.dump({"total_pct": round(total, 2),
|
||||
"buckets": {k: round(v, 3) for k, v in ranked},
|
||||
"comms": {k: round(v, 3) for k, v in comm_ranked}},
|
||||
sys.stdout, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
return
|
||||
|
||||
print(f"# subsystem ranking (self%, total accounted = {total:.1f}%)")
|
||||
print(f"{'BUCKET':<18}{'SELF%':>8}")
|
||||
for name, pct in ranked:
|
||||
print(f"{name:<18}{pct:>8.2f}")
|
||||
print()
|
||||
print(f"# by thread comm")
|
||||
for name, pct in comm_ranked:
|
||||
print(f"{name:<18}{pct:>8.2f}")
|
||||
print()
|
||||
# grep-friendly one-liners for the wrapper / quick scraping.
|
||||
for name, pct in ranked:
|
||||
print(f"@BUCKET@ {name} {pct:.2f}")
|
||||
if buckets["unattributed"] > 5.0:
|
||||
print(f"# WARNING: unattributed {buckets['unattributed']:.1f}% > 5% — "
|
||||
f"add/Tune a NATIVE_RULES regex.", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+265
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: GPL-3.0+
|
||||
#
|
||||
# codegen_ab.sh — deterministic A/B of TWO eerunner binaries that differ ONLY in
|
||||
# emitted JIT code, over a fixed scene. Reports retired-instructions and cycles
|
||||
# deltas (median over N runs) plus IPC. This is THE metric for comparing two
|
||||
# codegen variants on SD865 (and M2); profile_run.sh's wallclock is NOT.
|
||||
#
|
||||
# WHY THIS EXISTS / WHEN TO USE IT (see README.md "Codegen A/B"):
|
||||
# profile_run.sh's perf-record WALLCLOCK is invalid for comparing two binaries.
|
||||
# perf's sampling interrupts perturb the async EE<->MTVU<->MTGS sync pacing in a
|
||||
# BINARY-DEPENDENT way, manufacturing phantom wallclock deltas — a reproducible,
|
||||
# thermal-controlled +11% "regression" that was pure measurement artifact (see
|
||||
# memory feedback_sd865_codegen_ab_use_instructions_not_wallclock). No-perf
|
||||
# wallclock is too noisy (~±15% session drift) to resolve a <10% codegen effect.
|
||||
# The DETERMINISTIC metric is retired instructions via `perf stat`, drift-immune
|
||||
# and reproducible to <0.1%. Pair with cycles for IPC: instructions up but cycles
|
||||
# flat => the OoO A77 absorbed the extra code density (the EE-density-is-neutral
|
||||
# lesson). A pure-density change should show |Δinsns| > |Δcycles|.
|
||||
#
|
||||
# SHELL GOTCHA baked in: savestate/ISO paths contain spaces ("... (USA).iso"). They
|
||||
# are inlined QUOTED into the perf-stat command, NEVER via an unquoted variable —
|
||||
# word-splitting fast-fails the binary and `perf stat` then reports counts from the
|
||||
# ~0.01s startup window (a garbage ~tens-of-millions count that looks real). The
|
||||
# sanity floor below rejects any per-run instruction count < 1e8 for exactly this.
|
||||
#
|
||||
# Usage (run ON the target device, like profile_run.sh):
|
||||
# tools/perf/codegen_ab.sh --device sd865 --scene sotc-01 \
|
||||
# --base pcsx2-eerunner-base --new pcsx2-eerunner-new --runs 3
|
||||
# # --base/--new: an absolute path, or a bare name resolved against device BIN_DIR.
|
||||
# # --renderer null (default) is the most deterministic; use vk only if the change
|
||||
# # touches GS-feeding (GIF/PATH3/XGKICK) codegen.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO="$(cd "$HERE/../.." && pwd)"
|
||||
|
||||
SCENE=""
|
||||
DEVICE=""
|
||||
BASE=""
|
||||
NEW=""
|
||||
RUNS=3
|
||||
RENDERER="null" # null = most deterministic for codegen A/B
|
||||
FRAMES_OVERRIDE=""
|
||||
OUT="$HOME/pcsx2-profiles"
|
||||
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--scene) SCENE="$2"; shift 2;;
|
||||
--device) DEVICE="$2"; shift 2;;
|
||||
--base) BASE="$2"; shift 2;;
|
||||
--new) NEW="$2"; shift 2;;
|
||||
--runs) RUNS="$2"; shift 2;;
|
||||
--renderer) RENDERER="$2"; shift 2;;
|
||||
--frames) FRAMES_OVERRIDE="$2"; shift 2;;
|
||||
--out) OUT="$2"; shift 2;;
|
||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0;;
|
||||
*) die "unknown arg: $1";;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$DEVICE" ]] || die "--device <label> required (see tools/perf/devices/)"
|
||||
[[ -n "$SCENE" ]] || die "--scene <id> required (see tools/perf/scenes/)"
|
||||
[[ -n "$BASE" ]] || die "--base <binary> required"
|
||||
[[ -n "$NEW" ]] || die "--new <binary> required"
|
||||
|
||||
DEVENV="$HERE/devices/$DEVICE.env"
|
||||
[[ -f "$DEVENV" ]] || die "no device profile: $DEVENV"
|
||||
# shellcheck disable=SC1090
|
||||
source "$DEVENV" # provides BIN_DIR, CYCLES_EVENT, optional PIN/ISO_ROOT, exports HOME/LD_LIBRARY_PATH
|
||||
: "${BIN_DIR:?devices/$DEVICE.env must set BIN_DIR}"
|
||||
|
||||
SCENEENV="$HERE/scenes/$SCENE.env"
|
||||
[[ -f "$SCENEENV" ]] || die "no scene profile: $SCENEENV"
|
||||
# shellcheck disable=SC1090
|
||||
source "$SCENEENV" # provides ISO, SAVESTATE, FRAMES, LABEL
|
||||
: "${ISO:?scenes/$SCENE.env must set ISO}"
|
||||
: "${SAVESTATE:?scenes/$SCENE.env must set SAVESTATE}"
|
||||
: "${FRAMES:=300}"
|
||||
[[ -n "$FRAMES_OVERRIDE" ]] && FRAMES="$FRAMES_OVERRIDE"
|
||||
|
||||
# Resolve binary refs: bare name -> BIN_DIR/<name>, else use the path as given.
|
||||
resolve_bin() { case "$1" in */*) echo "$1";; *) echo "$BIN_DIR/$1";; esac; }
|
||||
BASE_BIN="$(resolve_bin "$BASE")"
|
||||
NEW_BIN="$(resolve_bin "$NEW")"
|
||||
|
||||
command -v perf >/dev/null || die "perf not found"
|
||||
[[ -x "$BASE_BIN" ]] || die "base binary not executable: $BASE_BIN"
|
||||
[[ -x "$NEW_BIN" ]] || die "new binary not executable: $NEW_BIN"
|
||||
[[ -f "$ISO" ]] || die "ISO not found: $ISO"
|
||||
[[ -f "$SAVESTATE" ]] || die "savestate not found: $SAVESTATE"
|
||||
|
||||
OUTDIR="$OUT/$DEVICE/codegen-ab/$SCENE"
|
||||
mkdir -p "$OUTDIR"
|
||||
RESULTS="$OUTDIR/results.tsv"
|
||||
: > "$RESULTS" # tag \t run \t instructions \t cycles
|
||||
|
||||
echo "== codegen A/B [$LABEL · $SCENE] renderer=$RENDERER frames=$FRAMES runs=$RUNS =="
|
||||
echo " base = $BASE_BIN"
|
||||
echo " new = $NEW_BIN"
|
||||
echo " metric = perf stat -e instructions,cycles --inherit (deterministic; wallclock is NOT used)"
|
||||
|
||||
# Hottest thermal zone in °C (integer), or "?" when unavailable. Logged per
|
||||
# run so a fan/thermal confound is visible in the record instead of silent.
|
||||
read_temp_c() {
|
||||
local t
|
||||
t="$(cat /sys/class/thermal/thermal_zone*/temp 2>/dev/null | sort -rn | head -1)"
|
||||
[[ -n "$t" ]] && echo $((t / 1000)) || echo "?"
|
||||
}
|
||||
|
||||
# Run ONE measurement of one binary under perf stat; parse retired
|
||||
# instructions + cycles. Callers interleave base/new (see the run loop below):
|
||||
# an all-base-then-all-new order lets a session-long thermal ramp (fan
|
||||
# profile, heat soak) correlate with binary identity and masquerade as a
|
||||
# codegen delta in the cycles metric. Interleaving decorrelates it;
|
||||
# instructions were never exposed (retired counts are clock-independent).
|
||||
# NOTE: $PIN is an intentional word-split command prefix (e.g. `taskset -c 4-7`);
|
||||
# the savestate/ISO are inlined QUOTED so their spaces never split (the gotcha).
|
||||
run_one() {
|
||||
local tag="$1" bin="$2" k="$3"
|
||||
local stat="$OUTDIR/$tag.run$k.stat.txt"
|
||||
local runlog="$OUTDIR/$tag.run$k.out.log"
|
||||
local temp_c; temp_c="$(read_temp_c)"
|
||||
EERUNNER_SYNCMTGS=0 EERUNNER_MTVU=1 EERUNNER_EE=jit \
|
||||
perf stat -e instructions,cycles --inherit -- \
|
||||
${PIN:-} "$bin" --liverun --renderer "$RENDERER" --frames "$FRAMES" \
|
||||
--savestate "$SAVESTATE" --iso "$ISO" \
|
||||
> "$runlog" 2> "$stat" || true
|
||||
# EE-thread CPU seconds from the runner's @THREADCPU@ shutdown lines.
|
||||
# The process-wide perf counts above include the GS/MTVU/worker threads,
|
||||
# which DILUTE an EE-only codegen delta by the EE thread's share of
|
||||
# process cycles (~50% measured on SotC/null) — an EE-thread-scoped
|
||||
# secondary metric keeps effect sizes honest. (2026-07-06 audit.)
|
||||
local ee_s
|
||||
ee_s="$(awk -F': ' '/@THREADCPU@ CPU Thread:/ { gsub(/ s$/,"",$2); if ($2+0 > m) m=$2+0 } END { printf "%.2f", m }' "$runlog")"
|
||||
# EE-thread-SCOPED hardware counters from the runner's @THREADPERF@ lines
|
||||
# (per-tid perf_event_open over the frame window). THE primary metric since
|
||||
# 2026-07-12: deterministic like process insns, but undiluted by GS/MTVU
|
||||
# threads and excluding boot/savestate-load work. Zero when unsupported
|
||||
# (old binary / perf_event_paranoid) — summary falls back to process-wide.
|
||||
# (max across same-named threads: spawned helpers can inherit the "CPU Thread"
|
||||
# comm; the EE core is always the one with the most retired instructions)
|
||||
local ee_ins ee_cyc
|
||||
ee_ins="$(awk '/@THREADPERF@ CPU Thread:/ { for (i=1;i<=NF;i++) if ($i ~ /^instructions=/) { v=$i; sub(/^instructions=/,"",v); if (v+0 > m) m=v+0 } } END { printf "%.0f", m }' "$runlog")"
|
||||
ee_cyc="$(awk '/@THREADPERF@ CPU Thread:/ { for (i=1;i<=NF;i++) if ($i ~ /^cycles=/) { v=$i; sub(/^cycles=/,"",v); if (v+0 > m) m=v+0 } } END { printf "%.0f", m }' "$runlog")"
|
||||
# perf stat writes counts to stderr. Anchor on the EVENT token ($2), so the
|
||||
# "insn per cycle" comment on the instructions line can't be misread as cycles.
|
||||
# On multi-PMU boxes (Apple M2: apple_avalanche_pmu/instructions/u +
|
||||
# apple_blizzard_pmu/instructions/u) the generic event expands to one line per
|
||||
# PMU; take the FIRST numeric match — perf lists the P-core PMU first, and the
|
||||
# pinned emulator threads make the E-core count sub-1% noise. On sd865 the
|
||||
# event is a bare "instructions"/"cycles" token, matched the same way.
|
||||
local parsed ins cyc
|
||||
parsed="$(awk '
|
||||
{ v=$1; gsub(/,/,"",v) }
|
||||
$2 ~ /(^|\/)instructions([:\/]|$)/ && v ~ /^[0-9]+$/ && ins=="" { ins=v }
|
||||
$2 ~ /(^|\/)cycles([:\/]|$)/ && v ~ /^[0-9]+$/ && cyc=="" { cyc=v }
|
||||
END { printf "%s %s", (ins==""?0:ins), (cyc==""?0:cyc) }
|
||||
' "$stat")"
|
||||
ins="${parsed% *}"; cyc="${parsed#* }"
|
||||
# Sanity floor: a real liverun retires billions of instructions. A count this
|
||||
# small means the binary fast-failed (word-split args / bad path / crash) and
|
||||
# perf counted only its startup window — NOT a real measurement.
|
||||
if [[ "${ins:-0}" -lt 100000000 ]]; then
|
||||
echo " $tag run$k: FAST-FAIL (instructions=$ins < 1e8). Check $stat (word-split? bad path? crash)." >&2
|
||||
tail -n 20 "$stat" >&2 || true
|
||||
die "aborting: $tag run$k did not run a real workload"
|
||||
fi
|
||||
# A missing @THREADCPU@ line means the VM never actually ran (e.g. GS init
|
||||
# failure: renderer rejected over ssh) — startup alone can retire billions of
|
||||
# instructions on a slow core, sailing past the 1e8 floor while measuring
|
||||
# nothing. The shutdown thread-CPU report only prints after a real run.
|
||||
if ! awk "BEGIN{exit !(${ee_s:-0} > 0)}"; then
|
||||
echo " $tag run$k: FAST-FAIL (no @THREADCPU@ EE-thread line — VM never ran). Check $runlog." >&2
|
||||
tail -n 15 "$runlog" >&2 || true
|
||||
die "aborting: $tag run$k did not boot the VM (GS init failure?)"
|
||||
fi
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$tag" "$k" "$ins" "$cyc" "${ee_s:-0}" "${ee_ins:-0}" "${ee_cyc:-0}" >> "$RESULTS"
|
||||
awk -v t="$tag" -v k="$k" -v i="$ins" -v c="$cyc" -v tc="$temp_c" -v e="${ee_s:-?}" \
|
||||
-v ei="${ee_ins:-0}" -v ec="${ee_cyc:-0}" \
|
||||
'BEGIN{printf " %-4s run%s: EE insns=%.3fB cycles=%.3fB | proc insns=%.3fB cycles=%.3fB | ee_thread=%ss temp=%s°C\n", t, k, ei/1e9, ec/1e9, i/1e9, c/1e9, e, tc}'
|
||||
}
|
||||
|
||||
# Interleaved run order (base,new,base,new,...) — see run_one for why.
|
||||
for k in $(seq 1 "$RUNS"); do
|
||||
run_one base "$BASE_BIN" "$k"
|
||||
run_one new "$NEW_BIN" "$k"
|
||||
done
|
||||
|
||||
# Median per tag + deltas -> summary.md.
|
||||
python3 - "$OUTDIR" "$RESULTS" "$LABEL" "$SCENE" "$DEVICE" "$RENDERER" "$FRAMES" "$RUNS" "$BASE_BIN" "$NEW_BIN" <<'PY'
|
||||
import statistics, sys, os
|
||||
outdir, results, label, scene, device, rend, frames, runs, base_bin, new_bin = sys.argv[1:11]
|
||||
rows = {"base": {"ins": [], "cyc": [], "ee": [], "eins": [], "ecyc": []},
|
||||
"new": {"ins": [], "cyc": [], "ee": [], "eins": [], "ecyc": []}}
|
||||
for line in open(results):
|
||||
parts = line.rstrip("\n").split("\t")
|
||||
if len(parts) not in (4, 5, 7): continue
|
||||
tag, _k, ins, cyc = parts[:4]
|
||||
if tag in rows:
|
||||
rows[tag]["ins"].append(int(ins)); rows[tag]["cyc"].append(int(cyc))
|
||||
if len(parts) >= 5 and float(parts[4]) > 0:
|
||||
rows[tag]["ee"].append(float(parts[4]))
|
||||
if len(parts) == 7:
|
||||
if int(parts[5]) > 0: rows[tag]["eins"].append(int(parts[5]))
|
||||
if int(parts[6]) > 0: rows[tag]["ecyc"].append(int(parts[6]))
|
||||
|
||||
def med(xs): return statistics.median(xs) if xs else float("nan")
|
||||
def delta(b, n): return (n / b - 1) * 100 if b == b and n == n and b else float("nan")
|
||||
b_ins, b_cyc = med(rows["base"]["ins"]), med(rows["base"]["cyc"])
|
||||
n_ins, n_cyc = med(rows["new"]["ins"]), med(rows["new"]["cyc"])
|
||||
d_ins, d_cyc = delta(b_ins, n_ins), delta(b_cyc, n_cyc)
|
||||
b_ipc = b_ins / b_cyc if b_cyc else float("nan")
|
||||
n_ipc = n_ins / n_cyc if n_cyc else float("nan")
|
||||
be_ins, be_cyc = med(rows["base"]["eins"]), med(rows["base"]["ecyc"])
|
||||
ne_ins, ne_cyc = med(rows["new"]["eins"]), med(rows["new"]["ecyc"])
|
||||
de_ins, de_cyc = delta(be_ins, ne_ins), delta(be_cyc, ne_cyc)
|
||||
have_ee_hw = be_ins == be_ins and ne_ins == ne_ins # NaN-safe
|
||||
|
||||
# Per-tag run spread (max-min)/median, to flag if determinism slipped.
|
||||
def spread(xs): return (max(xs) - min(xs)) / statistics.median(xs) * 100 if xs else float("nan")
|
||||
|
||||
out = os.path.join(outdir, "summary.md")
|
||||
with open(out, "w") as o:
|
||||
o.write(f"# Codegen A/B — {label} · {scene} (renderer={rend})\n\n")
|
||||
o.write(f"- device: **{device}** · runs: {runs} · frames: {frames}\n")
|
||||
o.write(f"- base: `{base_bin}`\n- new: `{new_bin}`\n\n")
|
||||
if have_ee_hw:
|
||||
o.write("**Primary metric: EE-thread-scoped retired instructions** (@THREADPERF@ per-tid "
|
||||
"perf_event_open over the frame window — undiluted by GS/MTVU threads, excludes boot).\n\n")
|
||||
o.write("| EE thread | base (median) | new (median) | Δ |\n|---|---|---|---|\n")
|
||||
o.write(f"| instructions | {be_ins/1e9:.3f}B | {ne_ins/1e9:.3f}B | **{de_ins:+.2f}%** |\n")
|
||||
o.write(f"| cycles | {be_cyc/1e9:.3f}B | {ne_cyc/1e9:.3f}B | **{de_cyc:+.2f}%** |\n")
|
||||
o.write(f"| IPC | {be_ins/be_cyc:.3f} | {ne_ins/ne_cyc:.3f} | {delta(be_ins/be_cyc, ne_ins/ne_cyc):+.2f}% |\n\n")
|
||||
o.write("| whole process | base (median) | new (median) | Δ |\n|---|---|---|---|\n")
|
||||
o.write(f"| instructions | {b_ins/1e9:.3f}B | {n_ins/1e9:.3f}B | {d_ins:+.2f}% |\n")
|
||||
o.write(f"| cycles | {b_cyc/1e9:.3f}B | {n_cyc/1e9:.3f}B | {d_cyc:+.2f}% |\n")
|
||||
o.write(f"| IPC | {b_ipc:.3f} | {n_ipc:.3f} | {(n_ipc/b_ipc-1)*100:+.2f}% |\n")
|
||||
b_ee, n_ee = med(rows["base"]["ee"]), med(rows["new"]["ee"])
|
||||
if b_ee == b_ee and n_ee == n_ee and b_ee: # NaN-safe
|
||||
d_ee = (n_ee / b_ee - 1) * 100
|
||||
o.write(f"| EE-thread CPU s | {b_ee:.2f} | {n_ee:.2f} | {d_ee:+.2f}% |\n")
|
||||
if not have_ee_hw:
|
||||
o.write(f"\n- ⚠ no @THREADPERF@ EE-thread counters in this run (old binary or perf_event_paranoid): "
|
||||
f"the whole-process rows above are the only hardware counts, and an EE-only codegen change "
|
||||
f"is diluted by the GS/MTVU threads' share of process cycles.\n")
|
||||
o.write("\n")
|
||||
sp = rows['base']['eins'] if have_ee_hw else rows['base']['ins']
|
||||
sn = rows['new']['eins'] if have_ee_hw else rows['new']['ins']
|
||||
o.write(f"- run spread (max−min)/median on the primary insns metric: base {spread(sp):.3f}%, "
|
||||
f"new {spread(sn):.3f}% "
|
||||
f"(want <0.1% — larger means determinism slipped; raise --runs or pin harder).\n")
|
||||
pi, pc = (de_ins, de_cyc) if have_ee_hw else (d_ins, d_cyc)
|
||||
verdict = ("density-only (cycles absorbed by OoO)" if abs(pc) + 0.3 < abs(pi)
|
||||
else "real cycle effect")
|
||||
o.write(f"- read: |Δinsns|={abs(pi):.2f}% vs |Δcycles|={abs(pc):.2f}% → **{verdict}**.\n")
|
||||
print(f"wrote {out}")
|
||||
if have_ee_hw:
|
||||
print(f" EE-thread: insns {de_ins:+.2f}% cycles {de_cyc:+.2f}% (proc: insns {d_ins:+.2f}%, cycles {d_cyc:+.2f}%)")
|
||||
else:
|
||||
print(f" instructions: {d_ins:+.2f}% cycles: {d_cyc:+.2f}% (base IPC {b_ipc:.3f} -> new {n_ipc:.3f})")
|
||||
PY
|
||||
echo "done. summary: $OUTDIR/summary.md"
|
||||
@@ -0,0 +1,23 @@
|
||||
# Device profile: Apple M2 Max on Asahi Linux (Fedora Asahi, kernel 6.19).
|
||||
# Sourced by tools/perf/profile_run.sh ($REPO is defined before sourcing).
|
||||
#
|
||||
# This is the convenient first profiling target (full perf access). The SD865
|
||||
# handheld gets its own devices/sd865.env later — same scripts, no code change.
|
||||
|
||||
# Where the clang-perf build put the runner binaries.
|
||||
BIN_DIR="$REPO/build-perf/bin"
|
||||
|
||||
# perf sampling frequency (Hz). 999 ~ avoids 60 Hz vblank harmonics. Drop to 499
|
||||
# if `perf record` warns about throttling on the Apple PMU (verify at go/no-go #1).
|
||||
FREQ_DEFAULT=999
|
||||
|
||||
# Cycle event. M2 exposes TWO PMUs (apple_avalanche_pmu = P-core, apple_blizzard_pmu
|
||||
# = E-core). Plain "cycles" expands to BOTH → two event sections in the report that
|
||||
# sum to ~200%. PCSX2 pins its hot threads to P-cores (it logs "enabling thread
|
||||
# pinning"), so the P-core PMU captures the vast majority of emulation samples (go/no-go
|
||||
# #1 2026-06-24: 2K P-core vs 76 E-core samples). Pin to the P-core PMU for a single,
|
||||
# clean event section. VERIFIED WORKING 2026-06-24 (277 JIT symbols resolved).
|
||||
CYCLES_EVENT="apple_avalanche_pmu/cycles/"
|
||||
|
||||
# Optional: root dir for ISOs, referenced as $ISO_ROOT in scenes/*.env.
|
||||
# ISO_ROOT="$HOME/games/ps2"
|
||||
@@ -0,0 +1,46 @@
|
||||
# Device profile: MANGMI Air X "MQ65" — SM6115-class SoC running ROCKNIX
|
||||
# (Kryo 260: 2-wide in-order-ish A73 golds + A53 silvers, ARMv8.0 NO-LSE, 4K pages,
|
||||
# Adreno 610/turnip). Sourced by codegen_ab.sh / profile_run.sh ON the device.
|
||||
#
|
||||
# This is the PRIMARY DENSITY GATE for EE-JIT codegen work (GE campaign): the 2-wide
|
||||
# A73 pays for emitted-code volume that the SD865's OoO A77 absorbs (MQ65 baseline
|
||||
# 2026-07-12: JIT-emitted +37% cycles/frame vs aether — scratchpad/mq65-aethersx2-
|
||||
# baseline-2026-07-12/NOTES.md). SD865 stays the throughput/regression gate.
|
||||
#
|
||||
# Topology (verified via /proc/cpuinfo + cpufreq policies, 2026-07-12):
|
||||
# cpu0-3 = Kryo 260 Silver, A53-class (CPU part 0x801), policy0, max 2016 MHz
|
||||
# cpu4-7 = Kryo 260 Gold, A73-class (CPU part 0x800), policy4, max 2112 MHz
|
||||
# NOTE the part-id direction: 0x800 = Gold/A73 (the BIG cores, 4-7) — an earlier
|
||||
# memory note had this backwards. Pin to the golds.
|
||||
#
|
||||
# Per-session protocol (no fan on this device — interleaving is the thermal control;
|
||||
# codegen_ab logs SoC temp per run so a ramp is visible):
|
||||
# pin : for p in /sys/devices/system/cpu/cpufreq/policy{0,4}; do echo performance > $p/scaling_governor; done
|
||||
# restore: ... echo ondemand > $p/scaling_governor ...
|
||||
#
|
||||
# Binary provenance: build-rocknix on the M2 (Release -O3 -g, global IPO,
|
||||
# -march=armv8-a -moutline-atomics — MANDATORY: v8.0 has no LSE and Fedora-host
|
||||
# libs that inline LSE atomics SIGILL here; HOST_PAGE_SIZE=4096 for the 4K kernel).
|
||||
# ROMs on this device are .chd (scene envs fall back .iso -> .chd).
|
||||
#
|
||||
# ⚠ turnip-over-ssh reports Vulkan 1.0 -> GS init fails from scripted launches.
|
||||
# Irrelevant for codegen_ab (--renderer null, no GS init); do NOT try --renderer vk
|
||||
# over ssh on this device — live GS runs need the user at the device.
|
||||
|
||||
export HOME=/storage/pcsx2
|
||||
export LD_LIBRARY_PATH=/storage/pcsx2/lib
|
||||
|
||||
# Runner binaries (4K-page armv8.0 build).
|
||||
BIN_DIR="/storage/pcsx2/bin"
|
||||
|
||||
# perf sampling frequency (Hz) for profile_run.sh. 999 ~ avoids vblank harmonics.
|
||||
FREQ_DEFAULT=999
|
||||
|
||||
# Single standard ARM PMU — plain "cycles" is correct.
|
||||
CYCLES_EVENT="cycles"
|
||||
|
||||
# Pin the emulator to the A73 gold cores (see topology note above).
|
||||
PIN="taskset -c 4-7"
|
||||
|
||||
# ISOs/CHDs on the device SD card.
|
||||
export ISO_ROOT="/storage/roms/ps2"
|
||||
@@ -0,0 +1,45 @@
|
||||
# Device profile: Rockchip RK3562 handheld — AISLPC RG43H Pro running dArkOS-style
|
||||
# Debian 13 (glibc 2.41, kernel 5.10, 4K pages). Sourced by codegen_ab.sh / profile_run.sh.
|
||||
#
|
||||
# THE in-order validation target for the EE-SRA pin ladder: 4x Cortex-A53 @ 2.016 GHz,
|
||||
# one homogeneous cluster (no big.LITTLE, so no $PIN needed), 2 GB RAM. The campaign's
|
||||
# load-latency-removal thesis predicts pins pay MORE here than on the OoO A77 —
|
||||
# this device is what "in-order-motivated" in the landing rule refers to.
|
||||
#
|
||||
# Binary provenance: byte-identical copies of the SD865-measured artifacts
|
||||
# (/storage/pcsx2/bin/pcsx2-eerunner-s{0..3} on the Rocknix device) — the clang-sd865
|
||||
# preset has no -mcpu/-march flags, so the code is generic armv8-a with outline
|
||||
# atomics and runs on A53 unchanged. Shared libs are the Fedora dev-box closure
|
||||
# minus glibc (Debian's 2.41 matches), staged to /home/ark/yaps2/lib.
|
||||
#
|
||||
# Thermal: FANLESS. soc-thermal passive throttle trips at 75degC (critical 115degC),
|
||||
# idles ~54degC. All 4 cores run the `performance` governor.
|
||||
#
|
||||
# MEASUREMENT PROTOCOL (validated 2026-07-05): pin the clock BEFORE every session —
|
||||
# for c in 0 1 2 3; do echo 1608000 | sudo tee /sys/devices/system/cpu/cpu$c/cpufreq/scaling_max_freq; done
|
||||
# and restore 2016000 after. At stock 2016 MHz a SotC run throttles 2016->1800->1608
|
||||
# mid-run (temp 67->81degC); at the 1608 cap frequency holds constant for a full run
|
||||
# with temp plateauing ~77degC. Unpinned, even INSTRUCTIONS are invalid on this box:
|
||||
# spin-wait retire scales with clock (same run: 100.5B insns unpinned vs 90.4B
|
||||
# pinned — SD865 measured 90.3B for the identical scene). Constant frequency beats
|
||||
# maximum frequency.
|
||||
|
||||
# Self-contained yaps2 tree in ark's HOME (rootfs is btrfs; /roms is exfat and only
|
||||
# holds ISOs). HOME points here so DataRoot=$HOME/.config/YAPS2 (BIOS, sstates).
|
||||
export HOME=/home/ark/yaps2
|
||||
export LD_LIBRARY_PATH=/home/ark/yaps2/lib
|
||||
|
||||
# Runner binaries (4K-page build, same bytes as the SD865 stage).
|
||||
BIN_DIR="/home/ark/yaps2/bin"
|
||||
|
||||
# perf sampling frequency (Hz). 999 ~ avoids 60 Hz vblank harmonics.
|
||||
FREQ_DEFAULT=999
|
||||
|
||||
# Single standard ARM PMU (perf 6.12, perf_event_paranoid=2 is fine for
|
||||
# per-process `perf stat --inherit`).
|
||||
CYCLES_EVENT="cycles"
|
||||
|
||||
# No PIN: 4 homogeneous A53 cores — the emulator threads get all of them.
|
||||
|
||||
# ISOs on the exfat roms partition (108G).
|
||||
export ISO_ROOT="/roms/ps2"
|
||||
@@ -0,0 +1,38 @@
|
||||
# Device profile: Snapdragon 865 (SM8250) handheld — Retroid Flip 2 running ROCKNIX
|
||||
# (glibc 2.41, kernel 7.0.x, 4K pages, Adreno 650 Vulkan). Sourced by profile_run.sh.
|
||||
#
|
||||
# This is the REAL deploy target (the M2 is only a dev/validation box). The CPU shape
|
||||
# measured here — not the M2's — gates the neither cherry-pick relevance decisions.
|
||||
#
|
||||
# Topology: 8 cores, reported flat 0..7 in one cluster, but physically big.LITTLE:
|
||||
# cores 0-3 = A55-class silver (CPU part 0x805), cores 4-7 = A77-class (part 0xd0d).
|
||||
# PCSX2 does NOT auto-split the cluster, so we force the whole emulator onto the A77
|
||||
# cores with taskset (see $PIN) — the perf-relevant performant cores.
|
||||
#
|
||||
# Binary provenance: built on the M2 from the clang-sd865 preset (CMakeUserPresets.json:
|
||||
# build-perf clone with HOST_PAGE_SIZE=0x1000 + HOST_CACHE_LINE_SIZE=64), then staged to
|
||||
# /storage/pcsx2 with its full dynamic-lib closure (the M2 16K-page build aborts here on
|
||||
# the page-size hardware check). ROMs live on the device SD card at /storage/roms/ps2.
|
||||
|
||||
# Self-contained PCSX2 tree on the device's writable ext4 (root's HOME; /root is RO
|
||||
# squashfs on ROCKNIX). HOME points here so DataRoot=$HOME/.config/PCSX2 (BIOS, sstates,
|
||||
# jitdump cache) all stay under it.
|
||||
export HOME=/storage/pcsx2
|
||||
export LD_LIBRARY_PATH=/storage/pcsx2/lib
|
||||
|
||||
# Runner binaries (4K-page build).
|
||||
BIN_DIR="/storage/pcsx2/bin"
|
||||
|
||||
# perf sampling frequency (Hz). 999 ~ avoids 60 Hz vblank harmonics.
|
||||
FREQ_DEFAULT=999
|
||||
|
||||
# Cycle event. The SD865 has a single standard ARM PMU (unlike the M2's dual Apple
|
||||
# PMU), so plain "cycles" is correct — one clean event section.
|
||||
CYCLES_EVENT="cycles"
|
||||
|
||||
# Pin the emulator to the A77 big cores (see topology note above).
|
||||
PIN="taskset -c 4-7"
|
||||
|
||||
# ISOs on the device SD card. scenes/*.env reference this as $ISO_ROOT; exported so the
|
||||
# `${ISO_ROOT:-<m2 default>}` guard in each scene picks it up.
|
||||
export ISO_ROOT="/storage/roms/ps2"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,211 @@
|
||||
# FX combined telemetry session — 2026-07-11
|
||||
|
||||
One instrumented deploy answers three FX ceilings at once (FX-03 SMC words/s,
|
||||
FX-16 fusable-branch share, FX-07 allocator evictions), plus the FX-15 THP
|
||||
madvise rides along for a later iTLB A/B. Ledger: `annotations/_fex/triage/`.
|
||||
|
||||
## Binary
|
||||
|
||||
`build-rocknix` built with all three census gates ON:
|
||||
|
||||
```
|
||||
CMAKE_CXX_FLAGS = -march=armv8-a -moutline-atomics -O3 -g \
|
||||
-DEE_SMC_TELEM=1 -DEE_BRSHAPE_CENSUS=1 -DJIT_ALLOC_CENSUS=1
|
||||
```
|
||||
|
||||
⚠️ **REVERT AFTER THE CAMPAIGN** — EE_SMC_TELEM emits real instructions at
|
||||
every ProtMode_Manual block head; A/B timing binaries must not carry it:
|
||||
|
||||
```
|
||||
cmake -DCMAKE_CXX_FLAGS:STRING="-march=armv8-a -moutline-atomics -O3 -g" /home/bmd/pcsx2/build-rocknix
|
||||
```
|
||||
|
||||
FX-15's madvise(MADV_HUGEPAGE) is in the binary UNCONDITIONALLY (default-on
|
||||
product behavior, Linux only). The A/B ran with a `YAPS2_NO_THP=1` env gate
|
||||
as the off-arm; that gate was REMOVED post-A/B (env gates are testing-only)
|
||||
— the harness now uses a `prctl(PR_SET_THP_DISABLE)` exec wrapper instead,
|
||||
verified on-device (zero arena hugepages under the wrapper).
|
||||
Device THP mode confirmed `madvise` on the Flip2.
|
||||
|
||||
## Device
|
||||
|
||||
Retroid Pocket Flip2 (SD865-class) — `root@192.168.4.42`, deployed via
|
||||
`~/rocknix-deploy/deploy-rocknix.sh` to `/storage/pcsx2`.
|
||||
|
||||
## Protocol
|
||||
|
||||
1. Deploy (done by Claude): `~/rocknix-deploy/deploy-rocknix.sh`
|
||||
2. User: play each title ~5+ min of REAL gameplay (not menus):
|
||||
SotC, R&C UYA, Katamari. Optional 4th: Burnout 3 (SMC-suspect class).
|
||||
3. **All census output lands in `/storage/.config/YAPS2/logs/emulog.txt`**
|
||||
(NOT stdout) as `JITTELEM` lines:
|
||||
- `JITTELEM smc:` — rate line every 5s **only while manual-SMC blocks run**
|
||||
(silence = the tax is zero on that title), split real-SMC vs
|
||||
`ts` (thread-stack-forced 0x81/0x80001 pages — different fix shape).
|
||||
Bar for funding the FX-03 NEON rewrite: >= ~1M words/s on some title.
|
||||
- `JITTELEM brshape[tick/ee-reset/shutdown]:` — 3 lines: fwd-cond share,
|
||||
dist histogram, FUSABLE plain/likely % of blocks + kill reasons. FX-16
|
||||
wants the FUSABLE share; single-digit % = fusion is dead.
|
||||
- `JITTELEM alloc[tick/ee-reset/shutdown]:` — gpr live-evict ee/iop/other,
|
||||
ee-neon dead vs LIVE, mVU VF/VI evictions. FX-07: ee expected 0;
|
||||
iop nonzero funds the dead-first rider; mVU VF vs 0.1%-of-VU-ops bar.
|
||||
- brshape/alloc also print on a 30s `tick` so a hard kill loses <=30s.
|
||||
4. Collect per title: `ssh root@192.168.4.42 'grep JITTELEM /storage/.config/YAPS2/logs/emulog.txt'`
|
||||
— the emulog restarts per launch, so grab (or let Claude grab) after each title.
|
||||
|
||||
## Title results
|
||||
|
||||
### Baldur's Gate: Dark Alliance (user session, 463s, clean shutdown)
|
||||
|
||||
- **SMC: real-SMC is ZERO** (0 seqs ever emitted). The entire manual tax is
|
||||
the `contains_thread_stack` heuristic class: the SAME 23 seqs / 207 words
|
||||
as SotC (kernel 0x81/0x80001 pages — universal, game-independent),
|
||||
running 2.0–2.7M w/s sustained; session totals 101M checks / 996M words
|
||||
(avg 2.15M w/s, 9.9 words/check) ≈ ~18M insns/s ≈ ~0.6% of an A77.
|
||||
**Reframe for FX-03**: these blocks are excluded from manual_counter
|
||||
promotion so they re-check forever — the fix menu includes handling the
|
||||
ts class specifically (promotion/narrower checks), not just NEON widening.
|
||||
- **brshape**: 12,888 blocks | fwd-cond 50.8% | FUSABLE plain 6.9% +
|
||||
likely 0.8% + diamond 41 | killers: ctrl 1702, sys 11, xpage 7.
|
||||
Nearly identical shape to SotC — fusable share looks like a ~7% cross-title
|
||||
constant.
|
||||
- **alloc**: EE live-evict 1 (noise), IOP 0, ee-neon LIVE 5, mVU VF 16/3925
|
||||
(0.4%), VI 180. Second real-workload nil.
|
||||
|
||||
Raw log archived in the session scratchpad (`fx-telem-logs/bgda-2310.emulog.txt`).
|
||||
|
||||
### R&C Up Your Arsenal (user session, 349s, clean shutdown)
|
||||
|
||||
- **SMC: negligible on this title.** Real-SMC bursty 0–0.024M w/s (592k
|
||||
checks / 3.06M words total; 3,143 seqs / 19k words EMITTED — plenty of
|
||||
manual pages get compiled, they're just cold). ts class only 0.05M w/s
|
||||
(110 seqs — a bigger set than BG:DA/SotC's 23, but ~40× colder).
|
||||
Combined ≈ 0.02% of an A77. **Key insight: the ts block SET is
|
||||
universal but its RATE is not** — it tracks kernel/scheduler pressure
|
||||
(BG:DA 2.4M w/s, SotC ~0.3–1M, UYA 0.05M).
|
||||
- **brshape**: 42,555 blocks | fwd-cond 43.9% | FUSABLE plain 4.5% +
|
||||
likely 0.7% + diamond 75. Cross-title fusable range now 4.5–7.7%.
|
||||
- **alloc**: EE 0, IOP 0, ee-neon LIVE 0, mVU VF 116/31,829 (0.36%),
|
||||
VI 1,477. Third real-workload nil.
|
||||
|
||||
Raw log: `fx-telem-logs/uya-*.emulog.txt` (session scratchpad).
|
||||
|
||||
### Shadow of the Colossus (user session, 576s incl. boss battle, clean shutdown)
|
||||
|
||||
- **SMC**: the one title so far with REAL manual-SMC execution — steady
|
||||
0.15–0.18M w/s (totals 23.4M checks / 123.5M words, avg 0.21M w/s over
|
||||
3,338 seqs, 5.3 words/check) + ts steady 0.33M w/s (104 seqs / 805
|
||||
words, 10.8 words/check). Combined ≈ 0.54M w/s ≈ 0.14% of an A77.
|
||||
- **brshape**: 59,178 blocks | fwd-cond 47.5% | FUSABLE plain 7.6% +
|
||||
likely 0.9% + diamond 73 — confirms the smoke.
|
||||
- **alloc**: EE 1, IOP 0, ee-neon LIVE 0, mVU VF 51/10,031 (0.5%), VI 233.
|
||||
|
||||
### Burnout 3: Takedown (user session, 616s, clean shutdown)
|
||||
|
||||
- **SMC: mild** — the WaitLoop-history title is NOT SMC-hot at runtime:
|
||||
real-SMC 120 seqs / 1,205 words emitted, avg 0.011M w/s; ts 0.096M w/s.
|
||||
Combined 0.107M w/s ≈ 0.03% of an A77.
|
||||
- **brshape**: 50,207 blocks | fwd-cond 46.3% | FUSABLE plain 5.4% +
|
||||
likely 0.1% (B3 barely uses branch-likely) | its >32 bucket is the
|
||||
largest of any title (8,070).
|
||||
- **alloc**: EE 0, IOP 0, ee-neon LIVE 3, mVU VF 265/74,408 (0.36%,
|
||||
94 dirty), VI 1,838 — heaviest mVU workload yet, same ~0.4% VF rate.
|
||||
|
||||
### Katamari Damacy (user session, 102s, clean shutdown — ran ON the FX-03a binary)
|
||||
|
||||
- **SMC: real-SMC ZERO** (0 seqs ever emitted); ts class steady 0.88–1.02M
|
||||
w/s (totals 9.41M checks / 75.7M words, avg 0.89M w/s, 8.0 words/check) —
|
||||
2nd-highest ts rate after BG:DA. On the FX-03a emission (~1.3 insns/word)
|
||||
that's ~1.2M insns/s ≈ **0.04% of an A77** — confirmation the fix landed
|
||||
where it matters on a second heavy-ts title.
|
||||
- **brshape**: 10,920 blocks | fwd-cond 39.9% | FUSABLE plain 5.1% +
|
||||
likely 0.7% + diamond 25 | killers: ctrl 1,415, sys 43, xpage 7.
|
||||
- **alloc**: EE 0, IOP 0, ee-neon LIVE 0, mVU VF 417/25,464 (1.6%,
|
||||
63 dirty), VI 2,161. Fifth real-workload nil on every EE/IOP channel.
|
||||
|
||||
### FX-03 five-title table (avg session words/s)
|
||||
|
||||
| title | real-SMC | ts (kernel pages) | combined | % of one A77 (@7 insn/word, pre-FX-03a) |
|
||||
|---|---|---|---|---|
|
||||
| BG:DA | 0 | 2.15M | 2.15M | ~0.6% |
|
||||
| Katamari | 0 | 0.89M | 0.89M | ~0.25% |
|
||||
| SotC | 0.21M | 0.33M | 0.54M | ~0.14% |
|
||||
| Burnout 3 | 0.011M | 0.096M | 0.11M | ~0.03% |
|
||||
| UYA | 0.009M | 0.05M | 0.06M | ~0.02% |
|
||||
|
||||
Only BG:DA clears the 1M-w/s bar, and it does so ENTIRELY via the
|
||||
contains_thread_stack heuristic class. FX-03a (618fe597c) cut the per-word
|
||||
cost ~6× for all rows; BG:DA re-run and the Katamari row above are on the
|
||||
new emission.
|
||||
|
||||
Raw logs: `fx-telem-logs/*.emulog.txt` (session scratchpad).
|
||||
|
||||
## Campaign verdicts (2026-07-12 eval)
|
||||
|
||||
- **FX-03 → applied** (618fe597c, FX-03a): reframed from "NEON-widen for
|
||||
SMC-heavy titles" to "kernel-page heuristic tax"; hoisted-base Ldp/Ccmp
|
||||
chain took it from ~7–9 to ~1.3 insns/word. User-validated on BG:DA
|
||||
(behaviorally transparent, 0 discards) and Katamari.
|
||||
- **FX-16 → wontfix**: static fusable pool thin (plain 4.5–7.7%, diamond
|
||||
~0) and the per-site saving collapsed — pins ride through tails, short
|
||||
blocks already skip the event check, EE dynamic-allocator state at tails
|
||||
is near-empty. Est. 0.05–0.3% of EE-thread insns vs high emitter
|
||||
complexity. Reopen only if RK3562 becomes binding AND an exec-weighted
|
||||
counter shows fusable sites disproportionately hot.
|
||||
- **FX-07 → wontfix**: eviction never fires in real workloads (EE 0–1,
|
||||
IOP 0, NEON LIVE 0–5 across five titles); mVU VF 0.36–1.6% with tiny
|
||||
dirty subset, not worth an ABI bump. Footnote: VI is the only churning
|
||||
channel (5–7× VF) — future mVU perf work should look at VI pool sizing,
|
||||
not eviction policy.
|
||||
- **FX-15 → applied, kept default-ON** (faaf117bf). SD865 iTLB A/B
|
||||
(fx15-ab-1: 4 titles × 4 ABBA rounds, same eerunner, `YAPS2_NO_THP=1`
|
||||
toggle, fan pinned, `tools/perf/fx15_thp_ab.sh` + `fx15_thp_report.py`):
|
||||
|
||||
| title | ITLB_WALK | L1I_TLB_REFILL | cycles | wall |
|
||||
|---|---|---|---|---|
|
||||
| SotC | −35.4% | −8.7% | +0.10% | +0.36% |
|
||||
| Katamari | −24.9% | −33.8% | +0.08% | +0.00% |
|
||||
| OutRun | −22.5% | −4.3% | −0.46% | −0.39% |
|
||||
| UYA | −14.6% | −58.9% | +0.11% | +1.09% |
|
||||
|
||||
Mechanism confirmed, throughput neutral: baseline A77 iTLB pressure is
|
||||
already negligible (ITLB_WALK 0.008–0.025/kinsn — one walk per 40k–125k
|
||||
insns; the 1280-entry L2 TLB covers the ~8MB live JIT footprint even in
|
||||
4K pages) and STALL_FRONTEND is unchanged, so frontend stalls are
|
||||
icache/branch, not TLB. Kept: zero cost, no regression, and the
|
||||
in-order A53 (RK3562, far smaller TLBs) should benefit more — optional
|
||||
RK wallclock A/B if that device returns to rotation. THP probes: 8 MB
|
||||
of hugepages materialized per title, exactly in the two madvised rec
|
||||
ranges (EE+IOP at 0x111d00000, mVU0+1 at 0x118d00000).
|
||||
|
||||
Campaign CLOSED 2026-07-12. Remaining FX queue: FX-05 (mVU digest DX),
|
||||
FX-14/FX-09 (someday). Device left on the clean (census-off) binary;
|
||||
build-rocknix flags reverted.
|
||||
|
||||
## Smoke result (SotC gameplay savestate, 75s, 2026-07-11)
|
||||
|
||||
**FX-03 bar MET on the first title**: sustained 0.78–1.49M manual words/s
|
||||
(avg ~1.1M) through real gameplay — ~900k check executions/s over a tiny
|
||||
manual set (224 seqs / 1253 words emitted, avg 5.6 words/block). At ~7
|
||||
insns/word that's ~40M insns/s of re-check tax on the EE thread. The
|
||||
ts-vs-real split (added after the smoke run) will say whether it's the
|
||||
thread-stack heuristic pages or real SMC pages.
|
||||
|
||||
## First data already in hand (M2, recompiler_tests workload)
|
||||
|
||||
`JITTELEM alloc[exit]: gpr-pool 159918 allocs | live-evict ee 0 iop 9347 other 0 (dirty 3607)`
|
||||
`JITTELEM alloc[exit]: ee-neon 600 allocs | evict dead 34 LIVE 18 || mVU 5338 vf-allocs | VF evict 2020 (dirty 0) VI evict 3`
|
||||
|
||||
EE=0 confirms the FX-07 audit; IOP nonzero already on synthetic load.
|
||||
|
||||
## Known pre-existing noise (do not attribute to the gates)
|
||||
|
||||
- vurunner corpus on tip is NOT clean: cycle-divergence lines on most game
|
||||
dirs (E-bit class — partially matches the MGS2-era benign catalog), large
|
||||
vumem byte-diff dumps on 13/19 dirs (Okami 838k lines, AC3 708k, DMC3
|
||||
459k), and `MTGS::WaitGS` asserts/aborts on 3 dirs (Burnout 3, GTA LCS,
|
||||
GTA SA). **Confirmed pre-existing**: gates-OFF control vs gates-ON telem
|
||||
binaries produce byte-identical outputs on all 19 dirs (corpus_ab in the
|
||||
2026-07-11 session scratchpad). Needs its own triage — how much is the
|
||||
known-benign XGKICK/PATH1/flag-carry class vs a real regression from the
|
||||
2026-07-09/10 landings, and when the corpus was last actually green.
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/bin/bash
|
||||
# fx15_thp_ab.sh — interleaved A/B for FX-15 (MADV_HUGEPAGE on the JIT code caches).
|
||||
#
|
||||
# ONE pcsx2-eerunner binary, two arms: A = prctl(PR_SET_THP_DISABLE) before
|
||||
# exec (4K pages, baseline — the flag survives execve, so no in-tree gate),
|
||||
# B = default (THP madvise on). Both arms exec through the same python3
|
||||
# wrapper so interpreter startup cancels. Same savestate-anchored --liverun
|
||||
# workloads as
|
||||
# callret_ab.sh, ABBA-interleaved so thermal/clock drift cancels, perf stat
|
||||
# around each run. Throughput truth = wall seconds per N frames (unlimited
|
||||
# limiter, headless --renderer null); mechanism check = ITLB_WALK (r35) and
|
||||
# L1I_TLB_REFILL (r02) per kinsn.
|
||||
#
|
||||
# During the first B round of each game a background probe snapshots
|
||||
# AnonHugePages from /proc/<pid>/smaps for the JIT arena (VA >= 0x100000000)
|
||||
# into <out>/<tag>.thp — if those are all zero the A/B is null by construction
|
||||
# (madvise didn't take) and the numbers mean nothing.
|
||||
#
|
||||
# Usage:
|
||||
# fx15_thp_ab.sh -b <eerunner> -g <games.cfg> [-f frames] [-r rounds]
|
||||
# [-c cpulist] [-o outdir]
|
||||
# games.cfg lines: name|savestate|iso
|
||||
#
|
||||
# Device notes (SD865/Rocknix): run with -c 4-7 (A77 cluster). Fan pinned to
|
||||
# max and RESTORED to auto on exit. EERUNNER_SYNCMTGS=0 always (bare --liverun
|
||||
# wedges at frame 1 without it).
|
||||
set -u
|
||||
|
||||
FRAMES=2000
|
||||
ROUNDS=4
|
||||
CPUS=""
|
||||
OUT="fx15-thp-ab-$(date +%m%d-%H%M%S)"
|
||||
BIN="" GAMES=""
|
||||
|
||||
while getopts "b:g:f:r:c:o:" opt; do
|
||||
case $opt in
|
||||
b) BIN=$OPTARG ;;
|
||||
g) GAMES=$OPTARG ;;
|
||||
f) FRAMES=$OPTARG ;;
|
||||
r) ROUNDS=$OPTARG ;;
|
||||
c) CPUS=$OPTARG ;;
|
||||
o) OUT=$OPTARG ;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
done
|
||||
[ -x "$BIN" ] && [ -f "$GAMES" ] || {
|
||||
echo "usage: $0 -b <eerunner> -g <games.cfg> [-f frames] [-r rounds] [-c cpus] [-o outdir]" >&2
|
||||
exit 2
|
||||
}
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# --- device pinning (no-op off-device) --------------------------------------
|
||||
FAN=""
|
||||
restore() {
|
||||
if [ -n "$FAN" ]; then
|
||||
echo 2 > "$FAN/pwm1_enable" 2>/dev/null
|
||||
echo "fan restored to auto ($FAN)"
|
||||
fi
|
||||
}
|
||||
trap restore EXIT
|
||||
for h in /sys/class/hwmon/hwmon*; do
|
||||
[ -f "$h/name" ] && [ "$(cat "$h/name" 2>/dev/null)" = "pwmfan" ] || continue
|
||||
FAN=$h
|
||||
echo 1 > "$FAN/pwm1_enable" && echo 255 > "$FAN/pwm1"
|
||||
echo "fan pinned to max ($FAN)"
|
||||
done
|
||||
for g in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
|
||||
echo performance > "$g" 2>/dev/null
|
||||
done
|
||||
echo "global THP mode: $(cat /sys/kernel/mm/transparent_hugepage/enabled 2>/dev/null)" | tee "$OUT/thp-mode.txt"
|
||||
|
||||
# Rocknix device: the eerunner binaries need the bundled libs (libjpeg/lz4).
|
||||
[ -d /storage/pcsx2/lib ] && export LD_LIBRARY_PATH=/storage/pcsx2/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}
|
||||
|
||||
# TLB event set (A77: 6 programmable + fixed cycles, no multiplexing):
|
||||
# r02 L1I_TLB_REFILL, r35 ITLB_WALK, r34 DTLB_WALK, r23 STALL_FRONTEND
|
||||
EVENTS="cycles,instructions,r02,r35,r34,r23"
|
||||
TASKSET=""
|
||||
[ -n "$CPUS" ] && TASKSET="taskset -c $CPUS"
|
||||
|
||||
probe_thp() { # $1=outfile — sample JIT-arena AnonHugePages of the live run
|
||||
sleep 25
|
||||
local pid
|
||||
pid=$(pgrep -n -f pcsx2-eerunner)
|
||||
[ -n "$pid" ] || { echo "no pid" > "$1"; return; }
|
||||
# JIT arena = 0x1_00000000..: 9-hex-digit VMA start beginning with 1.
|
||||
# (busybox awk: no strtonum)
|
||||
awk '/^[0-9a-f]+-[0-9a-f]+ /{vma=$1; keep=(index($1,"-")==10 && substr($1,1,1)=="1")}
|
||||
/^AnonHugePages:/{if (keep && $2 > 0) print vma, $0}' \
|
||||
"/proc/$pid/smaps" > "$1" 2>/dev/null
|
||||
echo "total-kB $(awk '{s+=$3} END{print s+0}' "$1")" >> "$1"
|
||||
}
|
||||
|
||||
# Exec wrappers: PR_SET_THP_DISABLE (41) is inherited across execve, so arm A
|
||||
# disables THP from OUTSIDE the process; arm B execs through the identical
|
||||
# wrapper minus the prctl so interpreter startup is symmetric.
|
||||
WRAP_A='import ctypes,os,sys; ctypes.CDLL(None).prctl(41,1,0,0,0); os.execv(sys.argv[1], sys.argv[1:])'
|
||||
WRAP_B='import os,sys; os.execv(sys.argv[1], sys.argv[1:])'
|
||||
|
||||
run_one() { # $1=arm(A|B) $2=name $3=state $4=iso $5=round
|
||||
local tag="$2-$1-r$5" wrap="$WRAP_B"
|
||||
[ "$1" = A ] && wrap="$WRAP_A"
|
||||
echo "=== $tag ($(date +%H:%M:%S)) ==="
|
||||
if [ "$1" = B ] && [ "$5" = 1 ]; then
|
||||
probe_thp "$OUT/$tag.thp" &
|
||||
fi
|
||||
EERUNNER_SYNCMTGS=0 $TASKSET perf stat -e "$EVENTS" \
|
||||
-o "$OUT/$tag.stat" -- \
|
||||
python3 -c "$wrap" \
|
||||
"$BIN" --liverun --renderer null --savestate "$3" --iso "$4" \
|
||||
--frames "$FRAMES" > "$OUT/$tag.log" 2>&1
|
||||
echo "exit=$? $(grep -o 'PerfLog session:.*' "$OUT/$tag.log" | tail -1)"
|
||||
sleep 5 # cool-down between runs
|
||||
}
|
||||
|
||||
for round in $(seq 1 "$ROUNDS"); do
|
||||
# ABBA: odd rounds run A first, even rounds B first.
|
||||
if [ $((round % 2)) -eq 1 ]; then order="A B"; else order="B A"; fi
|
||||
for arm in $order; do
|
||||
while IFS='|' read -r name state iso; do
|
||||
case $name in ''|\#*) continue ;; esac
|
||||
run_one "$arm" "$name" "$state" "$iso" "$round"
|
||||
done < "$GAMES"
|
||||
done
|
||||
done
|
||||
wait
|
||||
echo "done — stats in $OUT/*.stat, THP probes in $OUT/*-B-r1.thp"
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
# fx15_thp_report.py — summarize an fx15_thp_ab.sh output dir.
|
||||
#
|
||||
# Per (game, arm): mean/stdev of wall (PerfLog session line — the throughput
|
||||
# truth), cycles, instructions, IPC, and the TLB mechanism events per kinsn
|
||||
# (r02 L1I_TLB_REFILL, r35 ITLB_WALK, r34 DTLB_WALK, r23 STALL_FRONTEND).
|
||||
# Then B (THP) vs A (YAPS2_NO_THP=1) deltas. Counters are whole-process;
|
||||
# ABBA interleave + identical boot work make the delta meaningful.
|
||||
#
|
||||
# usage: fx15_thp_report.py <outdir>
|
||||
|
||||
import re
|
||||
import statistics
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
EVENTS = ["cycles", "instructions", "r02", "r35", "r34", "r23"]
|
||||
EVENT_LABEL = {
|
||||
"r02": "L1I_TLB_REFILL",
|
||||
"r35": "ITLB_WALK",
|
||||
"r34": "DTLB_WALK",
|
||||
"r23": "STALL_FRONTEND",
|
||||
}
|
||||
|
||||
|
||||
def parse_stat(path):
|
||||
vals = {}
|
||||
for line in path.read_text().splitlines():
|
||||
m = re.match(r"\s*([\d,]+)\s+(\S+)", line)
|
||||
if m and m.group(2) in EVENTS:
|
||||
vals[m.group(2)] = int(m.group(1).replace(",", ""))
|
||||
m = re.match(r"\s*([\d.]+)\s+seconds time elapsed", line)
|
||||
if m:
|
||||
vals["elapsed"] = float(m.group(1))
|
||||
return vals
|
||||
|
||||
|
||||
def parse_wall(logpath):
|
||||
if not logpath.exists():
|
||||
return None
|
||||
m = None
|
||||
for line in logpath.read_text(errors="replace").splitlines():
|
||||
mm = re.search(r"PerfLog session: (\d+) frames in ([\d.]+)s wall", line)
|
||||
if mm:
|
||||
m = float(mm.group(2))
|
||||
return m
|
||||
|
||||
|
||||
def main():
|
||||
outdir = Path(sys.argv[1])
|
||||
runs = defaultdict(list) # (game, arm) -> [dict]
|
||||
for stat in sorted(outdir.glob("*.stat")):
|
||||
m = re.match(r"(.+)-([AB])-r(\d+)\.stat", stat.name)
|
||||
if not m:
|
||||
continue
|
||||
game, arm = m.group(1), m.group(2)
|
||||
vals = parse_stat(stat)
|
||||
wall = parse_wall(stat.with_suffix(".log"))
|
||||
if wall is not None:
|
||||
vals["wall"] = wall
|
||||
if "cycles" in vals:
|
||||
runs[(game, arm)].append(vals)
|
||||
|
||||
games = sorted({g for g, _ in runs})
|
||||
for game in games:
|
||||
print(f"\n=== {game} ===")
|
||||
agg = {}
|
||||
for arm in "AB":
|
||||
rr = runs.get((game, arm), [])
|
||||
if not rr:
|
||||
continue
|
||||
a = {}
|
||||
for k in ["wall", "elapsed", *EVENTS]:
|
||||
xs = [r[k] for r in rr if k in r]
|
||||
if xs:
|
||||
a[k] = (statistics.mean(xs),
|
||||
statistics.stdev(xs) if len(xs) > 1 else 0.0)
|
||||
agg[arm] = a
|
||||
label = "A thp-off" if arm == "A" else "B thp-on "
|
||||
ki = a["instructions"][0] / 1e3
|
||||
per_ki = " ".join(
|
||||
f"{EVENT_LABEL[e]}={a[e][0] / ki:.3f}"
|
||||
for e in ["r02", "r35", "r34"] if e in a)
|
||||
stallpct = 100.0 * a["r23"][0] / a["cycles"][0] if "r23" in a else 0
|
||||
wall = f"wall={a['wall'][0]:.2f}s±{a['wall'][1]:.2f}" if "wall" in a else ""
|
||||
print(f" {label} n={len(rr)} {wall} cycles={a['cycles'][0]/1e9:.3f}G "
|
||||
f"IPC={a['instructions'][0]/a['cycles'][0]:.3f} "
|
||||
f"| per-kinsn: {per_ki} | stall_fe={stallpct:.1f}%cyc")
|
||||
if "A" in agg and "B" in agg:
|
||||
def delta(k):
|
||||
return 100.0 * (agg["B"][k][0] - agg["A"][k][0]) / agg["A"][k][0]
|
||||
parts = [f"wall {delta('wall'):+.2f}%"] if "wall" in agg["A"] and "wall" in agg["B"] else []
|
||||
parts += [f"cycles {delta('cycles'):+.2f}%"]
|
||||
parts += [f"{EVENT_LABEL[e]} {delta(e):+.1f}%"
|
||||
for e in ["r02", "r35", "r34", "r23"]
|
||||
if e in agg["A"] and e in agg["B"]]
|
||||
print(f" B-vs-A: {' '.join(parts)}")
|
||||
|
||||
thps = sorted(outdir.glob("*.thp"))
|
||||
if thps:
|
||||
print("\nTHP probes (arm B, round 1):")
|
||||
for t in thps:
|
||||
total = [l for l in t.read_text().splitlines() if l.startswith("total-kB")]
|
||||
print(f" {t.name}: {total[0] if total else 'no data'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,252 @@
|
||||
# Icache-locality Phase 1 — measured verdict on the hot/cold arena question (2026-07-10)
|
||||
|
||||
Campaign context: pstef (neither/LRPS2) argued from his Beetle Saturn JIT that
|
||||
"cache coherency" is a big JIT problem — an interpreter is a small icache-resident
|
||||
loop with the guest program streaming through *dcache*, while a JIT expands the
|
||||
guest program into host code that must live in *icache*, so on a small-L1I core the
|
||||
JIT's miss rate can eat its dynamic-instruction win. His lrps2 P3-B (hot/cold block
|
||||
split + segmented slab arena) is the structural answer. Prior yaps2 evidence had
|
||||
parked the arena (differences-2026-07-01/05-ee-dispatch-jitslab.md) and closed the
|
||||
iTLB hypothesis (MADV_HUGEPAGE reverted), but NO absolute L1I / frontend-stall /
|
||||
working-set numbers existed on either tier. This fills that hole.
|
||||
|
||||
Plan: ~/.claude/plans/streamed-soaring-hennessy.md. Tools this produced:
|
||||
`tools/perf/icache_stat.sh` (EE-thread-scoped PMU shares, on-device),
|
||||
`ee_code_census.py --footprint` (hot working set vs L1I from jitdump+perf.data),
|
||||
`pcsx2/arm64/JitTelemetry.h` (Devel-only JITTELEM maintenance counters).
|
||||
|
||||
## 1a. EE-thread PMU shares (perf stat -t, 10s windows ×2 runs, median)
|
||||
|
||||
Steady = attach 8s after EE thread appears; storm = attach at thread birth
|
||||
(savestate-load compile burst, 8s window). Fan pinned max + performance governor
|
||||
(SD865, A77 @2.42GHz, 64KB L1I); 1608MHz pin (RK3562, A53, 32KB L1I, fanless).
|
||||
Binary: Release build-sd865 tip (nightly-58-g891f3b519); RK: build-rk3562
|
||||
(-march=armv8-a -moutline-atomics) same source.
|
||||
|
||||
### SD865 (Retroid Flip 2, cores 4-7)
|
||||
|
||||
| scene·mode | IPC | frontend-stall | backend-stall | L1I MPKI | L1I miss% | iTLB MPKI | BrMis/kinsn |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| SotC steady | 1.86 | **17.6%** | 29.9% | 4.83 | 1.84% | 0.13 | 1.90 |
|
||||
| UYA steady | 2.57 | **7.9%** | 26.2% | 4.06 | 1.68% | 0.01 | 0.24 |
|
||||
| SotC storm | 1.67 | 21.2% | 29.4% | 20.83 | 7.58% | 0.73 | 2.44 |
|
||||
| UYA storm | 2.34 | 14.2% | 22.6% | 10.42 | 4.52% | 0.18 | 1.12 |
|
||||
|
||||
### RK3562 (RG43H Pro; A53 doesn't implement STALL_FRONTEND/BACKEND → NA)
|
||||
|
||||
| scene·mode | IPC | L1I MPKI | L1I miss% | iTLB MPKI | BrMis/kinsn |
|
||||
|---|---|---|---|---|---|
|
||||
| SotC steady | 0.45 | **15.99** | 2.27% | 0.13 | 6.11 |
|
||||
| UYA steady | 0.54 | **9.77** | 1.48% | 0.08 | 5.30 |
|
||||
| UYA storm | 0.49 | 20.97 | 3.12% | 0.20 | 6.82 |
|
||||
|
||||
## 1b. Hot JIT working set — CORRECTED 2026-07-10 (same day)
|
||||
|
||||
⚠️ **The original 999Hz per-window numbers (27-33KB "fits in L1I") were a
|
||||
sampling-resolution artifact and are RETRACTED.** At ~400-700 JIT samples/s, a
|
||||
1s window cannot measure more than ~24-38KB at 90% coverage — the "measurement"
|
||||
sat at its own ceiling. Caught by bmd's challenge ("show me actual gameplay"):
|
||||
the cold-boot session's window-size sweep showed 90%KB scaling with window size
|
||||
(21KB @1s → 78-102KB @5s), the signature of an under-resolved estimate.
|
||||
|
||||
**Resolution-honest control: `perf record -F 20000 -t <EE tid>` on-device**
|
||||
(UYA gameplay savestate, 12s, 240k samples — per-window ceiling ~600KB, far
|
||||
above the measurement):
|
||||
|
||||
| group (EE thread) | whole-12s 90% | per-1s-window 90% med / p90 / max |
|
||||
|---|---|---|
|
||||
| EE | 292 KB | **121 / 125 / 125 KB** |
|
||||
| VU0 | 9 KB | 8 KB |
|
||||
| IOP | 91 KB | 16 KB |
|
||||
| ALL-JIT (EE thread) | 256 KB | **131 / 135 / 136 KB** |
|
||||
|
||||
The med≈max flatness says this is a genuinely stable per-second working set,
|
||||
not phase noise. **The EE thread's instantaneous JIT working set is ~130KB —
|
||||
2× the A77's 64KB L1I, 4× the A53's 32KB.** (VU1 executes on the MTVU thread /
|
||||
different core; its 999Hz numbers are likewise resolution-suspect and would
|
||||
need their own 20kHz pass.)
|
||||
|
||||
Real-gameplay corroboration (bmd cold-boot + ~3min live UYA play on device,
|
||||
999Hz whole-process, so treat as LOWER BOUNDS): whole-session ALL-JIT 90% =
|
||||
622KB of 1.06MB touched; 5s windows 78-102KB@90% against a ~119KB resolution
|
||||
ceiling — consistent with the 20kHz ~130KB figure.
|
||||
|
||||
This RECONCILES with (not contradicts) the measured L1I MPKI 4-5 on the A77:
|
||||
a ~130KB looping working set over a 64KB L1I yields moderate, L2-served miss
|
||||
rates — the misses are real and recoverable, not absent.
|
||||
|
||||
## 1d. REAL-GAMEPLAY session (bmd playing UYA on device, 2026-07-10 session 2)
|
||||
|
||||
~3min live play, capture loop cycling: 10s stalls window / 10s L1I window /
|
||||
10s 20kHz EE-thread footprint burst (5 of each; window 1 = boot/menus).
|
||||
|
||||
| window | IPC | frontend% | backend% | L1I MPKI | miss% | iTLB | BrMis/kinsn |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| boot/menus | 1.53 | 15.97 | 36.72 | 5.91 | 2.25% | 0.39 | 1.76 |
|
||||
| gameplay 1 | 1.83 | 18.14 | 35.72 | 3.67 | 1.56% | 0.20 | 1.51 |
|
||||
| gameplay 2 | 2.03 | 17.28 | 28.68 | 5.30 | 2.25% | 0.32 | 1.90 |
|
||||
| gameplay 3 | 2.00 | 17.24 | 29.46 | 4.06 | 1.73% | 0.22 | 1.59 |
|
||||
| gameplay 4 | 1.95 | 18.12 | 29.91 | 3.83 | 1.65% | 0.22 | 1.49 |
|
||||
|
||||
20kHz footprint bursts (EE thread; per-1s-window 90% med/max): menus 40KB;
|
||||
gameplay **136-165KB med, up to 191KB max** (EE alone 118-143KB med; VU0 ~8-9KB;
|
||||
whole-10s-burst 90% = 346-436KB).
|
||||
|
||||
**Real gameplay vs the static savestate scene (UYA):** frontend-stall
|
||||
**16-18% vs 7.9%** (2.2x), BrMis **1.5-1.9 vs 0.24/kinsn** (7x), L1I MPKI ~4.5
|
||||
vs 4.1 (similar), IPC 1.8-2.0 vs 2.57, working set 136-191KB vs ~131KB.
|
||||
⚠️ Methodology consequence: the pinned savestate scenes are fine for codegen
|
||||
A/B DELTAS but materially UNDERSTATE absolute frontend behavior (branch variety
|
||||
especially). Absolute characterization needs live play.
|
||||
|
||||
## 1c. Maintenance-storm counters (JITTELEM, M2 Devel build, boot-from-savestate)
|
||||
|
||||
| game | first 5s window | steady decay |
|
||||
|---|---|---|
|
||||
| UYA | 31,052 linkPatch + 13,993 blkFlush (2.9MB) + 123 vuFlush (996KB), 7 pageResets | +2911 → +1918 → **+16 per 5s** by t+20s |
|
||||
| SotC | 53,394 linkPatch + 22,355 blkFlush (4.7MB) + 103 vuFlush (346KB), 24 pageResets | silent after first window |
|
||||
|
||||
Every linkPatch/blkFlush is a full-barrier `__builtin___clear_cache` today. Full
|
||||
resets: only the 2-3 at VM init — **never exhaustion-driven** (64MB EE region;
|
||||
SotC's entire boot storm emitted 4.7MB). Block discards: 0 in both.
|
||||
|
||||
## Gate table resolution
|
||||
|
||||
| Gate (from plan) | Result | Consequence |
|
||||
|---|---|---|
|
||||
| SD865 frontend ≲10% + low L1I MPKI → not a wall | **Split.** UYA passes (7.9%, 4.1 MPKI). SotC fails the bar (17.6%) — and SotC is the EE-bound title | Icache is not dismissible on SD865, but see the ceiling math below: the achievable prize is small |
|
||||
| 90% footprint ≪ L1I on both tiers → Saturn condition absent | **REVERSED after the 20kHz control (see 1b)**: EE-thread instantaneous set ≈ **130KB = 2× A77 L1I / 4× A53 L1I**. The original "28-33KB fits" was resolution-limited and is retracted | pstef's mechanism (JIT working set > L1I) **DOES hold** here. The consequences stay bounded by the measured MPKI/stall shares — JIT ≫ interp regardless — but locality/packing levers now have a mechanistic basis on BOTH tiers |
|
||||
| Patch storms ≥O(1000)/s during loads → batch (Phase 2) | Frequency gate met (6-15k clear_cache/s in load storms) — but the derived cost is ~300 cyc × 15k/s ≈ **0.2% of one core for a few seconds**, zero steady-state | **DEFER Phase 2.** Below the ≥0.2%-EE-thread ship bar as a perf item. Revisit only as hygiene when the linker is touched anyway, or if device load-stutter becomes a complaint |
|
||||
| High frontend + low L1I + high BrMis → mispredict campaign instead | Both elevated on SotC. Mispredicts ≈ 3.5/kcycle × ~11cyc ≈ 4% of SotC cycles; L1I plausibly ~7-13%. On RK, BrMis 5-7/kinsn ≈ ~3% (short A53 pipe) | Frontend work on SD865-SotC should consider BOTH; RSB (P2-2) remains the mispredict lever |
|
||||
|
||||
## Ceiling math (the honest sizing)
|
||||
|
||||
- **RK3562 (in-order, misses mostly exposed):** L1I MPKI 16 (SotC) / 9.8 (UYA)
|
||||
× ~13cyc L2 hit ≈ **7-9% of EE-thread cycles**. This is the Phase-3 locality
|
||||
ceiling on the RK tier — real, consistent with the EE-SRA "A53 wall = icache"
|
||||
postmortem, and only a fraction is reachable by layout (much of the MPKI is
|
||||
phase-transition/compulsory refetch, which no hot/cold split removes).
|
||||
- **SD865 (OoO):** steady MPKI 4-5 against a ~130KB working set over 64KB L1I
|
||||
(corrected 1b) — misses are L2-served and partially hidden by fetch-ahead;
|
||||
recoverable pool ≈ MPKI × ~10-15cyc ≈ 2-4% of EE-thread cycles.
|
||||
Frontend-stall share bounds ALL icache+fetch+mispredict work at 17.6% (SotC) /
|
||||
7.9% (UYA).
|
||||
- **The arena/segment-eviction machinery (lrps2 JITSlab) attacks none of this:**
|
||||
full resets never fire, dead-block sparsity is iTLB-irrelevant (MPKI ≤0.2,
|
||||
re-confirming the closed THP verdict), and transition refetches are compulsory.
|
||||
**PARK stands, now with absolute data.**
|
||||
|
||||
## Verdicts (as corrected 2026-07-10 after the 20kHz control)
|
||||
|
||||
1. **pstef's mechanism is REAL here: the EE thread's instantaneous JIT working
|
||||
set (~130KB static scene, 136-191KB real gameplay) exceeds L1I on both
|
||||
tiers.** What does NOT transfer is the Saturn *severity*: the measured cost
|
||||
is bounded — L1I MPKI ~4.5 (A77 live play) / 10-16 (A53) at 1.5-2.3% miss
|
||||
rates, iTLB closed, JIT ≫ interp throughout. Real-gameplay frontend-stall
|
||||
is **16-18%** on the A77 (the static scene's 7.9% understated it), split
|
||||
roughly half icache (~4-6% of cycles) / half mispredict+fetch. On the A53
|
||||
the icache pool is the **7-9%** computed below.
|
||||
2. **P3-B arena/segment-eviction machinery: still PARKED** — it attacks full
|
||||
resets and maintenance, which remain non-issues (boot-only storms, no
|
||||
exhaustion). But **hot/cold layout + density levers are UPGRADED from
|
||||
"RK-tier-only" to "live on both tiers, measure-gated"** — a 2× L1I overshoot
|
||||
with real MPKI is exactly what packing/cold-extraction can shave.
|
||||
3. **Phase 2 icache-batching: DEFER** (frequency gate met, cycle math sub-bar).
|
||||
4. **Live follow-ups:** (a) the Phase-3 cold-byte census (SMC inline compares,
|
||||
exit tails, literal pools, padding) now matters for BOTH tiers; (b) the
|
||||
mispredict rate is 1.5-1.9/kinsn in REAL gameplay on both UYA and SotC (the
|
||||
static UYA scene's 0.24 was unrepresentative) — the RSB/P2-2 line is a live
|
||||
SD865 lever, co-equal with icache; (c) real-gameplay capture DONE (1d) —
|
||||
Phase 1 is complete.
|
||||
5. **Methodology lesson (why the first verdict was wrong):** a per-window
|
||||
footprint estimate is bounded by samples-per-window; at 999Hz the ceiling
|
||||
(~24-38KB) sat exactly where the "measurement" landed. Any footprint claim
|
||||
must quote its resolution ceiling; sweep window sizes — scaling with window
|
||||
size means under-resolution. 20kHz single-thread attach is the honest tool.
|
||||
|
||||
## Phase 3 — cold-byte census + line utilization (2026-07-10): LAYOUT CLASS CLOSED
|
||||
|
||||
Design plan: streamed-soaring-hennessy (Phase-3 rewrite). Tool:
|
||||
`ee_code_census.py --coldbytes` — splits every jitdump block into
|
||||
`smc_preamble` (exact structural match of the ProtMode_Manual inline compare,
|
||||
validated by disassembly) / `body` / `tail_junk` (bytes after the last
|
||||
unconditional branch = vixl literal pools) plus inter-block `align_pad`, then
|
||||
sample-weights with the session-2 20kHz EE-thread bursts and computes **line
|
||||
utilization**: what fraction of the hot working set's 64B-line bytes is
|
||||
executed block body.
|
||||
|
||||
### Static byte layout (UYA session-2 jitdump, deduped)
|
||||
|
||||
| group | blocks | KB | smc% | junk% | pad% |
|
||||
|---|---|---|---|---|---|
|
||||
| EE | 38,043 | 6041 | **10.26** (620KB, 2627 blocks) | 0.55 | 4.03 |
|
||||
| IOP | 7,947 | 1398 | — | 2.39 | 0.99 |
|
||||
| VU1/VIF/VU0 | 1,126 | 1845 | — | ~0 | ~0.1 |
|
||||
|
||||
### Executed share + line utilization (5×10s 20kHz bursts pooled; gameplay-only burst in parens)
|
||||
|
||||
| metric | value |
|
||||
|---|---|
|
||||
| smc_preamble, % of EE-block samples | **0.421%** (0.30%) |
|
||||
| smc_preamble, % of ALL-JIT samples | 0.257% (0.18%) |
|
||||
| tail_junk samples | 0.000% |
|
||||
| **line utilization, 90%-coverage set** | **92.7%** (91.5%) — body 92.7 / smc 0.4 / junk 0.4 / cold-block 2.8 / gap 3.7 |
|
||||
| line utilization, all sampled lines | 89.7% (89.7%) |
|
||||
|
||||
### Gate resolutions (all four steps, one census)
|
||||
|
||||
| lever | gate | measured | verdict |
|
||||
|---|---|---|---|
|
||||
| L1 SMC stub+table redesign | ≥1% samples or ≥3% hot bytes | 0.42% / 0.41% | **DISMISSED-with-number.** The static 620KB of compare code is real but almost entirely in cold blocks — the hot set barely executes it |
|
||||
| L2 literal/pad packing | ≥5% of hot-line bytes | junk+gap ≈ 4.1% | **DISMISSED.** And most "gap" is 16B block alignment, which buys fetch alignment |
|
||||
| L3 cold-tail extraction | never-sampled runs inside hot blocks | hot blocks are >91% body | **DISMISSED.** Nothing to extract — exits/slowpaths were already out-of-line |
|
||||
| L4 cross-block packing (arena) | utilization <0.7 reopens, ≥0.8 closes | **0.915–0.927** | **PERMANENTLY CLOSED** |
|
||||
|
||||
### The Phase-3 verdict
|
||||
|
||||
**Compile-order bump allocation already produces a ~92%-packed hot working
|
||||
set.** The theoretical maximum ANY layout work (hot/cold split, arenas,
|
||||
cold-extraction, padding elimination — the entire class) could recover is
|
||||
~8% of the working set ≈ 11KB of 130KB — nowhere near the 66–127KB L1I
|
||||
overshoot. The working set is irreducible by layout: it is executed body
|
||||
bytes. What can still shrink it: **emitted-code density** (fewer bytes per
|
||||
hot block — the EE-SRA class of work) and nothing else. The live frontend
|
||||
levers on SD865 therefore remain density + the RSB/P2-2 mispredict line
|
||||
(1.5–1.9/kinsn in real play); on RK3562 density only.
|
||||
|
||||
This also finishes the pstef adjudication with an unexpected twist: his
|
||||
mechanism holds (working set ≫ L1I), but his structural remedy (hot/cold
|
||||
arenas) has no prize here — a JIT that keeps slowpaths out-of-line and emits
|
||||
in compile order gets the layout for free.
|
||||
|
||||
Repro: `ee_code_census.py <jit.dump> <hf-*.perf.data ...> --coldbytes`
|
||||
(session-2 raw data: scratchpad `icache/session2/`).
|
||||
|
||||
## Caveats
|
||||
|
||||
- Footprint measured on the M2 (identical emitters/layout logic; host ≠ device),
|
||||
1s windows, ~13s of gameplay; sample-weighted by retired-IP, which approximates
|
||||
fetch footprint. Device-side footprint would need jitdump+record on-device.
|
||||
- Sessions are short (minutes). Locality decay over 30+ min (block-cache churn
|
||||
interleaving hot/cold across the region) is unmeasured — re-run 1b on a long
|
||||
session before ever un-parking segment eviction.
|
||||
- Storm windows include VM boot + savestate hydration, not an in-game level load;
|
||||
the in-game load shape is expected smaller (no full init, ProgCache hydrates VU).
|
||||
- A53 reports 0 for unimplemented PMU events; icache_stat.sh treats 0 as NA.
|
||||
- Whole-process confounds avoided by `perf stat -t <EE tid>`; VU1 (MTVU) is NOT in
|
||||
these EE-thread numbers.
|
||||
|
||||
## Repro
|
||||
|
||||
```
|
||||
# device, after pinning fan/governor (sd865) or 1608MHz (rk3562):
|
||||
tools/perf/icache_stat.sh --device sd865 --scene sotc-01 --bin <eerunner>
|
||||
tools/perf/icache_stat.sh --device sd865 --scene sotc-01 --bin <eerunner> --delay 0 --window 8
|
||||
# footprint (build-perf binary, local):
|
||||
perf record -F 999 -o fp.perf.data -- pcsx2-eerunner --liverun --renderer null \
|
||||
--frames 1800 --savestate <p2s> --iso <iso> --perf-jitdump
|
||||
ee_code_census.py <jit-*.dump> fp.perf.data --footprint
|
||||
# storm counters: any Devel binary, grep JITTELEM in the run log.
|
||||
```
|
||||
|
||||
Raw outputs: device `~/pcsx2-profiles/{sd865,rk3562}/icache/<scene>/{steady,storm}/`.
|
||||
Executable
+239
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: GPL-3.0+
|
||||
#
|
||||
# icache_stat.sh — EE-thread-scoped icache / frontend PMU characterization of ONE
|
||||
# binary over a fixed scene. This is the Phase-1a tool of the icache-locality
|
||||
# campaign (tools/perf/icache-2026-07.md): it answers "is icache locality a wall
|
||||
# AT ALL on this device?", which no codegen A/B delta can (those only measure
|
||||
# changes, not absolute shares).
|
||||
#
|
||||
# Method: launch the runner, find the EE thread ("CPU Thread") by comm, attach
|
||||
# `perf stat -t <tid>` for a fixed window, kill the runner. Two complementary
|
||||
# event groups run in separate passes so nothing multiplexes:
|
||||
# stalls: cycles,instructions,STALL_FRONTEND(r23),STALL_BACKEND(r24)
|
||||
# l1i: cycles,instructions,L1I_CACHE_REFILL(r01),L1I_CACHE(r14),
|
||||
# L1I_TLB_REFILL(r02),BR_MIS_PRED(r10)
|
||||
# Derived: IPC, frontend/backend stall shares, L1I MPKI + miss rate, iTLB MPKI,
|
||||
# branch-mispredicts/kinsn. A77/A55 support all of these; A53 (rk3562) may not
|
||||
# implement r23/r24 — those cells degrade to NA, the L1I group still works.
|
||||
#
|
||||
# --delay chooses the window: default 8s after the EE thread appears (steady
|
||||
# state, past the savestate-load compile storm); --delay 0 measures the storm
|
||||
# itself (compile/link/flush burst) — capture BOTH when filling in the campaign
|
||||
# gate table.
|
||||
#
|
||||
# Same operational discipline as codegen_ab.sh: run ON the device, pin the fan
|
||||
# + governor first (memory: feedback_sd865_ab_pin_the_fan), paths with spaces
|
||||
# stay quoted, and a sanity floor rejects windows whose instruction count says
|
||||
# the runner fast-failed.
|
||||
#
|
||||
# Usage (on the target device):
|
||||
# tools/perf/icache_stat.sh --device sd865 --scene sotc-01 --bin pcsx2-eerunner-base
|
||||
# tools/perf/icache_stat.sh --device sd865 --scene uya-gameplay --bin pcsx2-eerunner-base --delay 0
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
SCENE=""
|
||||
DEVICE=""
|
||||
BIN=""
|
||||
RUNS=2
|
||||
RENDERER="null"
|
||||
FRAMES_OVERRIDE=""
|
||||
WINDOW=10 # perf stat attach window, seconds
|
||||
DELAY=8 # seconds after EE thread appears before attaching (0 = storm)
|
||||
THREAD_NAME="CPU Thread"
|
||||
OUT="$HOME/pcsx2-profiles"
|
||||
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--scene) SCENE="$2"; shift 2;;
|
||||
--device) DEVICE="$2"; shift 2;;
|
||||
--bin) BIN="$2"; shift 2;;
|
||||
--runs) RUNS="$2"; shift 2;;
|
||||
--renderer) RENDERER="$2"; shift 2;;
|
||||
--frames) FRAMES_OVERRIDE="$2"; shift 2;;
|
||||
--window) WINDOW="$2"; shift 2;;
|
||||
--delay) DELAY="$2"; shift 2;;
|
||||
--thread) THREAD_NAME="$2"; shift 2;;
|
||||
--out) OUT="$2"; shift 2;;
|
||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0;;
|
||||
*) die "unknown arg: $1";;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$DEVICE" ]] || die "--device <label> required (see tools/perf/devices/)"
|
||||
[[ -n "$SCENE" ]] || die "--scene <id> required (see tools/perf/scenes/)"
|
||||
[[ -n "$BIN" ]] || die "--bin <binary> required"
|
||||
|
||||
DEVENV="$HERE/devices/$DEVICE.env"
|
||||
[[ -f "$DEVENV" ]] || die "no device profile: $DEVENV"
|
||||
# shellcheck disable=SC1090
|
||||
source "$DEVENV"
|
||||
: "${BIN_DIR:?devices/$DEVICE.env must set BIN_DIR}"
|
||||
|
||||
SCENEENV="$HERE/scenes/$SCENE.env"
|
||||
[[ -f "$SCENEENV" ]] || die "no scene profile: $SCENEENV"
|
||||
# shellcheck disable=SC1090
|
||||
source "$SCENEENV"
|
||||
: "${ISO:?scenes/$SCENE.env must set ISO}"
|
||||
: "${SAVESTATE:?scenes/$SCENE.env must set SAVESTATE}"
|
||||
: "${FRAMES:=600}"
|
||||
[[ -n "$FRAMES_OVERRIDE" ]] && FRAMES="$FRAMES_OVERRIDE"
|
||||
|
||||
case "$BIN" in */*) BIN_PATH="$BIN";; *) BIN_PATH="$BIN_DIR/$BIN";; esac
|
||||
command -v perf >/dev/null || die "perf not found"
|
||||
[[ -x "$BIN_PATH" ]] || die "binary not executable: $BIN_PATH"
|
||||
[[ -f "$ISO" ]] || die "ISO not found: $ISO"
|
||||
[[ -f "$SAVESTATE" ]] || die "savestate not found: $SAVESTATE"
|
||||
|
||||
MODE=$([[ "$DELAY" == "0" ]] && echo storm || echo steady)
|
||||
OUTDIR="$OUT/$DEVICE/icache/$SCENE/$MODE"
|
||||
mkdir -p "$OUTDIR"
|
||||
RESULTS="$OUTDIR/results.tsv"
|
||||
: > "$RESULTS" # group \t run \t event \t count
|
||||
|
||||
GROUP_NAMES=(stalls l1i)
|
||||
GROUP_EVENTS=("cycles,instructions,r23,r24" "cycles,instructions,r01,r14,r02,r10")
|
||||
|
||||
echo "== icache stat [$LABEL · $SCENE] $MODE: delay=${DELAY}s window=${WINDOW}s renderer=$RENDERER runs=$RUNS =="
|
||||
echo " bin = $BIN_PATH thread = '$THREAD_NAME'"
|
||||
|
||||
read_temp_c() {
|
||||
local t
|
||||
t="$(cat /sys/class/thermal/thermal_zone*/temp 2>/dev/null | sort -rn | head -1)"
|
||||
[[ -n "$t" ]] && echo $((t / 1000)) || echo "?"
|
||||
}
|
||||
|
||||
find_tid() { # $1 = pid, $2 = thread comm
|
||||
local t
|
||||
for t in /proc/"$1"/task/*; do
|
||||
[[ -r "$t/comm" && "$(cat "$t/comm" 2>/dev/null)" == "$2" ]] && { basename "$t"; return 0; }
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
run_one() {
|
||||
local gname="$1" events="$2" k="$3"
|
||||
local stat="$OUTDIR/$gname.run$k.stat.txt"
|
||||
local runlog="$OUTDIR/$gname.run$k.out.log"
|
||||
local temp_c; temp_c="$(read_temp_c)"
|
||||
|
||||
# NOTE: $PIN is an intentional word-split prefix; ISO/savestate quoted (spaces).
|
||||
EERUNNER_SYNCMTGS=0 EERUNNER_MTVU=1 EERUNNER_EE=jit \
|
||||
${PIN:-} "$BIN_PATH" --liverun --renderer "$RENDERER" --frames "$FRAMES" \
|
||||
--savestate "$SAVESTATE" --iso "$ISO" > "$runlog" 2>&1 &
|
||||
local pid=$!
|
||||
|
||||
local tid="" i
|
||||
for i in $(seq 1 60); do
|
||||
tid="$(find_tid "$pid" "$THREAD_NAME")" && break
|
||||
kill -0 "$pid" 2>/dev/null || break
|
||||
sleep 0.5
|
||||
done
|
||||
if [[ -z "$tid" ]]; then
|
||||
kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null
|
||||
die "$gname run$k: EE thread '$THREAD_NAME' never appeared (runner died? see $runlog)"
|
||||
fi
|
||||
|
||||
[[ "$DELAY" != "0" ]] && sleep "$DELAY"
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
die "$gname run$k: runner exited before the measurement window (see $runlog)"
|
||||
fi
|
||||
|
||||
perf stat -e "$events" -t "$tid" -- sleep "$WINDOW" > /dev/null 2> "$stat"
|
||||
|
||||
kill "$pid" 2>/dev/null
|
||||
wait "$pid" 2>/dev/null
|
||||
|
||||
# Parse "count event" lines; "<not supported>/<not counted>" -> NA. Strip
|
||||
# the privilege-modifier suffix (":u" when not root) so tokens match.
|
||||
awk -v g="$gname" -v k="$k" '
|
||||
$2 != "" {
|
||||
v=$1; gsub(/,/,"",v)
|
||||
ev=$2; sub(/:[a-zA-Z]+$/, "", ev)
|
||||
if (v ~ /^[0-9]+$/) printf "%s\t%s\t%s\t%s\n", g, k, ev, v
|
||||
else if ($1 ~ /^<not/) {
|
||||
ev=$3; sub(/:[a-zA-Z]+$/, "", ev)
|
||||
printf "%s\t%s\t%s\tNA\n", g, k, ev
|
||||
}
|
||||
}
|
||||
' "$stat" >> "$RESULTS"
|
||||
|
||||
local ins
|
||||
ins="$(awk -F'\t' -v g="$gname" -v k="$k" '$1==g && $2==k && $3=="instructions" {print $4}' "$RESULTS")"
|
||||
if [[ "${ins:-NA}" == "NA" || "${ins:-0}" -lt 100000000 ]]; then
|
||||
echo " $gname run$k: FAST-FAIL (instructions=${ins:-none} < 1e8 in ${WINDOW}s window)." >&2
|
||||
tail -n 20 "$stat" >&2 || true
|
||||
die "aborting: $gname run$k did not measure a real workload"
|
||||
fi
|
||||
echo " $gname run$k: done (temp=${temp_c}°C, tid=$tid)"
|
||||
}
|
||||
|
||||
for k in $(seq 1 "$RUNS"); do
|
||||
for gi in "${!GROUP_NAMES[@]}"; do
|
||||
run_one "${GROUP_NAMES[$gi]}" "${GROUP_EVENTS[$gi]}" "$k"
|
||||
done
|
||||
done
|
||||
|
||||
# Median per event across runs -> derived metrics -> summary.md.
|
||||
python3 - "$OUTDIR" "$RESULTS" "$LABEL" "$SCENE" "$DEVICE" "$MODE" "$WINDOW" "$RUNS" "$BIN_PATH" <<'PY'
|
||||
import statistics, sys, os
|
||||
outdir, results, label, scene, device, mode, window, runs, bin_path = sys.argv[1:10]
|
||||
ev = {}
|
||||
for line in open(results):
|
||||
g, k, e, v = line.rstrip("\n").split("\t")
|
||||
if v != "NA":
|
||||
ev.setdefault((g, e), []).append(int(v))
|
||||
|
||||
def med(g, e):
|
||||
xs = ev.get((g, e))
|
||||
if not xs:
|
||||
return None
|
||||
m = statistics.median(xs)
|
||||
# A53 reports 0 for unimplemented events (r23/r24) instead of
|
||||
# <not supported>; a true zero is impossible on a real workload.
|
||||
return m if m > 0 else None
|
||||
|
||||
def spread(g, e):
|
||||
xs = ev.get((g, e))
|
||||
return (max(xs) - min(xs)) / statistics.median(xs) * 100 if xs and len(xs) > 1 else 0.0
|
||||
|
||||
def fmt(x, unit=""):
|
||||
return f"{x:.2f}{unit}" if x is not None else "NA"
|
||||
|
||||
rows = []
|
||||
for g in ("stalls", "l1i"):
|
||||
cyc, ins = med(g, "cycles"), med(g, "instructions")
|
||||
if not cyc or not ins:
|
||||
continue
|
||||
rows.append((f"[{g}] IPC", ins / cyc, ""))
|
||||
if g == "stalls":
|
||||
sf, sb = med(g, "r23"), med(g, "r24")
|
||||
rows.append(("frontend-stall share", 100 * sf / cyc if sf is not None else None, "%"))
|
||||
rows.append(("backend-stall share", 100 * sb / cyc if sb is not None else None, "%"))
|
||||
else:
|
||||
l1r, l1a, itlb, brm = med(g, "r01"), med(g, "r14"), med(g, "r02"), med(g, "r10")
|
||||
rows.append(("L1I MPKI", 1000 * l1r / ins if l1r is not None else None, ""))
|
||||
rows.append(("L1I miss rate", 100 * l1r / l1a if (l1r is not None and l1a) else None, "%"))
|
||||
rows.append(("iTLB MPKI (L1I_TLB_REFILL)", 1000 * itlb / ins if itlb is not None else None, ""))
|
||||
rows.append(("BR_MIS_PRED per kinsn", 1000 * brm / ins if brm is not None else None, ""))
|
||||
|
||||
out = os.path.join(outdir, "summary.md")
|
||||
with open(out, "w") as o:
|
||||
o.write(f"# icache stat — {label} · {scene} · {device} · {mode}\n\n")
|
||||
o.write(f"- bin: `{bin_path}` · window {window}s ×{runs} runs, EE-thread-scoped (`perf stat -t`)\n\n")
|
||||
o.write("| metric | value |\n|---|---|\n")
|
||||
for name, val, unit in rows:
|
||||
o.write(f"| {name} | {fmt(val, unit)} |\n")
|
||||
o.write(f"\n- run spread instructions: stalls {spread('stalls','instructions'):.1f}%, "
|
||||
f"l1i {spread('l1i','instructions'):.1f}% (window-based, not frame-locked — "
|
||||
f"expect a few %; this tool measures SHARES, not deltas)\n")
|
||||
o.write("- context: L1I 64KB on A77, 32KB on A55/A53. NA = PMU event not supported.\n")
|
||||
print(f"wrote {out}")
|
||||
for name, val, unit in rows:
|
||||
print(f" {name:30s} {fmt(val, unit)}")
|
||||
PY
|
||||
echo "done. summary: $OUTDIR/summary.md"
|
||||
@@ -0,0 +1,42 @@
|
||||
# mVU-4 on-device A/B — SD865 (Retroid Flip2), 2026-07-12
|
||||
|
||||
A/B of the four EE→mVU translation enhancements (survey 2026-07-12) via
|
||||
`codegen_ab.sh`, base `287ef4499` (waitMTVU fix in, enhancements out) vs new
|
||||
`152c2670d` (all four in):
|
||||
|
||||
1. `050ee4af7` — mVU pinned-base sweep (hot scalars via [x24,#imm]; ABI v8)
|
||||
2. `a73a5c564` — IBcc condition reg-carry to the block tail (v9)
|
||||
3. `7622f2e27` — inline JR/JALR jump-cache probe (v10)
|
||||
4. `152c2670d` — COP2 macro-op EE-cache eviction narrowed to {x14,x15,x28}+Q0-Q3
|
||||
|
||||
Protocol: Flip2 @ 192.168.4.42 (ROCKNIX 20260705, fresh image), fan pinned to
|
||||
max, performance governor, taskset A77 cores, renderer null, 600 frames,
|
||||
3 interleaved runs/binary, `perf stat -e instructions,cycles --inherit`
|
||||
(process-wide — includes the MTVU thread, where the mVU wins land).
|
||||
Temps 43–51 °C across measured runs; insns spread <0.05% (new side).
|
||||
|
||||
| scene | Δ instructions | Δ cycles | IPC base→new |
|
||||
|--------------|---------------:|---------:|---------------|
|
||||
| uya-gameplay | **−4.64%** | **−2.70%** | 2.308 → 2.262 |
|
||||
| sotc-01 | −2.09% | −0.41% | 1.833 → 1.802 |
|
||||
| bgda-01 | −1.38% | −1.58% | 1.940 → 1.944 |
|
||||
|
||||
Reading:
|
||||
- **UYA is the headline** — VU share ~36% on this title, and the pinned-base
|
||||
sweep hits every mVU block entry (mVUtestCycles) + every branch/divFlag/
|
||||
VIbackup round-trip; the COP2 eviction narrowing adds EE-side savings on
|
||||
the macro-heavy code. cycles −2.70% is EE-SRA-class (that campaign's UYA
|
||||
was −1.11%).
|
||||
- IPC dipping on UYA/SotC while cycles drop = the removed instructions were
|
||||
the cheap dual-issuable address-materialization filler; the remaining mix
|
||||
is denser but net faster. Same signature as EE-SRA on the A77.
|
||||
- SotC's small cycle delta at −2.09% insns matches its profile (EE thread is
|
||||
the wall, VU is fine); the JR-probe effect is folded in but not separable
|
||||
here.
|
||||
- EE-thread seconds (secondary): UYA 8.20→8.06 (−1.7%), SotC/BGDA ≈ flat to
|
||||
−2% — consistent with the COP2 narrowing being the EE-side contributor.
|
||||
|
||||
Raw logs: device `/storage/pcsx2/run_ab.log` + `~/pcsx2-profiles/sd865/codegen-ab/<scene>/`.
|
||||
Not yet done: interactive UYA/SotC play validation; RK3562 A/B (in-order A53
|
||||
should benefit MORE from the density/latency wins — memory
|
||||
`project_rk_inorder_target_reelevated`).
|
||||
@@ -0,0 +1,186 @@
|
||||
# OutRun 2006 C2C (SLES-53998) on SD865 — benchmark + attribution (2026-07-01)
|
||||
|
||||
User report: below 60 fps on the Rocknix SD865 handheld (Retroid Flip 2, Adreno 650).
|
||||
PAL disc but the game runs **SDTV 480p progressive**, so the target is **60 fps**.
|
||||
Scene: in-race savestate slot 03 (`scenes/outrun2-race.env`), eerunner `--liverun`,
|
||||
600 frames, A77-pinned, production knobs (async MTGS, MTVU on, EE jit). User's Qt INI
|
||||
matches the rig shape (MTVU on, native res, VK renderer, no cycle hacks).
|
||||
|
||||
## Headline numbers (no-perf warm runs; startup ≈1.6 s subtracted via 60f/600f delta)
|
||||
|
||||
| config | 600-frame wall | effective fps |
|
||||
|---|---|---|
|
||||
| vk, GPU at 305 MHz (device default — see governor finding) | 17.92 s | **~37 fps** |
|
||||
| vk, GPU forced 800 MHz | 13.24 s | **~52 fps** |
|
||||
| null renderer (CPU-emulation ceiling) | 7.16 s | **~109 fps** |
|
||||
|
||||
## Finding 1 — Rocknix GPU devfreq governor never ramps (**+40% fps for free**)
|
||||
|
||||
`/sys/class/devfreq/3d00000.gpu` runs `simple_ondemand`, and during PCSX2 — both
|
||||
eerunner and the user's exact live pcsx2-qt session (verified by sampling `cur_freq`
|
||||
mid-race) — the Adreno 650 sits at its **305 MHz floor** the entire time. `trans_stat`
|
||||
shows the GPU has spent ~99% of uptime at 305 MHz. The bursty submit pattern +
|
||||
GPU-on-critical-path serialization (below) keeps reported utilization under the ramp
|
||||
threshold at min freq, so the governor never escalates: a feedback trap (slow GPU →
|
||||
stalled pipeline → low measured load → stays slow).
|
||||
|
||||
**Fix deployed 2026-07-01:** `/storage/pcsx2/run-pcsx2.sh` now floors `min_freq` to
|
||||
800 MHz for the session and restores it on exit (EXIT trap; `exec` dropped so the trap
|
||||
fires — survives SIGKILL of pcsx2-qt). Pre-change script kept at
|
||||
`run-pcsx2.sh.pre-gpufloor`. OutRun 2006: 37 → 52 fps headless; **user-confirmed
|
||||
48 fps on-screen live** (the live session adds present/compositor at 1080p + real
|
||||
audio over the headless rig number).
|
||||
|
||||
⚠️ **Rig caveat:** all *prior* SD865 `vk` profiles (the 2026-06-24 SotC/UYA baseline)
|
||||
were also taken with the GPU stuck at 305 MHz. Their GS/GPU-driver/sync shares are
|
||||
shaped by an artificially slow GPU. `null` profiles and `codegen_ab.sh` (null-renderer
|
||||
`perf stat`) are unaffected. Any future vk-renderer measurement on this device should
|
||||
pin the GPU clock first.
|
||||
|
||||
## Finding 2 — the game is GS-pipeline bound, and GPU time is serial with GS-thread CPU
|
||||
|
||||
Attribution (vk, 3-run medians; shape identical at 305 and 800 MHz): **GS 32%**,
|
||||
VU1-JIT 17%, EE-JIT 7.7%, VIF 5.4. Per-thread sample split: GS thread 36-37%,
|
||||
CPU(EE) thread 34%, MTVU 25% — **no thread saturated** (GS thread ≈45-70% of one
|
||||
core), and GPU busy (DRM fdinfo `drm-engine-gpu`) is only **23% at 800 MHz**.
|
||||
|
||||
Yet the clock A/B moved fps 1:1 with GPU time, so GPU work is not overlapped with
|
||||
GS-thread work. The serial model reproduces both measurements:
|
||||
|
||||
```
|
||||
frame ≈ GS-thread CPU (~13-14 ms) + GPU (~12 ms @305 / ~4.6 ms @800)
|
||||
305 MHz: ~26.5 ms → 38 fps (measured 37)
|
||||
800 MHz: ~17.8 ms → 56 fps (measured ~52)
|
||||
```
|
||||
|
||||
60 fps needs 16.7 ms: at 800 MHz we're ~1-3 ms short (live: 48 fps → need ~4 ms). Levers:
|
||||
1. **GS↔GPU serialization**: `GSDeviceVK::ExecuteCommandBufferForReadback` appears in
|
||||
the GS-thread profile — the game triggers synchronous GPU readbacks (fence wait is
|
||||
off-CPU, so its true cost is invisible to cycle sampling; only the trigger shows).
|
||||
Each drain serializes GS-thread CPU behind GPU completion, which is exactly the
|
||||
observed fps ≈ 1/(GS_cpu + GPU) behavior. Quantifying needs an instrumented build
|
||||
(readback counter) — the Rocknix kernel has no CONFIG_UPROBE_EVENTS, so perf-probe
|
||||
call counting is unavailable. Worth up to ~5 ms/frame at 800 MHz if overlapped.
|
||||
2. **GS-thread software cost** (13-14 ms/frame). Top symbols: packed GIF vertex decode
|
||||
`GIFPackedRegHandlerSTQRGBAXYZF2` 4.0% + `XYZF2` 2.0% (≈18% of the GS thread),
|
||||
texture-from-local-mem conversion `ReadTexture32/16` ≈3.2%, `Draw()` 1.3%,
|
||||
`FindMinMax` 1.3%, `ClearGSLocalMemory` 1.2%, `Transfer<3>` 1.1%. All upstream-shared
|
||||
GS code (`isa_native`), not arm64-specific.
|
||||
3. CPU-emulation side is NOT the constraint here (null = 109 fps): EE-JIT 7.7%,
|
||||
VU0 0.3% — OutRun stresses none of the usual arm64 recompiler hot spots. Live thread
|
||||
placement is also fine (scheduler puts GS/MTVU on the A77 cores unpinned; GS thread
|
||||
~67% of a core at 48 fps).
|
||||
|
||||
## Fix landed 2026-07-01 (follow-up session): mid-frame command buffer kick
|
||||
|
||||
Attribution (temp RBDIAG logging, since removed): the game downloads a **64×64
|
||||
PSMCT16 render target at BP 0x3fc0** via local→host TRXDIR, 3×/frame — the classic
|
||||
sun/lens-flare occlusion-test pattern. Each `GSTextureCache::Read` records the copy
|
||||
then immediately `Flush()`es → `ExecuteCommandBufferForReadback` → full pipeline
|
||||
drain, because nothing had been submitted since the previous frame.
|
||||
|
||||
`27984e960` (GS/Vulkan): kick the command buffer at draw entry once ≥8 render passes
|
||||
accumulated since last submit, gated to readback-prone frames (within 128 RPs of a
|
||||
readback) and outside-a-render-pass. GPU now executes concurrently with GS-thread
|
||||
recording; the readback wait finds work already done. `c3846f1c4` (eerunner):
|
||||
EERUNNER_HWDL / EERUNNER_SPINCPU gates + @GSSTAT@ per-frame stats line.
|
||||
|
||||
Measured (600-frame runs @800 MHz, warm):
|
||||
|
||||
| config | wall | ms/frame | eff. fps |
|
||||
|---|---|---|---|
|
||||
| before kick | 13.4-13.8 s | 19.6 | ~51 |
|
||||
| kick=8 | 11.8-12.0 s | 17.0 | ~59 |
|
||||
| kick=8 + HWSpinCPUForReadbacks | **11.30-11.38 s** | **16.2** | **~61.5** |
|
||||
| Unsynchronized ceiling (accuracy-breaking) | 8.3 s | 11.1 | ~90 |
|
||||
|
||||
Residual ~3 ms/frame = 3 × ~1ms irreducible synchronous round-trips (submit + tiny
|
||||
GPU copy + fence + wake). Further wins would need async readback semantics (defer
|
||||
the local-mem write past the MTGS sync — semantics change) or a dedicated transfer
|
||||
queue. Deployed to the device (pcsx2-qt rebuild + `HWSpinCPUForReadbacks = true` in
|
||||
the shared INI). Next CPU-side lever if 60 doesn't hold live: GS-thread software
|
||||
cost (GIF packed decode ~18% of GS thread, ReadTexture ~10%).
|
||||
|
||||
## Follow-up round (user: "occasionally dips below 60") — 2026-07-01 late
|
||||
|
||||
New instrument: eerunner now prints `@THREADCPU@ <comm>: <s>` (utime+stime per VM
|
||||
thread) at liverun end — wallclock A/B here has a ±0.3 s noise floor per 600-frame
|
||||
run; GS-thread CPU is stable to ±0.05 s. Key decomposition at 800 MHz, kick+spin:
|
||||
|
||||
- GS thread 12.9 ms/frame CPU (INCLUDES readback spin), EE 10.3, MTVU 8.2; frame 16.2.
|
||||
- Unsynchronized probe: wall 7.8 s (10.3 ms/frame), GS-cpu 9.0 ms/frame ⇒ sync
|
||||
readbacks still cost **~5.9 ms/frame wall** (3.9 GS spin + ~2 EE-side MTGS stall).
|
||||
- Per-readback fence wait (timed): mean 778 µs, median ~300 µs, spikes 4-10 ms.
|
||||
RBKICK diag: at readback time `rp_since_submit=0` — no unsubmitted backlog; the 3
|
||||
readbacks CLUSTER (draw sun quads → read → redraw same RT → read), so the cost is
|
||||
the round-trip itself (end-pass + RGB5A1 convert pass + copy + submit + fence),
|
||||
not queued work. The 38 draws/frame into the sun RT sit inside one render pass.
|
||||
|
||||
What landed (tested 1060/1060, deployed):
|
||||
|
||||
- `ce186a0ad` GS: VertexKick hot path de-called (CheckOverlapVerts inline fast
|
||||
reject; Limit24BitDepth config test before GSIsHardwareRenderer()+psm walk).
|
||||
Only ~1% of GS-thread CPU — A77 OoO hides call overhead (neither-campaign lesson
|
||||
again) — kept as shared-code-clean.
|
||||
- `2a5c0b1bd` GS/VK: kick never blocks (ActivateCommandBuffer fence-waits on the
|
||||
reused buffer when only 3 command buffers cycle under 9 submits/frame — check
|
||||
next-buffer fence before kicking); HintReadbackSource producer-draw kick.
|
||||
Both ~neutral for OutRun in isolation (its producer draws are in_rp so the
|
||||
producer kick rarely fires), kept for the no-hidden-stall property.
|
||||
- Devel→Release for device builds (build-sd865 + build-rocknix): ~0.5% only —
|
||||
GS hot loops are hand-vectorized; asserts were the documented ~1% background.
|
||||
|
||||
Where the remaining time is (if another round is wanted):
|
||||
1. ~5.9 ms/frame readback sync: floor is 3× (~0.3 ms fence + convert pass + submit).
|
||||
Options: CPU-side RGB5A1 conversion (drops the StretchRect render pass from the
|
||||
sync window, ~0.3-0.6 ms/frame); async readback semantics (accuracy trade, the
|
||||
~90 fps Unsync ceiling); per-game HWDownloadMode.
|
||||
2. ~9 ms/frame GS software: top items GIF packed decode ~19% of GS thread,
|
||||
ReadTexture32/16 ~11% (already ld2-deswizzle / well-vectorized — bandwidth-bound),
|
||||
FindMinMax 4.5%. Long-tail grind, no single big lever found.
|
||||
|
||||
## Round 4 (2026-07-02): CPU governor — the live dip source. ✅ PINNED 60 FPS
|
||||
|
||||
User A/B'd the CPU-pack revert live: identical shimmer (pre-existing, minimal) and
|
||||
identical framerate — R3's headless win is invisible at vsync, and the dips-to-55
|
||||
persisted. The live-vs-rig gap pointed at clocking: **cores 4-7 (A77) run the
|
||||
`ondemand` cpufreq governor and were observed at 1.06/2.42 GHz between bursts.**
|
||||
The headless rig cannot see this (sustained saturation keeps the cores ramped);
|
||||
live gameplay breathes every frame and eats the governor's ramp lag as dips.
|
||||
|
||||
Fix: `run-pcsx2.sh` now sets the `performance` governor on cores 4-7 for the
|
||||
session, next to the GPU floor, both restored by one EXIT trap (previous script
|
||||
at `run-pcsx2.sh.pre-cpufloor`). **User-confirmed: pinned 60 fps throughout.**
|
||||
|
||||
Final chain for OutRun 2006 on this device: 37 fps (stock) → 48 (GPU floor) →
|
||||
mostly-60 with dips (VK kick + spin + Release) → **locked 60** (CPU governor).
|
||||
Both governor traps are Rocknix-level and affect every game, not just OutRun;
|
||||
any future *live* fps observation on this device is meaningless unless launched
|
||||
through run-pcsx2.sh (or with both clocks pinned by hand).
|
||||
|
||||
## Round 3 (2026-07-02): Unsync A/B on-screen + CPU-side RGB5A1 pack
|
||||
|
||||
Live user A/B with per-game `HWDownloadMode = 3`: **locked 60 fps, but sky and car
|
||||
reflections garbage** — the readbacks feed same-frame sky/reflection rendering, not
|
||||
just a lens-flare intensity test. Unsync is confirmed both as the fps ceiling and as
|
||||
unusable. (Override reverted/deleted.)
|
||||
|
||||
`d84a571a2` GS/HW: native-scale 16-bit readbacks now copy the RGBA8 target directly
|
||||
and pack to RGB5A1 on the CPU while writing to local memory (bit-identical to
|
||||
`ps_convert_rgb5a1_16bits` — verified same formula in VK/GL/DX11 shaders). This
|
||||
removes the StretchRect render pass + temp UInt16 target from inside every
|
||||
synchronous readback window. Result @800 MHz, 600 f: **15.4 ms/frame (was 16.2),
|
||||
and the run-to-run spread collapsed** (10.79-10.88 s vs 10.85-11.36 s) — the dips
|
||||
were readback-window jitter. ~65 fps effective headless.
|
||||
|
||||
## Repro
|
||||
|
||||
```bash
|
||||
ssh root@192.168.4.30
|
||||
cd /storage/pcsx2/tools/perf && ./profile_run.sh --device sd865 --scene outrun2-race --renderer both --runs 3
|
||||
# GPU clock A/B: echo 800000000 > /sys/class/devfreq/3d00000.gpu/min_freq (restore 305000000)
|
||||
# GPU busy: /proc/<pid>/fdinfo/<drm fd> drm-engine-gpu delta / wall ns
|
||||
```
|
||||
|
||||
Artifacts on device: `/storage/pcsx2-profiles/sd865/outrun2-race/{vk,null}/` (305 MHz)
|
||||
and `/storage/pcsx2-profiles-gpu800/sd865/outrun2-race/vk/` (800 MHz).
|
||||
Executable
+204
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: GPL-3.0+
|
||||
#
|
||||
# profile_run.sh — one-command, repeatable, attributed CPU profile of the PCSX2
|
||||
# ARM64 port. Step 0 of the neither cherry-pick funnel: a CURRENT bottleneck
|
||||
# baseline. Built/validated on M2 Asahi first; the SAME script runs on SD865 with
|
||||
# a different devices/<label>.env (no code change).
|
||||
#
|
||||
# Two drivers:
|
||||
# eerunner — whole-system (EE-JIT + MTVU + IOP + VIF + real GS), perf-recorded,
|
||||
# JIT-symbolized via jitdump, bucketed into a subsystem ranking.
|
||||
# gsrunner — GS-only deterministic @HWSTAT@ frame-time + thread-% cross-check
|
||||
# (no perf/bucketing; gsrunner replays GIF packets, runs no EE/VU JIT).
|
||||
#
|
||||
# Methodology (CLAUDE.md): this rig measures bucket *SHARES* (attribution: where does
|
||||
# time go), which are stable across the MTVU-nondeterministic liverun, so we median
|
||||
# per-bucket share across runs. The wallclock printed below is a coarse progress
|
||||
# indicator ONLY.
|
||||
#
|
||||
# ⚠️ DO NOT use this script's wallclock to A/B two codegen variants. perf-record
|
||||
# sampling perturbs the async EE<->MTVU<->MTGS sync in a BINARY-DEPENDENT way and
|
||||
# manufactures phantom wallclock deltas (a +11% "regression" that was pure artifact —
|
||||
# see memory feedback_sd865_codegen_ab_use_instructions_not_wallclock). For comparing
|
||||
# two binaries that differ only in emitted code, use ./codegen_ab.sh (deterministic
|
||||
# `perf stat -e instructions,cycles`), NOT this.
|
||||
#
|
||||
# Usage:
|
||||
# tools/perf/profile_run.sh --device m2max-asahi --scene uya-gameplay --renderer both --runs 3
|
||||
# tools/perf/profile_run.sh --device m2max-asahi --driver gsrunner --gs-dump test-dumps/foo.gs.zst
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO="$(cd "$HERE/../.." && pwd)"
|
||||
|
||||
DRIVER=eerunner
|
||||
SCENE=""
|
||||
DEVICE=""
|
||||
RENDERER="" # vk | null | both ; default from scene RENDERERS, else vk
|
||||
RUNS=3
|
||||
OUT="$HOME/pcsx2-profiles"
|
||||
FREQ="" # overrides device FREQ
|
||||
GS_DUMP="" # gsrunner driver
|
||||
LOOP=3 # gsrunner loop count
|
||||
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--driver) DRIVER="$2"; shift 2;;
|
||||
--scene) SCENE="$2"; shift 2;;
|
||||
--device) DEVICE="$2"; shift 2;;
|
||||
--renderer) RENDERER="$2"; shift 2;;
|
||||
--runs) RUNS="$2"; shift 2;;
|
||||
--out) OUT="$2"; shift 2;;
|
||||
--freq) FREQ="$2"; shift 2;;
|
||||
--gs-dump) GS_DUMP="$2"; shift 2;;
|
||||
--loop) LOOP="$2"; shift 2;;
|
||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0;;
|
||||
*) die "unknown arg: $1";;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$DEVICE" ]] || die "--device <label> required (see tools/perf/devices/)"
|
||||
DEVENV="$HERE/devices/$DEVICE.env"
|
||||
[[ -f "$DEVENV" ]] || die "no device profile: $DEVENV"
|
||||
# shellcheck disable=SC1090
|
||||
source "$DEVENV" # provides BIN_DIR, FREQ (default), CYCLES_EVENT, optional ISO_ROOT
|
||||
: "${BIN_DIR:?devices/$DEVICE.env must set BIN_DIR}"
|
||||
: "${CYCLES_EVENT:=cycles}"
|
||||
[[ -n "$FREQ" ]] || FREQ="${FREQ_DEFAULT:-999}"
|
||||
|
||||
# --- precondition: perf sampling must be permitted -----------------------------
|
||||
if [[ "$DRIVER" == "eerunner" ]]; then
|
||||
PARANOID="$(cat /proc/sys/kernel/perf_event_paranoid 2>/dev/null || echo 99)"
|
||||
if [[ "$PARANOID" -gt 1 ]]; then
|
||||
die "perf_event_paranoid=$PARANOID (need <=1). Fix: sudo sysctl kernel.perf_event_paranoid=1"
|
||||
fi
|
||||
command -v perf >/dev/null || die "perf not found"
|
||||
fi
|
||||
|
||||
ts() { date +%s.%N; }
|
||||
|
||||
###############################################################################
|
||||
# gsrunner driver — deterministic GS-only @HWSTAT@ cross-check (no perf record)
|
||||
###############################################################################
|
||||
if [[ "$DRIVER" == "gsrunner" ]]; then
|
||||
[[ -n "$GS_DUMP" ]] || die "--gs-dump <dump.gs[.zst]> required for gsrunner"
|
||||
GBIN="$BIN_DIR/pcsx2-gsrunner"
|
||||
[[ -x "$GBIN" ]] || die "missing $GBIN (build it: cmake --build build-perf --target pcsx2-gsrunner)"
|
||||
RUNDIR="$OUT/$DEVICE/gs-$(basename "$GS_DUMP" | tr './' '__')"
|
||||
mkdir -p "$RUNDIR"
|
||||
echo "== gsrunner -perf $GS_DUMP (loop=$LOOP) =="
|
||||
"$GBIN" -surfaceless -perf -loop "$LOOP" "$GS_DUMP" 2>&1 | tee "$RUNDIR/stdout.txt" | grep '@HWSTAT@' || true
|
||||
echo "artifacts: $RUNDIR/stdout.txt"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
# eerunner driver — whole-system perf profile
|
||||
###############################################################################
|
||||
[[ -n "$SCENE" ]] || die "--scene <id> required for eerunner (see tools/perf/scenes/)"
|
||||
SCENEENV="$HERE/scenes/$SCENE.env"
|
||||
[[ -f "$SCENEENV" ]] || die "no scene profile: $SCENEENV"
|
||||
# shellcheck disable=SC1090
|
||||
source "$SCENEENV" # provides ISO, SAVESTATE, FRAMES, RENDERERS, LABEL
|
||||
: "${ISO:?scenes/$SCENE.env must set ISO}"
|
||||
: "${SAVESTATE:?scenes/$SCENE.env must set SAVESTATE}"
|
||||
: "${FRAMES:=300}"
|
||||
[[ -n "$RENDERER" ]] || RENDERER="${RENDERERS:-vk}"
|
||||
[[ "$RENDERER" == "both" ]] && RENDERER="vk null"
|
||||
|
||||
EBIN="$BIN_DIR/pcsx2-eerunner"
|
||||
[[ -x "$EBIN" ]] || die "missing $EBIN (build: cmake --build build-perf --target pcsx2-eerunner)"
|
||||
# ( ... || true ) so pipefail sees grep's status, not eerunner --help's exit 1
|
||||
# (eerunner treats --help as an unknown arg: prints usage, then exits non-zero).
|
||||
( "$EBIN" --help 2>&1 || true ) | grep -q -- '--perf-jitdump' \
|
||||
|| die "$EBIN has no --perf-jitdump — rebuild from the patched source (clang-perf)."
|
||||
[[ -f "$ISO" ]] || die "ISO not found: $ISO"
|
||||
[[ -f "$SAVESTATE" ]] || die "savestate not found: $SAVESTATE"
|
||||
|
||||
# jitdump dir is EmuFolders::Cache = ~/.config/PCSX2/cache (set by eerunner).
|
||||
JITDIR="${PCSX2_CACHE:-$HOME/.config/PCSX2/cache}"
|
||||
|
||||
for REND in $RENDERER; do
|
||||
SDIR="$OUT/$DEVICE/$SCENE/$REND"
|
||||
mkdir -p "$SDIR"
|
||||
echo "== $LABEL [$SCENE] renderer=$REND frames=$FRAMES runs=$RUNS =="
|
||||
for k in $(seq 1 "$RUNS"); do
|
||||
RUNDIR="$SDIR/run$k"; mkdir -p "$RUNDIR"
|
||||
rm -rf "$JITDIR"/pcsx2-perf-* 2>/dev/null || true
|
||||
echo "-- run $k/$RUNS --"
|
||||
t0="$(ts)"
|
||||
# Production-representative knobs: async MTGS, MTVU on, EE jit. (eerunner test
|
||||
# harness env vars — not shipped gates.) SPU2 stays Null (audio excluded by design).
|
||||
# NOTE: do NOT use --per-thread — on the Apple M2 PMU it follows only the
|
||||
# blocked main thread and captures ~0 samples. Default inherit mode samples all
|
||||
# worker threads (CPU/MTVU/GS) and still records comm/tid per sample, so the
|
||||
# by-comm axis survives. --call-graph omitted (this perf rejects =no; flat is what
|
||||
# we want anyway). Verified working on m2max-asahi 2026-06-24.
|
||||
# $PIN (from the device env) optionally prefixes the binary to pin it to a
|
||||
# CPU set — on the SD865 big.LITTLE this is `taskset -c 4-7` so the whole
|
||||
# emulator runs on the A77 prime/gold cores (the perf-relevant target), not
|
||||
# the A55 silver cores. Empty on the M2 (single P-cluster, no pinning needed).
|
||||
EERUNNER_SYNCMTGS=0 EERUNNER_MTVU=1 EERUNNER_EE=jit \
|
||||
perf record -e "$CYCLES_EVENT" -F "$FREQ" -k mono \
|
||||
-o "$RUNDIR/perf.data" -- \
|
||||
${PIN:-} "$EBIN" --liverun --renderer "$REND" --frames "$FRAMES" \
|
||||
--savestate "$SAVESTATE" --iso "$ISO" --perf-jitdump \
|
||||
>"$RUNDIR/stdout.txt" 2>&1 || true
|
||||
t1="$(ts)"
|
||||
echo "$(awk "BEGIN{printf \"%.3f\", $t1-$t0}")" > "$RUNDIR/wallclock.txt"
|
||||
|
||||
# Pull in the jitdump this PID produced so JIT symbols resolve.
|
||||
JD="$(ls -t "$JITDIR"/pcsx2-perf-*/jit-*.dump 2>/dev/null | head -1 || true)"
|
||||
if [[ -n "$JD" ]]; then cp "$JD" "$RUNDIR/jit.dump"; fi
|
||||
( cd "$RUNDIR" && perf inject --jit -i perf.data -o perf.jit.data 2>/dev/null ) || \
|
||||
cp "$RUNDIR/perf.data" "$RUNDIR/perf.jit.data"
|
||||
perf report -i "$RUNDIR/perf.jit.data" --stdio --percent-limit 0 -g none \
|
||||
> "$RUNDIR/report.txt" 2>/dev/null || true
|
||||
python3 "$HERE/bucket_perf.py" --json "$RUNDIR/report.txt" > "$RUNDIR/buckets.json" || true
|
||||
# Progress line: report samples + the top bucket from buckets.json (NOT a grep of
|
||||
# report.txt — the @BUCKET@ markers only exist in bucket_perf.py's table mode).
|
||||
SAMP="samples=$(awk '/^# Samples:/{print $3; exit}' "$RUNDIR/report.txt" 2>/dev/null)"
|
||||
TOP="$(python3 -c "import json;b=json.load(open('$RUNDIR/buckets.json'))['buckets'];k=max(b,key=b.get);print(f'{k} {b[k]:.0f}%')" 2>/dev/null || echo '?')"
|
||||
echo " wallclock=$(cat "$RUNDIR/wallclock.txt")s $SAMP top=$TOP"
|
||||
done
|
||||
|
||||
# Median wallclock + median per-bucket share across the K runs -> summary.md.
|
||||
python3 - "$SDIR" "$REND" "$SCENE" "$LABEL" "$DEVICE" "$FREQ" "$FRAMES" "$RUNS" "$CYCLES_EVENT" <<'PY'
|
||||
import json, glob, os, statistics, sys
|
||||
sdir, rend, scene, label, device, freq, frames, runs, event = sys.argv[1:10]
|
||||
bj = sorted(glob.glob(os.path.join(sdir, "run*", "buckets.json")))
|
||||
wc = []
|
||||
for d in sorted(glob.glob(os.path.join(sdir, "run*"))):
|
||||
p = os.path.join(d, "wallclock.txt")
|
||||
if os.path.exists(p):
|
||||
try: wc.append(float(open(p).read().strip()))
|
||||
except ValueError: pass
|
||||
agg = {}
|
||||
for f in bj:
|
||||
try: data = json.load(open(f))
|
||||
except Exception: continue
|
||||
for k, v in data.get("buckets", {}).items():
|
||||
agg.setdefault(k, []).append(v)
|
||||
med = {k: statistics.median(v) for k, v in agg.items() if v}
|
||||
ranked = sorted(med.items(), key=lambda kv: kv[1], reverse=True)
|
||||
med_wc = statistics.median(wc) if wc else float("nan")
|
||||
out = os.path.join(sdir, "summary.md")
|
||||
with open(out, "w") as o:
|
||||
o.write(f"# {label} — {scene} (renderer={rend})\n\n")
|
||||
o.write(f"- device: **{device}** · runs: {runs} · frames: {frames} · perf -F {freq} -e {event}\n")
|
||||
o.write(f"- median wallclock (incl. startup+savestate-load): **{med_wc:.3f} s** — coarse only; NOT valid for codegen A/B (use codegen_ab.sh)\n")
|
||||
o.write(f"- ⚠️ audio OUTPUT excluded (eerunner forces SPU2 Backend=Null); the SPU2 DSP core still runs and is counted.\n")
|
||||
o.write(f"- renderer note: `vk` = whole-system (on M2 the GPU-driver/kernel buckets are the host Asahi stack, NOT an SD865 proxy); `null` = CPU-emulation shape with the GS thread dropped (closest to the SD865-relevant CPU cost, but GIF/PATH3 under-consumed so VU1/XGKICK are distorted).\n\n")
|
||||
o.write("| bucket | median self% |\n|---|---|\n")
|
||||
for k, v in ranked:
|
||||
o.write(f"| {k} | {v:.2f} |\n")
|
||||
if med.get("unattributed", 0) > 5:
|
||||
o.write(f"\n⚠️ unattributed {med['unattributed']:.1f}% > 5% — tune bucket_perf.py NATIVE_RULES.\n")
|
||||
print(f"wrote {out}")
|
||||
PY
|
||||
done
|
||||
echo "done. summaries under $OUT/$DEVICE/$SCENE/"
|
||||
@@ -0,0 +1,10 @@
|
||||
# Scene: Baldur's Gate: Dark Alliance — user-captured gameplay (2026-07-05,
|
||||
# slot 01). EE-heavy hack-n-slash; third register-economy data point next to
|
||||
# EE-bound SotC and VU-bound UYA for the EE-SRA pin-ladder decisions.
|
||||
# Assets are copyrighted (not checked in); these paths are this dev box's real layout.
|
||||
LABEL="BGDA — gameplay · slot 01"
|
||||
ISO_ROOT="${ISO_ROOT:-/home/bmd/dev/ps2/My Sony PlayStation 2 (USA) Collection}"
|
||||
ISO="$ISO_ROOT/Baldur's Gate - Dark Alliance (USA).iso"
|
||||
SAVESTATE="$HOME/.config/YAPS2/sstates/SLUS-20035 (773A8DAB).01.p2s"
|
||||
FRAMES=600 # same N every run
|
||||
RENDERERS="vk null"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user