Skip to content

fix(agc): lower hull exec masks restored through phis to own lane bits - #4172

Merged
oneandonlydean merged 1 commit into
boykopovar:mainfrom
Celegans12:fix/agc-tessellation-hull-lane-masks
Oct 14, 2026
Merged

oneandonlydean merged 1 commit into
boykopovar:mainfrom
Celegans12:fix/agc-tessellation-hull-lane-masks

Conversation

@Celegans12

@Celegans12 Celegans12 commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

What

In a hull, each host invocation is one guest lane, so a lane mask can only be kept if every use reads the invocation's own bit. Tessellation lowering recognised that for a ballot-derived saved exec, but a wave64 hull that narrows EXEC with s_and_saveexec_b64 and restores it through phis (also inside a scalar loop) was refused as "uses a lane mask other than its own lane's bit".

  • Mask words are rebuilt as the invocation's own bit from both words together: a ballot becomes its predicate, and/or/xor/not their logical forms, SelectU32 and phis boolean selects and phis (loops included), all-zero or all-one words constants. Both halves must have the same shape and ballot the same predicate; wave32 uses the low word only.
  • Hull values that only feed each other, such as an unused saved-exec loop, are removed before the remaining-ballot check. A use that reads other lanes' bits (popcount, an SCC test of a mask) keeps its ballot and is still refused.
  • The words are replaced through identities and block conditions are resolved afterwards, as the front end does. A hull branching on a compare copied to VCC (s_mov_b64 vcc, s_cbranch_vccnz) otherwise kept its block condition on the replaced word and failed SPIR-V emission. Fix and test by @EagleBearerYS.
  • Only hull programs are rewritten; local and domain programs keep their exec words for the half-wave reduction matcher (fix(shader): prove half-wave reduction keys through selected and merged lane bits #2449-fix(shader): read lane 63 of a wave64 v_max_f32/v_min_f32 half-wave scan #2451).

PPSA26344's first terrain hull (0x80002fc700) restores its exec this way; with this change its replayed request gets past the lane-mask refusal.

#5457 builds on this stack and edits the same lowering and test file.

Tested

Based on main f265cd5. Windows 11 (MinGW-w64 GCC 15.2.0) and Linux: configure -DBUILD_TESTING=ON, targeted test builds, ctest:

In agc_driver_tessellation, HullAndSaveexec is now a rendered check, HullSaveexecLoop (exec restored through loop phis, with a dead mask accumulator) is new and rendered, HullLaneCount (s_bcnt1_i32_b64 exec as data) is still refused, and RequireCompareBranches compiles a hull and a domain that branch on a compare copied to VCC. agc_shader_memory checks that domain wave reductions under s_orn2_saveexec_b64 still reduce on 32 and 64 lanes. They fail on main with the lane-mask refusal; without the identity replacement the compare-branch hull fails with "branch condition ... was not emitted before the branch".

Not tested: console behaviour.

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw (NotImplemented_nid_no_patch); silent stubs are listed under Silent stubs in their library's technical debt file
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: none
  • Unblocks: PPSA26344 Ghost of Yōtei (PPSA26344) #675
  • GPUs tested: NVIDIA GeForce RTX 3080 Laptop GPU, driver 610.62, Windows 11; llvmpipe (Mesa 25.2.8), WSL Ubuntu 24.04
  • AI-assisted: yes

@Celegans12

Copy link
Copy Markdown
Contributor Author

I force-pushed this (and restacked #4201 and #4222 on it) after a Yōtei run found a problem the first version caused. The own-lane-bit rewrite also ran in domain and local programs, and there it turned s_orn2_saveexec_b64 vcc, exec into plain booleans before the half-wave reduction matcher could see the exec words. The terrain domain program's two wave reductions then failed with "v_readlane_b32 of lane 63 is outside the 32-lane host subgroup".

The rewrite now runs in hull programs only, which are the only ones that need it. The terrain hull's SPIR-V is byte-identical, and agc_shader_memory gained three domain checks (UMin on 32 lanes, OR on 64, divergent keys) that fail on the previous head with the game's message.

Checks after the change

@blawesom

Copy link
Copy Markdown
Contributor

Rebase handoff — rebased on top of the rebased chain (rebase/4114 → rebase/4163 → this), all on current main. Authorship unchanged.

One test-file conflict with main's newer ShaderMemory.cpp tests: both sides added independent tests at the same spot — kept main's v_movreld_b32 window tests and this PR's domain-stage reduction test together.

Verified on Linux (CachyOS, kernel 7.2.9, GCC 16.2.1, Ninja, Release, BUILD_TESTING=ON, AMD RX 9070 XT / RADV):

  • builds; tools/check_conventions.py --base origin/main passes
  • full suite: 949/949 passed

To pick it up (pull the chain in order):

git fetch https://github.com/blawesom/AnyPS5 rebase/4114 rebase/4163 rebase/4172

Heads: rebase/4114 = d0ea5f4d1, rebase/4163 = 652f2763d, rebase/4172 = 65ad9582b. #4201 on top of this is next.

@EagleBearerYS

Copy link
Copy Markdown
Contributor

lowerOwnLaneBits replaces lane-bit words with ReplaceAllUsesWith(bit), but BlockInfo::condition is a plain pointer, not a use. Take a hull that branches on a compare copied to VCC (v_cmp_* s[n:n+1], s_mov_b64 vcc, s[n:n+1], s_cbranch_vccnz):

  • its block condition stays on the replaced value;
  • after dead-code elimination, SPIR-V emission fails with branch condition Unreachable of block 0 (defined in no block) was not emitted before the branch.

Resident Evil Village (PPSA01556) hit this in a domain program through the old SavedLaneBit loop, which has the same pattern. With your chain that program no longer goes through it, but a hull test with such a branch still fails on #4222's head (3a72fa3). The front end avoids this by replacing through an identity. Doing the same here fixes it:

Fix and test, on #4222's head
diff --git a/core/libs/prx/libSceAgcDriver/tests/execution/Tessellation.cpp b/core/libs/prx/libSceAgcDriver/tests/execution/Tessellation.cpp
index 79c98df98..461063765 100644
--- a/core/libs/prx/libSceAgcDriver/tests/execution/Tessellation.cpp
+++ b/core/libs/prx/libSceAgcDriver/tests/execution/Tessellation.cpp
@@ -51,6 +51,16 @@ alignas(256) constexpr std::array<std::uint32_t, 49> HullCode{
     0xbf810000,
 };
 
+alignas(256) constexpr std::array<std::uint32_t, 54> HullCompareBranch{
+    0xd4040016, 0x00020080, 0xbeea0416, 0xbf870001, 0xbf800000, 0xd7650000, 0x000100c1, 0xd7660000,
+    0x000200c1, 0xbf8a0000, 0x7da206f9, 0x81060000, 0xd5480014, 0x02151101, 0x362402ff, 0x000000ff,
+    0x160028a0, 0xd5430015, 0x040224ff, 0x00000060, 0xdbfc0000, 0x02000015, 0x162c2890, 0xd5430016,
+    0x045a24ff, 0x00000030, 0xbf8cc07f, 0xe0781000, 0x02020216, 0x7da82881, 0xbe94047e, 0xb06a0060,
+    0x162602f9, 0x0086066a, 0xd8d80008, 0x06000013, 0xd8dc1008, 0x07000013, 0xbf8cc07f, 0x7e300306,
+    0x7e320306, 0x7e340306, 0x7e360306, 0x34142484, 0xbefe0414, 0xe0781000, 0x0403180a, 0x4a162481,
+    0x16161690, 0x4c1616ff, 0x00008000, 0xe0741000, 0x0202070b, 0xbf810000,
+};
+
 alignas(256) constexpr std::array<std::uint32_t, 51> HullRestoredExecSaved{
     0xd7650000, 0x000100c1, 0xd7660000, 0x000200c1, 0xbf8a0000, 0x7da206f9, 0x81060000, 0xd5480014,
     0x02151101, 0x362402ff, 0x000000ff, 0x160028a0, 0xd5430015, 0x040224ff, 0x00000060, 0xdbfc0000,
@@ -76,6 +86,14 @@ alignas(256) constexpr std::array<std::uint32_t, 34> DomainRelativeData{
     0x14151214, 0xbf810000,
 };
 
+alignas(256) constexpr std::array<std::uint32_t, 35> DomainCompareBranch{
+    0xf8000941, 0x00000000, 0xd4040014, 0x00020a80, 0xbeea0414, 0xbf870001, 0xbf800000, 0x34020e84,
+    0x16020283, 0xe0341000, 0x04030a01, 0xe0341010, 0x04030c01, 0xe0341020, 0x04030e01, 0xbf8c3f70,
+    0x08040af2, 0x08040d02, 0x10201505, 0x3e201906, 0x3e201d02, 0x10221705, 0x3e221b06, 0x3e221f02,
+    0xd5510012, 0x040a0d05, 0x102424ff, 0x40400000, 0x7e260280, 0x7e2802f2, 0xf80008cf, 0x14131110,
+    0xf800020f, 0x14131214, 0xbf810000,
+};
+
 alignas(256) constexpr std::array<std::uint32_t, 29> LocalIndexAsData{
     0xd5480004, 0x02090502, 0xd5480005, 0x02090102, 0xd5480006, 0x02110902, 0xd5480007, 0x02111102,
     0x7e080d04, 0x7e0a0d05, 0x7e0c0d06, 0x7e0e0d07, 0x10100cff, 0x3f19999a, 0x061010ff, 0xbf666666,
@@ -357,6 +375,12 @@ void ExpectRefusal(const ShaderRecompiler::SpirvTarget& target, ShaderStage stag
     Require(false, "a tessellation program was accepted although " + reason);
 }
 
+void RequireCompareBranches(const ShaderRecompiler::SpirvTarget& target) {
+    std::uint32_t cursor = 0;
+    Require(!Compile(target, ShaderStage::TessellationEvaluation, DomainCompareBranch, cursor).spirv.empty(), "a domain program branching on a compare copied to VCC was not compiled");
+    Require(!Compile(target, ShaderStage::TessellationControl, HullCompareBranch, cursor).spirv.empty(), "a hull program branching on a compare copied to VCC was not compiled");
+}
+
 std::array<std::uint8_t, 4> PixelAt(std::uint32_t x, std::uint32_t y) {
     const auto offset = (static_cast<std::size_t>(y) * Width + x) * 4u;
     return {std::to_integer<std::uint8_t>(Pixels[offset]), std::to_integer<std::uint8_t>(Pixels[offset + 1]), std::to_integer<std::uint8_t>(Pixels[offset + 2]), std::to_integer<std::uint8_t>(Pixels[offset + 3])};
@@ -700,6 +724,7 @@ int main() {
         RequirePatchConstants(*device, target);
         RequireRelativePatches(*device, target);
         RequireHullBdaReads(*device, target);
+        RequireCompareBranches(target);
         device.reset();
         RequireRegisteredStages();
         std::puts("Tessellation tests passed");
diff --git a/core/shader/recompiler/Optimization/src/TessellationLowering.cpp b/core/shader/recompiler/Optimization/src/TessellationLowering.cpp
index 6028333e9..8d9e91c42 100644
--- a/core/shader/recompiler/Optimization/src/TessellationLowering.cpp
+++ b/core/shader/recompiler/Optimization/src/TessellationLowering.cpp
@@ -588,9 +588,12 @@ private:
             for (IrValue* inst : block->Instructions()) {
                 const auto words = LaneBitWords(inst, program.WaveSize());
                 if (!words) continue;
-                if (IrValue* bit = laneBits.Of(*words)) inst->ReplaceAllUsesWith(bit);
+                if (IrValue* bit = laneBits.Of(*words)) inst->ReplaceUsesWith(bit, true);
             }
         }
+        ResolveControlFlowIdentities(program);
+        constexpr DeadCodeEliminator identities;
+        identities.RemoveIdentities(program);
     }
 
     void collect() {

Village also needs two tessellation changes your chain doesn't have:

  • integer partitioning: equal spacing, with the factor cap at 64;
  • hulls with one input or one output control point: the LS stride is taken from the patch term, and a control-point write is recognised when it fits in one record. That's the rule your chain uses for domain reads.

All three are rebased on #4222 with tests, red/green on Windows, and passing on Linux. With them, 19 of Village's 21 dumped tessellation programs compile on your chain. The other 2 only miss #4114's patch bound, because the dumped requests carry no NUM_PATCHES; at 128 or 170 patches all 21 compile.

How would you like them? I could open them as PRs that depend on #4222, you could take the lowerOwnLaneBits fix into #4172, or they could go to main first. They conflict with your chain in TessellationLowering.cpp, ShaderInputInfoBuilder.cpp and the tests, so I'd rather follow your order.

@adriadam10 adriadam10 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, the approach looks right. One blocker:

  • lowerOwnLaneBits (TessellationLowering.cpp:584) uses ReplaceAllUsesWith(bit), but BlockInfo::condition (set in ExecMask.cpp:206) is a plain pointer that the replacement doesn't touch. A hull that branches on a compare copied to VCC (v_cmp_* s[n:n+1], s_mov_b64 vcc, s[n:n+1], s_cbranch_vccnz) keeps its block condition on the replaced value, so after dead-code elimination SPIR-V emission fails with branch condition ... was not emitted before the branch. @EagleBearerYS reported this above with a hull test and a fix that replaces through an identity, as the front end does. Please take that fix and its HullCompareBranch test (adjusted to this head), and check that the test fails without the change.

Everything else I read looks consistent with the description: the half-wave matcher is untouched for local/domain programs and HullLaneCount is still refused. agc_shader_memory, agc_driver_graphics and agc_tessellation_factor_ring pass here; agc_driver_tessellation passes on CI's lavapipe.

@Celegans12

Copy link
Copy Markdown
Contributor Author

@adriadam10 @EagleBearerYS I've folded the block-condition fix into this PR's commit. lowerOwnLaneBits now replaces the mask words through identities and resolves the block conditions afterwards, the same way the front end does, so a hull that branches on a compare copied to VCC keeps its condition through dead-code elimination. Thanks, @EagleBearerYS, for tracking this down and sending the fix with a ready test; you're credited in the commit message.

The HullCompareBranch test is in as you wrote it, along with its DomainCompareBranch half, moved onto this head as RequireCompareBranches. With the old ReplaceAllUsesWith it fails with the same "branch condition ... was not emitted before the branch" error on the hull (the domain half compiles either way), and with the fix it passes together with the other tessellation and shader tests on Windows and on Linux.

I also rebased onto current main and restacked #4201 and #4222 on the new head. While doing that I moved #4201's request version to 24, because main now uses 23 for the pixel layer export flag. #5457 is stacked on the old heads, so it'll need a rebase onto these. On your question about the other two fixes, keeping #5457 as its own PR on top of #4222 seems right to me, since they're separate cases from what this chain handles. I'm happy to run it against Yōtei's hulls once it's rebased. Could you take another look when you have a moment?

Details

New heads: #4172 8cbf91d5d2, #4201 f823bf932f, #4222 f33872bec3 (based on main ee9037d; all three still merge cleanly with current main).

Without the fix (this head with only the lowerOwnLaneBits change reverted):

agc_driver_tessellation ... Failed
ShaderRecompiler::Recompile: SPIR-V emission failed: hash=0x140695163831296 stage=7 reason=branch condition Unreachable of block 0 (defined in no block) was not emitted before the branch
0% tests passed, 1 tests failed out of 1

With the fix, Windows 11 on an RTX 3080 Laptop GPU (driver 610.62):

Linux (upstream's job in WSL, GCC 13.3, llvmpipe), #4222's head merged with main: the same 5 tests pass (5/5).

@EagleBearerYS

Copy link
Copy Markdown
Contributor

Thanks for folding the block-condition fix and its test into this commit, and for the credit.

I've rebased #5457 onto #4222's new head (f33872b). Its two commits are unchanged apart from one test-array conflict, and its head is now 5589f92. On my fork's full Build, with that branch merged into main, agc_driver_tessellation passes on Linux (lavapipe) with both RequireCompareBranches and #5457's RequireSingleControlPointHulls in it, and the rest of the suite passes on Linux and Windows (run).

Yes please on Yōtei's hulls, and thanks for offering.

A wave64 hull program narrows EXEC with s_and_saveexec_b64 and restores it
later, so its exec words reach the lane-bit test as ANDs with the host
ballot, as loop or join phis, and through separate ballots for the two
halves. Tessellation lowering refused them as "uses a lane mask other
than its own lane's bit".

The own-lane bit of a mask pair is now rebuilt from the words' operations
(ballots, bitwise ops, selects, phis, constants); both halves must have
the same shape and ballot the same predicate. Dead hull cycles such as a
saved-exec loop are removed before the remaining-ballot check, so a mask
whose other lanes are read (s_bcnt1_i32_b64 exec) is still refused. The
words are replaced through identities and block conditions are resolved,
so a branch on a compare copied to VCC keeps its condition. Local and
domain programs keep their exec words for the half-wave matcher.

Block conditions fix and HullCompareBranch test from EagleBearerYS.
@Celegans12
Celegans12 force-pushed the fix/agc-tessellation-hull-lane-masks branch from 8cbf91d to 866ed44 Compare October 14, 2026 03:06
@oneandonlydean oneandonlydean self-assigned this Oct 14, 2026
@oneandonlydean
oneandonlydean dismissed adriadam10’s stale review October 14, 2026 06:15

Met on 866ed44: every point of this review is addressed; full build green on this head and Sarah passes.

@oneandonlydean
oneandonlydean merged commit 58d98de into boykopovar:main Oct 14, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agc libSceAgc* docs shader core/shader

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants