brmis: re-verification after challenge — retract 3-5x margin, correct funnel bounds

bmd challenged the Step-0 dismissal. Recheck (brmis_recheck.py) found the
entry8 window (2 insns) is far narrower than the real r22 skid (regularly
>64 insns; 76% of EE-block samples land on non-branch IPs, 0.86% on b.cond)
so the sub-block attribution was interpretation, not measurement. Corrected
skid-honest funnel bounds: UYA <=21.5% of mispredicts / 0.79% of EE-thread
cycles, SotC <=28.0% / 1.29% (absurd ceiling 36%/1.67%). G1 still fails --
UYA at every bound, SotC at the honest bound -- so the RSB stays dismissed,
but the margin is honest now. Native ~54% confirmed genuine by function-level
symbolization against the deployed binary: spu2Mix, vifTransferLoop<1>,
outline-atomics thunks (MTVU/MTGS rings), Gif_Unit::Execute,
_cpuEventTest_Shared; plus recClearIOP + mVUlookupProg_VU0 top-10 in UYA.
Raw capture data preserved at scratchpad/brmis-2026-07-10/ (untracked).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Brian Degenhardt
2026-07-13 15:55:14 -07:00
co-authored by Claude Fable 5
parent f9f83a19e6
commit cc339f93cd
2 changed files with 228 additions and 16 deletions
+88 -16
View File
@@ -5,8 +5,12 @@ 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: G1 fails by 35× on both axes, in both games. The RSB/RAS return
cache is DISMISSED with numbers; the mispredict campaign closes at Step 0.**
**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 35×" 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
@@ -42,23 +46,80 @@ native / dispatcher regions / JIT blocks (instruction class at IP + entry8).
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. entry8 conflates the dispatcher-Br
edge with taken conditional block exits (the long-form `b.cc +8; B <link>`
puts BOTH edge types' targets at block entries), so it is an upper-bound
contribution to the funnel, not a measurement of it.
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.230.48%** |
| SotC | 7.5% 12.6% | **0.350.58%** |
| UYA | 6.1% (dispatchers alone) 13.0% (+ all entry8) | ~~0.230.48%~~ |
| SotC | 7.5% 12.6% | ~~0.350.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 110134
immediate branches/kinsn vs 1216 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.6% of the EE thread — and the realistic prize is smaller (the
funnel includes JALR targets and jump tables a return cache can't predict,
entry8 is mostly guest-cond, and the RSB adds ~6 insns/call + ~8 insns/return
of hot bytes against a 92%-utilized icache working set).
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
@@ -87,19 +148,30 @@ funnel a rounding-error bucket.
## Gate ledger
| Gate | Threshold | Measured | Verdict |
| Gate | Threshold | Measured (post-re-verification bounds) | Verdict |
|---|---|---|---|
| G1 share | ≥35% of mispredict samples | 613% (both games) | **FAIL** |
| G1 pool | ≥1.5% of EE-thread cycles | 0.230.58% | **FAIL** |
| 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).
## 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: scratchpad/icache/session3-{uya,sotc}/ (b79df7f1 session dir).
Data: `/home/bmd/pcsx2/scratchpad/brmis-2026-07-10/session3-{uya,sotc}/`
(durable copy; originals in the b79df7f1 session scratchpad under /tmp).
+140
View File
@@ -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}")