mirror of
https://github.com/ARMSX2/ARMSX2.git
synced 2026-08-24 16:50:16 -07:00
tests: cover Q/P instance rotation across a branch
The VU's Q and P scalars are double-buffered, and microVU keeps both buffers live in one host vector. When a DIV or an EFU op's latency expires mid-block the current instance flips, but every compiled block is entered assuming instance #0 — so a branch out of that block has to physically swap the two lanes first. Nothing in the suite had ever branched with a Q or P value in flight, so that swap was unreached. A dropped swap is silent: the target block reads the previous quotient, which is an ordinary float that propagates through the rest of the microprogram. Each case therefore seeds the stale buffer with a distinct sentinel and asserts the absolute post-branch value — a JIT-vs-interp diff alone would also pass if the swap were dropped on both sides. Also covers mVUendProgram's division-flag transfer, which only runs when the program ends inside the FDIV flag latency. Every other Q test drains the pipe with VWAITQ first, so that path had never run either. STATUS is opted out of the cross-engine diff there: the console captures already settled that the sticky D/I bits accumulate, which microVU does and the shared interpreter does not (vu_sticky_console_conformance_tests.cpp). Mutation-checked: neutralising the Q swap fails exactly the three Q cases, the P swap exactly the P case, and dropping the end-program mVUdivSet exactly the two flag cases.
This commit is contained in:
@@ -88,6 +88,7 @@ add_pcsx2_test(recompiler_tests
|
||||
vu_madda_acc_lane_tests.cpp
|
||||
vu_broadcast_lane_tests.cpp
|
||||
vu_minmax_order_tests.cpp
|
||||
vu_pq_boundary_tests.cpp
|
||||
vu_overflow_hack_tests.cpp
|
||||
vu_mac_flag_pack_tests.cpp
|
||||
ee_vu0_cfc2_ctc2_tests.cpp
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// SPDX-FileCopyrightText: 2026 yaps2 Dev Team
|
||||
// SPDX-License-Identifier: GPL-3.0+
|
||||
|
||||
// Q/P pipeline state across a BLOCK BOUNDARY — branches and end-of-program.
|
||||
//
|
||||
// The behavioural model this suite exists for:
|
||||
//
|
||||
// The VU's Q and P scalars are double-buffered. While a DIV/SQRT/RSQRT (Q) or
|
||||
// an EFU op (P) is in flight, the architectural value a reader sees and the
|
||||
// slot the producer will land in are two different registers. microVU tracks
|
||||
// which of the two is "current" per compiled instruction as mVU.q / mVU.p, and
|
||||
// keeps both live in one host vector — qmmPQ lane 0 = Q, lane 1 = pending_q,
|
||||
// lane 2 = P, lane 3 = pending_p.
|
||||
//
|
||||
// Every compiled block, however, is entered assuming instance #0. So when a
|
||||
// producer's latency expires mid-block, the instance flips, and any branch out
|
||||
// of that block must physically swap the two lanes before the jump — otherwise
|
||||
// the target block reads the stale buffer and the whole program computes with
|
||||
// the PREVIOUS quotient. That swap (mVUsetupBranch, microVU_Branch-arm64.inl)
|
||||
// had no test coverage at all: nothing in the suite had ever branched with a
|
||||
// Q or P value in flight.
|
||||
//
|
||||
// The failure mode is silent and plausible: a stale Q is a real float that
|
||||
// propagates through the rest of the microprogram. So each test seeds the
|
||||
// pre-branch Q/P with a distinctive sentinel and asserts the ABSOLUTE
|
||||
// post-branch value, not just JIT-vs-interp agreement — a diff alone would
|
||||
// also pass if the swap were dropped on both sides, and a sentinel-free test
|
||||
// would pass if the two buffers happened to hold the same number.
|
||||
//
|
||||
// The end-of-program half covers the other boundary: mVUendProgram's
|
||||
// division-flag transfer, which only runs when the program ends while the
|
||||
// DIV's flag latency is still outstanding. That is the case the older
|
||||
// Vu0Qpipe tests deliberately avoid (they drain with VWAITQ first), so it too
|
||||
// was unreached.
|
||||
|
||||
#include "harness/VuTestHarness.h"
|
||||
|
||||
#include "VU.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <bit>
|
||||
|
||||
namespace recompiler_tests {
|
||||
|
||||
using namespace vu;
|
||||
|
||||
namespace {
|
||||
|
||||
inline VuOp LowerOnly(u32 lower) { return VuOp{lower, VNOP_U()}; }
|
||||
inline VuOp UpperOnly(u32 upper) { return IBit(VuOp{VLitZero(), upper}); }
|
||||
// I-bit set so the zero lower word is the VI[REG_I] immediate rather than
|
||||
// decoding as `LQ vf0` — the canonical NOP-pair idiom.
|
||||
inline VuOp Nop() { return IBit(VuOp{VLitZero(), VNOP_U()}); }
|
||||
|
||||
// DIV/SQRT latency is 7 cycles (microVU_Lower-arm64.inl, mVUanalyzeFDIV);
|
||||
// ESADD's EFU latency is 11. Padding by exactly the latency puts the
|
||||
// instance flip immediately before the branch, which is the state under test.
|
||||
constexpr int kDivLatency = 7;
|
||||
constexpr int kEsaddLatency = 11;
|
||||
|
||||
// Sentinels: what a dropped lane swap would leave behind. Chosen far from
|
||||
// every computed result so a stale read is unmistakable in the failure text.
|
||||
constexpr float kStaleQ = 1000.0f;
|
||||
constexpr float kStaleP = 500.0f;
|
||||
|
||||
void PushNops(std::vector<VuOp>& prog, int count)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
prog.push_back(Nop());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// =========================================================================
|
||||
// Q instance across a branch — mVUsetupBranch's lane-0/1 swap
|
||||
// =========================================================================
|
||||
|
||||
TEST(VuPqBoundary, QInstanceSurvivesUnconditionalBranch)
|
||||
{
|
||||
// vf1.x / vf2.x = 6/2 = 3. The quotient lands in the pending buffer, the
|
||||
// instance flips as its latency expires, and the branch must carry it into
|
||||
// lane 0 for the target block to read.
|
||||
VuTestHarness h(0);
|
||||
h.SetQ(std::bit_cast<u32>(kStaleQ));
|
||||
h.SetVf(vf::vf1, 6.0f, 0.0f, 0.0f, 0.0f);
|
||||
h.SetVf(vf::vf2, 2.0f, 0.0f, 0.0f, 0.0f);
|
||||
h.SetVf(vf::vf6, 10.0f, 10.0f, 10.0f, 10.0f); // addend on the taken side
|
||||
h.SetVf(vf::vf7, -50.0f, -50.0f, -50.0f, -50.0f); // addend on the skipped pair
|
||||
|
||||
std::vector<VuOp> prog;
|
||||
prog.push_back(LowerOnly(VDIV_L(vf::vf1, /*fsf=*/0, vf::vf2, /*ftf=*/0)));
|
||||
PushNops(prog, kDivLatency);
|
||||
prog.push_back(LowerOnly(VB_L(+2))); // branch over the poison pair
|
||||
prog.push_back(Nop()); // delay slot
|
||||
prog.push_back(UpperOnly(VADDq_U(mask::xyzw, vf::vf5, vf::vf7))); // skipped
|
||||
prog.push_back(UpperOnly(VADDq_U(mask::xyzw, vf::vf5, vf::vf6))); // target
|
||||
prog.push_back(EBitNopPair());
|
||||
h.LoadProgram(std::move(prog));
|
||||
|
||||
h.Run();
|
||||
|
||||
// 10 + 3 == 13. A dropped lane swap reads the seeded Q instead: 10 + 1000.
|
||||
// Landing on the skipped pair instead would give -50 + 3.
|
||||
EXPECT_FLOAT_EQ(h.GetVfJit(vf::vf5, 'x'), 13.0f)
|
||||
<< "target block read the wrong Q buffer across the branch";
|
||||
EXPECT_FLOAT_EQ(std::bit_cast<float>(h.GetViJit(REG_Q)), 3.0f);
|
||||
}
|
||||
|
||||
TEST(VuPqBoundary, QInstanceSurvivesConditionalBranchTaken)
|
||||
{
|
||||
VuTestHarness h(0);
|
||||
h.SetQ(std::bit_cast<u32>(kStaleQ));
|
||||
h.SetVf(vf::vf1, 9.0f, 0.0f, 0.0f, 0.0f);
|
||||
h.SetVf(vf::vf2, 4.0f, 0.0f, 0.0f, 0.0f);
|
||||
h.SetVf(vf::vf6, 1.0f, 1.0f, 1.0f, 1.0f);
|
||||
h.SetVf(vf::vf7, -50.0f, -50.0f, -50.0f, -50.0f);
|
||||
h.SetVi(vi::vi1, 0); // IBEQ vi1, vi0 => taken
|
||||
|
||||
std::vector<VuOp> prog;
|
||||
prog.push_back(LowerOnly(VDIV_L(vf::vf1, 0, vf::vf2, 0)));
|
||||
PushNops(prog, kDivLatency);
|
||||
prog.push_back(LowerOnly(VIBEQ_L(vi::vi1, vi::vi0, +2)));
|
||||
prog.push_back(Nop());
|
||||
prog.push_back(UpperOnly(VADDq_U(mask::xyzw, vf::vf5, vf::vf7))); // not-taken side
|
||||
prog.push_back(UpperOnly(VADDq_U(mask::xyzw, vf::vf5, vf::vf6))); // taken target
|
||||
prog.push_back(EBitNopPair());
|
||||
h.LoadProgram(std::move(prog));
|
||||
|
||||
h.Run();
|
||||
|
||||
EXPECT_FLOAT_EQ(h.GetVfJit(vf::vf5, 'x'), 3.25f); // 1 + 9/4
|
||||
EXPECT_FLOAT_EQ(std::bit_cast<float>(h.GetViJit(REG_Q)), 2.25f);
|
||||
}
|
||||
|
||||
TEST(VuPqBoundary, QInstanceSurvivesConditionalBranchNotTaken)
|
||||
{
|
||||
// The not-taken side of a conditional branch is a separate emit path
|
||||
// (condBranch compiles the fall-through inline and patches the taken
|
||||
// target), so it needs its own case: the swap must have happened before
|
||||
// EITHER successor runs.
|
||||
VuTestHarness h(0);
|
||||
h.SetQ(std::bit_cast<u32>(kStaleQ));
|
||||
h.SetVf(vf::vf1, 9.0f, 0.0f, 0.0f, 0.0f);
|
||||
h.SetVf(vf::vf2, 4.0f, 0.0f, 0.0f, 0.0f);
|
||||
h.SetVf(vf::vf6, 1.0f, 1.0f, 1.0f, 1.0f);
|
||||
h.SetVf(vf::vf7, -50.0f, -50.0f, -50.0f, -50.0f);
|
||||
h.SetVi(vi::vi1, 1); // IBEQ vi1, vi0 => not taken
|
||||
|
||||
std::vector<VuOp> prog;
|
||||
prog.push_back(LowerOnly(VDIV_L(vf::vf1, 0, vf::vf2, 0)));
|
||||
PushNops(prog, kDivLatency);
|
||||
prog.push_back(LowerOnly(VIBEQ_L(vi::vi1, vi::vi0, +4)));
|
||||
prog.push_back(Nop());
|
||||
prog.push_back(UpperOnly(VADDq_U(mask::xyzw, vf::vf5, vf::vf6))); // not-taken side
|
||||
prog.push_back(EBitNopPair()); // ends the not-taken path
|
||||
prog.push_back(Nop()); // its E-bit delay slot
|
||||
prog.push_back(UpperOnly(VADDq_U(mask::xyzw, vf::vf5, vf::vf7))); // taken target
|
||||
prog.push_back(EBitNopPair());
|
||||
h.LoadProgram(std::move(prog));
|
||||
|
||||
h.Run();
|
||||
|
||||
EXPECT_FLOAT_EQ(h.GetVfJit(vf::vf5, 'x'), 3.25f);
|
||||
EXPECT_FLOAT_EQ(std::bit_cast<float>(h.GetViJit(REG_Q)), 2.25f);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// P instance across a branch — mVUsetupBranch's lane-2/3 swap. VU1 only:
|
||||
// VU0 has no EFU, so P never goes in flight there.
|
||||
// =========================================================================
|
||||
|
||||
TEST(VuPqBoundary, PInstanceSurvivesBranchOnVu1)
|
||||
{
|
||||
// ESADD sums the squares of xyz into P: 3² + 4² + 0² = 25.
|
||||
VuTestHarness h(1);
|
||||
h.SetP(std::bit_cast<u32>(kStaleP));
|
||||
h.SetVf(vf::vf1, 3.0f, 4.0f, 0.0f, 99.0f);
|
||||
h.SetVf(vf::vf7, -1.0f, -1.0f, -1.0f, -1.0f);
|
||||
|
||||
std::vector<VuOp> prog;
|
||||
prog.push_back(LowerOnly(VESADD_L(vf::vf1)));
|
||||
PushNops(prog, kEsaddLatency);
|
||||
prog.push_back(LowerOnly(VB_L(+2)));
|
||||
prog.push_back(Nop());
|
||||
prog.push_back(LowerOnly(VMOVE_L(mask::xyzw, vf::vf5, vf::vf7))); // skipped
|
||||
prog.push_back(LowerOnly(VMFP_L(mask::xyzw, vf::vf5))); // target: vf5 = P
|
||||
prog.push_back(EBitNopPair());
|
||||
h.LoadProgram(std::move(prog));
|
||||
|
||||
h.Run();
|
||||
|
||||
EXPECT_FLOAT_EQ(h.GetVfJit(vf::vf5, 'x'), 25.0f)
|
||||
<< "target block read the wrong P buffer across the branch";
|
||||
EXPECT_FLOAT_EQ(std::bit_cast<float>(h.GetViJit(REG_P)), 25.0f);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// End-of-program division-flag transfer
|
||||
//
|
||||
// A DIV raises its invalid/divide-by-zero bits into a side latch, and they
|
||||
// reach the architectural STATUS flag only when the instruction 7 cycles
|
||||
// downstream commits them. If the program ENDS inside that window there is
|
||||
// no such instruction, so mVUendProgram runs the transfer itself. Programs
|
||||
// that drain the pipe with VWAITQ first (every other Q test in the suite)
|
||||
// never reach it.
|
||||
//
|
||||
// STATUS is opted out of the cross-engine diff here, not because the answer
|
||||
// is unknown but because it is already settled the other way round: the
|
||||
// console captures show the sticky D/I bits accumulate, which microVU does
|
||||
// and the shared interpreter does not (see
|
||||
// vu_sticky_console_conformance_tests.cpp, kMicroDivergences). So the JIT is
|
||||
// the side worth asserting, and its cause bit is the observable that proves
|
||||
// the end-of-program transfer ran at all.
|
||||
// =========================================================================
|
||||
|
||||
TEST(VuPqBoundary, DivByZeroFlagReachesStatusWhenProgramEndsInsideLatency)
|
||||
{
|
||||
// 1.0 / 0.0 — divide-by-zero, STATUS bit 0x20 (D).
|
||||
VuTestHarness h(0);
|
||||
h.IgnoreViInDiff(REG_STATUS_FLAG);
|
||||
h.SetVf(vf::vf1, 1.0f, 0.0f, 0.0f, 0.0f);
|
||||
h.SetVf(vf::vf2, 0.0f, 0.0f, 0.0f, 0.0f);
|
||||
h.LoadProgram({
|
||||
LowerOnly(VDIV_L(vf::vf1, 0, vf::vf2, 0)),
|
||||
EBitNopPair(),
|
||||
});
|
||||
|
||||
h.Run();
|
||||
|
||||
EXPECT_NE(h.GetViJit(REG_STATUS_FLAG) & 0x20u, 0u)
|
||||
<< "mVUendProgram must fold the pending divide-by-zero flag into STATUS "
|
||||
"when the program ends before the FDIV flag latency elapses";
|
||||
// The quotient itself still has to be committed on the way out.
|
||||
EXPECT_EQ(h.GetViJit(REG_Q), 0x7F7FFFFFu);
|
||||
}
|
||||
|
||||
TEST(VuPqBoundary, DivInvalidFlagReachesStatusWhenProgramEndsInsideLatency)
|
||||
{
|
||||
// 0.0 / 0.0 — invalid operation, STATUS bit 0x10 (I). Distinct latch bit
|
||||
// from the case above, so it catches a transfer that hard-codes one of them.
|
||||
VuTestHarness h(0);
|
||||
h.IgnoreViInDiff(REG_STATUS_FLAG);
|
||||
h.SetVf(vf::vf1, 0.0f, 0.0f, 0.0f, 0.0f);
|
||||
h.SetVf(vf::vf2, 0.0f, 0.0f, 0.0f, 0.0f);
|
||||
h.LoadProgram({
|
||||
LowerOnly(VDIV_L(vf::vf1, 0, vf::vf2, 0)),
|
||||
EBitNopPair(),
|
||||
});
|
||||
|
||||
h.Run();
|
||||
|
||||
EXPECT_NE(h.GetViJit(REG_STATUS_FLAG) & 0x10u, 0u)
|
||||
<< "mVUendProgram must fold the pending invalid-operation flag into "
|
||||
"STATUS when the program ends before the FDIV flag latency elapses";
|
||||
EXPECT_EQ(h.GetViJit(REG_Q), 0x7F7FFFFFu);
|
||||
}
|
||||
|
||||
} // namespace recompiler_tests
|
||||
Reference in New Issue
Block a user