Skip to content

libretro/ps2

Repository files navigation

LRPS2

PlayStation 2 emulation core for libretro (RetroArch), based on PCSX2. This fork's headline work is a native AArch64 (arm64) port: upstream PCSX2 emulation relies on x86-64 recompilers, which do not exist on ARM — this tree adds arm64 recompilers for the EE and IOP plus the supporting infrastructure to run correctly (and reasonably fast) on 64-bit ARM Linux devices.

Status (arm64)

Verified booting to real in-game content (Mega Man X7 gameplay, Gran Turismo 3 intro/FMV) on a Snapdragon-class device (Adreno 618, 4K-page Linux, glibc RetroArch flatpak + musl test host).

Profile from a save state, not from a boot. A headless run can only reach content the emulator walks to by itself, and for Gran Turismo 3 that is its opening movie -- 9000 frames (150 s of emulated time) with Start auto-pressed still land in the intro. Profiles taken that way put the IPU (IPUWorker + yuv2rgb, ~14 %) on top and leave the MTVU thread at 0.2 % of CPU, i.e. they measure the video decoder rather than the game, and they sent two optimisation attempts down blind alleys before that was noticed. Resuming from an in-race save state instead:

Thread CPU Inside it
EE 41 % 50 % in its own JIT, 43 % native C++ (SPU Mix 8.9 %, memcpy 4.0 %, event test 2.1 %), 7 % IOP JIT
GS 37 % Vulkan renderer
MTVU 23 % 75 % inside microVU1's generated code

No IPU at all, and the event test that looked like a 6-7 % problem is 2.3 %. Any performance claim here should say which of the two it came from.

Three findings that came out of getting that profile:

  • Loading a save state used to drop the EE onto the interpreter for the rest of the session (3.5x slower: GT3 in-race 92 s -> 26 s per 600 frames). The interpreter's execute loop always restarted in its boot stage, whose "interpret until the pc reaches EELOAD" condition can never be satisfied mid-game, so the JIT stage was never reached. Fixed: the loop resumes in GAME_RUNNING when g_GameStarted (which is part of the save state) says the entry point is behind us.
  • The EE thread is the critical path, and it is not waiting. Wall-clock timing of the sync points (LRPS2_SYNC_STATS=1) puts the EE's blocking at 0.97 s on the GS and 0.00 s on MTVU over a 600-frame in-race run. Letting the EE run frames ahead of the GS, or turning MTVU off, both measure as nothing. (The sampling profiler's cross-thread percentages are undercounts -- SIGPROF does not queue -- so only within-thread splits are trustworthy.)
  • The EE thread is not instruction-count-bound either. The in-race handoff work (C.63-C.65) removed 63 % of the interpreter handoffs, and the code-quality work (C.66-C.67) shrank the hottest block 35 % (3068 -> 1984 host bytes: dead COP2 flag pipelines dropped, unaligned pairs fused to single accesses) -- and wall time did not move for any of them. On this core (A76/A55) the OoO front end absorbs the removed instructions; what remains is bound by memory and dependency chains, so further emitted-code polishing has a low ceiling. These changes are kept for coverage, interpreter-fidelity, code density and x86 parity, not for speed.
  • The remaining native-C++ "plumbing" on the EE thread is protocol cost, not waste. Single-level caller attribution for leaf symbols (LRPS2_PROF_LR=memcpy,memcmp,__aarch64_ -- the SIGPROF handler records LR next to PC, and for a leaf function LR is the caller) pins it down: memcpy (4 %) is 59 % the MTGS ring write (WRITERING_DMA) and 31 % the MTVU ring write (VU_Thread::VifUnpack); the outlined atomics (2.2 %) are 84 % the WorkSema::NotifyOfWork fetch_add, one per VIF unpack packet; memcmp (1.2 %) is entirely the C.60 block-revalidation check. The WorkSema RMW in particular must stay per notify: an RMW always observes the latest state, which is what makes the sleep/wake handshake sound -- the tempting load-then-skip variant can read a stale RUNNING_N while the worker is going to sleep and miss the wakeup. What C.80 does instead is issue fewer notifies: a VIF unpack packet only publishes its ring data (the release store of the write pointer stays per-packet, so an awake worker drains it immediately) and defers the wakeup RMW to the next flush point -- any other MTVU command (they all notify), WaitVU (mandatory: WaitForEmpty trusts the sema state machine), Close, or the VIF1 DMA/MFIFO end-of-transfer tails (latency bound). In-race GT3 that removes 82 % of the notifies (3.9M unpacks -> 0.7M notifies per 600 frames) and the fetch_add leaves the EE-thread profile (2.2 % -> below threshold); wall time is flat, as with every plumbing change since C.49 (LRPS2_NO_VIF_LAZYKICK=1 restores the per-packet notify, LRPS2_MTVU_STATS=1 reports the deferred/issued counts). An SPU worker thread was measured and rejected: both test titles run with an SPU IRQ armed 93-98 % of all sample ticks and GT3's in-race IRQs are 100 % mixer-voice IRQA crossings, discovered tick-exactly during mixing -- async mixing cannot deliver them at the bit-identical IOP cycle, and the only sound design (speculative mixing with rollback) is out of proportion to the ~5-6 % ceiling (LRPS2_SPU_SYNC_STATS=1 collects the evidence).

Overall recompiler-suite progress: roughly ~88 % (weighted by remaining work; dynamic instruction coverage on the tested titles is much higher — the vast majority of EE+IOP instructions already execute natively, and the remaining interpreter handoffs are mostly untranslatable exceptions). Opcode coverage is essentially done; the current work is the quality of the emitted code, guided by the in-tree sampling profiler (LRPS2_PROF=1), which buckets PC samples by JIT code cache and resolves the rest against the core's symbol table.

EE (R5900) JIT — ~89 %

Native ALU, loads/stores incl. LQ/SQ (inline vtlb fast path) + unaligned family, branches + likely branches + BC0x/BC1x, block linking/chaining, MULT/DIV/MADD + HI/LO (both pipelines), complete COP1 (FPU) — data moves + all S-format arithmetic + CVT + C.cond, interpreter-exact clamp/flag semantics, CFC1/CTC1 native (C.65: fs is a compile-time constant, so CFC1 specializes to FCR31 load / revision constant / zero, and CTC1 to a non-FCR31 register is a nop), bit-exact MMI subset → NEON, MFSA/MTSA(B/H), kernel idle-loop skip, block revalidation cache, write-back GPR register cache with direct-to-cache emission, event tests gated on cycle >= nextEventCycle in block tails (the upstream-rec dispatcher rule; ~23 % faster wall clock on GT3, LRPS2_NO_EVTGATE=1 restores every-branch tests), default-rate cycle bookkeeping inlined into block tails (LRPS2_NO_INLINE_UPD=1 restores the helper call), block chaining keeps the frame live across hops (no per-block frame pop/push or x19 rematerialization; same on the IOP side). ADDI/DADDI (overflow trap dropped, exactly like the x86 rec), COP0 EI/DI, LQC2/SQC2 (128-bit VU0-register load/store, native since C.59: vu0Sync plus a 128-bit access straight into vuRegs[0].VF[ft], so the EE register cache survives an op MMX7 runs ~827K times; LRPS2_NO_EE_VUQMEM=1 restores the interpreter call). Memory goes through the vtlb fastmem area (C.50): the 4 GB host mapping in which every memory-backed guest page sits at its own guest address, so a load/store is one instruction (ldr/str <data>, [x28, w<addr>, UXTW]) with no vmap indirection. Hardware registers and unmapped pages are absent from the area, so touching one faults; the faulting instruction is rewritten into a branch to a slow stub emitted next to it at compile time, and the guest pc is blacklisted so later compiles of that op skip fastmem (LRPS2_NO_FASTMEM=1 restores the old inline vmap path, LRPS2_FASTMEM_LOG=1 traces the patches). Emitted-code quality: cpuRegs fields are reached through the x19 guest-reg base instead of a 4-instruction absolute address per access (C.46); load/store displacements fold into the address add (C.48); the vmap/fastmem base is pinned in x28 for the life of the block frame (C.49); the backpatch stubs (C.51), the misalign cancel paths (C.53), the event-due tails and the frame pop/ret (C.54) all live out of line past the epilogue, so the hot instruction stream carries no cold code; the chain epilogue folds the mirror mask and RAM range check into one test (C.52); an exit whose successor is known at compile time chains through a constant LUT slot with no pc read at all, on the event gate's not-due path where pc provably has not been redirected (C.54); the tail's cycle bookkeeping and cycle update are fused into one sequence (C.55); the FP loads/stores use fastmem too (C.56); and ADDIU/DADDIU fold their immediate into the add (C.57); the canonical unaligned pairs (LDL/LDR, LWL/LWR, SDL/SDR, SWL/SWR -- adjacent, left-then-right, matching registers and offsets) fuse into ONE host unaligned access through fastmem instead of two helper calls each, with a fault stub that replays the exact two-access guest-order semantics for hw pages (C.67, LRPS2_NO_EE_UPAIR=1 restores the helpers). Handoff coverage (C.63-C.65), measured from an in-race GT3 save state rather than an intro movie: ADD/SUB (trap dropped like ADDI), PLZCW (= CLS per 32-bit half), PREVH (= REV64 .8H) and CFC1/CTC1 are native, and a likely branch is no longer refused by an untranslatable delay slot -- GT3 is full of beql ..., panic; break asserts whose slot only runs when taken, i.e. never; the not-taken path is emitted natively and the taken path hands control to the interpreter at the branch (C.64). ERET is native too (C.77): the interrupt/exception return was the single biggest remaining breaker (196k per 600 in-race frames -- every interrupt return broke its block); it is a branch with no delay slot (pc from ErrorEPC/EPC by Status.ERL, clear that bit, pull nextEventCycle), and its tail deliberately does NO cycle flush and NO event test, mirroring the interpreter-handoff runner it replaces (a plain do execI while (!branch2) loop) -- the next block's gated tail delivers the pending interrupt; adding a flush+test here instead shifted MMX7's FMV frame phase (LRPS2_NO_EE_ERET=1 restores the handoff). The remaining MMI stragglers are native too (C.79): PPAC5 (per-word RGB1555 pack → NEON shift/mask), and the full-128-bit HI/LO moves PMFHI/PMFLO (rd <- HI/LO) and PMTHI/PMTLO (HI/LO <- rs), all bit-exact (LRPS2_NO_EE_MMIHL=1 restores the handoff). With those gone the only EE ops that still break a block are SYSCALL and the TLB/exception path — everything else GT3 and MMX7 execute is translated. (PMFHL/PMTHL stay with the interpreter deliberately: they never fire in the reference titles, so a native path could not be golden-verified.) Interpreter handoffs in-race: 887k -> 136k breakers per 600 frames (-85 % from the C.62 start), and what remains is SYSCALL (123k, exception path). Translating PLZCW also FIXED a timing deviation: a block that breaks at an untranslatable op flushes its cycle count there, a rounding point the interpreter does not have -- with PLZCW native, MMX7's frame-3000 framebuffer now matches the pure interpreter (88190948), which the old JIT did not. The bit-exactness reference for MMX7 moved accordingly. LRPS2_DUMP_RANGE=lo:hi dumps the guest ops a pc range compiles from with their translatability, and LRPS2_DUMP_HOST=<pc> writes a block's emitted host code out for objdump -D -b binary -m aarch64. Missing: SYSCALL/TLB (interpreter forever); PMFHL/PMTHL (never fire in the reference titles)

