From 76bba7ffd3c278221de8ad19a6fcddddb3692432 Mon Sep 17 00:00:00 2001 From: WizzardSK <42868978+WizzardSK@users.noreply.github.com> Date: Sun, 19 Jul 2026 04:48:39 +0200 Subject: [PATCH 1/9] Libretro: GitLab CI for the libretro buildbot (linux x64 + aarch64) (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The libretro buildbot's linux nightlies are driven by a per-core .gitlab-ci.yml on the git.libretro.com mirror, not by libretro-super recipes — this adds one, modeled on flycast's, building yaps2_libretro.so for linux x86_64 and aarch64 via the linux-cmake ci-templates. The buildbot containers ship neither Clang nor the pinned third-party libraries, so the job installs clang-17/lld-17 from apt.llvm.org and reuses build-dependencies-runner.sh for the dependency set. The ci-templates drive 'cmake --build --target ${CORENAME}_libretro', so a yaps2_libretro custom target aliasing pcsx2-libretro is added; the .so is picked up from the pcsx2-libretro/ build subdirectory via EXTRA_PATH. Inert on GitHub — the GitHub Actions nightly is unchanged. --- .gitlab-ci.yml | 92 +++++++++++++++++++++++++++++++++++ pcsx2-libretro/CMakeLists.txt | 5 ++ 2 files changed, 97 insertions(+) create mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000..2880b98b5b --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,92 @@ +# DESCRIPTION: GitLab CI/CD for libRetro (NOT FOR GitLab-proper) +# +# Builds yaps2_libretro.so on the libretro buildbot (git.libretro.com mirror) +# for Linux x86_64 and aarch64. Inert on GitHub — the GitHub Actions +# workflows under .github/workflows/ are the project's own CI. +# +# The buildbot containers ship no Clang and none of the pinned third-party +# libraries, so before_script installs clang-17 from apt.llvm.org and reuses +# .github/workflows/scripts/linux/build-dependencies-runner.sh to build the +# same dependency set the GitHub nightly uses (shaderc, SDL3, zstd, lz4, ...). + +############################################################################## +################################# BOILERPLATE ################################ +############################################################################## + +# Core definitions +.core-defs: + variables: + GIT_SUBMODULE_STRATEGY: recursive + CORENAME: yaps2 + # The core is emitted in the pcsx2-libretro/ subdirectory of the build + # tree; the ci-templates pick it up from $BUILD_DIR/$EXTRA_PATH/. + EXTRA_PATH: pcsx2-libretro + CORE_ARGS: >- + -DCMAKE_C_COMPILER=clang-17 + -DCMAKE_CXX_COMPILER=clang++-17 + -DCMAKE_EXE_LINKER_FLAGS_INIT=-fuse-ld=lld-17 + -DCMAKE_MODULE_LINKER_FLAGS_INIT=-fuse-ld=lld-17 + -DCMAKE_SHARED_LINKER_FLAGS_INIT=-fuse-ld=lld-17 + -DCMAKE_PREFIX_PATH=$CI_PROJECT_DIR/.deps + -DENABLE_LIBRETRO=ON + -DENABLE_QT_UI=OFF + -DENABLE_SDL_FRONTEND=OFF + -DENABLE_GSRUNNER=OFF + -DENABLE_TESTS=OFF + -DENABLE_RECOMPILER_TEST_HOOKS=OFF + -DENABLE_SETCAP=OFF + -DUSE_BACKTRACE=OFF + -DX11_API=OFF + -DWAYLAND_API=OFF + -DDISABLE_ADVANCE_SIMD=TRUE + -DOVERRIDE_HOST_PAGE_SIZE=4096 + -DCMAKE_DISABLE_PRECOMPILE_HEADERS=ON + +.core-defs-linux: + extends: .core-defs + before_script: + # The ci-template's script builds with `-j $NUMPROC`; its before_script + # (which normally sets it) is replaced by this one. + - export NUMPROC=$(($(nproc) / 2)); [ "$NUMPROC" -lt 1 ] && export NUMPROC=1 + - apt-get update + - >- + DEBIAN_FRONTEND=noninteractive apt-get -y install + build-essential cmake curl git ninja-build nasm pkg-config wget + gnupg lsb-release software-properties-common + zlib1g-dev libaio-dev libasound2-dev libcurl4-openssl-dev + libdbus-1-dev libdrm-dev libegl-dev libevdev-dev libgbm-dev + libgudev-1.0-dev libopengl-dev libpcap-dev libssl-dev libudev-dev + - wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh + - chmod +x /tmp/llvm.sh + - /tmp/llvm.sh 17 + - DEBIAN_FRONTEND=noninteractive apt-get -y install lld-17 + - ./.github/workflows/scripts/linux/build-dependencies-runner.sh "$CI_PROJECT_DIR/.deps" + +# Inclusion templates, required for the build to work +include: + # Linux + - project: 'libretro-infrastructure/ci-templates' + file: '/linux-cmake.yml' + +# Stages for building +stages: + - build-prepare + - build-shared + +############################################################################## +#################################### STAGES ################################## +############################################################################## +# +################################### DESKTOPS ################################# +# Linux 64-bit +libretro-build-linux-x64: + extends: + - .libretro-linux-cmake-x86_64 + - .core-defs-linux + image: $CI_SERVER_HOST:5050/libretro-infrastructure/libretro-build-amd64-ubuntu:backports + +# Linux 64-bit (ARM) +libretro-build-linux-aarch64: + extends: + - .libretro-linux-cmake-aarch64 + - .core-defs-linux diff --git a/pcsx2-libretro/CMakeLists.txt b/pcsx2-libretro/CMakeLists.txt index c0013dea36..a1e5727216 100644 --- a/pcsx2-libretro/CMakeLists.txt +++ b/pcsx2-libretro/CMakeLists.txt @@ -40,3 +40,8 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux") set_target_properties(pcsx2-libretro PROPERTIES LINK_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/link.T") endif() + +# The libretro buildbot's ci-templates build the target named +# _libretro (see .gitlab-ci.yml). +add_custom_target(yaps2_libretro) +add_dependencies(yaps2_libretro pcsx2-libretro) From 4fb25f230ffc8be8f548e9ac7122708d24d9201a Mon Sep 17 00:00:00 2001 From: Brian Degenhardt Date: Mon, 20 Jul 2026 15:26:24 -0700 Subject: [PATCH 2/9] SL-13: COP2 clamp-constant broadcast residency (q25/q26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-rolled COP2 macro FMAC clamps reloaded the ±FLT_MAX bounds from _cpuRegistersPack.cop2Rec at EVERY clamp site — up to 6 q-loads per guest op, 8398 static instructions across the UYA EE corpus and 9-21% of every hot COP2 block (5.1% of EErec exec-weighted instructions on the M2 sample profile). x86 never shows this class because SSE folds the operand (minps xmm, [mem]); AetherSX2-arm64 keeps the bounds register-resident. Dedicate q25 = maxFloat.4S / q26 = minFloat.4S, excluded from the EE NEON allocator pool (like q8/q9) and from the COP2 macro-mode mVU pool (microRegAlloc::reset(cop2mode), the NEON twin of the x26/x27 EE-pin gate). Re-materialization is 2 Dups from the pinned callee-saved s8/s9 scalars — no memory access (minFloat[i] == maxFloat[i] | 0x80000000 == -FLT_MAX exactly). The clamp itself is now a bare Fminnm+Fmaxnm. Compile-time validity discipline (s_cop2ClampConstsValid): - false at block start; first clamp site emits the 2 Dups; - invalidated by iFlushCall (any flushtype — every real C-call seam); - NOT invalidated by the VPU_STAT sync seams: the shared sync stubs re-materialize unconditionally on their taken path (always sound — q25/q26 can hold nothing else; the fast path touches no NEON); - NOT invalidated by fastmem: vtlbGetLiveRegisterMasks ORs q25/q26 into the recorded fpr_bitmask while valid, so a backpatched slowmem thunk preserves them like any live register; - NOT invalidated by the mVU-reuse macro wrappers (pool-gated, no C calls); - forks and superblock side exits carry the flag via BranchCompileState and Cop2VfCacheScope. M2 census (fresh 6000f UYA slot-02 liverun A/B, identical 7644-block set): clamp-const loads 8398 -> 0, replaced by 498 lazy establishments; EE corpus -7,446 insns (-1.83%) / -29.1 KiB; hot-90 set bytes -6.0% sample-weighted; COP2-dense physics blocks -15..-19% (0043C718 0.806, 0043A840 0.853). Flagship 003F7690 unchanged as expected (its density residual is GPR-quad seam round-trips, the SL-14 item). Unlike the reverted S4-4 outlining (insns-for-bytes trade, A77-neutral), this removes instructions AND bytes together on serial clamp->FMAC dependency chains. Gates: 1401/1401 recompiler_tests incl. 8 new EeVu0Cop2ClampResidency contract tests (establishment counts, taken-seam clamp correctness, fork sharing, sync-stub re-Dup byte-scan, EE+mVU pool exclusion probes); 8000-seed EE fuzz green; UYA slot-02 --stepdiff signature identical to the known-benign baseline (same timer skips, same 0x004c295c terminal). Co-Authored-By: Claude --- pcsx2/arm64/iCOP2-arm64.cpp | 153 +++++++-- pcsx2/arm64/iCore-arm64.cpp | 24 +- pcsx2/arm64/iCore-arm64.h | 11 + pcsx2/arm64/iR5900-arm64.cpp | 10 + pcsx2/arm64/iR5900-arm64.h | 27 +- pcsx2/arm64/microVU-arm64.cpp | 10 + pcsx2/arm64/microVU_IR-arm64.h | 28 ++ pcsx2/arm64/recVTLB-arm64.cpp | 7 + tests/ctest/core/recompilers/CMakeLists.txt | 1 + .../ee_vu0_cop2_clamp_residency_tests.cpp | 304 ++++++++++++++++++ 10 files changed, 531 insertions(+), 44 deletions(-) create mode 100644 tests/ctest/core/recompilers/ee_vu0_cop2_clamp_residency_tests.cpp diff --git a/pcsx2/arm64/iCOP2-arm64.cpp b/pcsx2/arm64/iCOP2-arm64.cpp index 58b628a23f..8564526eec 100644 --- a/pcsx2/arm64/iCOP2-arm64.cpp +++ b/pcsx2/arm64/iCOP2-arm64.cpp @@ -85,9 +85,10 @@ alignas(16) static const u32 s_cop2DestMasks[16][4] = { // tiny compile-time cache keeps them resident in q16..q20 and the op bodies // compute 3-operand NEON straight from the cache registers. // -// Register choice: q16-q26 have no fixed user in EE-block emission context +// Register choice: q16-q24 have no fixed user in EE-block emission context // (q0-q7 = allocator temp/FPR first-fit + vtlb data + mVU macro window, // q8/q9 = pinned FPU clamp constants, q10-q15 = allocator GPR-quad/FPR homes, +// q25/q26 = SL-13 clamp-constant broadcasts (cop2EnsureClampConsts below), // q27/q28 = VOPMULA/VCLIP + flag-body scratch, q29-q31 = per-op scratch). // They are caller-saved and NOT preserved by the fastmem fault thunk (which // only saves allocator-tracked regs), so the cache must never survive any op @@ -468,8 +469,12 @@ static_assert(offsetof(cpuRegistersPack, cop2Rec) + sizeof(EeCop2RecState) <= 16 // (Re)write the pack copies of the COP2 rec constants. Called from // recResetRaw, so the harnesses that reset the rec before compiling are -// covered too. minFloat is the pre-negated clamp lower bound: the clamp -// emitters spend a 1-insn load where they used to spend an Fneg. +// covered too. minFloat is the pre-negated clamp lower bound. Since SL-13 +// the clamp emitters no longer LOAD maxFloat/minFloat (the bounds live +// broadcast in q25/q26, re-materialized from s8/s9 — see +// cop2EnsureClampConsts below); the pack fields stay as the documented +// canonical values (minFloat[i] == maxFloat[i] | 0x80000000 == -FLT_MAX is +// the identity the s9 Dup relies on) and for any future dest-mask work. void cop2RecWritePackConstants() { EeCop2RecState& st = _cpuRegistersPack.cop2Rec; @@ -481,15 +486,80 @@ void cop2RecWritePackConstants() st.denormStatusFlag = 0; } +// ========================================================================= +// SL-13: clamp-constant broadcast residency (q25/q26) +// ========================================================================= +// The clamp bounds live register-resident: q25 = maxFloat.4S (+FLT_MAX per +// lane), q26 = minFloat.4S (-FLT_MAX per lane). Both are excluded from the +// EE NEON allocator pool (NEON_RESERVED_COP2_CLAMPMAX/MIN, iCore-arm64.cpp) +// and from the COP2 macro-mode mVU pool (microRegAlloc::reset(cop2mode)), so +// no EE-block emission can clobber them. Re-materialization is 2 Dups from +// the pinned s8 = +FLT_MAX / s9 = -FLT_MAX callee-saved scalars +// (_DynGen_EnterRecompiledCode) — no memory access, and the sources survive +// every C call by AAPCS64. minFloat[i] == maxFloat[i] | 0x80000000 == +// -FLT_MAX exactly (see cop2RecWritePackConstants), so s9 is the exact +// broadcast source. +// +// Compile-time validity discipline (s_cop2ClampConstsValid): +// - false at block start; the first clamp site emits the 2 Dups. +// - iFlushCall (ANY flushtype — every real C-call seam) invalidates: the +// callee may clobber caller-saved q25/q26. The next clamp site re-Dups. +// - The VPU_STAT-conditional sync seams do NOT invalidate: the shared sync +// stubs re-Dup unconditionally on their taken path after the C calls +// (always sound — q25/q26 can hold nothing else), and their fast path +// touches no NEON. +// - Fastmem sites do NOT invalidate: vtlbGetLiveRegisterMasks ORs q25/q26 +// into the recorded fpr_bitmask while valid, so a backpatched slowmem +// thunk save/restores them around its C call like any live register. +// - The mVU-reuse macro wrappers do NOT invalidate: their pool excludes +// q25/q26 under cop2mode and they emit no C calls. +// - Branch forks and superblock side exits snapshot/restore the flag via +// BranchCompileState (iR5900-arm64.cpp). +// Establishment must stay on unconditionally-executed emission paths — never +// emit the Dups inside a runtime-conditional arm (a post-merge site compiled +// valid would be wrong on the arm that skipped them). All current clamp +// sites are straight-line within their op bodies. + +static bool s_cop2ClampConstsValid = false; + +#ifdef PCSX2_RECOMPILER_TESTS +u32 g_cop2ClampConstEstablishCount = 0; +#endif + +bool cop2ClampConstsValid() +{ + return s_cop2ClampConstsValid; +} + +void cop2ClampConstsSetValid(bool valid) +{ + s_cop2ClampConstsValid = valid; +} + +void cop2ClampConstsInvalidate() +{ + s_cop2ClampConstsValid = false; +} + +static void cop2EnsureClampConsts() +{ + if (s_cop2ClampConstsValid) + return; + armAsm->Dup(a64::v25.V4S(), a64::v8.V4S(), 0); // +FLT_MAX broadcast + armAsm->Dup(a64::v26.V4S(), a64::v9.V4S(), 0); // -FLT_MAX broadcast + s_cop2ClampConstsValid = true; +#ifdef PCSX2_RECOMPILER_TESTS + g_cop2ClampConstEstablishCount++; +#endif +} + // Clamp the result register to [-FLT_MAX, +FLT_MAX] (removes infinities and // NaNs). FMINNM/FMAXNM match x86 MINPS/MAXPS semantics: NaN → non-NaN operand. static void cop2ClampResultReg(const a64::VRegister& result) { - armAsm->Ldr(RQSCRATCH2, armCpuRegMem(&_cpuRegistersPack.cop2Rec.maxFloat)); - armAsm->Ldr(RQSCRATCH3, armCpuRegMem(&_cpuRegistersPack.cop2Rec.minFloat)); - - armAsm->Fminnm(result.V4S(), result.V4S(), RQSCRATCH2.V4S()); // clamp to +FLT_MAX - armAsm->Fmaxnm(result.V4S(), result.V4S(), RQSCRATCH3.V4S()); // clamp to -FLT_MAX + cop2EnsureClampConsts(); + armAsm->Fminnm(result.V4S(), result.V4S(), a64::v25.V4S()); // clamp to +FLT_MAX + armAsm->Fmaxnm(result.V4S(), result.V4S(), a64::v26.V4S()); // clamp to -FLT_MAX } static void cop2ClampResult() @@ -497,28 +567,14 @@ static void cop2ClampResult() cop2ClampResultReg(RQSCRATCH); } -// Single-temp variant of cop2ClampReg: clamps `qreg` to [-FLT_MAX, +FLT_MAX] -// using just one scratch register (it reloads the bound between the two -// clamps). Needed when pre-clamping a broadcast FMAC operand, where Fs/Ft -// already occupy two of the three q-scratch regs and only one is free. -static void cop2ClampRegOneTmp(const a64::VRegister& qreg, const a64::VRegister& tmp) -{ - armAsm->Ldr(tmp, armCpuRegMem(&_cpuRegistersPack.cop2Rec.maxFloat)); - armAsm->Fminnm(qreg.V4S(), qreg.V4S(), tmp.V4S()); // clamp to +FLT_MAX - armAsm->Ldr(tmp, armCpuRegMem(&_cpuRegistersPack.cop2Rec.minFloat)); - armAsm->Fmaxnm(qreg.V4S(), qreg.V4S(), tmp.V4S()); // clamp to -FLT_MAX -} - // Non-destructive clamp: dst = clamp(src) without modifying src (which may be -// a live VF-cache register). Same 4-insn cost as cop2ClampRegOneTmp — the -// first Fminnm is 3-operand, so preserving src is free. -static void cop2ClampInto(const a64::VRegister& dst, const a64::VRegister& src, - const a64::VRegister& tmp) +// a live VF-cache register) — the first Fminnm is 3-operand, so preserving +// src is free. +static void cop2ClampInto(const a64::VRegister& dst, const a64::VRegister& src) { - armAsm->Ldr(tmp, armCpuRegMem(&_cpuRegistersPack.cop2Rec.maxFloat)); - armAsm->Fminnm(dst.V4S(), src.V4S(), tmp.V4S()); - armAsm->Ldr(tmp, armCpuRegMem(&_cpuRegistersPack.cop2Rec.minFloat)); - armAsm->Fmaxnm(dst.V4S(), dst.V4S(), tmp.V4S()); + cop2EnsureClampConsts(); + armAsm->Fminnm(dst.V4S(), src.V4S(), a64::v25.V4S()); + armAsm->Fmaxnm(dst.V4S(), dst.V4S(), a64::v26.V4S()); } // ======================================================================== @@ -1066,6 +1122,16 @@ static const u8* cop2DynGenOneSyncStub(void (*syncFn)(), void (*finishFn)()) armReloadCycleDelta(); armReloadEEClobberedPins(); + // SL-13: the callees (and any VU0 micro they ran) clobber caller-saved + // q25/q26 — re-materialize the clamp-constant broadcasts so sites whose + // compile-time validity rides through this seam stay correct. Always + // sound: q25/q26 are pool-reserved and can hold nothing else, and the + // s8/s9 sources are callee-saved (low 64 bits). The fast path above + // touches no NEON, so validity rides it untouched. Pinned by + // EeVu0Cop2ClampResidency.SyncStubsReDupClampConsts. + armAsm->Dup(a64::v25.V4S(), a64::v8.V4S(), 0); + armAsm->Dup(a64::v26.V4S(), a64::v9.V4S(), 0); + armAsm->Ldr(a64::x30, a64::MemOperand(a64::sp, 48)); armAsm->Ldp(a64::x14, a64::x15, a64::MemOperand(a64::sp, 32)); armAsm->Ldp(a64::x6, a64::x7, a64::MemOperand(a64::sp, 16)); @@ -1084,6 +1150,24 @@ void cop2DynGenSyncStubs() s_cop2SyncStubs[kCop2SyncStubFinish] = cop2DynGenOneSyncStub(nullptr, _vu0FinishMicro); } +#ifdef PCSX2_RECOMPILER_TESTS +// SL-13 pin surface: emitted sync-stub code ranges, so tests can assert the +// taken path re-materializes the q25/q26 clamp broadcasts (the seam-survival +// invariant is emission-level — end-to-end runs only catch it when the C +// path happens to clobber q25/q26). Kind indexes follow emission order; the +// end of stub k is the start of stub k+1 (contiguous emission), and the last +// stub is bounded by the dispatcher's Perf-registered range — tests scan to +// the final Ret instead. +int cop2TestGetSyncStubCount() +{ + return kCop2SyncStubCount; +} +const u8* cop2TestGetSyncStub(int kind) +{ + return (kind >= 0 && kind < kCop2SyncStubCount) ? s_cop2SyncStubs[kind] : nullptr; +} +#endif + // Emit conditional VU0 sync: uses EEINST analysis flags when available, // falls back to runtime VPU_STAT check otherwise. // Implements the COP2_Interlock + mVUSyncVU0/mVUFinishVU0 sync protocol. @@ -1642,10 +1726,10 @@ static void cop2LoadBroadcast(const a64::VRegister& qreg, int vfReg, int bc) a64::VRegister mulA = fs; \ if (mulClamp) \ { \ - cop2ClampInto(RQSCRATCH, fs, RQSCRATCH3); \ + cop2ClampInto(RQSCRATCH, fs); \ mulA = RQSCRATCH; \ if (_XYZW_cop2 == 0xf) \ - cop2ClampRegOneTmp(RQSCRATCH2, RQSCRATCH3); \ + cop2ClampResultReg(RQSCRATCH2); /* in-place operand clamp */ \ } \ const a64::VRegister rd = cop2ResultReg(_Fd_cop2, _XYZW_cop2); \ armAsm->neonOp(rd.V4S(), mulA.V4S(), RQSCRATCH2.V4S()); \ @@ -1823,8 +1907,7 @@ void recCOP2_VMSUB() // a zero broadcast Ft must become FLT_MAX*0 = 0 rather than Inf*0 = NaN folded // to +/-FLT_MAX by the result clamp. MSUBx/y/z/w use mVU_FMACd (clampType=0, // no cFs) — that Fs divergence is shared/by-design, so MSUB keeps clampFs=false. -// The MADDw extras (cACC|cFt) are a separate concern. RQSCRATCH2/RQSCRATCH3 are -// free as the ±FLT_MAX bounds here (Ft/ACC are loaded after the clamp). +// The MADDw extras (cACC|cFt) are a separate concern. #define COP2_MADD_BC(name, addOp, bc, clampFs) \ void recCOP2_V##name() \ { \ @@ -1834,7 +1917,7 @@ void recCOP2_VMSUB() a64::VRegister mulA = fs; \ if (clampFs) \ { \ - cop2ClampInto(RQSCRATCH, fs, RQSCRATCH3); \ + cop2ClampInto(RQSCRATCH, fs); \ mulA = RQSCRATCH; \ } \ cop2LoadBroadcast(RQSCRATCH2, _Ft_cop2, bc); \ @@ -1970,10 +2053,10 @@ COP2_ACCUM_OP(MULA, Fmul) a64::VRegister mulA = fs; \ if (mulClamp) \ { \ - cop2ClampInto(RQSCRATCH, fs, RQSCRATCH3); \ + cop2ClampInto(RQSCRATCH, fs); \ mulA = RQSCRATCH; \ if (_XYZW_cop2 == 0xf) \ - cop2ClampRegOneTmp(RQSCRATCH2, RQSCRATCH3); \ + cop2ClampResultReg(RQSCRATCH2); /* in-place operand clamp */ \ } \ const a64::VRegister rdA = cop2ResultRegACC(_XYZW_cop2); \ armAsm->neonOp(rdA.V4S(), mulA.V4S(), RQSCRATCH2.V4S()); \ diff --git a/pcsx2/arm64/iCore-arm64.cpp b/pcsx2/arm64/iCore-arm64.cpp index 23eee4cd5b..9fe4f243b0 100644 --- a/pcsx2/arm64/iCore-arm64.cpp +++ b/pcsx2/arm64/iCore-arm64.cpp @@ -709,7 +709,21 @@ static constexpr u32 NEON_RESERVED_FPU_MAX = 8; static constexpr u32 NEON_RESERVED_FPU_MIN = 9; // (The callee-saved allocator range q10-q15 is declared in iCore-arm64.h — -// NEON_CALLEE_SAVED_START/END; indices 8/9 reserved above.) +// NEON_CALLEE_SAVED_START/END; indices 8/9 reserved above. SL-13 reserves +// q25/q26 the same way for the COP2 clamp-constant broadcasts — +// NEON_RESERVED_COP2_CLAMPMAX/MIN in iCore-arm64.h.) +static bool _isReservedNEONreg(u32 i) +{ + return i == NEON_RESERVED_FPU_MAX || i == NEON_RESERVED_FPU_MIN || + i == NEON_RESERVED_COP2_CLAMPMAX || i == NEON_RESERVED_COP2_CLAMPMIN; +} + +#ifdef PCSX2_RECOMPILER_TESTS +bool eeTestNeonRegIsReserved(int hostreg) +{ + return _isReservedNEONreg(static_cast(hostreg)); +} +#endif // Free-slot-only probe of a range: no eviction, -1 when the range is full. // Used by the FPR-class allocators to PREFER a call-surviving home (GE-15) @@ -719,7 +733,7 @@ static int _getFreeArm64NEONInRangeNoEvict(u32 minreg, u32 maxreg) { for (u32 i = minreg; i < maxreg; i++) { - if (i == NEON_RESERVED_FPU_MAX || i == NEON_RESERVED_FPU_MIN) + if (_isReservedNEONreg(i)) continue; if (!arm64neon[i].inuse) return static_cast(i); @@ -735,7 +749,7 @@ int _getFreeArm64NEON(u32 minreg, u32 maxreg) // Check for free registers for (u32 i = minreg; i < maxreg; i++) { - if (i == NEON_RESERVED_FPU_MAX || i == NEON_RESERVED_FPU_MIN) + if (_isReservedNEONreg(i)) continue; if (!arm64neon[i].inuse) return i; @@ -746,7 +760,7 @@ int _getFreeArm64NEON(u32 minreg, u32 maxreg) bestcount = 0xffff; for (u32 i = minreg; i < maxreg; i++) { - if (i == NEON_RESERVED_FPU_MAX || i == NEON_RESERVED_FPU_MIN) + if (_isReservedNEONreg(i)) continue; pxAssert(arm64neon[i].inuse); if (arm64neon[i].needed) @@ -787,7 +801,7 @@ int _getFreeArm64NEON(u32 minreg, u32 maxreg) bestcount = 0xffff; for (u32 i = minreg; i < maxreg; i++) { - if (i == NEON_RESERVED_FPU_MAX || i == NEON_RESERVED_FPU_MIN) + if (_isReservedNEONreg(i)) continue; pxAssert(arm64neon[i].inuse); if (arm64neon[i].needed) diff --git a/pcsx2/arm64/iCore-arm64.h b/pcsx2/arm64/iCore-arm64.h index 7b3bb8391a..5efc61217f 100644 --- a/pcsx2/arm64/iCore-arm64.h +++ b/pcsx2/arm64/iCore-arm64.h @@ -131,6 +131,17 @@ struct _arm64gprregs static constexpr u32 NEON_CALLEE_SAVED_START = 10; static constexpr u32 NEON_CALLEE_SAVED_END = 16; // exclusive +// SL-13: q25/q26 are dedicated to the COP2 macro clamp-constant broadcasts +// (q25 = maxFloat.4S = +FLT_MAX, q26 = minFloat.4S = -FLT_MAX) and excluded +// from the NEON allocator pool entirely, like q8/q9. They are lazily +// re-materialized per block from the pinned s8/s9 scalars (2 Dups, no memory) +// — see cop2EnsureClampConsts in iCOP2-arm64.cpp for the compile-time +// validity discipline. The COP2 macro-mode mVU pool excludes them too +// (microRegAlloc::reset(cop2mode)); micro-mode mVU may clobber them freely — +// every EE-side path back from micro execution re-materializes. +static constexpr u32 NEON_RESERVED_COP2_CLAMPMAX = 25; +static constexpr u32 NEON_RESERVED_COP2_CLAMPMIN = 26; + // x86 type aliases — used by shared analysis code (iR5900Analysis.cpp) #define XMMTYPE_TEMP NEONTYPE_TEMP #define XMMTYPE_GPRREG NEONTYPE_GPRREG diff --git a/pcsx2/arm64/iR5900-arm64.cpp b/pcsx2/arm64/iR5900-arm64.cpp index 4716b8f551..f86fbd8ac1 100644 --- a/pcsx2/arm64/iR5900-arm64.cpp +++ b/pcsx2/arm64/iR5900-arm64.cpp @@ -669,6 +669,12 @@ void iFlushCall(int flushtype) // (Cop2VfCacheScope) so each fork emits its own writebacks. cop2VfCacheFlush(); + // SL-13: the callee may clobber the caller-saved q25/q26 clamp-constant + // broadcasts — pure compile-time invalidation (constants are clean by + // definition; the next clamp site re-materializes with 2 Dups). + // Unconditional on flushtype: ANY C call can clobber them. + cop2ClampConstsInvalidate(); + // Free caller-saved registers for (int i = 0; i < NUM_ARM_GPR_REGS; i++) { @@ -1610,10 +1616,12 @@ struct BranchCompileState u32 blockCycles; EEINST* instInfo; Cop2VfCacheState vfCache; + bool clampConstsValid; // SL-13: q25/q26 broadcast validity at the fork point void capture() { vfCache = cop2VfCacheGetState(); + clampConstsValid = cop2ClampConstsValid(); blockCycles = s_nBlockCycles; memcpy(constRegs, g_cpuConstRegs, sizeof(g_cpuConstRegs)); hasConstReg = g_cpuHasConstReg; @@ -1626,6 +1634,7 @@ struct BranchCompileState void restore() const { cop2VfCacheSetState(vfCache); + cop2ClampConstsSetValid(clampConstsValid); s_nBlockCycles = blockCycles; memcpy(g_cpuConstRegs, constRegs, sizeof(g_cpuConstRegs)); g_cpuHasConstReg = hasConstReg; @@ -3233,6 +3242,7 @@ static void recRecompile(const u32 startpc) g_branch = 0; cop2VfCacheReset(); + cop2ClampConstsInvalidate(); // SL-13: q25/q26 state unknown at block entry s_pCurBlock->SetFnptr(block_fnptr); s_nBlockCycles = 0; diff --git a/pcsx2/arm64/iR5900-arm64.h b/pcsx2/arm64/iR5900-arm64.h index f179cd86a7..6f423a368e 100644 --- a/pcsx2/arm64/iR5900-arm64.h +++ b/pcsx2/arm64/iR5900-arm64.h @@ -737,16 +737,35 @@ Cop2VfCacheState cop2VfCacheGetState(); // fork-tail peek support void cop2VfCacheSetState(const Cop2VfCacheState&); bool cop2OpPreservesVfCache(u32 code); // classifier for recompileNextInstruction -// RAII: preserve the compile-time cache state across a branch-fork tail +// SL-13: compile-time validity of the q25/q26 clamp-constant broadcasts +// (defined in iCOP2-arm64.cpp — see cop2EnsureClampConsts for the full +// discipline). Invalidate at every real C-call seam (iFlushCall) and at +// block start; the flag joins BranchCompileState for forks/side exits; +// vtlbGetLiveRegisterMasks reads it to make fastmem thunks preserve q25/q26. +bool cop2ClampConstsValid(); +void cop2ClampConstsSetValid(bool valid); +void cop2ClampConstsInvalidate(); + +// RAII: preserve the compile-time COP2 residency state (VF cache + SL-13 +// clamp-const validity) across a branch-fork tail // (SetBranchImm/SetBranchImmCall/SetBranchReg) whose iFlushCall destructively // flushes — the sibling fork must re-emit its own writebacks from the same // pre-tail state, since the cached values stay register-resident along every -// runtime path. +// runtime path (and q25/q26 stay materialized on the not-taken path). struct Cop2VfCacheScope { Cop2VfCacheState state; - Cop2VfCacheScope() : state(cop2VfCacheGetState()) {} - ~Cop2VfCacheScope() { cop2VfCacheSetState(state); } + bool clampConstsValid; + Cop2VfCacheScope() + : state(cop2VfCacheGetState()) + , clampConstsValid(cop2ClampConstsValid()) + { + } + ~Cop2VfCacheScope() + { + cop2VfCacheSetState(state); + cop2ClampConstsSetValid(clampConstsValid); + } }; // COP2 macro-mode microVU0 state setup/teardown (defined in microVU-arm64.cpp). diff --git a/pcsx2/arm64/microVU-arm64.cpp b/pcsx2/arm64/microVU-arm64.cpp index d0fceb33e9..f7869f8a82 100644 --- a/pcsx2/arm64/microVU-arm64.cpp +++ b/pcsx2/arm64/microVU-arm64.cpp @@ -2099,6 +2099,16 @@ bool mVUTestProbe_VIPoolUsable(int hostreg, bool cop2mode) return usable; } +// SL-13 twin: is host NEON reg q in the VF allocation pool under +// cop2mode? Macro mode must exclude q25/q26 (EE clamp-constant broadcasts). +bool mVUTestProbe_NeonPoolUsable(int hostreg, bool cop2mode) +{ + microVU0.regAlloc->reset(cop2mode); + const bool usable = microVU0.regAlloc->isUsableNeon(hostreg); + microVU0.regAlloc->reset(false); + return usable; +} + // waitMTVU stub-shape probe: returns the emitted VU1-sync thunk entry // (mVU.waitMTVU, generated once by mVUgenerateDispatchers) for VU `index`. // mVUaddrFix branches to this thunk when VU0/COP2 touches VU1 register space diff --git a/pcsx2/arm64/microVU_IR-arm64.h b/pcsx2/arm64/microVU_IR-arm64.h index e09d660a6d..6f349e8af2 100644 --- a/pcsx2/arm64/microVU_IR-arm64.h +++ b/pcsx2/arm64/microVU_IR-arm64.h @@ -60,6 +60,7 @@ protected: int counter; int neonWatermark; // see getNeonWatermark() int index; // VU0 or VU1 + bool neonCop2Mode; // SL-13: macro mode — q25/q26 unallocatable (EE clamp consts) VURegs& regs() const { return ::vuRegs[index]; } @@ -88,11 +89,19 @@ protected: armAsm->Dup(reg.V4S(), reg.V4S(), 0); // Broadcast to all lanes } + // SL-13: NEON pool gate — cop2mode excludes q25/q26 (see reset()). + __ri bool neonUsable(int i) const + { + return !neonCop2Mode || (i != 25 && i != 26); + } + // Find least-recently-used NEON reg (recursive, for eviction) int findFreeNeonRec(int startIdx) { for (int i = startIdx; i < neonAllocTotal; i++) { + if (!neonUsable(i)) + continue; if (!neonMap[i].isNeeded) { int x = findFreeNeonRec(i + 1); @@ -109,6 +118,8 @@ protected: // Prefer unoccupied temp regs for (int i = 0; i < neonAllocTotal; i++) { + if (!neonUsable(i)) + continue; if (!neonMap[i].isNeeded && neonMap[i].VFreg < 0) { neonWatermark = std::max(neonWatermark, i + 1); @@ -256,6 +267,15 @@ public: // EeVu0Cop2Macro.MacroModeVIPoolExcludesEEPinHosts. (x86's cop2mode // meaning — fastmem-base/text-pointer usability — doesn't apply here; // those bases are pinned outside the allocatable set.) + // + // SL-13: cop2mode likewise gates the q25/q26 NEON slots — in EE-block + // context they hold the COP2 clamp-constant broadcasts (see + // NEON_RESERVED_COP2_CLAMPMAX/MIN, iCore-arm64.h), and the clamp validity + // flag deliberately RIDES THROUGH the mVU-reuse macro wrappers (they emit + // no C call), so an mVU allocation landing there would silently corrupt + // the constants for every later clamp site in the block. Micro mode keeps + // both (micro programs run under the dispatcher; EE re-materializes on + // every path back). Pinned by EeVu0Cop2ClampResidency.MacroModeNeonPool*. void reset(bool cop2mode = false) { // Clear x26/x27 unconditionally so no VI binding survives a @@ -265,6 +285,7 @@ public: clearGPR(27); gprMap[26].usable = !cop2mode; gprMap[27].usable = !cop2mode; + neonCop2Mode = cop2mode; for (int i = 0; i < neonAllocTotal; i++) clearNeon(i); for (int i = 0; i < gprAllocCount; i++) @@ -874,6 +895,13 @@ public: return i >= 0 && i < gprAllocCount && gprMap[i].usable; } + // NEON twin of isUsableGPR (SL-13 q25/q26 clamp-const isolation) — see + // mVUTestProbe_NeonPoolUsable. + bool isUsableNeon(int i) const + { + return i >= 0 && i < neonAllocTotal && neonUsable(i); + } + // Move VI value into a specific GPR (for address computation etc.) void moveVIToGPR(const a64::Register& dstReg, int vi, bool signext = false) { diff --git a/pcsx2/arm64/recVTLB-arm64.cpp b/pcsx2/arm64/recVTLB-arm64.cpp index 3b9bc025cb..9ce584bd9b 100644 --- a/pcsx2/arm64/recVTLB-arm64.cpp +++ b/pcsx2/arm64/recVTLB-arm64.cpp @@ -227,6 +227,13 @@ static void vtlbGetLiveRegisterMasks(u32& gpr_bitmask, u32& fpr_bitmask) if (arm64neon[i].inuse) fpr_bitmask |= (1u << i); } + + // SL-13: the q25/q26 clamp-constant broadcasts are not allocator state, + // but when compile-time valid they must survive a backpatched slowmem + // thunk's C call like any live register — clamp sites after this access + // were compiled without re-materialization. + if (cop2ClampConstsValid()) + fpr_bitmask |= (1u << NEON_RESERVED_COP2_CLAMPMAX) | (1u << NEON_RESERVED_COP2_CLAMPMIN); } // Emit a single fastmem load instruction and register backpatch info. diff --git a/tests/ctest/core/recompilers/CMakeLists.txt b/tests/ctest/core/recompilers/CMakeLists.txt index 9034893aea..2c5d2e5985 100644 --- a/tests/ctest/core/recompilers/CMakeLists.txt +++ b/tests/ctest/core/recompilers/CMakeLists.txt @@ -87,6 +87,7 @@ add_pcsx2_test(recompiler_tests ee_vu0_cfc2_ctc2_tests.cpp ee_vu0_qmfc2_qmtc2_tests.cpp ee_vu0_cop2_transfer_residency_tests.cpp + ee_vu0_cop2_clamp_residency_tests.cpp ee_vu0_cop2_macro_tests.cpp vu1_alu_upper_tests.cpp vu1_alu_lower_tests.cpp diff --git a/tests/ctest/core/recompilers/ee_vu0_cop2_clamp_residency_tests.cpp b/tests/ctest/core/recompilers/ee_vu0_cop2_clamp_residency_tests.cpp new file mode 100644 index 0000000000..bffc5777d2 --- /dev/null +++ b/tests/ctest/core/recompilers/ee_vu0_cop2_clamp_residency_tests.cpp @@ -0,0 +1,304 @@ +// SPDX-FileCopyrightText: 2026 yaps2 Dev Team +// SPDX-License-Identifier: GPL-3.0+ + +// SL-13 — COP2 clamp-constant broadcast residency (q25/q26). +// +// The macro-mode FMAC clamp bounds (±FLT_MAX) live register-resident in +// q25/q26, lazily re-materialized per block from the pinned s8/s9 scalars +// (2 Dups, zero memory) instead of being reloaded from +// _cpuRegistersPack.cop2Rec at every clamp site. These are contract tests +// for the compile-time validity discipline (cop2EnsureClampConsts, +// iCOP2-arm64.cpp): +// +// - one establishment per straight-line clamp chain; +// - re-establishment after any real C-call seam (iFlushCall); +// - the VPU_STAT sync stubs re-materialize on their taken path, so +// validity may ride through sync seams; +// - q25/q26 are excluded from the EE NEON allocator pool and from the +// COP2 macro-mode mVU pool (nothing in EE-block emission can clobber +// them). +// +// The end-to-end value tests double as JIT-vs-interp oracles: an overflow +// FMAC must produce exactly ±FLT_MAX (0x7f7fffff), which fails if a clamp +// site ever sees garbage bounds. The emission-level pins (establishment +// counter, stub byte-scan, pool probes) catch policy regressions +// deterministically — the value tests alone would only fail when the C +// path happens to clobber q25/q26. + +#include "harness/EeRecTestHarness.h" + +#include "VU.h" +#include "VUmicro.h" +#include "Config.h" + +#include + +// SL-13 test hooks (PCSX2_RECOMPILER_TESTS builds). Global scope — defined +// outside namespaces in the arm64 sources. +extern u32 g_cop2ClampConstEstablishCount; // iCOP2-arm64.cpp +int cop2TestGetSyncStubCount(); // iCOP2-arm64.cpp +const u8* cop2TestGetSyncStub(int kind); // iCOP2-arm64.cpp +bool mVUTestProbe_NeonPoolUsable(int hostreg, bool cop2mode); // microVU-arm64.cpp +bool eeTestNeonRegIsReserved(int hostreg); // iCore-arm64.cpp + +namespace recompiler_tests { + +using namespace mips; +using namespace mips::ee; +using namespace vu; + +namespace { + +constexpr u32 mask_xyzw = 0xF; + +// FLT_MAX per lane; ~2^114 per lane. Product overflows to +inf, which the +// mandatory PS2 result clamp turns into exactly +FLT_MAX (0x7f7fffff). +constexpr u32 kFltMaxBits = 0x7f7fffffu; +constexpr u32 kBigBits = 0x78000000u; + +void SeedOverflowOperands(EeRecTestHarness& h, u32 fs, u32 ft) +{ + h.SeedVu0VfBits(fs, kFltMaxBits, kFltMaxBits, kFltMaxBits, kFltMaxBits); + h.SeedVu0VfBits(ft, kBigBits, kBigBits, kBigBits, kBigBits); + // The overflow FMACs set STATUS/MAC O-flags; with no consumer in the + // program the JIT's flag-liveness elision (EP-2a, vuFlagHack default) + // legitimately skips the architectural VI writes the interp performs. + // Flag behavior is pinned elsewhere — these tests pin clamp VALUES. + h.IgnoreVu0Vi(REG_STATUS_FLAG); + h.IgnoreVu0Vi(REG_MAC_FLAG); +} + +void ExpectClampedToFltMax(EeRecTestHarness& h, u32 vf) +{ + for (char l : {'x', 'y', 'z', 'w'}) + { + EXPECT_EQ(h.GetVu0VfBitsJit(vf, l), kFltMaxBits) << "lane " << l; + EXPECT_EQ(h.GetVu0VfBitsJit(vf, l), h.GetVu0VfBitsInterp(vf, l)) << "lane " << l; + } +} + +// Micro at PC 0: 32 NOP pairs (outlasts the 16-cycle sync kickstart window) +// then E-bit. Idempotent — pure NOPs, so JIT/interp replay divergence is +// impossible. Keeps VPU_STAT busy across the first post-VCALLMS sync seam. +void SeedLongNopMicro(EeRecTestHarness& h) +{ + u32 off = 0; + for (int i = 0; i < 32; i++, off += 8) + h.SeedVu0Microprogram(off, {NopPair()}); + h.SeedVu0Microprogram(off, { + EBitNopPair(), + NopPair(), // explicit E-bit delay pair — keep micro mem deterministic + }); +} + +} // namespace + +// ========================================================================= +// Emission policy — establishment counts +// ========================================================================= + +// A straight-line chain of clamping FMACs pays ONE establishment (2 Dups) +// for the whole block; every clamp site is then a bare Fminnm+Fmaxnm. +TEST(EeVu0Cop2ClampResidency, EstablishOncePerStraightLineChain) +{ + EeRecTestHarness h; + h.EnableVu0Capture(); + h.EnableCop1(); + SeedOverflowOperands(h, 1, 2); + h.LoadProgram({ + VMUL_C2(mask_xyzw, 3, 1, 2), + VMUL_C2(mask_xyzw, 4, 1, 2), + VMUL_C2(mask_xyzw, 5, 1, 2), + }); + const u32 before = g_cop2ClampConstEstablishCount; + h.Run(); + EXPECT_EQ(g_cop2ClampConstEstablishCount - before, 1u) + << "3 clamping FMACs in one block must establish q25/q26 exactly once"; + ExpectClampedToFltMax(h, 3); + ExpectClampedToFltMax(h, 4); + ExpectClampedToFltMax(h, 5); +} + +// VCALLMS is a real C-call seam (iFlushCall(FLUSH_INTERPRETER)) — validity +// must NOT ride through it; the next clamp site re-establishes. +TEST(EeVu0Cop2ClampResidency, ReestablishAfterCCallSeam) +{ + EeRecTestHarness h; + h.EnableVu0Capture(); + h.EnableCop1(); + h.SeedVu0Vi(REG_VPU_STAT, 0); + SeedOverflowOperands(h, 1, 2); + // Trivial immediate-E micro: the VCALLMS is here purely as an in-block + // C-call seam. + h.SeedVu0Microprogram(0, { + EBitNopPair(), + NopPair(), + }); + h.LoadProgram({ + VMUL_C2(mask_xyzw, 3, 1, 2), + VCALLMS(0), + VMUL_C2(mask_xyzw, 4, 1, 2), + }); + const u32 before = g_cop2ClampConstEstablishCount; + h.Run(); + EXPECT_EQ(g_cop2ClampConstEstablishCount - before, 2u) + << "the C-call seam must invalidate; the post-seam FMAC re-establishes"; + ExpectClampedToFltMax(h, 3); + ExpectClampedToFltMax(h, 4); +} + +// ========================================================================= +// Runtime — clamp correctness through a TAKEN sync seam +// ========================================================================= + +// VCALLMS kicks a >16-cycle micro, so the following FMAC's analysis-marked +// sync seam (EEINST_COP2_FINISH_VU0 → SyncFinish stub) is TAKEN at runtime: +// the stub's C path drains the micro (arbitrary caller-saved NEON traffic), +// and the FMAC's clamp must still produce exactly ±FLT_MAX afterwards. +TEST(EeVu0Cop2ClampResidency, ClampCorrectAfterTakenSyncSeam) +{ + EeRecTestHarness h; + h.EnableVu0Capture(); + h.EnableCop1(); + h.SeedVu0Vi(REG_VPU_STAT, 0); + SeedOverflowOperands(h, 1, 2); + SeedLongNopMicro(h); + h.LoadProgram({ + VMUL_C2(mask_xyzw, 3, 1, 2), // pre-kick clamp (establishes) + VCALLMS(0), // kick — VPU_STAT busy + VMUL_C2(mask_xyzw, 4, 1, 2), // sync seam TAKEN (drains micro), then clamp + VMUL_C2(mask_xyzw, 5, 1, 2), // rides the post-seam establishment + }); + h.Run(); + ExpectClampedToFltMax(h, 3); + ExpectClampedToFltMax(h, 4); + ExpectClampedToFltMax(h, 5); +} + +// ========================================================================= +// Fork discipline — establishment state across branch arms +// ========================================================================= + +// The flag joins BranchCompileState: establishment before the branch is +// restored for the sibling fork, so neither arm re-establishes and both +// arms' clamps stay correct. Run both runtime paths. +TEST(EeVu0Cop2ClampResidency, ForkArmsShareEstablishment) +{ + for (const u64 t0 : {u64(0), u64(1)}) // not-taken / taken + { + EeRecTestHarness h; + h.EnableVu0Capture(); + h.EnableCop1(); + h.SetGpr64(reg::t0, t0); + SeedOverflowOperands(h, 1, 2); + h.LoadProgram({ + VMUL_C2(mask_xyzw, 3, 1, 2), // establishes before the fork + BEQ(reg::t0, reg::zero, 2), // fork + VMUL_C2(mask_xyzw, 4, 1, 2), // delay slot — clamps on both paths + VMUL_C2(mask_xyzw, 5, 1, 2), // fall-through arm + VMUL_C2(mask_xyzw, 6, 1, 2), // branch target + }); + h.Run(); + ExpectClampedToFltMax(h, 3); + ExpectClampedToFltMax(h, 4); + ExpectClampedToFltMax(h, 6); + if (t0 != 0) + ExpectClampedToFltMax(h, 5); // fall-through executed too + } +} + +// ========================================================================= +// Emission pins — sync stubs and allocator pools +// ========================================================================= + +// Every shared VU0-sync stub must re-materialize q25/q26 on its taken path: +// scan each stub's emitted words for the exact Dup pair before the taken +// path's terminating Ret. Encodings verified against the ARM ARM: +// dup v25.4s, v8.s[0] = 0x4E040519 +// dup v26.4s, v9.s[0] = 0x4E04053A +TEST(EeVu0Cop2ClampResidency, SyncStubsReDupClampConsts) +{ + // Any Run() (re)generates the dispatchers + stubs. + EeRecTestHarness h; + h.LoadProgram({NOP}); + h.Run(); + + constexpr u32 kDupMax = 0x4E040519u; + constexpr u32 kDupMin = 0x4E04053Au; + constexpr u32 kRet = 0xD65F03C0u; + + ASSERT_GT(cop2TestGetSyncStubCount(), 0); + for (int kind = 0; kind < cop2TestGetSyncStubCount(); kind++) + { + const u32* words = reinterpret_cast(cop2TestGetSyncStub(kind)); + ASSERT_NE(words, nullptr) << "stub " << kind; + // Stub layout: fast-path Ret first, taken path ends in the 2nd Ret. + bool sawDupMax = false, sawDupMin = false; + int rets = 0; + int i = 0; + for (; i < 96 && rets < 2; i++) + { + if (words[i] == kDupMax) + sawDupMax = true; + else if (words[i] == kDupMin) + sawDupMin = true; + else if (words[i] == kRet) + rets++; + } + EXPECT_EQ(rets, 2) << "stub " << kind << " shape drifted (scan window)"; + EXPECT_TRUE(sawDupMax) << "stub " << kind << " taken path lost the q25 re-Dup"; + EXPECT_TRUE(sawDupMin) << "stub " << kind << " taken path lost the q26 re-Dup"; + } +} + +// q25/q26 are reserved out of the EE NEON allocator pool (like q8/q9); the +// rest of the pool is untouched. +TEST(EeVu0Cop2ClampResidency, EeAllocatorReservesClampRegs) +{ + for (int reserved : {8, 9, 25, 26}) + EXPECT_TRUE(eeTestNeonRegIsReserved(reserved)) << "q" << reserved; + for (int usable : {0, 7, 10, 15, 16, 24, 27, 28}) + EXPECT_FALSE(eeTestNeonRegIsReserved(usable)) << "q" << usable; +} + +// COP2 macro mode runs mVU emitters (the mVU-reuse wrappers) inline in EE +// blocks WITHOUT a C-call seam, and clamp validity deliberately rides +// through them — so the macro-mode mVU NEON pool must exclude q25/q26. +// Micro mode keeps the full pool. +TEST(EeVu0Cop2ClampResidency, MacroModeNeonPoolExcludesClampRegs) +{ + EXPECT_FALSE(mVUTestProbe_NeonPoolUsable(25, /*cop2mode*/ true)); + EXPECT_FALSE(mVUTestProbe_NeonPoolUsable(26, /*cop2mode*/ true)); + EXPECT_TRUE(mVUTestProbe_NeonPoolUsable(25, /*cop2mode*/ false)); + EXPECT_TRUE(mVUTestProbe_NeonPoolUsable(26, /*cop2mode*/ false)); + EXPECT_TRUE(mVUTestProbe_NeonPoolUsable(0, /*cop2mode*/ true)); + EXPECT_TRUE(mVUTestProbe_NeonPoolUsable(24, /*cop2mode*/ true)); + EXPECT_TRUE(mVUTestProbe_NeonPoolUsable(27, /*cop2mode*/ true)); +} + +// The mVU-reuse wrappers must not disturb validity: FMAC → VMFIR (mVU-reuse +// wrapper, no C call) → FMAC pays ONE establishment, and the post-wrapper +// clamp is still exact. +TEST(EeVu0Cop2ClampResidency, ValidityRidesThroughMvuReuseWrapper) +{ + EeRecTestHarness h; + h.EnableVu0Capture(); + h.EnableCop1(); + h.SeedVu0Vi(5, 0x1234); + SeedOverflowOperands(h, 1, 2); + h.LoadProgram({ + VMUL_C2(mask_xyzw, 3, 1, 2), + VMFIR_C2(mask_xyzw, /*ft*/ 7, /*is*/ 5), // mVU-reuse wrapper op + VMUL_C2(mask_xyzw, 4, 1, 2), + }); + const u32 before = g_cop2ClampConstEstablishCount; + h.Run(); + EXPECT_EQ(g_cop2ClampConstEstablishCount - before, 1u) + << "the mVU-reuse wrapper must not invalidate (its pool excludes q25/q26)"; + ExpectClampedToFltMax(h, 3); + ExpectClampedToFltMax(h, 4); + for (char l : {'x', 'y', 'z', 'w'}) + EXPECT_EQ(h.GetVu0VfBitsJit(7, l), h.GetVu0VfBitsInterp(7, l)) << "lane " << l; +} + +} // namespace recompiler_tests From 47ec1b7d69f531f4e7ee5dc23a322399878f8e4f Mon Sep 17 00:00:00 2001 From: Brian Degenhardt Date: Mon, 20 Jul 2026 15:36:07 -0700 Subject: [PATCH 3/9] SL-14: retain EE-class NEON through the COP2 sync seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cop2FlushForConditionalSync freed EVERY in-use NEON register at every VPU_STAT-conditional sync seam, even though the C call sits behind the runtime check and is rarely taken — in the hottest UYA EE block (EE_003F7690, 16 seams) that meant a store->reload round-trip for the same GPR quads every ~6 guest instructions (61% of its q-loads reloaded same-block stores; the eejit ours-vs-aether snapshot pinned this as the flagship's density residual vs aether's whole-block NEON residency). Extend the S4-2 GPR/FPRC retention policy to the NEON file: - NEONTYPE_GPRREG quads / FPREG / FPACC are KEPT mapped with no writeback. The shared sync stubs blind-save the pool NEON file (q0-q7/q10-q24/q27/q28, 25 quads, full 128 bits) around the C calls on the TAKEN path only — state-agnostic, no per-site knowledge, and the 400 B of stack traffic sits on a path about to run a whole VU0 microprogram. q8/q9 need only their callee-saved low 64 bits; q25/q26 are re-materialized by the SL-13 Dups. - NEONTYPE_VFREG still frees WITH writeback (the VU0 micro writes VF, a retained mirror would go stale — same reason the VI mirrors free, and the EP-2b VF compile-cache flush stays). - TEMPs still free. Soundness is the S4-2 invariant: the sync callees (vu0SyncThin / vu0SyncRunAheadThin / _vu0FinishMicro / _vu0WaitMicro -> CpuVU0->Execute) never read or write EE GPR memory or fpr/fprc, so stale canonical memory during the call is unobservable; events raised by the micro are flagged and handled at block end, where dirty entries write back as today. M2 census (6000f UYA slot-02, identical 7644-block set, base = SL-13): flagship 3636 -> 3512 B (GPR-quad loads 78 -> 58), 003F7820 -6.3%, hot-90 sample-weighted bytes -1.8% — concentrated exactly where the SV-0 sizing said the class lives. Cumulative S5 vs pre-SL-13 base: hot-90 sample-weighted -7.7%, EE corpus -2.0%, clamp-const loads 8398 -> 0. Gates: 1404/1404 recompiler_tests (3 new EeVu0Cop2SeamSurvival tests: retention-filter policy probe, dirty-MMI-quad and FPREG riding a runtime-TAKEN seam); 8000-seed EE fuzz green; UYA slot-02 --stepdiff STEPDIFF output byte-identical to the SL-13 run (known-benign signature). Co-Authored-By: Claude --- pcsx2/arm64/iCOP2-arm64.cpp | 107 ++++++++++++++++-- pcsx2/arm64/iCore-arm64.h | 8 +- .../ee_vu0_cop2_clamp_residency_tests.cpp | 70 +++++++++++- 3 files changed, 171 insertions(+), 14 deletions(-) diff --git a/pcsx2/arm64/iCOP2-arm64.cpp b/pcsx2/arm64/iCOP2-arm64.cpp index 8564526eec..30aefe40f0 100644 --- a/pcsx2/arm64/iCOP2-arm64.cpp +++ b/pcsx2/arm64/iCOP2-arm64.cpp @@ -1033,17 +1033,28 @@ void vu0SyncRunAheadThin() // - VIREG entries are freed WITH writeback: VU0 execution writes VU0.VI, so // a retained VI mirror would go stale across the call. // - TEMP / PCWRITEBACK entries are freed (transient, no reloadable home). -// - NEON: same free policy as FLUSH_FREE_XMM — 128-bit classes can't ride a -// C call and the macro body that follows wants the file to itself. The VF -// compile cache (q16-q20) dies at any C seam. +// - NEON (SL-14, the S4-2 policy extended to the NEON file): GPRREG quads / +// FPREG / FPACC are KEPT mapped with no writeback — the stub blind-saves +// the pool NEON registers around the C calls (q0-q7, q10-q24, q27/q28; +// q8/q9 scalar consts are callee-saved low-64, q25/q26 re-Dup'd), so full +// 128-bit values survive both paths in-register. Same observability +// argument as the GPR retention: the callees never touch EE GPR memory or +// fpr/fprc. NEONTYPE_VFREG is freed WITH writeback — VU0 execution on the +// taken path writes VF, so a retained VF mirror would go stale (same +// reason the VI mirrors free, and why the EP-2b VF compile cache flush +// stays). TEMPs are freed (transient). static void cop2FlushForConditionalSync() { cop2VfCacheFlush(); for (int i = 0; i < NUM_ARM_NEON_REGS; i++) { - if (arm64neon[i].inuse) - _freeNEONreg(i); + if (!arm64neon[i].inuse) + continue; + const u8 type = arm64neon[i].type; + if (type == NEONTYPE_GPRREG || type == NEONTYPE_FPREG || type == NEONTYPE_FPACC) + continue; // retained — the sync stub blind-preserves the NEON file + _freeNEONreg(i); // VFREG (writeback — the micro writes VF) / TEMP } for (int i = 0; i < NUM_ARM_GPR_REGS; i++) @@ -1056,6 +1067,44 @@ static void cop2FlushForConditionalSync() } } +#ifdef PCSX2_RECOMPILER_TESTS +// SL-14 pin surface: populate a synthetic allocator state (one CLEAN entry +// per NEON class — clean so the filter's frees emit no writeback code), run +// the seam-preparation filter, and report which classes survived. Returns a +// bitmask: bit0=GPRREG bit1=FPREG bit2=FPACC bit3=VFREG bit4=TEMP. The +// retention contract is 0b00111 (GPRREG/FPREG/FPACC ride, VFREG/TEMP free). +u32 cop2TestSeamRetentionFilter() +{ + _initArm64NEONregs(); + const auto set = [](int idx, int type, int reg) { + arm64neon[idx].inuse = 1; + arm64neon[idx].type = static_cast(type); + arm64neon[idx].reg = static_cast(reg); + arm64neon[idx].mode = MODE_READ; + arm64neon[idx].needed = 0; + }; + set(3, NEONTYPE_GPRREG, 5); + set(4, NEONTYPE_FPREG, 2); + set(5, NEONTYPE_FPACC, NEONFPU_ACC); + set(6, NEONTYPE_VFREG, 2); + set(7, NEONTYPE_TEMP, -1); + cop2FlushForConditionalSync(); + u32 mask = 0; + if (arm64neon[3].inuse) + mask |= 1u << 0; + if (arm64neon[4].inuse) + mask |= 1u << 1; + if (arm64neon[5].inuse) + mask |= 1u << 2; + if (arm64neon[6].inuse) + mask |= 1u << 3; + if (arm64neon[7].inuse) + mask |= 1u << 4; + _initArm64NEONregs(); + return mask; +} +#endif + // ========================================================================= // S4-2: shared DynGen VU0-sync stubs // ========================================================================= @@ -1074,9 +1123,15 @@ static void cop2FlushForConditionalSync() // // Fast path (VPU_STAT bit 0 clear — VU0 idle): Ldr + Tbnz + Ret. // Sync path: raw-save LR + the caller-saved EE int-allocator pool regs -// (x4-x7/x14/x15 — where retained GPR/FPRC values live), publish the -// absolute cycle, flush the lazy-dirty caller-saved pins, run the sync -// callee(s), re-derive the cycle delta, reload pins, restore, Ret. +// (x4-x7/x14/x15 — where retained GPR/FPRC values live) + the pool NEON +// file (SL-14: q0-q7/q10-q24/q27/q28, full 128 bits — where retained +// GPRREG-quad/FPREG/FPACC values live; q8/q9 need only their callee-saved +// low 64, q25/q26 are re-Dup'd from s8/s9 instead), publish the absolute +// cycle, flush the lazy-dirty caller-saved pins, run the sync callee(s), +// re-derive the cycle delta, reload pins, restore, Ret. The blind NEON +// save is state-agnostic — the shared stub needs no per-site knowledge — +// and its 400 B of stack traffic sits on the rarely-taken path that is +// about to run a whole VU0 microprogram anyway. enum : int { @@ -1099,11 +1154,28 @@ static const u8* cop2DynGenOneSyncStub(void (*syncFn)(), void (*finishFn)()) armAsm->Ret(); armAsm->Bind(&doSync); - armAsm->Stp(a64::x4, a64::x5, a64::MemOperand(a64::sp, -64, a64::PreIndex)); + armAsm->Stp(a64::x4, a64::x5, a64::MemOperand(a64::sp, -464, a64::PreIndex)); armAsm->Stp(a64::x6, a64::x7, a64::MemOperand(a64::sp, 16)); armAsm->Stp(a64::x14, a64::x15, a64::MemOperand(a64::sp, 32)); armAsm->Str(a64::x30, a64::MemOperand(a64::sp, 48)); + // SL-14: blind-save the pool NEON file (25 quads, [sp,#64..#448]) so + // retained 128-bit allocator values (GPRREG quads / FPREG / FPACC — + // cop2FlushForConditionalSync keeps them mapped) survive the C calls. + armAsm->Stp(a64::q0, a64::q1, a64::MemOperand(a64::sp, 64)); + armAsm->Stp(a64::q2, a64::q3, a64::MemOperand(a64::sp, 96)); + armAsm->Stp(a64::q4, a64::q5, a64::MemOperand(a64::sp, 128)); + armAsm->Stp(a64::q6, a64::q7, a64::MemOperand(a64::sp, 160)); + armAsm->Stp(a64::q10, a64::q11, a64::MemOperand(a64::sp, 192)); + armAsm->Stp(a64::q12, a64::q13, a64::MemOperand(a64::sp, 224)); + armAsm->Stp(a64::q14, a64::q15, a64::MemOperand(a64::sp, 256)); + armAsm->Stp(a64::q16, a64::q17, a64::MemOperand(a64::sp, 288)); + armAsm->Stp(a64::q18, a64::q19, a64::MemOperand(a64::sp, 320)); + armAsm->Stp(a64::q20, a64::q21, a64::MemOperand(a64::sp, 352)); + armAsm->Stp(a64::q22, a64::q23, a64::MemOperand(a64::sp, 384)); + armAsm->Stp(a64::q24, a64::q27, a64::MemOperand(a64::sp, 416)); + armAsm->Str(a64::q28, a64::MemOperand(a64::sp, 448)); + // Publish the absolute cycle before the sync — the callees read // cpuRegs.cycle to determine how many VU0 micro cycles to run — and // flush the lazy-dirty caller-saved pins before the first call clobbers @@ -1132,10 +1204,25 @@ static const u8* cop2DynGenOneSyncStub(void (*syncFn)(), void (*finishFn)()) armAsm->Dup(a64::v25.V4S(), a64::v8.V4S(), 0); armAsm->Dup(a64::v26.V4S(), a64::v9.V4S(), 0); + // SL-14: restore the blind-saved NEON file (mirror of the saves above). + armAsm->Ldr(a64::q28, a64::MemOperand(a64::sp, 448)); + armAsm->Ldp(a64::q24, a64::q27, a64::MemOperand(a64::sp, 416)); + armAsm->Ldp(a64::q22, a64::q23, a64::MemOperand(a64::sp, 384)); + armAsm->Ldp(a64::q20, a64::q21, a64::MemOperand(a64::sp, 352)); + armAsm->Ldp(a64::q18, a64::q19, a64::MemOperand(a64::sp, 320)); + armAsm->Ldp(a64::q16, a64::q17, a64::MemOperand(a64::sp, 288)); + armAsm->Ldp(a64::q14, a64::q15, a64::MemOperand(a64::sp, 256)); + armAsm->Ldp(a64::q12, a64::q13, a64::MemOperand(a64::sp, 224)); + armAsm->Ldp(a64::q10, a64::q11, a64::MemOperand(a64::sp, 192)); + armAsm->Ldp(a64::q6, a64::q7, a64::MemOperand(a64::sp, 160)); + armAsm->Ldp(a64::q4, a64::q5, a64::MemOperand(a64::sp, 128)); + armAsm->Ldp(a64::q2, a64::q3, a64::MemOperand(a64::sp, 96)); + armAsm->Ldp(a64::q0, a64::q1, a64::MemOperand(a64::sp, 64)); + armAsm->Ldr(a64::x30, a64::MemOperand(a64::sp, 48)); armAsm->Ldp(a64::x14, a64::x15, a64::MemOperand(a64::sp, 32)); armAsm->Ldp(a64::x6, a64::x7, a64::MemOperand(a64::sp, 16)); - armAsm->Ldp(a64::x4, a64::x5, a64::MemOperand(a64::sp, 64, a64::PostIndex)); + armAsm->Ldp(a64::x4, a64::x5, a64::MemOperand(a64::sp, 464, a64::PostIndex)); armAsm->Ret(); return start; diff --git a/pcsx2/arm64/iCore-arm64.h b/pcsx2/arm64/iCore-arm64.h index 5efc61217f..1ba5cbf376 100644 --- a/pcsx2/arm64/iCore-arm64.h +++ b/pcsx2/arm64/iCore-arm64.h @@ -126,8 +126,12 @@ struct _arm64gprregs // the pinned FPU clamp constants and are excluded from the pool entirely). // AAPCS64 preserves only the LOWER 64 bits of v8-v15 across C calls, so // full-128-bit classes (NEONTYPE_GPRREG quads, VFREG) can never be retained -// across a seam — but 32-bit FPR-class slots (FPREG/FPACC, lane 0 only) can -// (GE-15; iFlushCall's retention loop keys off this range). +// across a PLAIN C seam — but 32-bit FPR-class slots (FPREG/FPACC, lane 0 +// only) can (GE-15; iFlushCall's retention loop keys off this range). The +// one exception is the VPU_STAT-conditional COP2 sync seam (SL-14): its +// shared stubs blind-save the full pool NEON file around the C calls on the +// taken path, so cop2FlushForConditionalSync retains GPRREG/FPREG/FPACC in +// ANY pool register there (VFREG still frees — the VU0 micro writes VF). static constexpr u32 NEON_CALLEE_SAVED_START = 10; static constexpr u32 NEON_CALLEE_SAVED_END = 16; // exclusive diff --git a/tests/ctest/core/recompilers/ee_vu0_cop2_clamp_residency_tests.cpp b/tests/ctest/core/recompilers/ee_vu0_cop2_clamp_residency_tests.cpp index bffc5777d2..e665faa926 100644 --- a/tests/ctest/core/recompilers/ee_vu0_cop2_clamp_residency_tests.cpp +++ b/tests/ctest/core/recompilers/ee_vu0_cop2_clamp_residency_tests.cpp @@ -33,11 +33,12 @@ #include -// SL-13 test hooks (PCSX2_RECOMPILER_TESTS builds). Global scope — defined -// outside namespaces in the arm64 sources. +// SL-13/SL-14 test hooks (PCSX2_RECOMPILER_TESTS builds). Global scope — +// defined outside namespaces in the arm64 sources. extern u32 g_cop2ClampConstEstablishCount; // iCOP2-arm64.cpp int cop2TestGetSyncStubCount(); // iCOP2-arm64.cpp const u8* cop2TestGetSyncStub(int kind); // iCOP2-arm64.cpp +u32 cop2TestSeamRetentionFilter(); // iCOP2-arm64.cpp (SL-14) bool mVUTestProbe_NeonPoolUsable(int hostreg, bool cop2mode); // microVU-arm64.cpp bool eeTestNeonRegIsReserved(int hostreg); // iCore-arm64.cpp @@ -276,6 +277,71 @@ TEST(EeVu0Cop2ClampResidency, MacroModeNeonPoolExcludesClampRegs) EXPECT_TRUE(mVUTestProbe_NeonPoolUsable(27, /*cop2mode*/ true)); } +// ========================================================================= +// SL-14 — sync-seam NEON survival +// ========================================================================= +// cop2FlushForConditionalSync retains EE-class NEON entries (GPRREG quads / +// FPREG / FPACC) through the VPU_STAT-conditional sync seam; the shared +// stubs blind-save the pool NEON file around their C calls on the taken +// path. VFREG mirrors and TEMPs still free (the VU0 micro writes VF). + +// The retention filter itself, pinned deterministically on a synthetic +// allocator state: bit0=GPRREG bit1=FPREG bit2=FPACC bit3=VFREG bit4=TEMP. +TEST(EeVu0Cop2SeamSurvival, RetentionFilterPolicy) +{ + EXPECT_EQ(cop2TestSeamRetentionFilter(), 0b00111u) + << "GPRREG/FPREG/FPACC must ride the seam; VFREG/TEMP must free"; +} + +// A dirty 128-bit MMI quad created AFTER the VCALLMS kick must survive the +// following FMAC's TAKEN sync seam in-register and produce correct results +// in a post-seam consumer and at block-end writeback. +TEST(EeVu0Cop2SeamSurvival, DirtyQuadRidesTakenSeam) +{ + EeRecTestHarness h; + h.EnableVu0Capture(); + h.EnableCop1(); + h.SeedVu0Vi(REG_VPU_STAT, 0); + SeedOverflowOperands(h, 1, 2); + SeedLongNopMicro(h); + h.SetMmiPair(reg::t2, 0x1111111122222222ull, 0x3333333344444444ull); + h.SetMmiPair(reg::t3, 0x0000000100000001ull, 0x0000000100000001ull); + h.LoadProgram({ + VCALLMS(0), // kick — VPU_STAT busy + PADDW(reg::t1, reg::t2, reg::t3), // dirty NEON quad, post-kick + VMUL_C2(mask_xyzw, 3, 1, 2), // FINISH seam TAKEN — quad must ride + PADDW(reg::t0, reg::t1, reg::t3), // consume the retained quad + }); + h.Run(); + h.ExpectGpr128(reg::t1, 0x1111111222222223ull, 0x3333333444444445ull); + h.ExpectGpr128(reg::t0, 0x1111111322222224ull, 0x3333333544444446ull); + ExpectClampedToFltMax(h, 3); +} + +// A dirty 32-bit FPU register (FPREG class) rides the same taken seam. +TEST(EeVu0Cop2SeamSurvival, FprRidesTakenSeam) +{ + EeRecTestHarness h; + h.EnableVu0Capture(); + h.EnableCop1(); + h.SeedVu0Vi(REG_VPU_STAT, 0); + SeedOverflowOperands(h, 1, 2); + SeedLongNopMicro(h); + h.SetFpr(2, 1.5f); + h.SetFpr(3, 2.25f); + h.LoadProgram({ + VCALLMS(0), + ADD_S(1, 2, 3), // f1 = 3.75, dirty FPREG + VMUL_C2(mask_xyzw, 3, 1, 2), // taken seam — FPREG must ride + ADD_S(4, 1, 1), // f4 = 7.5 from the retained value + }); + h.Run(); + EXPECT_EQ(h.GetFprBitsJit(1), h.GetFprBitsInterp(1)); + EXPECT_EQ(h.GetFprBitsJit(4), h.GetFprBitsInterp(4)); + EXPECT_EQ(h.GetFprBitsJit(4), 0x40F00000u); // 7.5f + ExpectClampedToFltMax(h, 3); +} + // The mVU-reuse wrappers must not disturb validity: FMAC → VMFIR (mVU-reuse // wrapper, no C call) → FMAC pays ONE establishment, and the post-wrapper // clamp is still exact. From da22a8b6722e1e96ed4175c5cf05fb6f003c61be Mon Sep 17 00:00:00 2001 From: Brian Degenhardt Date: Mon, 20 Jul 2026 15:56:28 -0700 Subject: [PATCH 4/9] Revert "SL-14: retain EE-class NEON through the COP2 sync seam" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 47ec1b7d6. SD865 codegen_ab (7-run fan-pinned medians, EE-thread-scoped) attributed a large dynamic regression to SL-14: UYA gameplay EE insns +3.3% / cycles +11.4% vs the SL-13-only arm. The 25-quad blind save/restore in the shared sync stubs is static-tiny but runs on every VPU_STAT-taken seam, and UYA keeps VU0 busy enough that the taken path executes millions of times per session — insurance for ~2-5 live registers paid at 52 memory ops per seam. SotC (cold seams) was neutral. The M2 static census (flagship -124 B) could not see this cost by construction. SL-13 (clamp-constant residency) survives its own gate and stays: UYA EE insns -0.69% / cycles noise-level, SotC -0.75% / -1.05%. Measurement: /storage/pcsx2-profiles/s5{,-attr} on the SD865 device; scratchpad/ee-surpass-2026-07/s4/SL13-CENSUS-2026-07-20.md. Co-Authored-By: Claude --- pcsx2/arm64/iCOP2-arm64.cpp | 107 ++---------------- pcsx2/arm64/iCore-arm64.h | 8 +- .../ee_vu0_cop2_clamp_residency_tests.cpp | 70 +----------- 3 files changed, 14 insertions(+), 171 deletions(-) diff --git a/pcsx2/arm64/iCOP2-arm64.cpp b/pcsx2/arm64/iCOP2-arm64.cpp index 30aefe40f0..8564526eec 100644 --- a/pcsx2/arm64/iCOP2-arm64.cpp +++ b/pcsx2/arm64/iCOP2-arm64.cpp @@ -1033,28 +1033,17 @@ void vu0SyncRunAheadThin() // - VIREG entries are freed WITH writeback: VU0 execution writes VU0.VI, so // a retained VI mirror would go stale across the call. // - TEMP / PCWRITEBACK entries are freed (transient, no reloadable home). -// - NEON (SL-14, the S4-2 policy extended to the NEON file): GPRREG quads / -// FPREG / FPACC are KEPT mapped with no writeback — the stub blind-saves -// the pool NEON registers around the C calls (q0-q7, q10-q24, q27/q28; -// q8/q9 scalar consts are callee-saved low-64, q25/q26 re-Dup'd), so full -// 128-bit values survive both paths in-register. Same observability -// argument as the GPR retention: the callees never touch EE GPR memory or -// fpr/fprc. NEONTYPE_VFREG is freed WITH writeback — VU0 execution on the -// taken path writes VF, so a retained VF mirror would go stale (same -// reason the VI mirrors free, and why the EP-2b VF compile cache flush -// stays). TEMPs are freed (transient). +// - NEON: same free policy as FLUSH_FREE_XMM — 128-bit classes can't ride a +// C call and the macro body that follows wants the file to itself. The VF +// compile cache (q16-q20) dies at any C seam. static void cop2FlushForConditionalSync() { cop2VfCacheFlush(); for (int i = 0; i < NUM_ARM_NEON_REGS; i++) { - if (!arm64neon[i].inuse) - continue; - const u8 type = arm64neon[i].type; - if (type == NEONTYPE_GPRREG || type == NEONTYPE_FPREG || type == NEONTYPE_FPACC) - continue; // retained — the sync stub blind-preserves the NEON file - _freeNEONreg(i); // VFREG (writeback — the micro writes VF) / TEMP + if (arm64neon[i].inuse) + _freeNEONreg(i); } for (int i = 0; i < NUM_ARM_GPR_REGS; i++) @@ -1067,44 +1056,6 @@ static void cop2FlushForConditionalSync() } } -#ifdef PCSX2_RECOMPILER_TESTS -// SL-14 pin surface: populate a synthetic allocator state (one CLEAN entry -// per NEON class — clean so the filter's frees emit no writeback code), run -// the seam-preparation filter, and report which classes survived. Returns a -// bitmask: bit0=GPRREG bit1=FPREG bit2=FPACC bit3=VFREG bit4=TEMP. The -// retention contract is 0b00111 (GPRREG/FPREG/FPACC ride, VFREG/TEMP free). -u32 cop2TestSeamRetentionFilter() -{ - _initArm64NEONregs(); - const auto set = [](int idx, int type, int reg) { - arm64neon[idx].inuse = 1; - arm64neon[idx].type = static_cast(type); - arm64neon[idx].reg = static_cast(reg); - arm64neon[idx].mode = MODE_READ; - arm64neon[idx].needed = 0; - }; - set(3, NEONTYPE_GPRREG, 5); - set(4, NEONTYPE_FPREG, 2); - set(5, NEONTYPE_FPACC, NEONFPU_ACC); - set(6, NEONTYPE_VFREG, 2); - set(7, NEONTYPE_TEMP, -1); - cop2FlushForConditionalSync(); - u32 mask = 0; - if (arm64neon[3].inuse) - mask |= 1u << 0; - if (arm64neon[4].inuse) - mask |= 1u << 1; - if (arm64neon[5].inuse) - mask |= 1u << 2; - if (arm64neon[6].inuse) - mask |= 1u << 3; - if (arm64neon[7].inuse) - mask |= 1u << 4; - _initArm64NEONregs(); - return mask; -} -#endif - // ========================================================================= // S4-2: shared DynGen VU0-sync stubs // ========================================================================= @@ -1123,15 +1074,9 @@ u32 cop2TestSeamRetentionFilter() // // Fast path (VPU_STAT bit 0 clear — VU0 idle): Ldr + Tbnz + Ret. // Sync path: raw-save LR + the caller-saved EE int-allocator pool regs -// (x4-x7/x14/x15 — where retained GPR/FPRC values live) + the pool NEON -// file (SL-14: q0-q7/q10-q24/q27/q28, full 128 bits — where retained -// GPRREG-quad/FPREG/FPACC values live; q8/q9 need only their callee-saved -// low 64, q25/q26 are re-Dup'd from s8/s9 instead), publish the absolute -// cycle, flush the lazy-dirty caller-saved pins, run the sync callee(s), -// re-derive the cycle delta, reload pins, restore, Ret. The blind NEON -// save is state-agnostic — the shared stub needs no per-site knowledge — -// and its 400 B of stack traffic sits on the rarely-taken path that is -// about to run a whole VU0 microprogram anyway. +// (x4-x7/x14/x15 — where retained GPR/FPRC values live), publish the +// absolute cycle, flush the lazy-dirty caller-saved pins, run the sync +// callee(s), re-derive the cycle delta, reload pins, restore, Ret. enum : int { @@ -1154,28 +1099,11 @@ static const u8* cop2DynGenOneSyncStub(void (*syncFn)(), void (*finishFn)()) armAsm->Ret(); armAsm->Bind(&doSync); - armAsm->Stp(a64::x4, a64::x5, a64::MemOperand(a64::sp, -464, a64::PreIndex)); + armAsm->Stp(a64::x4, a64::x5, a64::MemOperand(a64::sp, -64, a64::PreIndex)); armAsm->Stp(a64::x6, a64::x7, a64::MemOperand(a64::sp, 16)); armAsm->Stp(a64::x14, a64::x15, a64::MemOperand(a64::sp, 32)); armAsm->Str(a64::x30, a64::MemOperand(a64::sp, 48)); - // SL-14: blind-save the pool NEON file (25 quads, [sp,#64..#448]) so - // retained 128-bit allocator values (GPRREG quads / FPREG / FPACC — - // cop2FlushForConditionalSync keeps them mapped) survive the C calls. - armAsm->Stp(a64::q0, a64::q1, a64::MemOperand(a64::sp, 64)); - armAsm->Stp(a64::q2, a64::q3, a64::MemOperand(a64::sp, 96)); - armAsm->Stp(a64::q4, a64::q5, a64::MemOperand(a64::sp, 128)); - armAsm->Stp(a64::q6, a64::q7, a64::MemOperand(a64::sp, 160)); - armAsm->Stp(a64::q10, a64::q11, a64::MemOperand(a64::sp, 192)); - armAsm->Stp(a64::q12, a64::q13, a64::MemOperand(a64::sp, 224)); - armAsm->Stp(a64::q14, a64::q15, a64::MemOperand(a64::sp, 256)); - armAsm->Stp(a64::q16, a64::q17, a64::MemOperand(a64::sp, 288)); - armAsm->Stp(a64::q18, a64::q19, a64::MemOperand(a64::sp, 320)); - armAsm->Stp(a64::q20, a64::q21, a64::MemOperand(a64::sp, 352)); - armAsm->Stp(a64::q22, a64::q23, a64::MemOperand(a64::sp, 384)); - armAsm->Stp(a64::q24, a64::q27, a64::MemOperand(a64::sp, 416)); - armAsm->Str(a64::q28, a64::MemOperand(a64::sp, 448)); - // Publish the absolute cycle before the sync — the callees read // cpuRegs.cycle to determine how many VU0 micro cycles to run — and // flush the lazy-dirty caller-saved pins before the first call clobbers @@ -1204,25 +1132,10 @@ static const u8* cop2DynGenOneSyncStub(void (*syncFn)(), void (*finishFn)()) armAsm->Dup(a64::v25.V4S(), a64::v8.V4S(), 0); armAsm->Dup(a64::v26.V4S(), a64::v9.V4S(), 0); - // SL-14: restore the blind-saved NEON file (mirror of the saves above). - armAsm->Ldr(a64::q28, a64::MemOperand(a64::sp, 448)); - armAsm->Ldp(a64::q24, a64::q27, a64::MemOperand(a64::sp, 416)); - armAsm->Ldp(a64::q22, a64::q23, a64::MemOperand(a64::sp, 384)); - armAsm->Ldp(a64::q20, a64::q21, a64::MemOperand(a64::sp, 352)); - armAsm->Ldp(a64::q18, a64::q19, a64::MemOperand(a64::sp, 320)); - armAsm->Ldp(a64::q16, a64::q17, a64::MemOperand(a64::sp, 288)); - armAsm->Ldp(a64::q14, a64::q15, a64::MemOperand(a64::sp, 256)); - armAsm->Ldp(a64::q12, a64::q13, a64::MemOperand(a64::sp, 224)); - armAsm->Ldp(a64::q10, a64::q11, a64::MemOperand(a64::sp, 192)); - armAsm->Ldp(a64::q6, a64::q7, a64::MemOperand(a64::sp, 160)); - armAsm->Ldp(a64::q4, a64::q5, a64::MemOperand(a64::sp, 128)); - armAsm->Ldp(a64::q2, a64::q3, a64::MemOperand(a64::sp, 96)); - armAsm->Ldp(a64::q0, a64::q1, a64::MemOperand(a64::sp, 64)); - armAsm->Ldr(a64::x30, a64::MemOperand(a64::sp, 48)); armAsm->Ldp(a64::x14, a64::x15, a64::MemOperand(a64::sp, 32)); armAsm->Ldp(a64::x6, a64::x7, a64::MemOperand(a64::sp, 16)); - armAsm->Ldp(a64::x4, a64::x5, a64::MemOperand(a64::sp, 464, a64::PostIndex)); + armAsm->Ldp(a64::x4, a64::x5, a64::MemOperand(a64::sp, 64, a64::PostIndex)); armAsm->Ret(); return start; diff --git a/pcsx2/arm64/iCore-arm64.h b/pcsx2/arm64/iCore-arm64.h index 1ba5cbf376..5efc61217f 100644 --- a/pcsx2/arm64/iCore-arm64.h +++ b/pcsx2/arm64/iCore-arm64.h @@ -126,12 +126,8 @@ struct _arm64gprregs // the pinned FPU clamp constants and are excluded from the pool entirely). // AAPCS64 preserves only the LOWER 64 bits of v8-v15 across C calls, so // full-128-bit classes (NEONTYPE_GPRREG quads, VFREG) can never be retained -// across a PLAIN C seam — but 32-bit FPR-class slots (FPREG/FPACC, lane 0 -// only) can (GE-15; iFlushCall's retention loop keys off this range). The -// one exception is the VPU_STAT-conditional COP2 sync seam (SL-14): its -// shared stubs blind-save the full pool NEON file around the C calls on the -// taken path, so cop2FlushForConditionalSync retains GPRREG/FPREG/FPACC in -// ANY pool register there (VFREG still frees — the VU0 micro writes VF). +// across a seam — but 32-bit FPR-class slots (FPREG/FPACC, lane 0 only) can +// (GE-15; iFlushCall's retention loop keys off this range). static constexpr u32 NEON_CALLEE_SAVED_START = 10; static constexpr u32 NEON_CALLEE_SAVED_END = 16; // exclusive diff --git a/tests/ctest/core/recompilers/ee_vu0_cop2_clamp_residency_tests.cpp b/tests/ctest/core/recompilers/ee_vu0_cop2_clamp_residency_tests.cpp index e665faa926..bffc5777d2 100644 --- a/tests/ctest/core/recompilers/ee_vu0_cop2_clamp_residency_tests.cpp +++ b/tests/ctest/core/recompilers/ee_vu0_cop2_clamp_residency_tests.cpp @@ -33,12 +33,11 @@ #include -// SL-13/SL-14 test hooks (PCSX2_RECOMPILER_TESTS builds). Global scope — -// defined outside namespaces in the arm64 sources. +// SL-13 test hooks (PCSX2_RECOMPILER_TESTS builds). Global scope — defined +// outside namespaces in the arm64 sources. extern u32 g_cop2ClampConstEstablishCount; // iCOP2-arm64.cpp int cop2TestGetSyncStubCount(); // iCOP2-arm64.cpp const u8* cop2TestGetSyncStub(int kind); // iCOP2-arm64.cpp -u32 cop2TestSeamRetentionFilter(); // iCOP2-arm64.cpp (SL-14) bool mVUTestProbe_NeonPoolUsable(int hostreg, bool cop2mode); // microVU-arm64.cpp bool eeTestNeonRegIsReserved(int hostreg); // iCore-arm64.cpp @@ -277,71 +276,6 @@ TEST(EeVu0Cop2ClampResidency, MacroModeNeonPoolExcludesClampRegs) EXPECT_TRUE(mVUTestProbe_NeonPoolUsable(27, /*cop2mode*/ true)); } -// ========================================================================= -// SL-14 — sync-seam NEON survival -// ========================================================================= -// cop2FlushForConditionalSync retains EE-class NEON entries (GPRREG quads / -// FPREG / FPACC) through the VPU_STAT-conditional sync seam; the shared -// stubs blind-save the pool NEON file around their C calls on the taken -// path. VFREG mirrors and TEMPs still free (the VU0 micro writes VF). - -// The retention filter itself, pinned deterministically on a synthetic -// allocator state: bit0=GPRREG bit1=FPREG bit2=FPACC bit3=VFREG bit4=TEMP. -TEST(EeVu0Cop2SeamSurvival, RetentionFilterPolicy) -{ - EXPECT_EQ(cop2TestSeamRetentionFilter(), 0b00111u) - << "GPRREG/FPREG/FPACC must ride the seam; VFREG/TEMP must free"; -} - -// A dirty 128-bit MMI quad created AFTER the VCALLMS kick must survive the -// following FMAC's TAKEN sync seam in-register and produce correct results -// in a post-seam consumer and at block-end writeback. -TEST(EeVu0Cop2SeamSurvival, DirtyQuadRidesTakenSeam) -{ - EeRecTestHarness h; - h.EnableVu0Capture(); - h.EnableCop1(); - h.SeedVu0Vi(REG_VPU_STAT, 0); - SeedOverflowOperands(h, 1, 2); - SeedLongNopMicro(h); - h.SetMmiPair(reg::t2, 0x1111111122222222ull, 0x3333333344444444ull); - h.SetMmiPair(reg::t3, 0x0000000100000001ull, 0x0000000100000001ull); - h.LoadProgram({ - VCALLMS(0), // kick — VPU_STAT busy - PADDW(reg::t1, reg::t2, reg::t3), // dirty NEON quad, post-kick - VMUL_C2(mask_xyzw, 3, 1, 2), // FINISH seam TAKEN — quad must ride - PADDW(reg::t0, reg::t1, reg::t3), // consume the retained quad - }); - h.Run(); - h.ExpectGpr128(reg::t1, 0x1111111222222223ull, 0x3333333444444445ull); - h.ExpectGpr128(reg::t0, 0x1111111322222224ull, 0x3333333544444446ull); - ExpectClampedToFltMax(h, 3); -} - -// A dirty 32-bit FPU register (FPREG class) rides the same taken seam. -TEST(EeVu0Cop2SeamSurvival, FprRidesTakenSeam) -{ - EeRecTestHarness h; - h.EnableVu0Capture(); - h.EnableCop1(); - h.SeedVu0Vi(REG_VPU_STAT, 0); - SeedOverflowOperands(h, 1, 2); - SeedLongNopMicro(h); - h.SetFpr(2, 1.5f); - h.SetFpr(3, 2.25f); - h.LoadProgram({ - VCALLMS(0), - ADD_S(1, 2, 3), // f1 = 3.75, dirty FPREG - VMUL_C2(mask_xyzw, 3, 1, 2), // taken seam — FPREG must ride - ADD_S(4, 1, 1), // f4 = 7.5 from the retained value - }); - h.Run(); - EXPECT_EQ(h.GetFprBitsJit(1), h.GetFprBitsInterp(1)); - EXPECT_EQ(h.GetFprBitsJit(4), h.GetFprBitsInterp(4)); - EXPECT_EQ(h.GetFprBitsJit(4), 0x40F00000u); // 7.5f - ExpectClampedToFltMax(h, 3); -} - // The mVU-reuse wrappers must not disturb validity: FMAC → VMFIR (mVU-reuse // wrapper, no C call) → FMAC pays ONE establishment, and the post-wrapper // clamp is still exact. From 679c2308410014cb35134597c845670441cd16c5 Mon Sep 17 00:00:00 2001 From: Brian Degenhardt Date: Mon, 20 Jul 2026 16:05:59 -0700 Subject: [PATCH 5/9] ee/fpu: make add/sub guard-bit emulation a toggleable option (default on) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reintroduces the fpuGuardedAddSub Recompiler option removed in 37bc5e164, but with the default flipped to ON so the PS2-accurate behavior is what every game gets unless a title is explicitly opted out. The removal's concern was per-game GameDB maintenance; a global default-on knob sidesteps that entirely — no GameDB plumbing is restored, so games can be flagged later once individually confirmed. Both JITs gate the single-precision add/sub masking on CHECK_FPU_GUARDED again (arm64 fpuEmitGuardedAddSub early-out; x86 FPU_ADD/FPU_SUB), which also restores the eerunner --set fpuGuardedAddSub twindiff A/B knob. The toggle only affects the fast path: Full clamp mode runs the DOUBLE path, which guards unconditionally and ignores this bit. It composes orthogonally with Extra clamp mode's operand clamping (that stays on the fast path and is honored independently). Exposed as a checkbox in both the Qt Advanced settings and the FullscreenUI (handheld) CPU page, with tooltips noting the Full-mode no-op. Test-side, EeRecTestHarness gains DisableFpuGuarded() and the guard-bit suite pins the opt-out path (EeRecFpuGuardBit.DisableEmitsPlainOpMatchingInterp). recompiler_tests: 1405/1405 green. Co-Authored-By: Claude --- pcsx2-qt/Settings/AdvancedSettingsWidget.cpp | 6 ++++ pcsx2-qt/Settings/AdvancedSettingsWidget.ui | 8 ++++++ pcsx2/Config.h | 4 ++- pcsx2/ImGui/FullscreenUI_Settings.cpp | 4 +++ pcsx2/Pcsx2Config.cpp | 2 ++ pcsx2/arm64/iFPU-arm64.cpp | 28 +++++++++++++++---- pcsx2/x86/iFPU.cpp | 10 ++++--- .../recompilers/ee_rec_fpu_guardbit_tests.cpp | 27 ++++++++++++++++-- .../recompilers/harness/EeRecTestHarness.cpp | 13 +++++++++ .../recompilers/harness/EeRecTestHarness.h | 8 ++++++ 10 files changed, 98 insertions(+), 12 deletions(-) diff --git a/pcsx2-qt/Settings/AdvancedSettingsWidget.cpp b/pcsx2-qt/Settings/AdvancedSettingsWidget.cpp index 4b6ecc4153..e197e03733 100644 --- a/pcsx2-qt/Settings/AdvancedSettingsWidget.cpp +++ b/pcsx2-qt/Settings/AdvancedSettingsWidget.cpp @@ -23,6 +23,7 @@ AdvancedSettingsWidget::AdvancedSettingsWidget(SettingsWindow* settings_dialog, SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.eeFastmem, "EmuCore/CPU/Recompiler", "EnableFastmem", true); SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.pauseOnTLBMiss, "EmuCore/CPU/Recompiler", "PauseOnTLBMiss", false); SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.extraMemory, "EmuCore/CPU", "ExtraMemory", false); + SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.eeFpuGuardedAddSub, "EmuCore/CPU/Recompiler", "fpuGuardedAddSub", true); SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.vu0Recompiler, "EmuCore/CPU/Recompiler", "EnableVU0", true); SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.vu1Recompiler, "EmuCore/CPU/Recompiler", "EnableVU1", true); @@ -99,6 +100,11 @@ AdvancedSettingsWidget::AdvancedSettingsWidget(SettingsWindow* settings_dialog, dialog()->registerWidgetHelp(m_ui.extraMemory, tr("Enable Extended RAM (Dev Console)"), tr("Unchecked"), tr("Exposes additional memory to the virtual machine, expanding the EE and IOP memory to 128MB and 8MB respectively.")); + dialog()->registerWidgetHelp(m_ui.eeFpuGuardedAddSub, tr("FPU Add/Sub Guard Bits"), tr("Checked"), + //: "Guard bits" = extra low mantissa bits a standards-compliant FPU keeps during add/sub; the PS2's EE FPU does not, so the recompiler masks them to match. + tr("Emulates the EE FPU's missing add/sub mantissa guard bits for hardware-accurate results. Leave checked; a few games (e.g. True Crime, Jak 3) misrender without it. " + "Unchecking is a minor speedup for EE-FPU-heavy games verified to render correctly without it. Has no effect when the EE Clamping Mode is set to Full.")); + dialog()->registerWidgetHelp(m_ui.vu0RoundingMode, tr("VU0 Rounding Mode"), tr("Chop/Zero (Default)"), tr("Changes how PCSX2 handles rounding while emulating the Emotion Engine's Vector Unit 0 (EE VU0). " "The default value handles the vast majority of games; modifying this setting when a game is not having a visible problem will cause stability issues and/or crashes.")); diff --git a/pcsx2-qt/Settings/AdvancedSettingsWidget.ui b/pcsx2-qt/Settings/AdvancedSettingsWidget.ui index 393528742d..63793e33b9 100644 --- a/pcsx2-qt/Settings/AdvancedSettingsWidget.ui +++ b/pcsx2-qt/Settings/AdvancedSettingsWidget.ui @@ -180,6 +180,13 @@ + + + + FPU Add/Sub Guard Bits + + + @@ -555,6 +562,7 @@ eeFastmem pauseOnTLBMiss extraMemory + eeFpuGuardedAddSub vu0RoundingMode vu0ClampMode vu1RoundingMode diff --git a/pcsx2/Config.h b/pcsx2/Config.h index 06c197c8fc..2c5c49c7de 100644 --- a/pcsx2/Config.h +++ b/pcsx2/Config.h @@ -674,7 +674,8 @@ struct Pcsx2Config bool fpuOverflow : 1, fpuExtraOverflow : 1, - fpuFullMode : 1; + fpuFullMode : 1, + fpuGuardedAddSub : 1; // EE FPU add/sub guard-bit emulation (single-precision fast path). ON by default — the PS2-accurate behavior. Opt-OUT globally via INI for EE-FPU-heavy titles verified to render fine without it (each ADD.S/SUB.S then costs one op instead of the guard sequence). Independent of the clamp tiers: Full mode runs the DOUBLE path, which guards unconditionally regardless of this bit. bool EnableEECache : 1; @@ -1558,6 +1559,7 @@ namespace EmuFolders #define CHECK_FPU_EXTRA_OVERFLOW (EmuConfig.Cpu.Recompiler.fpuExtraOverflow) // If enabled, Operands are checked for infinities before being used in the FPU recs #define CHECK_FPU_EXTRA_FLAGS 1 // Always enabled now // Sets D/I flags on FPU instructions #define CHECK_FPU_FULL (EmuConfig.Cpu.Recompiler.fpuFullMode) +#define CHECK_FPU_GUARDED (EmuConfig.Cpu.Recompiler.fpuGuardedAddSub) // If enabled (default), add/sub emulate the PS2 FPU's missing mantissa guard bits on the single-precision fast path. Disable only for EE-heavy titles confirmed not to need it. //------------ EE Recompiler defines - Comment to disable a recompiler --------------- diff --git a/pcsx2/ImGui/FullscreenUI_Settings.cpp b/pcsx2/ImGui/FullscreenUI_Settings.cpp index 203754b1d0..d396a5a69e 100644 --- a/pcsx2/ImGui/FullscreenUI_Settings.cpp +++ b/pcsx2/ImGui/FullscreenUI_Settings.cpp @@ -5529,6 +5529,10 @@ void FullscreenUI::DrawAdvancedSettingsPage() DrawToggleSetting(bsi, FSUI_ICONSTR(ICON_FA_MEMORY, "Enable Extended RAM (Dev Console)"), FSUI_CSTR("Exposes additional memory to the virtual machine, expanding the EE and IOP memory to 128MB and 8MB respectively."), "EmuCore/CPU", "ExtraMemory", false); + DrawToggleSetting(bsi, FSUI_ICONSTR(ICON_FA_PLUS_MINUS, "FPU Add/Sub Guard Bits"), + FSUI_CSTR("Emulates the EE FPU's missing add/sub guard bits for hardware-accurate results (default). A few games need it; " + "disabling is a minor speedup for EE-FPU-heavy games verified correct without it. No effect in Full clamping mode."), + "EmuCore/CPU/Recompiler", "fpuGuardedAddSub", true); MenuHeading(FSUI_CSTR("Vector Units")); DrawIntListSetting(bsi, FSUI_ICONSTR(ICON_FA_ARROW_TREND_DOWN, "VU0 Rounding Mode"), diff --git a/pcsx2/Pcsx2Config.cpp b/pcsx2/Pcsx2Config.cpp index eb79b1f81d..9a4faa78b9 100644 --- a/pcsx2/Pcsx2Config.cpp +++ b/pcsx2/Pcsx2Config.cpp @@ -473,6 +473,7 @@ Pcsx2Config::RecompilerOptions::RecompilerOptions() fpuOverflow = true; //fpuExtraOverflow = false; //fpuFullMode = false; + fpuGuardedAddSub = true; // PS2-accurate add/sub guard-bit emulation; opt-out for perf on titles verified not to need it. } void Pcsx2Config::RecompilerOptions::ApplySanityCheck() @@ -551,6 +552,7 @@ void Pcsx2Config::RecompilerOptions::LoadSave(SettingsWrapper& wrap) SettingsWrapBitBool(fpuOverflow); SettingsWrapBitBool(fpuExtraOverflow); SettingsWrapBitBool(fpuFullMode); + SettingsWrapBitBool(fpuGuardedAddSub); } u32 Pcsx2Config::RecompilerOptions::GetEEClampMode() const diff --git a/pcsx2/arm64/iFPU-arm64.cpp b/pcsx2/arm64/iFPU-arm64.cpp index 5ad6797683..22725e28fa 100644 --- a/pcsx2/arm64/iFPU-arm64.cpp +++ b/pcsx2/arm64/iFPU-arm64.cpp @@ -447,11 +447,13 @@ static a64::VRegister fpuClampMinMaxOperand(const a64::VRegister& src, const a64 // positions must read as zero on hardware. This masks the low mantissa bits of // the smaller-exponent operand by the exponent difference, then does the single // op. It is the arm64 fast-path port of x86 FPU_ADD_SUB (iFPU.cpp:402). Both -// JITs apply this masking unconditionally (x86 FPU_ADD/FPU_SUB, iFPU.cpp) — -// games like True Crime NYC and Jak 3 misrender without it, and flagging them -// per-game proved impractical. It reproduces the masking already present in the -// DOUBLE path's FPU_ADD_SUB (iFPUd-arm64.cpp:200); the CHECK_FPU_FULL (double) -// config dispatches to that path instead and never reaches here. +// JITs gate this masking on the same CHECK_FPU_GUARDED option (x86 FPU_ADD/ +// FPU_SUB, iFPU.cpp) — ON by default (games like True Crime NYC and Jak 3 +// misrender without it, and per-game flagging proved impractical) but opt-out +// globally for EE-heavy titles that don't need it; see the early-out below. It +// reproduces the masking already present in the DOUBLE path's FPU_ADD_SUB +// (iFPUd-arm64.cpp:200); the CHECK_FPU_FULL (double) config dispatches to that +// path instead and never reaches here (Full mode guards unconditionally). // // When |expd - expt| <= 1 the mask clears zero bits, so that (common) case skips // straight to the plain op. Only |diff| >= 2 masks the smaller-exponent operand; @@ -475,6 +477,22 @@ static a64::VRegister fpuClampMinMaxOperand(const a64::VRegister& src, const a64 static void fpuEmitGuardedAddSub(const a64::VRegister& dst, const a64::VRegister& s, const a64::VRegister& t, bool issub) { + // Guard-bit emulation is ON by default (CHECK_FPU_GUARDED) but can be turned + // off globally via the fpuGuardedAddSub Recompiler INI bool for EE-FPU-heavy + // titles verified to render fine without it. Off = a plain single op, matching + // AetherSX2 / PCSX2 v1.0 and the x86 FPU_ADD/FPU_SUB guard-off branch + // (iFPU.cpp). Returns before the NEON-temp alloc and GPR-scratch use below so + // nothing is booked on the fast path. (Full clamp mode is unaffected either + // way: it runs the DOUBLE path, which masks guard bits itself — iFPUd-arm64.cpp.) + if (!CHECK_FPU_GUARDED) + { + if (issub) + armAsm->Fsub(dst, s, t); + else + armAsm->Fadd(dst, s, t); + return; + } + // Alloc the NEON temp FIRST, before any raw GPR scratch below goes live. // The alloc can emit a victim eviction whose address materialization uses // scratch (today only x16/x17 via armMoveAddressToReg); keeping w9/w10 diff --git a/pcsx2/x86/iFPU.cpp b/pcsx2/x86/iFPU.cpp index 42fd33a750..dbc37122df 100644 --- a/pcsx2/x86/iFPU.cpp +++ b/pcsx2/x86/iFPU.cpp @@ -62,8 +62,10 @@ namespace DOUBLE #define FPUflagSO 0x00000010 #define FPUflagSU 0x00000008 -// Add/Sub opcodes produce the same results as the ps2 -#define FPU_CORRECT_ADD_SUB 1 +// Add/Sub guard-bit emulation (matching the PS2's missing mantissa guard bits) +// is gated at runtime on CHECK_FPU_GUARDED (the fpuGuardedAddSub Recompiler +// option, ON by default) so both JITs honor the same setting. The Full-mode +// DOUBLE path keeps its own unconditional guard (iFPUd.cpp). alignas(16) static const u32 s_neg[4] = {0x80000000, 0xffffffff, 0xffffffff, 0xffffffff}; alignas(16) static const u32 s_pos[4] = {0x7fffffff, 0xffffffff, 0xffffffff, 0xffffffff}; @@ -481,7 +483,7 @@ void FPU_ADD_SUB(int regd, int regt, int issub) void FPU_ADD(int regd, int regt) { - if (FPU_CORRECT_ADD_SUB) + if (CHECK_FPU_GUARDED) FPU_ADD_SUB(regd, regt, 0); else xADD.SS(xRegisterSSE(regd), xRegisterSSE(regt)); @@ -489,7 +491,7 @@ void FPU_ADD(int regd, int regt) void FPU_SUB(int regd, int regt) { - if (FPU_CORRECT_ADD_SUB) + if (CHECK_FPU_GUARDED) FPU_ADD_SUB(regd, regt, 1); else xSUB.SS(xRegisterSSE(regd), xRegisterSSE(regt)); diff --git a/tests/ctest/core/recompilers/ee_rec_fpu_guardbit_tests.cpp b/tests/ctest/core/recompilers/ee_rec_fpu_guardbit_tests.cpp index 2069ee7e42..b098e85eae 100644 --- a/tests/ctest/core/recompilers/ee_rec_fpu_guardbit_tests.cpp +++ b/tests/ctest/core/recompilers/ee_rec_fpu_guardbit_tests.cpp @@ -9,9 +9,12 @@ // zero on PS2 hardware. The recompiler reproduces this by masking the low // mantissa bits of the smaller-exponent operand by the exponent difference // before the op - x86 FPU_ADD_SUB (iFPU.cpp:402) and the arm64 -// fpuEmitGuardedAddSub (iFPU-arm64.cpp) — unconditionally in both JITs (games +// fpuEmitGuardedAddSub (iFPU-arm64.cpp). Both JITs gate it on the same +// CHECK_FPU_GUARDED / fpuGuardedAddSub option, which is ON by default (games // like True Crime NYC and Jak 3 misrender without it, and per-game flagging -// proved impractical). +// proved impractical) but can be turned off globally for EE-heavy titles that +// don't need it. These tests run under the default (ON); DisableEmitsPlainOp +// at the bottom pins the opt-out path. // // THESE ARE JIT-ONLY TESTS. The shared interpreter's ADD_S/SUB_S (FPU.cpp) is a // plain host float + float (fpuDouble() returns float and does no masking), @@ -206,3 +209,23 @@ TEST(EeRecFpuGuardBit, RandomizedMatchesX86Model) EXPECT_GT(checked, 1500) << "too many pairs skipped; the test is not exercising the mask"; } +// Opt-out path (guard OFF): the default-ON fpuGuardedAddSub option can be turned +// off globally for EE-heavy titles that don't need guard-bit accuracy. With it +// off, a guard-sensitive subtraction must emit a plain fsub — no masking — which +// makes the JIT bit-identical to the single-precision interpreter (interp never +// masked). This pins that the toggle actually gates AND that the fast path +// matches interp, so Run()'s JIT-vs-interp auto-diff holds. Same operands as +// SubMasksOneGuardBit, which (guard ON) asserts the masked 0x403fffff; here the +// bare/interp value 0x403ffffe is the result. +TEST(EeRecFpuGuardBit, DisableEmitsPlainOpMatchingInterp) +{ + EeRecTestHarness h; + h.EnableCop1(); + h.DisableFpuGuarded(); + h.SetFprBits(1, 0x40800000u); // 4.0 + h.SetFprBits(2, 0x3f800003u); // 1.0 + 3ulp + h.LoadProgram({ee::SUB_S(3, 1, 2)}); + h.Run(); + h.ExpectFpr(3, 0x403ffffeu); // bare == interp; masked (guard on) would be 0x403fffff +} + diff --git a/tests/ctest/core/recompilers/harness/EeRecTestHarness.cpp b/tests/ctest/core/recompilers/harness/EeRecTestHarness.cpp index 059b089b1c..128bc119e6 100644 --- a/tests/ctest/core/recompilers/harness/EeRecTestHarness.cpp +++ b/tests/ctest/core/recompilers/harness/EeRecTestHarness.cpp @@ -61,6 +61,9 @@ EeRecTestHarness::~EeRecTestHarness() if (fpu_mul_hack_changed_) EmuConfig.Gamefixes.FpuMulHack = prev_fpu_mul_hack_; + + if (fpu_guarded_changed_) + EmuConfig.Cpu.Recompiler.fpuGuardedAddSub = prev_fpu_guarded_; } void EeRecTestHarness::SetGpr64(u32 reg_idx, u64 value) @@ -115,6 +118,16 @@ void EeRecTestHarness::EnableFpuMulHack() EmuConfig.Gamefixes.FpuMulHack = true; } +void EeRecTestHarness::DisableFpuGuarded() +{ + if (!fpu_guarded_changed_) + { + prev_fpu_guarded_ = EmuConfig.Cpu.Recompiler.fpuGuardedAddSub; + fpu_guarded_changed_ = true; + } + EmuConfig.Cpu.Recompiler.fpuGuardedAddSub = false; +} + void EeRecTestHarness::SetStatusBits(u32 mask) { cpuRegs.CP0.n.Status.val |= mask; } // EE vtlb_memWrite on a direct RAM hit bypasses Cpu->Clear — upstream relies diff --git a/tests/ctest/core/recompilers/harness/EeRecTestHarness.h b/tests/ctest/core/recompilers/harness/EeRecTestHarness.h index 56a728f0fa..f5ddaf5647 100644 --- a/tests/ctest/core/recompilers/harness/EeRecTestHarness.h +++ b/tests/ctest/core/recompilers/harness/EeRecTestHarness.h @@ -69,6 +69,12 @@ public: void EnableFpuFullMode(); void EnableFpuMulHack(); + // Turns OFF the (default-ON) fpuGuardedAddSub Recompiler option so the JIT + // emits a plain single-precision add/sub with no guard-bit masking — the + // opt-out perf path. Off makes the JIT bit-identical to the single-precision + // interpreter (which never masks). Restored to its previous value in the dtor. + void DisableFpuGuarded(); + // ---- Memory ---- void WriteU8 (u32 addr, u8 value); @@ -343,6 +349,8 @@ private: bool prev_fpu_full_mode_ = false; bool fpu_mul_hack_changed_ = false; bool prev_fpu_mul_hack_ = false; + bool fpu_guarded_changed_ = false; + bool prev_fpu_guarded_ = false; }; } // namespace recompiler_tests From 8fa3a4db0db2e9e59a6bd3c0c377914c2c9a3aaf Mon Sep 17 00:00:00 2001 From: Brian Degenhardt Date: Mon, 20 Jul 2026 17:06:31 -0700 Subject: [PATCH 6/9] perf/census: classify COP2 VU0 macro traffic and 128-bit MMI NEON churn Add four categories to the EE code census analyzer: - cop2_vf_ldst(@VU0) / cop2_vu0_other for x24-based VU0 macro-mode load/store traffic (off<512 = VF/VI register file). - neon_gpr_ld/st(q@GPR) for 128-bit GPR-home traffic (MMI NEON residency churn), kept out of the scalar pin categories since rd is a v-reg number there and pin comparisons would be meaningless. Co-Authored-By: Claude --- tools/perf/ee_code_census.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tools/perf/ee_code_census.py b/tools/perf/ee_code_census.py index fd98f34dc0..bf4da8e3d5 100644 --- a/tools/perf/ee_code_census.py +++ b/tools/perf/ee_code_census.py @@ -91,7 +91,10 @@ FPU_BASE, FPU_END = 1168, 1600 CATS = [ "ldr_gpr_pinned(reload)", "ldr_gpr_pinned(MISSED)", "ldr_gpr_unpinned", - "str_gpr_pinned(wthru)", "str_gpr_unpinned", "ldrstr_hilo", + "str_gpr_pinned(wthru)", "str_gpr_unpinned", + "neon_gpr_ld(q@GPR)", "neon_gpr_st(q@GPR)", # 128-bit MMI residency churn + "cop2_vf_ldst(@VU0)", "cop2_vu0_other", # x24-based VU0 macro traffic + "ldrstr_hilo", "state_branch", "state_pc", "state_cycle", "state_fpu", "state_other", "mov_from_pin", "mov_to_pin", "pin_maint(sxtw/bfi/lsr)", "pin_as_operand", "bl_call", "other", @@ -136,6 +139,14 @@ def classify_block(code, pin_gpr, pins): for k in range(n): (i,) = struct.unpack_from("> 5) & 31, (i >> 16) & 31 + if rn == 24: + # x24 = &VU0 pinned base in EE blocks — COP2 macro-mode VF/VI traffic. + m = MEM_TOPS.get(i & 0xFFC00000) + if m: + ld, w = m + off = ((i >> 10) & 0xFFF) * w + c["cop2_vf_ldst(@VU0)" if off < 512 else "cop2_vu0_other"] += 1 + continue if rn == RSTATE: m = MEM_TOPS.get(i & 0xFFC00000) if m: @@ -144,7 +155,14 @@ def classify_block(code, pin_gpr, pins): if off < 512: g = off // 16 lane0 = (off % 16) == 0 - if ld: + if w == 16: + # 128-bit GPR home traffic (MMI NEON residency churn); + # kept out of the scalar pin categories — rd is a + # v-reg number there, pin comparisons are meaningless. + if ld: + per_gpr_ld[g] += 1 + c["neon_gpr_ld(q@GPR)" if ld else "neon_gpr_st(q@GPR)"] += 1 + elif ld: per_gpr_ld[g] += 1 if g in pin_gpr and lane0: c["ldr_gpr_pinned(reload)" if rd == pin_gpr[g] else "ldr_gpr_pinned(MISSED)"] += 1 From 3d4c08d3782dab43ad257230b8d078590d5fb464 Mon Sep 17 00:00:00 2001 From: Brian Degenhardt Date: Mon, 20 Jul 2026 17:37:36 -0700 Subject: [PATCH 7/9] perf: correct the MQ65 device profile's fan note The MQ65 does have a fan (gpio_fan hwmon, on/off, thermal-driven with a 65degC active trip); the profile claimed the device was fanless. A null-renderer codegen_ab session plateaus ~55-57degC and correctly never engages it, so the measurement protocol is unchanged. Co-Authored-By: Claude --- tools/perf/devices/mq65.env | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/perf/devices/mq65.env b/tools/perf/devices/mq65.env index 95fbe77bf0..86368b6436 100644 --- a/tools/perf/devices/mq65.env +++ b/tools/perf/devices/mq65.env @@ -13,7 +13,12 @@ # 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; +# Per-session protocol. The device HAS a fan (gpio_fan hwmon, on/off only), but it +# is thermal-framework-driven with an ACTIVE trip at 65degC (zone10 trip3; passive +# throttle 90degC) — a null-renderer codegen_ab run plateaus ~55-57degC and correctly +# never engages it, so no fan pin exists or is needed (verified 2026-07-20; an +# earlier note here claimed "no fan on this device", which was wrong). +# 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 ... From 4d7fd5935edb95db75a0505fe01b861b99b163aa Mon Sep 17 00:00:00 2001 From: Brian Degenhardt Date: Mon, 20 Jul 2026 22:50:18 -0700 Subject: [PATCH 8/9] arm64: make BASEBLOCKEX::fnptr authoritative when a block is recompiled in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recClear resets BLOCK->fnptr across the full extent of the blocks it removes but deliberately spares the in-progress block's entry, so a startpc can be recompiled while its BASEBLOCKEX is still live in the array. Both recompilers handled that by skipping New() entirely (BaseBlockArray::insert() has no dedup, so calling it would duplicate the entry) and reusing the old BASEBLOCKEX as-is — leaving fnptr pointing at the compile that was just superseded. Everything downstream of fnptr was then wrong: x86size is measured from the dead base, Remove() writes its `B JITCompile` redirect stub over dead code instead of the live entry, and New()'s pending-link patch never runs, so callers linked to that pc keep branching into stale code. Measured on Dirge of Cerberus (SLUS-21419): the reuse path is taken 6000+ times per 600 frames and fnptr was stale on every single one of them. New() now creates or re-binds, so both recompilers just call it unconditionally and fnptr always names the block's current code. Co-Authored-By: Claude --- pcsx2/arm64/BaseblockEx-arm64.h | 23 ++++++++++++++- pcsx2/arm64/iR3000A-arm64.cpp | 5 ++-- pcsx2/arm64/iR5900-arm64.cpp | 7 +++-- .../arm64_baseblocks_link_tests.cpp | 29 +++++++++++++++++++ 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/pcsx2/arm64/BaseblockEx-arm64.h b/pcsx2/arm64/BaseblockEx-arm64.h index a2c3066481..dc8cde688a 100644 --- a/pcsx2/arm64/BaseblockEx-arm64.h +++ b/pcsx2/arm64/BaseblockEx-arm64.h @@ -102,8 +102,29 @@ public: links.insert({pc, reinterpret_cast(patch_site) | (call ? kLinkSiteCallBit : 0)}); } + // Begin compiling the block at `startpc`, whose code starts at `fnptr`. + // Returns its BASEBLOCKEX, creating one if this startpc has no live block. + // + // A startpc can be recompiled while its BASEBLOCKEX is still in the array: + // recClear resets BLOCK->fnptr across the whole extent of the blocks it + // removes, but deliberately spares the in-progress block's entry, so the + // next dispatch recompiles a pc that still has a live entry. + // BaseBlockArray::insert() has no dedup, so that case has to reuse the + // existing entry — and it has to retarget it, otherwise fnptr keeps + // pointing at the superseded compile and every consumer of it is wrong: + // x86size is measured from a dead base, Remove() writes its redirect stub + // over dead code instead of the live entry, and the pending links below + // are never repointed, so callers keep branching into stale code. BASEBLOCKEX* New(u32 startpc, uptr fnptr) { + const int idx = Index(startpc); + BASEBLOCKEX* block = (idx >= 0 && blocks[idx].startpc == startpc) + ? &blocks[idx] : nullptr; + if (block) + block->fnptr = fnptr; + else + block = blocks.insert(startpc, fnptr); + // Patch any pending links waiting for a block at this PC. After // patching they go directly to fnptr instead of routing through // JITCompile. @@ -114,7 +135,7 @@ public: PatchAtomic(site, EncodeB(site, fnptr, (it->second & kLinkSiteCallBit) != 0)); } - return blocks.insert(startpc, fnptr); + return block; } int LastIndex(u32 startpc) const diff --git a/pcsx2/arm64/iR3000A-arm64.cpp b/pcsx2/arm64/iR3000A-arm64.cpp index f3bb70fdd7..f1ebf3f0ed 100644 --- a/pcsx2/arm64/iR3000A-arm64.cpp +++ b/pcsx2/arm64/iR3000A-arm64.cpp @@ -1304,9 +1304,8 @@ static void iopRecRecompile(const u32 startpc) // and triggers SIGILL. Same fix as EE rec at iR5900-arm64.cpp:1751. const uptr block_fnptr = (uptr)armGetCurrentCodePointer(); - s_pCurBlockEx = recBlocks.Get(HWADDR(startpc)); - if (!s_pCurBlockEx || s_pCurBlockEx->startpc != HWADDR(startpc)) - s_pCurBlockEx = recBlocks.New(HWADDR(startpc), block_fnptr); + // See the EE rec's equivalent: New() creates or re-binds. + s_pCurBlockEx = recBlocks.New(HWADDR(startpc), block_fnptr); psxbranch = 0; diff --git a/pcsx2/arm64/iR5900-arm64.cpp b/pcsx2/arm64/iR5900-arm64.cpp index f86fbd8ac1..434b998c5a 100644 --- a/pcsx2/arm64/iR5900-arm64.cpp +++ b/pcsx2/arm64/iR5900-arm64.cpp @@ -3236,9 +3236,10 @@ static void recRecompile(const u32 startpc) // and triggers SIGILL. const uptr block_fnptr = (uptr)armGetCurrentCodePointer(); - s_pCurBlockEx = recBlocks.Get(HWADDR(startpc)); - if (!s_pCurBlockEx || s_pCurBlockEx->startpc != HWADDR(startpc)) - s_pCurBlockEx = recBlocks.New(HWADDR(startpc), block_fnptr); + // New() both creates and re-binds: a startpc whose BASEBLOCKEX survived a + // straddled recClear is retargeted at the new code rather than left with a + // stale fnptr. + s_pCurBlockEx = recBlocks.New(HWADDR(startpc), block_fnptr); g_branch = 0; cop2VfCacheReset(); diff --git a/tests/ctest/core/recompilers/arm64_baseblocks_link_tests.cpp b/tests/ctest/core/recompilers/arm64_baseblocks_link_tests.cpp index d783252792..63cafeb64e 100644 --- a/tests/ctest/core/recompilers/arm64_baseblocks_link_tests.cpp +++ b/tests/ctest/core/recompilers/arm64_baseblocks_link_tests.cpp @@ -97,3 +97,32 @@ TEST(Arm64BaseBlocksLink, RemoveRedirectStubIsPlainBAndRelinkKeepsBl) EXPECT_EQ(DecodeImm26Bytes(*site_bl), reinterpret_cast(new_entry) - reinterpret_cast(site_bl)); } + +TEST(Arm64BaseBlocksLink, NewRebindsAnExistingBlockToItsNewCode) +{ + alignas(64) static u32 code[64]; + std::memset(code, 0, sizeof(code)); + + Arm64BaseBlocks bb; + bb.SetJITCompile(&code[0]); + + constexpr u32 kPc = 0x1000; + u32* const first = &code[16]; + u32* const second = &code[24]; + u32* const site = &code[8]; + + bb.Link(kPc, site); + bb.New(kPc, reinterpret_cast(first)); + ASSERT_EQ(bb.Get(kPc)->fnptr, reinterpret_cast(first)); + + // Recompiling a startpc whose BASEBLOCKEX survived a straddled recClear + // must retarget the entry rather than leave it aimed at the superseded + // compile — and must not produce a second entry for the same pc. + bb.New(kPc, reinterpret_cast(second)); + EXPECT_EQ(bb.Get(kPc)->fnptr, reinterpret_cast(second)); + EXPECT_EQ(bb.Index(kPc), 0); + // Callers follow the block to its new code instead of branching into the + // dead first compile. + EXPECT_EQ(DecodeImm26Bytes(*site), + reinterpret_cast(second) - reinterpret_cast(site)); +} From 3d71da84a1aa87b230ce7721bdfa368a83e232aa Mon Sep 17 00:00:00 2001 From: Brian Degenhardt Date: Mon, 20 Jul 2026 22:51:03 -0700 Subject: [PATCH 9/9] arm64: bound the block-link map by owner liveness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Link() inserted into the link multimap unconditionally and nothing ever pruned it, so entries accumulated for the whole lifetime of the map. Every recompile of a caller emits its branch at a fresh code address and appended another entry, and New(pc) then re-patched every site the map had ever seen for that pc — each with its own 4-byte __builtin___clear_cache. On Dirge of Cerberus (SLUS-21419) one PC had accumulated 13,372 stale sites, and 93.5% of EE-thread cycles were inside __aarch64_sync_cache_range, reached only via recRecompile -> Arm64BaseBlocks::New. The game hot-patches a small routine ~14x/frame, which is legitimate SMC and legitimately forces recompiles; the cost blew up quadratically because each recompile made the next one more expensive. A 4-byte flush measures ~157ns on an Apple M2, where IC IVAU is broadcast. Each entry now records the block that emitted it (owner startpc plus that block's code address), and both Link() and New() drop entries whose owner is gone or has since been recompiled. Pruning in New() alone is not sufficient: a destination that is never recompiled again would never have its list revisited, so it would still leak. Sites judged dead are unreachable code — and a false-dead would merely leave a site pointing at a removed block's redirect stub, which routes through the dispatcher. Two supporting changes: - Link() no longer flushes. Its patch site is inside the block being emitted, which armEndBlock() already covers with one whole-range flush. AetherSX2 does the same: its Link() performs no cache maintenance at all. - New() coalesces the sites it patches into contiguous ranges and issues one flush per run. The per-call cost is the dsb ish / isb pair, not the DC/IC. Measured, Dirge of Cerberus 3000 frames headless (--liverun, null renderer): fps 11.38 -> 238.8 EE thread 253.44s -> 9.49s CPU EE cycles 829.7G -> 30.6G EE IPC 0.247 -> 3.64 link map grew 87k->188k in 2min, unbounded -> plateaus ~12k, tracking the ~6.8k live blocks R&C UYA (no SMC pathology) is neutral: instructions identical, cycles +0.6%, inside its own 0.26% run-to-run spread. Co-Authored-By: Claude --- pcsx2/arm64/BaseblockEx-arm64.h | 202 +++++++++++++++--- pcsx2/arm64/iR3000A-arm64.cpp | 3 +- pcsx2/arm64/iR5900-arm64.cpp | 5 +- .../arm64_baseblocks_link_tests.cpp | 143 ++++++++++++- 4 files changed, 323 insertions(+), 30 deletions(-) diff --git a/pcsx2/arm64/BaseblockEx-arm64.h b/pcsx2/arm64/BaseblockEx-arm64.h index dc8cde688a..588aa68723 100644 --- a/pcsx2/arm64/BaseblockEx-arm64.h +++ b/pcsx2/arm64/BaseblockEx-arm64.h @@ -27,9 +27,28 @@ // Range: B imm26 covers ±128 MB. The EE recompiler region is 64 MB // (HostMemoryMap::EErecSize), and JITCompile lives in the same region, // so all link sites are reachable with a single B. +// +// Link-map liveness (2026-07-20). Entries used to accumulate for the whole +// lifetime of the map: Link() inserts unconditionally, and nothing pruned. +// Every recompile of a caller emits its branch at a *fresh* code address and +// appended another entry, so New(pc) re-patched every site the map had ever +// seen for that pc — each with its own 4-byte icache flush. Measured on +// Dirge of Cerberus: 13,372 stale sites on one PC, and 93.5% of EE-thread +// cycles inside __aarch64_sync_cache_range. Each entry now carries the +// identity of the block that emitted it (owner startpc + that block's code +// address), and both Link() and New() erase entries whose owner is gone or has +// since been recompiled. Cost becomes O(live callers) instead of O(all callers +// ever), and the map stops growing without bound. +// +// Liveness rests on two invariants: block code is bump-allocated and never +// reclaimed until a full Reset() (which also clears the map), so an fnptr +// value never identifies two different compiles; and BASEBLOCKEX::fnptr is +// authoritative for the block's *current* code, which is why New() updates +// it when a startpc is recompiled in place rather than leaving it stale. #pragma once +#include #include #include "x86/BaseblockEx.h" // BASEBLOCK, BASEBLOCKEX, BaseBlockArray, recLUT_SetPage @@ -37,12 +56,31 @@ class Arm64BaseBlocks { protected: - using linkmap_t = std::multimap; + // A registered branch site, tagged with the block that emitted it so + // New() can tell live sites from ones left behind by a superseded + // compile. See the liveness note in the file header. + struct LinkSite + { + // Patch site address, with kLinkSiteCallBit in bit 0. + uptr site; + // Block that emitted this site. owner_fnptr == 0 means "untracked": + // the site was registered outside a compile (only the direct unit + // tests do this), and is treated as permanently live. + u32 owner_startpc; + uptr owner_fnptr; + }; + + using linkmap_t = std::multimap; BaseBlockArray blocks; linkmap_t links; uptr jitcompile = 0; + // The block currently being compiled, published by New(). Link() stamps + // it onto every site it records. + u32 emitting_startpc = 0; + uptr emitting_fnptr = 0; + // Link sites are 4-byte-aligned instruction addresses, so bit 0 of the // stored uptr is free: it tags the site's branch form. Untagged = B, // tagged = BL (call-ret stack call sites — the BL pushes the hardware @@ -61,17 +99,90 @@ protected: return (call ? 0x94000000u : 0x14000000u) | (static_cast(imm26) & 0x03FFFFFFu); } - static void PatchAtomic(uptr site, u32 instr) + // Store only, no cache maintenance. Valid ONLY for a site inside the + // block currently being emitted: that buffer has not been executed yet, + // and armEndBlock() issues one whole-range __builtin___clear_cache over + // it before it can be. (AetherSX2 does the same — its Link() performs no + // flush at all, leaving it to the single range flush in armEndBlock.) + // Never use this on code that is already live; use PatchAtomic. + static void PatchWord(uptr site, u32 instr) { // 4-byte aligned word stores are atomic on AArch64. *reinterpret_cast(site) = instr; + } + + static void FlushRange(uptr lo, uptr hi) + { + __builtin___clear_cache(reinterpret_cast(lo), reinterpret_cast(hi)); + } + + static void PatchAtomic(uptr site, u32 instr) + { + PatchWord(site, instr); // Then make sure cores fetching instructions see the new word. - __builtin___clear_cache(reinterpret_cast(site), - reinterpret_cast(site) + 4); + FlushRange(site, site + 4); // Signal-safe (counter-only) — Remove() can call this from the // SIGSEGV fastmem handler. } + // Drop every entry for `dest_pc` whose owner is gone or has been + // recompiled since. Called from both Link() and New(): New() alone is not + // enough, because a destination that is never recompiled again would never + // have its list revisited, and the entries its callers keep re-registering + // would accumulate for the life of the map. Erasing a multimap element + // invalidates only that element's iterator, so the end of the range stays + // valid across the walk. + void PruneDeadLinks(u32 dest_pc) + { + const auto range = links.equal_range(dest_pc); + for (auto it = range.first; it != range.second;) + { + if (IsOwnerLive(it->second)) + ++it; + else + it = links.erase(it); + } + } + + // True when `ls` still lives inside a block that is both present and has + // not been recompiled since the site was registered. + bool IsOwnerLive(const LinkSite& ls) + { + if (ls.owner_fnptr == 0) + return true; // untracked registration (direct unit tests) + + const BASEBLOCKEX* owner = Get(ls.owner_startpc); + return owner && owner->startpc == ls.owner_startpc && owner->fnptr == ls.owner_fnptr; + } + + // Coalesce the just-patched sites into as few cache-maintenance ranges as + // possible. The per-call cost is dominated by the `dsb ish` / `isb` pair, + // not by the DC/IC ops, so N adjacent 4-byte flushes cost ~N barrier pairs + // where one range flush costs one. Sites emitted by the same block sit a + // few instructions apart, so this usually collapses to a single range. + static void FlushPatchedSites(uptr* sites, u32 count) + { + if (count == 0) + return; + + std::sort(sites, sites + count); + + constexpr uptr kMaxGap = 256; + uptr lo = sites[0], hi = sites[0] + 4; + for (u32 i = 1; i < count; i++) + { + if (sites[i] - hi <= kMaxGap) + { + hi = sites[i] + 4; + continue; + } + FlushRange(lo, hi); + lo = sites[i]; + hi = sites[i] + 4; + } + FlushRange(lo, hi); + } + public: Arm64BaseBlocks() : blocks(0x4000) @@ -96,27 +207,37 @@ public: BASEBLOCKEX* target = Get(pc); const uptr target_addr = (target && target->startpc == pc) ? target->fnptr : jitcompile; - PatchAtomic(reinterpret_cast(patch_site), + // No flush: the site is in the block being emitted right now, and + // armEndBlock() range-flushes that buffer. See PatchWord. + PatchWord(reinterpret_cast(patch_site), EncodeB(reinterpret_cast(patch_site), target_addr, call)); - links.insert({pc, reinterpret_cast(patch_site) | (call ? kLinkSiteCallBit : 0)}); + // Reap this destination's dead entries before adding ours, so its list + // tracks live callers rather than every caller it has ever had. + PruneDeadLinks(pc); + + links.insert({pc, + LinkSite{reinterpret_cast(patch_site) | (call ? kLinkSiteCallBit : 0), + emitting_startpc, emitting_fnptr}}); } // Begin compiling the block at `startpc`, whose code starts at `fnptr`. // Returns its BASEBLOCKEX, creating one if this startpc has no live block. // - // A startpc can be recompiled while its BASEBLOCKEX is still in the array: - // recClear resets BLOCK->fnptr across the whole extent of the blocks it - // removes, but deliberately spares the in-progress block's entry, so the - // next dispatch recompiles a pc that still has a live entry. - // BaseBlockArray::insert() has no dedup, so that case has to reuse the - // existing entry — and it has to retarget it, otherwise fnptr keeps - // pointing at the superseded compile and every consumer of it is wrong: - // x86size is measured from a dead base, Remove() writes its redirect stub - // over dead code instead of the live entry, and the pending links below - // are never repointed, so callers keep branching into stale code. + // A startpc can be recompiled while its BASEBLOCKEX is still in the array + // (recClear resets BLOCK->fnptr across a straddled extent but deliberately + // spares the in-progress block's entry). BaseBlockArray::insert() has no + // dedup, so that case must reuse the existing entry — and it must retarget + // it at the new code, otherwise fnptr keeps pointing at the superseded + // compile and every consumer of it is wrong: x86size is computed from a + // dead base, Remove() writes its redirect stub over dead code instead of + // the live entry, and Link() sends new callers into stale code. BASEBLOCKEX* New(u32 startpc, uptr fnptr) { + // Published for Link() to stamp onto the sites this block emits. + emitting_startpc = startpc; + emitting_fnptr = fnptr; + const int idx = Index(startpc); BASEBLOCKEX* block = (idx >= 0 && blocks[idx].startpc == startpc) ? &blocks[idx] : nullptr; @@ -125,16 +246,32 @@ public: else block = blocks.insert(startpc, fnptr); - // Patch any pending links waiting for a block at this PC. After - // patching they go directly to fnptr instead of routing through - // JITCompile. + // Repoint the links waiting on this PC at the new code, dropping any + // left behind by a block that has since been removed or recompiled. + // Those sites are unreachable code; patching them is pure waste, and + // it is that waste which used to dominate the EE thread. + uptr patched[64]; + u32 npatched = 0; + const auto range = links.equal_range(startpc); - for (auto it = range.first; it != range.second; ++it) + for (auto it = range.first; it != range.second;) { - const uptr site = it->second & ~kLinkSiteCallBit; - PatchAtomic(site, EncodeB(site, fnptr, (it->second & kLinkSiteCallBit) != 0)); + if (!IsOwnerLive(it->second)) + { + it = links.erase(it); + continue; + } + + const uptr site = it->second.site & ~kLinkSiteCallBit; + PatchWord(site, EncodeB(site, fnptr, (it->second.site & kLinkSiteCallBit) != 0)); + if (npatched < std::size(patched)) + patched[npatched++] = site; + else + FlushRange(site, site + 4); // overflow: flush as we go + ++it; } + FlushPatchedSites(patched, npatched); return block; } @@ -193,8 +330,10 @@ public: // Signal-safe: writes a redirect stub at each removed block's entry // point so any stale link still resolves through JITCompile, then // erases from the flat sorted array. Does NOT touch the link map — - // stale entries there are harmless (they just trigger a re-patch on - // the next compile cycle for the same PC). + // mutating an STL container here is not signal-safe. The entries this + // strands are reaped by the next New() for their destination PC, which + // sees the owner is gone; until then the redirect stub keeps any site + // still pointing at this block correct. // // SL-1: a resident self-loop's back-edge is an internal B to the loop-top // (past the entry redirect), so it gets its own atomic repoint — to the @@ -225,6 +364,8 @@ public: { blocks.clear(); links.clear(); + emitting_startpc = 0; + emitting_fnptr = 0; } #ifdef PCSX2_RECOMPILER_TESTS @@ -248,11 +389,22 @@ public: const auto range = links.equal_range(dst_pc); for (auto it = range.first; it != range.second; ++it) { - const uptr site = it->second & ~kLinkSiteCallBit; + const uptr site = it->second.site & ~kLinkSiteCallBit; if (site >= lo && site < hi) return true; } return false; } + + // Test-only: number of registered link sites targeting dst_pc, and the + // total across all destinations. Used to pin that the map stays bounded + // by *live* sites rather than growing with every recompile. + size_t LinkCount(u32 dst_pc) const + { + const auto range = links.equal_range(dst_pc); + return static_cast(std::distance(range.first, range.second)); + } + + size_t TotalLinkCount() const { return links.size(); } #endif }; diff --git a/pcsx2/arm64/iR3000A-arm64.cpp b/pcsx2/arm64/iR3000A-arm64.cpp index f1ebf3f0ed..90643b12a3 100644 --- a/pcsx2/arm64/iR3000A-arm64.cpp +++ b/pcsx2/arm64/iR3000A-arm64.cpp @@ -1304,7 +1304,8 @@ static void iopRecRecompile(const u32 startpc) // and triggers SIGILL. Same fix as EE rec at iR5900-arm64.cpp:1751. const uptr block_fnptr = (uptr)armGetCurrentCodePointer(); - // See the EE rec's equivalent: New() creates or re-binds. + // See the EE rec's equivalent: New() creates or re-binds, and publishes + // the owner for the link sites this block is about to register. s_pCurBlockEx = recBlocks.New(HWADDR(startpc), block_fnptr); psxbranch = 0; diff --git a/pcsx2/arm64/iR5900-arm64.cpp b/pcsx2/arm64/iR5900-arm64.cpp index 434b998c5a..34d1cfea62 100644 --- a/pcsx2/arm64/iR5900-arm64.cpp +++ b/pcsx2/arm64/iR5900-arm64.cpp @@ -3237,8 +3237,9 @@ static void recRecompile(const u32 startpc) const uptr block_fnptr = (uptr)armGetCurrentCodePointer(); // New() both creates and re-binds: a startpc whose BASEBLOCKEX survived a - // straddled recClear is retargeted at the new code rather than left with a - // stale fnptr. + // straddled recClear is retargeted at the new code rather than left with + // a stale fnptr. It also publishes the block as the owner of every link + // site the emission below registers. s_pCurBlockEx = recBlocks.New(HWADDR(startpc), block_fnptr); g_branch = 0; diff --git a/tests/ctest/core/recompilers/arm64_baseblocks_link_tests.cpp b/tests/ctest/core/recompilers/arm64_baseblocks_link_tests.cpp index 63cafeb64e..c9820d449a 100644 --- a/tests/ctest/core/recompilers/arm64_baseblocks_link_tests.cpp +++ b/tests/ctest/core/recompilers/arm64_baseblocks_link_tests.cpp @@ -98,6 +98,25 @@ TEST(Arm64BaseBlocksLink, RemoveRedirectStubIsPlainBAndRelinkKeepsBl) reinterpret_cast(new_entry) - reinterpret_cast(site_bl)); } +// --------------------------------------------------------------------------- +// Link-map liveness. Entries are stamped with the block that emitted them, so +// New() can drop the ones stranded in code that has been removed or superseded +// instead of re-patching them forever. Regression cover for the Dirge of +// Cerberus blowup: 13,372 dead sites on one PC, 93.5% of the EE thread spent +// flushing icache for branches nothing can reach. +// --------------------------------------------------------------------------- + +namespace +{ +// A link registered while `owner_pc` was being compiled at `owner_entry`. +void CompileAndLink(Arm64BaseBlocks& bb, u32 owner_pc, u32* owner_entry, u32 dest_pc, + u32* site, bool call = false) +{ + bb.New(owner_pc, reinterpret_cast(owner_entry)); + bb.Link(dest_pc, site, call); +} +} // namespace + TEST(Arm64BaseBlocksLink, NewRebindsAnExistingBlockToItsNewCode) { alignas(64) static u32 code[64]; @@ -116,13 +135,133 @@ TEST(Arm64BaseBlocksLink, NewRebindsAnExistingBlockToItsNewCode) ASSERT_EQ(bb.Get(kPc)->fnptr, reinterpret_cast(first)); // Recompiling a startpc whose BASEBLOCKEX survived a straddled recClear - // must retarget the entry rather than leave it aimed at the superseded - // compile — and must not produce a second entry for the same pc. + // must retarget the entry, not leave it aimed at the superseded compile. bb.New(kPc, reinterpret_cast(second)); EXPECT_EQ(bb.Get(kPc)->fnptr, reinterpret_cast(second)); + // And it must not have produced a duplicate array entry for the same pc. EXPECT_EQ(bb.Index(kPc), 0); // Callers follow the block to its new code instead of branching into the // dead first compile. EXPECT_EQ(DecodeImm26Bytes(*site), reinterpret_cast(second) - reinterpret_cast(site)); } + +TEST(Arm64BaseBlocksLink, StaleOwnerEntriesPrunedOnNew) +{ + alignas(64) static u32 code[64]; + std::memset(code, 0, sizeof(code)); + + Arm64BaseBlocks bb; + bb.SetJITCompile(&code[0]); + + constexpr u32 kOwner = 0x1000; + constexpr u32 kDest = 0x2000; + u32* const site_old = &code[8]; + + // Owner compiles and links to a destination that does not exist yet... + CompileAndLink(bb, kOwner, &code[16], kDest, site_old); + // ...then is recompiled into a shape that no longer branches there, so + // nothing re-registers a link for kDest and only New(kDest) can notice. + bb.New(kOwner, reinterpret_cast(&code[24])); + ASSERT_EQ(bb.LinkCount(kDest), 1u); + + const u32 stale_word = *site_old; + + // The destination compiles: the site left behind in the owner's dead + // first compile is dropped, not re-patched. + u32* const dest_entry = &code[40]; + bb.New(kDest, reinterpret_cast(dest_entry)); + + EXPECT_EQ(bb.LinkCount(kDest), 0u); + EXPECT_EQ(*site_old, stale_word); +} + +TEST(Arm64BaseBlocksLink, RemovedOwnerEntriesPruned) +{ + alignas(64) static u32 code[64]; + std::memset(code, 0, sizeof(code)); + + Arm64BaseBlocks bb; + bb.SetJITCompile(&code[0]); + + constexpr u32 kOwner = 0x1000; + constexpr u32 kDest = 0x2000; + u32* const site = &code[8]; + + CompileAndLink(bb, kOwner, &code[16], kDest, site); + ASSERT_EQ(bb.LinkCount(kDest), 1u); + + const int idx = bb.Index(kOwner); + ASSERT_GE(idx, 0); + bb.Remove(idx, idx); + + const u32 stale_word = *site; + bb.New(kDest, reinterpret_cast(&code[40])); + + // Owner is gone, so its site is unreachable code: dropped, never patched. + EXPECT_EQ(bb.LinkCount(kDest), 0u); + EXPECT_EQ(*site, stale_word); +} + +TEST(Arm64BaseBlocksLink, MapStaysBoundedUnderOwnerChurn) +{ + alignas(64) static u32 code[512]; + std::memset(code, 0, sizeof(code)); + + Arm64BaseBlocks bb; + bb.SetJITCompile(&code[0]); + + constexpr u32 kOwner = 0x1000; + constexpr u32 kDest = 0x2000; + constexpr int kRecompiles = 100; + + // The pathological shape: one block recompiled over and over, each compile + // re-registering its branch at a fresh address. Before the liveness + // filter this grew one entry per recompile forever (13,372 on one PC in + // Dirge of Cerberus). The list must stay at the single live caller the + // whole way through — the destination here is never recompiled, so + // nothing but Link() itself can do the reaping. + for (int i = 0; i < kRecompiles; i++) + { + CompileAndLink(bb, kOwner, &code[256 + i], kDest, &code[i]); + ASSERT_EQ(bb.LinkCount(kDest), 1u) << "grew at recompile " << i; + } + + const u32 first_word = code[0]; + u32* const dest_entry = &code[400]; + bb.New(kDest, reinterpret_cast(dest_entry)); + + // Exactly one live site survives — the one in the owner's current compile. + EXPECT_EQ(bb.LinkCount(kDest), 1u); + EXPECT_EQ(bb.TotalLinkCount(), 1u); + EXPECT_EQ(DecodeImm26Bytes(code[kRecompiles - 1]), + reinterpret_cast(dest_entry) - reinterpret_cast(&code[kRecompiles - 1])); + EXPECT_EQ(code[0], first_word); +} + +TEST(Arm64BaseBlocksLink, BlFormSurvivesPruning) +{ + alignas(64) static u32 code[64]; + std::memset(code, 0, sizeof(code)); + + Arm64BaseBlocks bb; + bb.SetJITCompile(&code[0]); + + constexpr u32 kOwner = 0x1000; + constexpr u32 kDest = 0x2000; + u32* const site_old = &code[8]; + u32* const site_new = &code[9]; + + CompileAndLink(bb, kOwner, &code[16], kDest, site_old, /*call=*/true); + CompileAndLink(bb, kOwner, &code[24], kDest, site_new, /*call=*/true); + + u32* const dest_entry = &code[40]; + bb.New(kDest, reinterpret_cast(dest_entry)); + + // The call-form tag rides through the liveness filter with the rest of + // the entry — the survivor is still a BL. + ASSERT_EQ(bb.LinkCount(kDest), 1u); + EXPECT_EQ(OpcodeBits(*site_new), kOpcBL); + EXPECT_EQ(DecodeImm26Bytes(*site_new), + reinterpret_cast(dest_entry) - reinterpret_cast(site_new)); +}