From 151b93fde95bd7ff801dba593d566ebbb176b03b Mon Sep 17 00:00:00 2001 From: Brian Degenhardt Date: Sat, 18 Jul 2026 08:34:06 -0700 Subject: [PATCH] =?UTF-8?q?SL-03:=20arm64=20EE=20rec:=20superblocks=20?= =?UTF-8?q?=E2=80=94=20forward=20conditionals=20become=20continuation=20si?= =?UTF-8?q?tes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block scanner no longer ends a block at a forward conditional branch (BEQ/BNE/BLEZ/BGTZ/BLTZ/BGEZ; non-likely, non-link, non-BCx): it records a continuation site and keeps scanning at the fallthrough, so the not-taken path compiles as one straight line — no pc store, no event check, no linked-B, no next-head reload; register and constant residency ride through the former boundary. The taken arm becomes a cold side exit outlined after the block tail: it snapshots the compile state at the branch (BranchCompileState, per pending exit), and its emission restores the snapshot, compiles the taken-path delay slot (unless TrySwapDelaySlot already hoisted it), and ends with the normal SetBranchImm flush + event + linked-B tail. Compare shapes mirror recSetBranchEQ/recSetBranchL with the sense inverted (branch out when TAKEN), const fast paths included. Analysis stays exactly as conservative as today's block ends at each former boundary: the liveness backward pass merges all-live at the branch and its delay slot (the taken path leaves the block there), and the COP2 deferred-commit passes run per segment delimited at sites. A backward-split landing on a site's delay slot clamps to the branch instead (a split pair would leave the delay slot outside the analyzed range). Event-check coarsening equals today's straight-line blocks: one check per exit, range still capped by the 4K page. Caps: 8 sites, 128 insns per block. Composition with SL-01: loops whose body contains a forward conditional were previously split at it and could never form a self-loop — now they fuse into one block and the loop-residency preheader/back-edge applies (pinned by LoopWithInternalForwardBranchBecomesResident). Deliberate non-sites: backward branches (loops keep the SL-01 shape), likely variants (taken-only delay slot — different continuation shape), BEQ rs==rt (unconditional idiom), branch-class delay slots, compile-time const-resolved-taken. BNE rs==rt and const-resolved-not-taken continue with no side exit. Tests: ee_rec_superblock_tests (formation via recEeBlockGuestSize, both runtime paths vs interp, delay-slot-both-paths, dirty-state flush at taken exits incl. NEON quads, const propagation, multi-site, cap, SMC in the fused range, memory traffic across a site); full recompiler_tests 1336/1336. Co-Authored-By: Claude Fable 5 --- pcsx2/arm64/iR5900-arm64.cpp | 237 ++++++++++- pcsx2/arm64/iR5900-arm64.h | 12 + pcsx2/arm64/iR5900Branch-arm64.cpp | 148 ++++++- tests/ctest/core/recompilers/CMakeLists.txt | 1 + .../recompilers/ee_rec_superblock_tests.cpp | 402 ++++++++++++++++++ .../recompilers/harness/EeRecTestHarness.cpp | 7 + .../recompilers/harness/EeRecTestHarness.h | 1 + 7 files changed, 798 insertions(+), 10 deletions(-) create mode 100644 tests/ctest/core/recompilers/ee_rec_superblock_tests.cpp diff --git a/pcsx2/arm64/iR5900-arm64.cpp b/pcsx2/arm64/iR5900-arm64.cpp index fe85dc23e2..0f36433394 100644 --- a/pcsx2/arm64/iR5900-arm64.cpp +++ b/pcsx2/arm64/iR5900-arm64.cpp @@ -1632,6 +1632,101 @@ void LoadBranchState() s_savedBranchState.restore(); } +// ===================================================================================================== +// SL-03: superblocks — conditional-fallthrough continuation +// ===================================================================================================== +// The scanner records forward conditional branches (BEQ/BNE/BLEZ/BGTZ/BLTZ/ +// BGEZ; non-likely, non-link) as continuation sites and keeps scanning at the +// fallthrough, so the not-taken path compiles as one straight line: no pc +// store, no event check, no linked-B, no next-head reload — register/const +// residency rides through the former boundary. The taken arm becomes a cold +// side exit outlined after the block tail; it snapshots the compile state at +// the branch, and its emission restores that snapshot, compiles the taken-path +// delay slot (unless TrySwapDelaySlot already hoisted it), and ends with the +// normal SetBranchImm tail. Analysis stays exactly as conservative as today's +// block ends: the liveness backward pass merges all-live at each site (the +// taken path leaves the block there), and the COP2 deferred-commit passes run +// per segment delimited at sites. Event-check coarsening equals today's +// straight-line blocks (one check per exit, range capped by the 4K page). + +static constexpr int kMaxContSites = 8; +static constexpr u32 kMaxSuperblockInsns = 128; + +static u32 s_contSitePcs[kMaxContSites]; // ascending (scan order) +static int s_numContSites = 0; + +struct SuperblockSideExit +{ + std::unique_ptr label; + u32 branchTo; + u32 dsPc; + bool needDs; + BranchCompileState state; +}; +static SuperblockSideExit s_sideExits[kMaxContSites]; +static int s_numSideExits = 0; + +bool recSuperblockIsContSite(u32 branch_pc) +{ + for (int i = 0; i < s_numContSites; i++) + if (s_contSitePcs[i] == branch_pc) + return true; + return false; +} + +// Snapshot the compile state for the taken path and hand back the label the +// handler's inverted condition branches to. Call after _eeFlushAllDirty and +// (for a swapped slot) after the delay-slot emission; the compare itself is a +// pure post-flush read and may be emitted after the snapshot. pc points at the +// delay slot here. +a64::Label* recSuperblockAddSideExit(u32 branch_target, bool need_delay_slot) +{ + pxAssert(s_numSideExits < kMaxContSites); + SuperblockSideExit& x = s_sideExits[s_numSideExits++]; + x.label = std::make_unique(); + x.branchTo = branch_target; + x.dsPc = pc; + x.needDs = need_delay_slot; + x.state.capture(); + return x.label.get(); +} + +// Emit every pending cold side exit after the block tail. Each restores its +// branch-point snapshot, so the exit charges exactly the branch-point cycles +// and SetBranchImm's flush writes back exactly what was live-dirty there. +// The mainline pc/size/recRAMCopy bookkeeping ran before this — pc is dead. +static void recEmitPendingSideExits() +{ + const int n = s_numSideExits; + s_numSideExits = 0; + for (int k = 0; k < n; k++) + { + SuperblockSideExit& x = s_sideExits[k]; + armAsm->Bind(x.label.get()); + x.state.restore(); + g_branch = 0; + if (x.needDs) + { + pc = x.dsPc; + recompileNextInstruction(true, false); + } + SetBranchImm(x.branchTo); + x.label.reset(); + } + g_branch = 1; +} + +// Liveness barrier for the backward pass: `addr` is a continuation branch or +// its delay slot. The taken path leaves the block after the delay slot, so no +// dead-value assumption may cross either out-state. +static bool recSuperblockLivenessBarrier(u32 addr) +{ + for (int i = 0; i < s_numContSites; i++) + if (addr == s_contSitePcs[i] || addr == s_contSitePcs[i] + 4) + return true; + return false; +} + // ===================================================================================================== // Instruction recompilation // ===================================================================================================== @@ -2710,6 +2805,15 @@ bool recEeLoopBackedgeInfo(u32 pc_query, uptr* site, uptr* stub) return true; } +// SL-03 introspection: guest-insn size of the compiled block starting at +// pc_query (0 = no block), so tests can assert superblock formation (the +// range spans past a continuation branch) vs termination (it doesn't). +u32 recEeBlockGuestSize(u32 pc_query) +{ + BASEBLOCKEX* b = recBlocks.Get(HWADDR(pc_query)); + return b ? b->size : 0; +} + // Test-harness recLUT coverage introspection: does this guest PC dispatch to // a real (compile-on-first-hit) LUT page rather than UnmappedRecLUTPage? // The harness can't execute code from ROM regions (it's hardwired to EE @@ -2850,6 +2954,63 @@ static bool recSkipTimeoutLoop(s32 reg, bool is_timeout_loop) // Main Recompilation Loop // ===================================================================================================== +// Scanner-side decode: is `code` any branch/jump/eret/syscall-class op (a +// block-ender or continuation candidate)? Used to refuse continuation through +// a branch whose delay slot is itself a branch (architecturally-UB shape) — +// those fall back to ending the block, which is today's behavior. +static bool eeScanInsnIsBranchClass(u32 code) +{ + switch (code >> 26) + { + case 0: // SPECIAL + { + const u32 funct = code & 0x3f; + return funct == 8 || funct == 9 || funct == 12 || funct == 13; // JR/JALR/SYSCALL/BREAK + } + case 1: // REGIMM + { + const u32 rt = (code >> 16) & 0x1f; + return rt < 4 || (rt >= 16 && rt < 20); + } + case 2: case 3: // J, JAL + case 4: case 5: case 6: case 7: // BEQ, BNE, BLEZ, BGTZ + case 20: case 21: case 22: case 23: // likely forms + return true; + case 16: // COP0: BC0x or ERET + return ((code >> 21) & 0x1f) == 8 || + (((code >> 21) & 0x1f) == 16 && (code & 0x3f) == 24); + case 17: case 18: // COP1/COP2: BCx + return ((code >> 21) & 0x1f) == 8; + } + return false; +} + +// Scanner-side continuation gate for a conditional branch at `i` targeting +// `target`. Forward-only (backward keeps the split/end logic), bounded, and +// refuses a branch-class delay slot. +static bool eeScanContinuable(u32 startpc, u32 i, u32 target) +{ + return target > i + 4 && + s_numContSites < kMaxContSites && + ((i + 8 - startpc) / 4) < kMaxSuperblockInsns && + !eeScanInsnIsBranchClass(memRead32(i + 4)); +} + +// A backward-split target that lands exactly on a continuation site's delay +// slot would leave that branch as the last insn of the range with its delay +// slot outside the analyzed block (instinfo overrun, split-pair emission). +// End the block before the branch instead; the split's purpose — making the +// target a block start — is preserved (a block starting at the delay-slot +// address compiles it as a plain instruction, which is the architectural +// meaning of branching into a delay slot). +static u32 eeSuperblockClampSplit(u32 target) +{ + for (int k = 0; k < s_numContSites; k++) + if (target == s_contSitePcs[k] + 4) + return s_contSitePcs[k]; + return target; +} + static void recRecompile(const u32 startpc) { u32 i; @@ -3036,6 +3197,7 @@ static void recRecompile(const u32 startpc) s_nEndBlock = 0xffffffff; s_branchTo = -1; s_branchLoopable = false; + s_numContSites = 0; // Timeout loop detection (matches x86 recSkipTimeoutLoop pattern): // addiu reg,reg,-N / nop*N / bne reg,zero,loop / nop @@ -3106,13 +3268,22 @@ static void recRecompile(const u32 startpc) { // rt 16-19 are the AL link variants — call-shaped tails // (SetBranchImmCall), not back-edge candidates. + // SL-03: forward BLTZ/BGEZ (rt 0/1) become continuation + // sites — scan on at the fallthrough. Likely + AL forms + // keep ending the block. + if (_Rt_ < 2 && eeScanContinuable(startpc, i, _Imm_ * 4 + i + 4)) + { + s_contSitePcs[s_numContSites++] = i; + i += 8; // skip the delay slot word in the scan + continue; + } s_branchLoopable = _Rt_ < 4; s_branchTo = _Imm_ * 4 + i + 4; // Backward branch into the current block: end the block at the // target so the loop head becomes its own linkable block. // Mirrors x86 iR5900.cpp:2362 and the COP1/COP2 case below. if (s_branchTo > startpc && s_branchTo < i) - s_nEndBlock = s_branchTo; + s_nEndBlock = eeSuperblockClampSplit(s_branchTo); else { s_nEndBlock = i + 8; @@ -3128,6 +3299,18 @@ static void recRecompile(const u32 startpc) goto StartRecomp; case 4: case 5: case 6: case 7: // BEQ, BNE, BLEZ, BGTZ + // SL-03: forward conditionals become continuation sites — scan + // on at the fallthrough. BEQ rs==rt is the unconditional-`b` + // idiom (always taken: everything after is unreachable on the + // fallthrough) and keeps ending the block. + if (!((cpuRegs.code >> 26) == 4 && _Rs_ == _Rt_) && + eeScanContinuable(startpc, i, _Imm_ * 4 + i + 4)) + { + s_contSitePcs[s_numContSites++] = i; + i += 8; // skip the delay slot word in the scan + continue; + } + [[fallthrough]]; case 20: case 21: // BEQL, BNEL case 22: case 23: // BLEZL, BGTZL s_branchLoopable = true; @@ -3136,7 +3319,7 @@ static void recRecompile(const u32 startpc) // is its own linkable block. Mirrors x86 iR5900.cpp:2387 and // the COP1/COP2 case below. if (s_branchTo > startpc && s_branchTo < i) - s_nEndBlock = s_branchTo; + s_nEndBlock = eeSuperblockClampSplit(s_branchTo); else { s_nEndBlock = i + 8; @@ -3159,7 +3342,7 @@ static void recRecompile(const u32 startpc) s_branchLoopable = true; s_branchTo = _Imm_ * 4 + i + 4; if (s_branchTo > startpc && s_branchTo < i) - s_nEndBlock = s_branchTo; + s_nEndBlock = eeSuperblockClampSplit(s_branchTo); else { s_nEndBlock = i + 8; @@ -3174,6 +3357,14 @@ static void recRecompile(const u32 startpc) StartRecomp: + // SL-03: a backward-split can truncate s_nEndBlock below already-recorded + // continuation sites — drop any site whose branch+delay-slot pair no longer + // fits inside [startpc, s_nEndBlock). (Sites are ascending; the compile + // loop never reaches a dropped one, but the analysis passes below must not + // treat it as an exit boundary either.) + while (s_numContSites > 0 && s_contSitePcs[s_numContSites - 1] + 8 > s_nEndBlock) + s_numContSites--; + // Self-modifying code detection: generate inline memory checks for manual blocks. const bool is_manual_block = memory_protect_recompiled_code(startpc, (s_nEndBlock - startpc) >> 2); @@ -3306,6 +3497,18 @@ StartRecomp: for (i = s_nEndBlock; i > startpc; i -= 4) { cpuRegs.code = memRead32(i - 4); + // SL-03: at a continuation site the taken path leaves the block + // after the delay slot — merge "everything live" (the block-end + // init state) into the out-state of both the branch and its delay + // slot so no dead-value assumption crosses the side exit. This is + // exactly today's block-end conservatism at the former boundary. + if (recSuperblockLivenessBarrier(i - 4)) + { + memset(pcur->regs, EEINST_LIVE, sizeof(pcur->regs)); + memset(pcur->fpuregs, EEINST_LIVE, sizeof(pcur->fpuregs)); + memset(pcur->vfregs, EEINST_LIVE, sizeof(pcur->vfregs)); + memset(pcur->viregs, EEINST_LIVE, sizeof(pcur->viregs)); + } pcur[-1] = pcur[0]; recBackpropBSC(cpuRegs.code, pcur - 1, pcur); pcur--; @@ -3315,12 +3518,27 @@ StartRecomp: // Run COP2 analysis passes — sets EEINST_COP2_SYNC_VU0/FINISH_VU0 flags // for conditional VU0 synchronization in transfer ops. + // SL-03: run per segment, delimited at continuation sites. Both passes + // defer commits forward (flag-hack elision, micro-finish placement); a + // deferral must not cross a side exit that can escape before the + // superseding instruction executes. Per-segment == today's per-block + // semantics at each former boundary. if (has_cop2_instructions) { - R5900::COP2MicroFinishPass().Run(startpc, s_nEndBlock, s_pInstCache + 1); + u32 seg_start = startpc; + for (int k = 0; k <= s_numContSites; k++) + { + const u32 seg_end = (k < s_numContSites) ? (s_contSitePcs[k] + 8) : s_nEndBlock; + if (seg_end <= seg_start) + continue; + EEINST* const seg_inst = s_pInstCache + 1 + (seg_start - startpc) / 4; + R5900::COP2MicroFinishPass().Run(seg_start, seg_end, seg_inst); - if (EmuConfig.Speedhacks.vuFlagHack) - R5900::COP2FlagHackPass().Run(startpc, s_nEndBlock, s_pInstCache + 1); + if (EmuConfig.Speedhacks.vuFlagHack) + R5900::COP2FlagHackPass().Run(seg_start, seg_end, seg_inst); + + seg_start = seg_end; + } } } @@ -3340,6 +3558,9 @@ StartRecomp: // Sweep any LDL/LDR fusion residue from an aborted prior compile (the // per-pair gate otherwise guarantees same-block consume). g_eeUnalignedFused = false; + // SL-03: sweep side-exit residue the same way (recEmitPendingSideExits + // drains the list on every completed compile). + s_numSideExits = 0; // SL-1 candidacy: a resident self-loop is a block whose terminal branch // targets its own startpc. Wait-loop-FF blocks keep the fast-forward @@ -3505,6 +3726,10 @@ StartRecomp: } } + // SL-03: outline the cold taken side exits after the tail (mainline pc is + // dead — size and recRAMCopy bookkeeping ran on it above). + recEmitPendingSideExits(); + pxAssert(armGetCurrentCodePointer() < SysMemory::GetEERecEnd()); // Size is from the aligned block_fnptr, not the pre-alignment recPtr — diff --git a/pcsx2/arm64/iR5900-arm64.h b/pcsx2/arm64/iR5900-arm64.h index 1e1b99524b..1b554cafd8 100644 --- a/pcsx2/arm64/iR5900-arm64.h +++ b/pcsx2/arm64/iR5900-arm64.h @@ -657,6 +657,18 @@ bool TrySwapDelaySlot(u32 rs, u32 rt, u32 rd, bool allow_loadstore); void SaveBranchState(); void LoadBranchState(); +// SL-03 superblocks: conditional-fallthrough continuation. The block scanner +// records forward conditional branches as continuation sites instead of ending +// the block there. A site's handler queries recSuperblockIsContSite(pc-4) and +// emits the inverted shape — branch OUT to a cold taken side exit, compile the +// delay slot inline, and fall through into the rest of the block — instead of +// the two-arm terminal fork. Side exits snapshot the compile state at the +// branch and are outlined after the block tail (recEmitPendingSideExits), +// where each restores its snapshot, compiles the taken-path delay slot, and +// emits the normal flush + event-check + linked-B tail. +bool recSuperblockIsContSite(u32 branch_pc); +vixl::aarch64::Label* recSuperblockAddSideExit(u32 branch_target, bool need_delay_slot); + void recompileNextInstruction(bool delayslot, bool swapped_delay_slot); // Block-tail transfer for register-indirect targets (recJR/recJALR). diff --git a/pcsx2/arm64/iR5900Branch-arm64.cpp b/pcsx2/arm64/iR5900Branch-arm64.cpp index c67007bcfa..cc3d94436c 100644 --- a/pcsx2/arm64/iR5900Branch-arm64.cpp +++ b/pcsx2/arm64/iR5900Branch-arm64.cpp @@ -136,6 +136,122 @@ static void recBindBranchLabel() s_pBranchLabel = nullptr; } +// ===================================================================================================== +// SL-03 superblock continuation — inverted branch emission +// ===================================================================================================== +// At a continuation site the block does NOT end: the handler emits the +// TAKEN-condition branch out to a cold side exit (recorded for outlined +// emission after the block tail), compiles the not-taken delay slot inline, +// and returns with g_branch clear so the main loop keeps compiling at the +// fallthrough. Compare shapes mirror recSetBranchEQ/recSetBranchL with the +// condition sense inverted (branch out when TAKEN instead of skip when +// not-taken), const fast paths included. + +// BEQ/BNE continuation. Returns true when fully handled (caller returns). +static bool recTrySuperblockContinueEQ(bool is_bne) +{ + if (!recSuperblockIsContSite(pc - 4)) + return false; + const u32 branchTo = ((s32)_Imm_ * 4) + pc; + + if (GPR_IS_CONST2(_Rs_, _Rt_)) + { + const bool taken = is_bne ? (g_cpuConstRegs[_Rs_].SD[0] != g_cpuConstRegs[_Rt_].SD[0]) : + (g_cpuConstRegs[_Rs_].SD[0] == g_cpuConstRegs[_Rt_].SD[0]); + if (taken) + return false; // resolved-taken: terminal — the const path emits it + recompileNextInstruction(true, false); // resolved fallthrough: no exit + return true; + } + + if (_Rs_ == _Rt_) + { + if (!is_bne) + return false; // BEQ rs==rt: always taken (scanner excludes; defensive) + recompileNextInstruction(true, false); // BNE rs==rt: never taken + return true; + } + + const int process = GPR_IS_CONST1(_Rs_) ? PROCESS_CONSTS : + (GPR_IS_CONST1(_Rt_) ? PROCESS_CONSTT : 0); + const bool swap = TrySwapDelaySlot(_Rs_, _Rt_, 0, true); + _eeFlushAllDirty(); + a64::Label* const exit = recSuperblockAddSideExit(branchTo, !swap); + + if (process) + { + const int constReg = (process & PROCESS_CONSTS) ? _Rs_ : _Rt_; + const int liveReg = (process & PROCESS_CONSTS) ? _Rt_ : _Rs_; + const s64 cval = g_cpuConstRegs[constReg].SD[0]; + const a64::Register live = loadGPRtoX(RXARG1, liveReg); + if (cval == 0) + { + // BEQ taken ⇔ live == 0 → Cbz; BNE taken ⇔ live != 0 → Cbnz. + if (is_bne) + armAsm->Cbnz(live, exit); + else + armAsm->Cbz(live, exit); + } + else + { + armAsm->Cmp(live, cval); + armAsm->B(exit, is_bne ? a64::ne : a64::eq); + } + } + else + { + const a64::Register rs = loadGPRtoX(RXARG1, _Rs_); + const a64::Register rt = loadGPRtoX(RXSCRATCH, _Rt_); + armAsm->Cmp(rs, rt); + armAsm->B(exit, is_bne ? a64::ne : a64::eq); + } + + if (!swap) + recompileNextInstruction(true, false); + return true; +} + +// BLEZ/BGTZ/BLTZ/BGEZ continuation; taken_cond is the TAKEN sense (le/gt/lt/ge). +static bool recTrySuperblockContinueSingle(a64::Condition taken_cond) +{ + if (!recSuperblockIsContSite(pc - 4)) + return false; + const u32 branchTo = ((s32)_Imm_ * 4) + pc; + + if (GPR_IS_CONST1(_Rs_)) + { + const s64 val = g_cpuConstRegs[_Rs_].SD[0]; + bool taken = false; + if (taken_cond == a64::le) taken = (val <= 0); + else if (taken_cond == a64::gt) taken = (val > 0); + else if (taken_cond == a64::lt) taken = (val < 0); + else if (taken_cond == a64::ge) taken = (val >= 0); + if (taken) + return false; // resolved-taken: terminal — the const path emits it + recompileNextInstruction(true, false); // resolved fallthrough: no exit + return true; + } + + const bool swap = TrySwapDelaySlot(_Rs_, 0, 0, true); + _eeFlushAllDirty(); + a64::Label* const exit = recSuperblockAddSideExit(branchTo, !swap); + const a64::Register rs = loadGPRtoX(RXSCRATCH, _Rs_); + + if (taken_cond == a64::lt) + armAsm->Tbnz(rs, 63, exit); // BLTZ taken ⇔ sign bit set + else if (taken_cond == a64::ge) + armAsm->Tbz(rs, 63, exit); // BGEZ taken ⇔ sign bit clear + else + { + armAsm->Cmp(rs, 0); + armAsm->B(exit, taken_cond); + } + + if (!swap) + recompileNextInstruction(true, false); + return true; +} + //// BEQ — branch if rs == rt static void recBEQ_const() { @@ -182,6 +298,8 @@ static void recBEQ_process(int process) void recBEQ() { + if (recTrySuperblockContinueEQ(false)) + return; if (GPR_IS_CONST2(_Rs_, _Rt_)) recBEQ_const(); else if (GPR_IS_CONST1(_Rs_)) @@ -238,6 +356,8 @@ static void recBNE_process(int process) void recBNE() { + if (recTrySuperblockContinueEQ(true)) + return; if (GPR_IS_CONST2(_Rs_, _Rt_)) recBNE_const(); else if (GPR_IS_CONST1(_Rs_)) @@ -433,10 +553,30 @@ static void recBranchSingleLikely(a64::Condition skip_cond) SetBranchImm(pc); } -void recBLEZ() { recBranchSingle(a64::gt); } -void recBGTZ() { recBranchSingle(a64::le); } -void recBLTZ() { recBranchSingle(a64::ge); } -void recBGEZ() { recBranchSingle(a64::lt); } +void recBLEZ() +{ + if (recTrySuperblockContinueSingle(a64::le)) + return; + recBranchSingle(a64::gt); +} +void recBGTZ() +{ + if (recTrySuperblockContinueSingle(a64::gt)) + return; + recBranchSingle(a64::le); +} +void recBLTZ() +{ + if (recTrySuperblockContinueSingle(a64::lt)) + return; + recBranchSingle(a64::ge); +} +void recBGEZ() +{ + if (recTrySuperblockContinueSingle(a64::ge)) + return; + recBranchSingle(a64::lt); +} void recBLEZL() { recBranchSingleLikely(a64::gt); } void recBGTZL() { recBranchSingleLikely(a64::le); } diff --git a/tests/ctest/core/recompilers/CMakeLists.txt b/tests/ctest/core/recompilers/CMakeLists.txt index 02fbe2aa7e..acfddcbfdf 100644 --- a/tests/ctest/core/recompilers/CMakeLists.txt +++ b/tests/ctest/core/recompilers/CMakeLists.txt @@ -41,6 +41,7 @@ add_pcsx2_test(recompiler_tests ee_rec_alu_imm_tests.cpp ee_rec_branch_tests.cpp ee_rec_loop_residency_tests.cpp + ee_rec_superblock_tests.cpp ee_rec_branch_in_delay_tests.cpp ee_rec_callret_tests.cpp ee_rec_cop0_tests.cpp diff --git a/tests/ctest/core/recompilers/ee_rec_superblock_tests.cpp b/tests/ctest/core/recompilers/ee_rec_superblock_tests.cpp new file mode 100644 index 0000000000..a5dde8eb14 --- /dev/null +++ b/tests/ctest/core/recompilers/ee_rec_superblock_tests.cpp @@ -0,0 +1,402 @@ +// SPDX-FileCopyrightText: 2002-2026 PCSX2 Dev Team +// SPDX-License-Identifier: GPL-3.0+ + +// SL-03 superblocks: forward conditional branches become continuation sites — +// the not-taken path compiles straight through (no tail, no head reload), the +// taken arm becomes a cold side exit outlined after the block tail. These +// tests pin block formation (via recEeBlockGuestSize), both runtime paths +// (Run() diffs the full architectural state against the interpreter), the +// delay-slot-on-both-paths contract, snapshot-flush coherence at taken exits, +// the S1×S2 composition (a loop with an internal forward branch becomes one +// resident self-loop), and the deliberate non-sites (backward, likely, +// unconditional-idiom, cap overflow). + +#include "harness/EeRecTestHarness.h" + +#include "R5900.h" +#include "vtlb.h" + +#include + +using namespace recompiler_tests; +using namespace mips; + +extern u32 recEeBlockGuestSize(u32 pc_query); +extern bool recEeBlockIsLoopResident(u32 pc_query); + +namespace { +constexpr u32 kPark = RecompilerTestEnvironment::kParkingPc; +constexpr u32 kProgPc = RecompilerTestEnvironment::kProgramPc; +constexpr u32 kDataAddr = 0x00020000; // EE RAM scratch, away from the program + +// Formation-asserting tests need a deterministic page state (the harness +// program page can flip to ProtMode_Manual across accumulated write→clear +// cycles in this process; manual blocks still superblock, but a fresh state +// keeps size assertions honest) — same recipe as the loop-residency tests. +void ResetRecAndPageProtection() +{ + recCpu.Reset(); + mmap_ResetBlockTracking(); +} +} // namespace + +// A forward BNE no longer ends the block: the compiled range spans the branch, +// its delay slot, both the skipped and merge instructions, and the terminal J. +TEST(EeRecSuperblock, ForwardBranchFusesBlock) +{ + ResetRecAndPageProtection(); + EeRecTestHarness h; + h.SetGpr64(reg::t0, 0); // not taken + h.LoadProgramNoTerm({ + BNE(reg::t0, reg::zero, 2), // idx0 → idx4 when taken + NOP, // idx1 ds + ADDIU(reg::t1, reg::zero, 5), // idx2 fallthrough-only + ADDIU(reg::t2, reg::zero, 7), // idx3 merge + J(kPark), NOP, // idx4/5 + }); + h.Run(); + h.ExpectGpr64(reg::t1, 5ull); + h.ExpectGpr64(reg::t2, 7ull); + // Pre-SL-03 this block was 2 insns (branch + delay slot). + EXPECT_EQ(recEeBlockGuestSize(kProgPc), 6u); +} + +// Same program, condition true: the cold taken side exit runs — it must skip +// the fallthrough-only instruction and land on the merge point. +TEST(EeRecSuperblock, TakenSideExitCorrect) +{ + EeRecTestHarness h; + h.SetGpr64(reg::t0, 1); // taken + h.LoadProgramNoTerm({ + BNE(reg::t0, reg::zero, 2), + NOP, + ADDIU(reg::t1, reg::zero, 5), + ADDIU(reg::t2, reg::zero, 7), + J(kPark), NOP, + }); + h.Run(); + h.ExpectGpr64(reg::t1, 0ull); + h.ExpectGpr64(reg::t2, 7ull); +} + +// The delay slot executes on BOTH paths: inline on the fallthrough, recompiled +// from the snapshot inside the side exit on the taken path. +TEST(EeRecSuperblock, DelaySlotExecutesOnBothPaths) +{ + for (const u64 cond : {0ull, 1ull}) + { + EeRecTestHarness h; + h.SetGpr64(reg::t0, cond); + h.SetGpr64(reg::t3, 0); + h.LoadProgramNoTerm({ + BNE(reg::t0, reg::zero, 2), // idx0 + ADDIU(reg::t3, reg::t3, 11), // idx1 ds: both paths + ADDIU(reg::t1, reg::zero, 5), // idx2 fallthrough-only + ADDIU(reg::t2, reg::zero, 7), // idx3 merge + J(kPark), NOP, + }); + h.Run(); + h.ExpectGpr64(reg::t3, 11ull); + h.ExpectGpr64(reg::t1, cond ? 0ull : 5ull); + h.ExpectGpr64(reg::t2, 7ull); + } +} + +// Dirty scalar + dirty 128-bit NEON state at the branch: the side exit's +// flush is emitted from the branch-point snapshot and must publish exactly +// that state before linking out (Run() diffs everything vs the interpreter). +TEST(EeRecSuperblock, TakenExitFlushesDirtyState) +{ + EeRecTestHarness h; + h.SetGpr64(reg::t0, 3); + h.SetGpr64(reg::t1, 4); + h.SetGpr128(reg::t4, 0x0000000100000002ull, 0x0000000300000004ull); + h.SetGpr128(reg::t5, 0x0000001000000010ull, 0x0000001000000010ull); + h.LoadProgramNoTerm({ + ADDU(reg::t2, reg::t0, reg::t1), // t2 = 7, dirty resident + ee::PADDW(reg::t4, reg::t4, reg::t5), // NEON quad dirty + BNE(reg::t2, reg::zero, 2), // taken → side exit must flush t2/t4 + NOP, + ADDIU(reg::t2, reg::zero, 0), // fallthrough-only (skipped) + ADDIU(reg::v0, reg::zero, 9), + J(kPark), NOP, + }); + h.Run(); + h.ExpectGpr64(reg::t2, 7ull); + h.ExpectGpr128(reg::t4, 0x0000001100000012ull, 0x0000001300000014ull); + h.ExpectGpr64(reg::v0, 9ull); +} + +// A compile-time constant created before the branch stays const through the +// continuation — the value must still be architecturally correct on both +// paths (the taken exit materializes it in its flush). +TEST(EeRecSuperblock, ConstRidesThroughContinuation) +{ + for (const u64 cond : {0ull, 1ull}) + { + EeRecTestHarness h; + h.SetGpr64(reg::t0, cond); + h.LoadProgramNoTerm({ + ORI(reg::t2, reg::zero, 0x123), // const before the branch + BNE(reg::t0, reg::zero, 2), + NOP, + ADDIU(reg::t1, reg::zero, 5), // fallthrough-only + ADDU(reg::t3, reg::t2, reg::t2), // merge: uses the const + J(kPark), NOP, + }); + h.Run(); + h.ExpectGpr64(reg::t2, 0x123ull); + h.ExpectGpr64(reg::t3, 0x246ull); + h.ExpectGpr64(reg::t1, cond ? 0ull : 5ull); + } +} + +// Two continuation sites in one block, all four taken/not-taken combinations. +TEST(EeRecSuperblock, MultipleContinuationSites) +{ + for (const u64 c0 : {0ull, 1ull}) + { + for (const u64 c1 : {0ull, 1ull}) + { + EeRecTestHarness h; + h.SetGpr64(reg::t0, c0); + h.SetGpr64(reg::t1, c1); + h.LoadProgramNoTerm({ + BNE(reg::t0, reg::zero, 2), // idx0 → idx3 (skip the t2 add, land ON the 2nd branch) + NOP, + ADDIU(reg::t2, reg::t2, 1), // idx2: only when !c0 + BNE(reg::t1, reg::zero, 2), // idx3 → idx6 (skip the t3 add) — both paths reach this + NOP, + ADDIU(reg::t3, reg::t3, 1), // idx5: only when !c1 + NOP, // idx6 + ADDIU(reg::v0, reg::zero, 9), // idx7 + J(kPark), NOP, + }); + h.Run(); + h.ExpectGpr64(reg::t2, c0 ? 0ull : 1ull); + h.ExpectGpr64(reg::t3, c1 ? 0ull : 1ull); + h.ExpectGpr64(reg::v0, 9ull); + } + } +} + +// Backward conditionals still end the block (loops stay S1's shape). +TEST(EeRecSuperblock, BackwardBranchStillEndsBlock) +{ + ResetRecAndPageProtection(); + EeRecTestHarness h; + h.SetGpr64(reg::t0, 0); + h.SetGpr64(reg::t1, 3); + h.LoadProgramNoTerm({ + // 0x00 loop: + ADDIU(reg::t0, reg::t0, 1), + ADDIU(reg::t1, reg::t1, -1), + BNE(reg::t1, reg::zero, -3), // → 0x00 + NOP, + J(kPark), NOP, + }); + h.Run(); + h.ExpectGpr64(reg::t0, 3ull); + EXPECT_EQ(recEeBlockGuestSize(kProgPc), 4u); // loop only — not fused onward +} + +// Likely branches keep ending the block (wave-1 scope: their delay slot is +// taken-path-only, a different continuation shape). +TEST(EeRecSuperblock, LikelyBranchStillEndsBlock) +{ + ResetRecAndPageProtection(); + EeRecTestHarness h; + h.SetGpr64(reg::t0, 0); + h.LoadProgramNoTerm({ + ee::BNEL(reg::t0, reg::zero, 2), // forward likely — terminal + NOP, + ADDIU(reg::t1, reg::zero, 5), + ADDIU(reg::t2, reg::zero, 7), + J(kPark), NOP, + }); + h.Run(); + h.ExpectGpr64(reg::t1, 5ull); + h.ExpectGpr64(reg::t2, 7ull); + EXPECT_EQ(recEeBlockGuestSize(kProgPc), 2u); +} + +// BEQ rs==rt is the unconditional-`b` idiom: always taken, so the fallthrough +// is unreachable and the block must end there. +TEST(EeRecSuperblock, UnconditionalBeqIdiomEndsBlock) +{ + ResetRecAndPageProtection(); + EeRecTestHarness h; + h.LoadProgramNoTerm({ + BEQ(reg::zero, reg::zero, 2), // b → idx4 + NOP, + ADDIU(reg::t1, reg::zero, 5), // unreachable + NOP, + ADDIU(reg::v0, reg::zero, 9), // idx4 + J(kPark), NOP, + }); + h.Run(); + h.ExpectGpr64(reg::t1, 0ull); + h.ExpectGpr64(reg::v0, 9ull); + EXPECT_EQ(recEeBlockGuestSize(kProgPc), 2u); +} + +// BNE rs==rt never takes: pure fallthrough, fused with no side exit. +TEST(EeRecSuperblock, NeverTakenBneSameRegContinues) +{ + ResetRecAndPageProtection(); + EeRecTestHarness h; + h.SetGpr64(reg::t0, 42); + h.LoadProgramNoTerm({ + BNE(reg::t0, reg::t0, 2), + NOP, + ADDIU(reg::t1, reg::zero, 5), + ADDIU(reg::t2, reg::zero, 7), + J(kPark), NOP, + }); + h.Run(); + h.ExpectGpr64(reg::t1, 5ull); + h.ExpectGpr64(reg::t2, 7ull); + EXPECT_EQ(recEeBlockGuestSize(kProgPc), 6u); +} + +// Compile-time const-resolved-taken branch is terminal (everything after is +// unreachable on this compile); const-resolved-not-taken continues inline. +TEST(EeRecSuperblock, ConstResolvedTakenEndsBlock) +{ + ResetRecAndPageProtection(); + EeRecTestHarness h; + h.LoadProgramNoTerm({ + ORI(reg::t0, reg::zero, 1), // t0 = const 1 + BNE(reg::t0, reg::zero, 2), // resolved taken → idx4 + NOP, + ADDIU(reg::t2, reg::zero, 7), // unreachable + ADDIU(reg::v0, reg::zero, 9), // idx4 + J(kPark), NOP, + }); + h.Run(); + h.ExpectGpr64(reg::t2, 0ull); + h.ExpectGpr64(reg::v0, 9ull); + EXPECT_EQ(recEeBlockGuestSize(kProgPc), 3u); // ORI + BNE + ds +} + +TEST(EeRecSuperblock, ConstResolvedNotTakenContinues) +{ + ResetRecAndPageProtection(); + EeRecTestHarness h; + h.LoadProgramNoTerm({ + ORI(reg::t0, reg::zero, 0), // t0 = const 0 + BNE(reg::t0, reg::zero, 2), // resolved not-taken → falls through + NOP, + ADDIU(reg::t2, reg::zero, 7), + ADDIU(reg::v0, reg::zero, 9), + J(kPark), NOP, + }); + h.Run(); + h.ExpectGpr64(reg::t2, 7ull); + h.ExpectGpr64(reg::v0, 9ull); + EXPECT_EQ(recEeBlockGuestSize(kProgPc), 7u); +} + +// The per-block site cap (8): the ninth forward conditional ends the block. +TEST(EeRecSuperblock, SiteCapEndsBlockAtNinthBranch) +{ + ResetRecAndPageProtection(); + EeRecTestHarness h; + h.SetGpr64(reg::t0, 0); + std::vector prog; + for (int i = 0; i < 9; i++) + { + prog.push_back(BNE(reg::t0, reg::zero, 1)); // → its own fallthrough + prog.push_back(NOP); + } + prog.push_back(ADDIU(reg::v0, reg::zero, 9)); + prog.push_back(J(kPark)); + prog.push_back(NOP); + h.LoadProgramNoTerm(prog); + h.Run(); + h.ExpectGpr64(reg::v0, 9ull); + EXPECT_EQ(recEeBlockGuestSize(kProgPc), 18u); // 8 fused pairs + terminal pair +} + +// The S1×S2 composition prize: a loop whose body contains a forward +// conditional used to be split into two blocks — now it is ONE self-loop +// superblock and SL-1 makes it loop-resident. Both body paths iterate. +TEST(EeRecSuperblock, LoopWithInternalForwardBranchBecomesResident) +{ + for (const u64 skip : {0ull, 1ull}) + { + ResetRecAndPageProtection(); + EeRecTestHarness h; + h.SetGpr64(reg::t0, 0); + h.SetGpr64(reg::t1, 5); + h.SetGpr64(reg::t2, 0); + h.SetGpr64(reg::t3, skip); + h.LoadProgramNoTerm({ + // 0x00 loop: + ADDIU(reg::t0, reg::t0, 1), // idx0: iteration count + BNE(reg::t3, reg::zero, 2), // idx1: forward → idx4 (skip the add) + NOP, // idx2 ds + ADDIU(reg::t2, reg::t2, 1), // idx3: only when !skip + ADDIU(reg::t1, reg::t1, -1), // idx4: merge + BNE(reg::t1, reg::zero, -6), // idx5: → 0x00 + NOP, // idx6 ds + J(kPark), NOP, + }); + h.Run(); + h.ExpectGpr64(reg::t0, 5ull); + h.ExpectGpr64(reg::t1, 0ull); + h.ExpectGpr64(reg::t2, skip ? 0ull : 5ull); + EXPECT_EQ(recEeBlockGuestSize(kProgPc), 7u) << "loop body not fused"; + EXPECT_TRUE(recEeBlockIsLoopResident(kProgPc)) << "fused loop not resident"; + } +} + +// SMC anywhere in the fused range must invalidate the whole superblock: a +// rewrite AFTER the continuation branch changes the recompiled semantics. +TEST(EeRecSuperblock, SmcInFusedRegionRecompiles) +{ + EeRecTestHarness h; + h.SetGpr64(reg::t0, 0); + h.LoadProgramNoTerm({ + BNE(reg::t0, reg::zero, 2), // idx0 + NOP, + ADDIU(reg::t1, reg::zero, 5), // idx2 + ADDIU(reg::t2, reg::zero, 7), // idx3 ← rewritten below + J(kPark), NOP, + }); + h.Run(); + h.ExpectGpr64(reg::t2, 7ull); + + h.TriggerSmc(kProgPc + 3 * 4, ADDIU(reg::t2, reg::zero, 0x55)); + + h.SetGpr64(reg::t0, 0); + h.Run(EeRecTestHarness::RunMode::PreserveCache); + h.ExpectGpr64(reg::t2, 0x55ull); +} + +// Store/load traffic straddling a continuation site: inline fastmem stores +// publish before the branch, loads after it read them, both paths. +TEST(EeRecSuperblock, MemoryTrafficAcrossContinuation) +{ + for (const u64 cond : {0ull, 1ull}) + { + EeRecTestHarness h; + h.SetGpr64(reg::t0, cond); + h.SetGpr64(reg::t4, kDataAddr); + h.SetGpr64(reg::t1, 0x1234); + h.TrackMemWindow(kDataAddr, 8); + h.LoadProgramNoTerm({ + SW(reg::t1, 0, reg::t4), // idx0 + BNE(reg::t0, reg::zero, 2), // idx1 → idx5 + NOP, + ADDIU(reg::t1, reg::t1, 1), // idx3: only when !cond + NOP, // idx4 + LW(reg::t2, 0, reg::t4), // idx5: reads the pre-branch store + SW(reg::t1, 4, reg::t4), + J(kPark), NOP, + }); + h.Run(); + h.ExpectGpr64(reg::t2, 0x1234ull); + EXPECT_EQ(h.ReadU32(kDataAddr + 4), cond ? 0x1234u : 0x1235u); + } +} diff --git a/tests/ctest/core/recompilers/harness/EeRecTestHarness.cpp b/tests/ctest/core/recompilers/harness/EeRecTestHarness.cpp index 531515e721..0b2239fd72 100644 --- a/tests/ctest/core/recompilers/harness/EeRecTestHarness.cpp +++ b/tests/ctest/core/recompilers/harness/EeRecTestHarness.cpp @@ -256,6 +256,13 @@ void EeRecTestHarness::LoadProgramNoTerm(std::initializer_list instructions LoadProgramImpl(instructions, /*append_term=*/false); } +void EeRecTestHarness::LoadProgramNoTerm(const std::vector& instructions) +{ + program_words_ = instructions; + for (size_t i = 0; i < program_words_.size(); ++i) + memWrite32(kProgramPc + static_cast(i * 4), program_words_[i]); +} + void EeRecTestHarness::SeedEntryState() { cpuRegs.GPR.n.ra.UD[0] = static_cast(static_cast(kParkingPc)); diff --git a/tests/ctest/core/recompilers/harness/EeRecTestHarness.h b/tests/ctest/core/recompilers/harness/EeRecTestHarness.h index 3879ef7955..35965f01f7 100644 --- a/tests/ctest/core/recompilers/harness/EeRecTestHarness.h +++ b/tests/ctest/core/recompilers/harness/EeRecTestHarness.h @@ -108,6 +108,7 @@ public: void LoadProgram(std::initializer_list instructions); void LoadProgram(const std::vector& instructions); void LoadProgramNoTerm(std::initializer_list instructions); + void LoadProgramNoTerm(const std::vector& instructions); // ---- Execute ----