IOP (R3000A) JIT — ~98 %

Native ALU incl. the trapping forms ADDI/ADD/SUB (this interpreter drops the overflow trap, so they are the U ops — same call the EE made in C.42) and RFE (pure Status bit shuffle) (C.68, LRPS2_NO_IOP_C68=1 restores the interpreter), aligned + unaligned (LWL/LWR/SWL/SWR) loads/stores, branches + delay slots, block linking, MULT/DIV + HI/LO moves, COP0 moves (MFC0/CFC0/MTC0/CTC0), write-back GPR register cache (w22–w27, LRPS2_NO_IOP_REGCACHE=1 to disable), inline RAM fastmem for loads (LRPS2_NO_IOP_FASTMEM=1 to disable; the remaining helper-call traffic is genuine HW-register polling), event tests gated on cycle >= iopNextEventCycle in branch tails (LRPS2_NO_IOP_EVTGATE=1 to disable); J native (only the module-import stub form j ...; li $zero, fn stays interpreted for the HLE hook — and that hook's resolution is cached per stub pc (C.71): the up-to-0x2000-byte backward magic scan, the std::string heap allocation and the library-name compare chain ran on EVERY stub execution, millions of times a minute; now a hit revalidates its inputs with three RAM reads (LRPS2_NO_IOP_JSTUB_CACHE=1 restores the uncached path — iopMemReadString left the profile, ~1.5-2 % of the EE thread). A straight-line run longer than the block cap chains to the next block instead of handing its fully-translatable tail to the interpreter (C.69, the EE's C.44 rule; LRPS2_NO_IOP_CAP_CHAIN=1 to disable), and an interpreter-tail block also covers its breaker word for SMC invalidation, so code loaded OVER what was data at compile time (IRX module loads) recompiles instead of re-handing off forever (C.70 — one stale MMX7 block was re-interpreting lw ra thousands of times a run). Handoff coverage is closed: LRPS2_IOP_HANDOFF_STATS=1 (histogram of interpreted ops, EE-style) shows exactly two breaker classes left on both test titles — the J import stub (HLE hook, by design) and SYSCALL (exception, interpreter forever); GTE/COP2 is PS1-only and never fires. The kernel idle spin (j <self> + nop) is fast-forwarded to the next event or the end of the cycle budget rather than emulated an iteration at a time (C.47, LRPS2_NO_IOP_IDLE=1 to disable) — it was 18 % of all JIT time; psxRegs fields go through the x19 base (C.46)

