FX-15: SD865 iTLB A/B — mechanism confirmed, throughput neutral, kept

ITLB_WALK -15% to -35% and L1I_TLB_REFILL -4% to -59% across 4 titles,
but cycles/wall flat (A77 baseline iTLB pressure already negligible;
1280-entry L2 TLB covers the live JIT footprint in 4K pages). THP
madvise stays default-ON: zero cost, no regression, in-order A53 upside.
Adds the reusable A/B harness (env-toggle arms + in-run smaps THP probe).

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 b9d708f59c
commit bcb8edaa9f
3 changed files with 250 additions and 2 deletions
+24 -2
View File
@@ -154,8 +154,30 @@ Raw logs: `fx-telem-logs/*.emulog.txt` (session scratchpad).
dirty subset, not worth an ABI bump. Footnote: VI is the only churning
channel (57× VF) — future mVU perf work should look at VI pool sizing,
not eviction policy.
- **FX-15 → pending iTLB A/B** (same binary, `YAPS2_NO_THP=1` toggle,
fan pinned).
- **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.0080.025/kinsn — one walk per 40k125k
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)
+117
View File
@@ -0,0 +1,117 @@
#!/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 = YAPS2_NO_THP=1 (4K pages, baseline),
# B = default (THP madvise on). 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"
}
run_one() { # $1=arm(A|B) $2=name $3=state $4=iso $5=round
local tag="$2-$1-r$5" envA=""
[ "$1" = A ] && envA="YAPS2_NO_THP=1"
echo "=== $tag ($(date +%H:%M:%S)) ==="
if [ "$1" = B ] && [ "$5" = 1 ]; then
probe_thp "$OUT/$tag.thp" &
fi
env $envA EERUNNER_SYNCMTGS=0 $TASKSET perf stat -e "$EVENTS" \
-o "$OUT/$tag.stat" -- \
"$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"
+109
View File
@@ -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}{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()