VU1 recompiler — ~70 %

microVU1 (armsx2 aVU transplant) is the default provider: native AArch64 VU codegen, MVU_DIFF-verified register-exact against the interpreter on both test titles, byte-identical framebuffers through 12000-frame runs. Instant-VU1 and MTVU are back to default-on with it. In-race profile facts (GT3 save state): EE never waits on MTVU (SyncStats 0.00 s) so VU1 speed does not gate wall time; mvu1 is ~75 % of the MTVU thread and FLAT (150 blocks, hottest 5.6 % of JIT) — no per-block target exists, only systematic emitter quality. First such passes done: C.72 folds the page offset of absolute-address accesses (flag spills, clamp constants) into the load/store instead of a separate add after adrp (armAbsMemOperand), and C.73 replaces the naive copy+4×Ins mVUshufflePS permute with the cheap NEON form where one exists (Dup/Rev64/Ext/identity; 2-lane swaps insert only the moved lanes) — and C.74 pins &mVU in x27 (RVUMVU, micro mode only — macro mode's x27 belongs to the EE rec) with the hot per-op scalars moved ahead of the ~290KB prog member so they encode as scaled immediates, while &mVU.regs() fields ride the existing x19 — and C.75 embeds copies of the emit-constant tables (clamp bounds, FTOI/ITOF scales, EFU polynomials) in microVU itself so the per-clamp constant loads are one [x27 + imm] instruction — the hottest block dropped 1776 → 1292 host bytes (−27 %), adrp count 54 → 9, and the register-exact MVU_DIFF shadow run from the in-race save state produces a byte-identical divergence log to the pre-C.72 baseline (the 9 logged programs are the known pre-existing interp-vs-microVU last-bit FP differences). LRPS2_PROF now attributes mvu0/mvu1 samples per guest block and LRPS2_DUMP_HOST_MVU=0x<pc> dumps a block's host code. Fallbacks: LRPS2_VU1REC_PAIR=1 (interp-pair rec), LRPS2_NO_VU1REC=1 (interpreter; both restore conservative non-instant/opt-in-MTVU behavior)

VU0 / COP2 — ~78 %

microVU0 runs VU0 micro programs (VCALLMS) natively; COP2 macro ALU ops emit native NEON directly into EE JIT blocks (aVU_Macro single-op emitters) with real flag liveness (C.66): the analysis runs over a run of consecutive macro ALU ops, capped at the block end, and only the last writer of each flag category computes it -- the same flag-hack the x86 rec ships by default (its COP2FlagHackPass, here run-local because this builder interleaves analysis with emission). Dead intermediate MAC/status pipelines (~40 host instructions per op) vanish: the hottest in-race GT3 block, VU0-macro matrix code, dropped 3068 -> 2004 host bytes. Wall-time flat (OoO absorbed the ALU); the win is code size, I-cache and x86 parity. LRPS2_NO_COP2_LIVENESS=1 restores all-live; BC2 branches native; the COP2 transfers (QMFC2/CFC2/QMTC2/CTC2) are native too (C.58) -- the interlock bit is known at compile time, so the _vu0FinishMicro/_vu0WaitMicro call is only emitted when set, and CFC2's REG_R quirk (writes UL[0], leaves UL[1]) is reproduced exactly. CTC2 to FBRST/CMSAR1 (VU reset / VU1 microprogram kick) and CALLMS/CALLMSR stay on the interpreter call, faithful to x86. C.78 macro emission quality, on the EE critical path (VU0 macro runs in the EE thread): the COP2 transfer bodies and the conservative VU0-busy check address vuRegs through an adrp+pageoff fold instead of a 4-instruction absolute materialization each (LRPS2_NO_COP2_ABSFOLD=1 restores them); the per-op macro bracket -- VU0-busy check, x19 repoint to &vuRegs[0], x19 restore to &cpuRegs (~11 instructions per op) -- is hoisted to once per run of consecutive native macro ALU ops (the C.66 liveness run): between the ops of a run nothing else is emitted, events only fire at block tails, and no run op can start a VU0 microprogram, so the bases stay valid across the run's whole extent (LRPS2_NO_COP2_RUNHOIST=1 restores per-op brackets); and x27 = &microVU0 is materialized inside the bracket, so mvuAbsMem's one-instruction [x27 + imm] addressing of the mVU scalars and the C.75 embedded constant tables now applies in macro mode too (LRPS2_NO_COP2_MX27=1 restores the adrp fold). FOOTGUN captured in the run predicate: VNOP/VWAITQ have EMPTY emitters (no setupMacroOp/endMacroOp), so a hoisted bracket ending on one never emits its x19 restore -- GT3's VU0->GS upload loop ends VSQI, VSQI, VNOP, VNOP and the following branch then read its GPRs out of VF registers (instant wedge); they are excluded from runs and emit through the per-op path. Hottest in-race block (VU0-macro matrix code) 1860 -> 1540 host bytes (-17 %); wall flat (C.49 lesson: OoO absorbs it), the win is code size, I-cache and parity. LRPS2_NO_VU0REC=1 / LRPS2_NO_EE_COP2MACRO=1 / LRPS2_NO_EE_COP2XFER=1 fall back

VIF unpack dynarec — ~100 %

NEON unpack kernels (armsx2 transplant); portable C fallback (LRPS2_NO_VIF_DYNAREC=1)

SMC / overlays

Compiled pages are write-protected; faults invalidate stale blocks (vtlb mmap_MarkCountedRamPage flow). A page the game keeps writing would otherwise ping-pong forever — fault, unprotect, drop blocks, revive them, re-protect, fault again — at a SIGSEGV plus four mprotect calls a cycle (the RAM page and its alias in the fastmem area). After kManualClears clears a page is therefore left writable and its blocks check their own source words at entry instead (C.60), which is where the chained entry point sits, so a chained jump validates too; the check is handed its block record at compile time rather than looking itself up (C.61). On GT3 that took the run from 48006 protects / 47803 clears to 251 / 49 — two pages had been ping-ponging ~23k times each — and mprotect left the profile entirely. LRPS2_NO_EE_MANUAL=1 restores protect-on-every-revive, LRPS2_SMC_STATS=1 dumps the per-page protect/clear counts

MTVU (VU1 thread)

Default-on with microVU1 (≥3 cores; LRPS2_NO_MTVU=1 disables). Partial-packet flush protocol prevents the continuous-microprogram livelock; with an interp-style VU1 provider MTVU stays opt-in (LRPS2_MTVU=1). Lazy VIF-unpack kick (C.80): an unpack packet publishes its ring data but defers the WorkSema wakeup RMW to the next flush point (any other MTVU command, WaitVU, Close, or the VIF1 DMA/MFIFO end-of-transfer tails) — in-race GT3 82 % of the notifies disappear (LRPS2_NO_VIF_LAZYKICK=1 restores per-packet notify, LRPS2_MTVU_STATS=1 reports the counts)

GS renderer

Standard Vulkan renderer (pcsx2_renderer = "Vulkan"). paraLLEl-GS requires GPU features (8/16-bit storage, small subgroups) that e.g. Adreno 618 lacks

MTGS

Works (GS thread active)

Correctness methodology: headless libretro harness runs are compared for byte-identical framebuffer output against the interpreter (LRPS2_NO_EEREC=1) and the mem-interpreted JIT (LRPS2_NO_EE_MEM=1) baselines; JIT-vs-baseline lockstep is verified with per-frame RAM+scratchpad hashes, per-op GPR-hash traces, and interrupt/syscall vector logs (see debug toggles below).

Building

CMake options common to all builds:

-DLIBRETRO=ON -DDISABLE_ADVANCE_SIMD=ON -DUSE_VULKAN=ON -DUSE_OPENGL=ON

The build produces pcsx2_libretro.so; install it into RetroArch's cores/ directory. A PS2 BIOS image is required in <system>/pcsx2/bios/.

Notes for arm64:

  • Build against the same libc your frontend uses (a musl-built core will not load into a glibc RetroArch flatpak; build inside the matching SDK).
  • 4K host pages are assumed on non-Apple aarch64 (16K remains for Apple Silicon); the vtlb page-protection code depends on this matching the kernel.

Debug/diagnostic environment toggles (arm64, all off by default)

Bisection switches:

Variable Effect
LRPS2_NO_EEREC=1 EE runs on the pure interpreter
LRPS2_NO_IOPREC=1 IOP runs on the pure interpreter
LRPS2_NO_EE_MEM/LOAD/STORE/BRANCH/MMI/MULDIV/COP1/LD64=1 Route the given EE op class to the interpreter
LRPS2_NO_EE_MMIHL=1 Route PPAC5 + PMFHI/PMFLO/PMTHI/PMTLO (C.79) to the interpreter
LRPS2_NO_EE_FPU_ARITH=1 Route COP1 S-format arithmetic to the interpreter (data moves stay native)
LRPS2_NO_EE_DIVS/SQRTS/RSQRTS=1 Route just DIV.S / SQRT.S / RSQRT.S to the interpreter
LRPS2_EE_SPLIT_MEM=1 End the block after every native mem op
LRPS2_NO_INLINE_MEM=1 Disable the inline vtlb fast path (wrapper calls only)
LRPS2_NO_EE_MANUAL=1 Re-protect a code page on every revive instead of letting it go manual (C.60)
LRPS2_MTVU=1 Opt in to the MTVU (VU1 worker thread)
LRPS2_VU1_INSTANT=1 Restore instant VU1 (off on arm64; see VU0/VU1 row)
LRPS2_MTVU_LOG=1 MTVU worker/GS packet + per-path GS byte counters

Tracing/logging:

Variable Effect
LRPS2_RAMCRC=1 Per-frame FNV hash of EE RAM + scratchpad (+ cycle)
LRPS2_DUMP=<path> + LRPS2_DUMP_FRAME=<n> Full RAM dump at frame n
LRPS2_TRACE=<path> + LRPS2_TRACE_LO/HI=<hex> (+_STEP, _FRAME) Binary (pc, GPR-hash) execution trace
LRPS2_EXCLOG=<path> COP0 state at every 0x180/0x200 vector entry (+ syscall number/args)
LRPS2_EVTLOG=<path> + LRPS2_EVT_LO/HI=<frame> Log every EE event test
LRPS2_WLOG=1 + LRPS2_WLO/WHI=<hex> + LRPS2_WFRAME=<n> Watch EE memory accesses in a physical range
LRPS2_IPU_LOG_FRAME=<n> Log IPU FDEC results + IPU1 DMA feed from frame n
LRPS2_FAULT_LOG=1 Log vtlb page-fault handler activity; on an unhandled fault, locate the faulting pc's JIT block (guest pc, emitted-code hexdump, guest MIPS source)
LRPS2_HANDOFF_STATS=1 Histogram of ops handed to the interpreter + first-op breaker table
LRPS2_JIT_STATS=1 JIT compile statistics (e.g. likely-branch counts)
LRPS2_SMC_STATS=1 Per-page write-protect/clear counts (which pages ping-pong between code and data)
LRPS2_EVT_STATS=1 EE event-test rate: entries, how many run the IOP or the interrupt scan, and which clamp set nextEventCycle
LRPS2_IOP_HANDOFF_STATS=1 (+LRPS2_IOP_HANDOFF_PC=<key>) IOP-side interpreted-op histogram + first-op breaker table (+ pinpoint the pcs behind one breaker key)
LRPS2_PROF=1 (+_HZ, _MAX) In-tree sampling profiler: per-thread CPU-time SIGPROF sampling, bucketed by JIT code cache, symbolized against .symtab, with per-guest-block hot lists
LRPS2_PROF_LR=sub1,sub2 Caller attribution for leaf symbols (memcpy/memcmp/__aarch64_*): samples matching a substring get their LR symbolized into a per-thread caller histogram
LRPS2_SYNC_STATS=1 Wall-clock blocking at the EE↔GS/MTVU sync points (who actually waits on whom)
LRPS2_SPU_STATS=1 / LRPS2_SPU_MUTE=1 SPU voice-shape statistics / mute the mixer to re-measure its wall-time ceiling
LRPS2_SPU_SYNC_STATS=1 SPU worker-thread feasibility: register read/write/DMA rates, armed-IRQ tick fraction, and which site (mixer/reverb/ADMA-input/DMA/register) each IRQA match came from
LRPS2_MTVU_STATS=1 C.80 lazy-kick effect: VIF unpack packets deferred vs MTVU notifies issued
LRPS2_DUMP_HOST=<pc> / LRPS2_DUMP_HOST_IOP=<pc> / LRPS2_DUMP_HOST_MVU=<pc> Write a compiled block's host code (+ guest words) for offline objdump -D -b binary -m aarch64
MVU_DIFF=1 microVU1-vs-interpreter register-exact shadow differential (needs instant VU1 — even an empty LRPS2_NO_VU1_INSTANT= disables it and poisons the log)

Measured and rejected

Kept here because each looks obviously worth doing until you measure it. The common thread: on this core (A76/A55) scalar integer ALU work is close to free, so anything that trades it for wider-but-longer-latency code, or for fewer instructions at the cost of a memory access, tends to lose.

Idea Why it was dropped
NEON IDCT_Block (IPU) Written and bit-exact on 602k blocks — but slower at every block density: 169 vs 73 ns/block for the full vector version, and even a hybrid keeping the scalar row pass (whose DC shortcut skips most rows of a real MPEG block) with a NEON column pass that needs no transpose is 78 vs 74. The scalar code is not naive; it is well matched to the data. yuv2rgb already has a NEON path, so IPU is closed
SPU mixer micro-optimisation The mixer is the biggest native C++ item on the EE thread in-game (Mix 7.6 %, reverb 1.2 %), but every cheap lever measures empty: the ceiling is ~6 % wall (muting the whole mixer: 17 -> 16 s medians); 47.1 of 48 voices are genuinely active in GT3 (engine sounds on every car) and none are silent, so the stopped-voice and silence shortcuts have nothing to skip; noise/modulation/volume-slides are 0.0 per sample; TimeUpdate mixes 1.01 samples per call, so a voice-outer reorder has no batch; and all 46 sustain voices have a nonzero envelope step, so an exact static-sustain ADSR skip has zero candidates. What remains is the price of 47 live voices at 48 kHz in already-tight scalar code (3-cache-line voice layout, prefetch, cached ADSR phases). The only lever left is an SPU worker thread -- a synchronisation project (games read ENDX/IRQA against mixing progress), not an optimisation. LRPS2_SPU_STATS=1 dumps the voice-shape numbers, LRPS2_SPU_MUTE=1 re-measures the ceiling
Coarsening the EE↔IOP slice The event test runs 102M times on a 3000-frame MMX7 run and is 5–7 % of the EE thread, but it is not slow — it is asked for that often. The 48-cycle re-test at the end of the test wins the clamp 663 times out of 76M; sweeping it 48→384 changed neither the call count, nor the output (bit-identical), nor the time. CPU_INT/cpuTestINTCInts keep pulling nextEventCycle back to 4–8 cycles, so the rate is the density of the DMA/INT schedule. Thinning that moves interrupt delivery, for a ceiling measured at 2–4 % (inside the noise)

Project Details

Upstream PCSX2: https://pcsx2.net/ — this core inherits its GPL/LGPL licensing; see the source headers. The arm64 AArch64 emitter is VIXL (vendored under 3rdparty/vixl).

About

No description, website, or topics provided.

Resources

License

Stars

73 stars

Watchers

8 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors