Repository navigation
Structurizer: shared merge splitting never terminates for nested selections in a two-latch loop #2642
Description
Summary
Structurizer::Structurize fails with CFG shared merge splitting exceeded budget on a control-flow shape with a loop that has two latches and nested selections sharing a join with the loop's back edge. The same error appears in #2523 (stop 9), but #2471 does not fix this shape: it fails the same way with #2471 applied.
Minimal reproduction (graph shape only, no shader code)
Using the makeGraph helper from core/libs/prx/libSceAgcDriver/tests/Structurizer.cpp:
auto graph = makeGraph({{1}, {2, 4}, {3, 4}, {4, 5}, {1, 6}, {1}, {}});
Structurizer{}.Structurize(graph);
// throws: CFG shared merge splitting exceeded budget: originalBlocks=9 currentBlocks=45 splitBudget=36Block 1 is a loop header with two latches (4 → 1 and 5 → 1). 1 → {2, 4}, 2 → {3, 4} and 3 → {4, 5} all reach block 4, and 4 → 6 is the only exit. I reduced this automatically from a 159-block compute shader of a retail Unreal Engine title. The reducer kept a bypass or an edge removal only while the same error still occurred. The original fails identically (originalBlocks=161 currentBlocks=805 splitBudget=644). Reproduced on main 1138fa96, with and without #2471.
What happens
Tracing splitSharedMergeBlocks (block ids after canonicalization):
header=3 merge=6 merge.preds=2(outside construct) 3(in) 5(in) -> split
header=3 merge=7 merge.preds=2(outside construct) 6(in) -> split
header=3 merge=8 merge.preds=2(outside construct) 7(in) -> split
... until the budget is exhausted
findSelectionMerge keeps returning the same shared merge for header 3. It is renumbered by one each round by moveBlockBefore, and it has a predecessor (2) outside header 3's construct. splitSharedMergeBlock moves the in-construct predecessors to a new synthetic block, but cannot remove the external one. The next round chooses the same merge again with the new synthetic block as its only in-construct predecessor. Every iteration adds one block and changes nothing else.
A local experiment: when the only in-construct predecessor is already an empty unconditional branch into the merge, return false from splitSharedMergeBlock. That stops the loop, but then Structurize fails with duplicate structured merge block 7 for header 3 (already used by header 2). So the loop is a symptom. The underlying problem is the merge choice for the nested selection, which ends up sharing its merge with the enclosing selection (header 2) inside the multi-latch loop. I did not pursue a fix further because it changes how merges are chosen, which is a design decision for the structurizer.
Notes
- Windows 11, MinGW-w64 GCC 15.2.0. The repro needs only
Structurizer.hppandmakeGraph, with no Vulkan or shader code. - The title reaches this after perf(recompiler): compute post-dominators visiting successors first #2105, perf(agc): drop the redundant structurize pre-check at registration #2327 and fix(recompiler): hold 64 images in a sampled image heap #2347. It prepares this shader at first use (after fix(agc): defer shaders that cannot be prepared at registration to their first use #1836) and aborts there.
Investigated and written by Claude (Claude Code) on a contributor's machine.
Activity
ArjenS-LA commentedon Oct 10, 2026
Independently reproduced the seven-block graph in a standalone executable, without Vulkan or game data.
Windows, MinGW-w64 GCC 15.2.0,
-std=c++20 -O2 -DNDEBUG:a29ef592: fails.Structurizer.cppfrom fix(shader): guard shared loop exit routing and preserve valid fallbacks #2471 head470f51ce: fails identically.Both report:
CFG shared merge splitting exceeded budget: originalBlocks=9 currentBlocks=45 splitBudget=36This confirms the reduced case remains unresolved by #2471’s latest change. It would be useful to retain it as a separate regression, checking preservation of both back edges and the exit through block 4 alongside successful structurization.
I also have an RTX 3050 Ti Laptop GPU available (NVIDIA 591.55, Vulkan 1.4) for testing a future fix with a bounded synthetic shader and expected outputs. No GPU execution is claimed here—the current reproducer fails before that stage.
AI-assisted: Codex performed the standalone builds and reproductions on my machine. No retail-title testing was performed.
AlxcBTW commentedon Oct 10, 2026
Fix: AlxcBTW/AnyPS5#20 (
d59a9167, onmainb5f5362). Full CI passed. I can't open it as a PR while intake is limited, so it's here if a collaborator wants to cherry-pick it.Your trace was right that the split loop is a symptom. The two latches themselves aren't the cause. The cause is the arm of the nested selection that continues the loop, and the same shape with one latch fails on
maintoo:canonicalizeNaturalLoopsboth latches go to one continue block. Block 4 becomes the loop's break/continue gateway, and both selections, 1 and 2, pick it as their merge.splitSharedMergeBlockgives selection 2 its own forwarding block S2.findSelectionMergethen recomputes the global post-dominator of {3, S2}, which is still 4. That is because 3 → 5 → continue → header → 1 → 4 bypasses S2 through the back edge.When the global merge is inside the loop construct, the change takes the arms' nearest common post-dominator over only the paths of the iteration that reach it. Paths that leave through the continue block or the loop's merge don't count. For your graph, selection 2 merges at its own block and selection 1 at 4. When no arm leaves early, the result is the same block as before. A fuzz over about 10,700 random graphs gives the same result as
mainfor every graphmainstructurizes. The change and #2471 cover different cases (global merge inside vs. outside the loop) and merge cleanly together.Tests:
agc_structurizer: your graph, plus three latches and an if-else with a continuing arm.agc_shader_control_flow: an RDNA program of the same shape.agc_driverGPU tests pass on Windows (RTX 5070 Laptop).@mrk1987, could you run the original 161-block compute shader through it? I only have the reduced graph. Budget failures that remain on
mainalso remain with this change: break/continue arms whose global merge is outside the loop, and nested loops that share a latch. Those need their own fixes.AI-assisted: yes (Claude Code).
SleglJan commentedon Oct 10, 2026
A data point from 007 First Light (PPSA11386 v01.018.000). The shader 007 stops on with this message has
originalBlocks=125where yours has 161:originalBlocks=125 currentBlocks=625 splitBudget=500(#2523, stop 9). I replayed its captured recompile request offline withagc_shader_replayonmaincf8b129. Every row includes SleglJan#21, which the shader also needs before it reaches the structurizer. Linux (CachyOS, GCC 16.2.1), Release withANYPS5_ENABLE_SPIRV_TOOLS=ON.main+ #21CFG shared merge splitting exceeded budget: originalBlocks=125 currentBlocks=625 splitBudget=500main+ #21 + AlxcBTW#20 (d59a916)main+ #21 + #2471 (470f51c)main+ #21 + #2471 + AlxcBTW#20GetImageResource dword 0 is not a valid runtime value(007 First Light (PPSA11386) on Linux: start-up stops in order and the PRs that pass them #2523, stop 10), with or without test(agc): expect the signature rejection for a corrupted request #20.AI-assisted: yes (Claude Code)
AlxcBTW commentedon Oct 10, 2026
@SleglJan thanks, that settles which change covers 007. Its stop 9 shader has the shape #2471 fixes: the global merge is outside the loop construct. AlxcBTW#20 covers the other case: a nested selection whose global merge is inside the loop and one arm continues it. That is the case in the reduced graph above. So the two complement each other, and 007 doesn't need #20.
@mrk1987 the shader your graph was reduced from (
originalBlocks=161) would show whether your title is the inside-the-loop case. If you can replay it with #20, that would settle it.AI-assisted: yes (Claude Code).
husseinkad commentedon Oct 10, 2026
Independent differential check of @AlxcBTW's
d59a9167(AlxcBTW#20), Windows 11 Pro 10.0.26200, WinLibs GCC 15.2.0 r7. The fuzz compiles the structurizer sources ofmain(b5f5362d) and of the fix into separate drivers and runs the same random reducible graphs (5 to 9 blocks, 600,000 seeds) through each, checking every result with the repo'srequireStructuredBranchesandrequireExactPostDominatorsplus a check that merges are dominated by their header and constructs are closed.main: no regression, 0 of 600,000. The output is identical tomain's on 537,352 graphs and differs on 5, all valid both ways (for example{{1},{4,2},{5,3},{1,5},{6,5},{6},{1,7},{}}).mainthrows the budget error: the fix structurizes 640 of them, all with valid output, and produces no invalid structure. No hangs or crashes.privatizeSharedReturns, only reached when some block hasestimatedSpirvWordsabove 256, so real shaders reach it and the test graphs with 0 words do not): 600,000 graphs with one random block set to 1000 words,mainsucceeds on 536,985 of them and the fix never throws or fails the helpers on any of those; it fixes 664 more.{{1},{2,5},{3,5},{4,5},{5,6},{1,7},{1},{}}:mainthrows, the fix succeeds.agc_structurizerandagc_shader_control_flowbuild and pass ond59a9167; with onlyStructurizer.cppreverted tomainboth fail withCFG shared merge splitting exceeded budget.main, with this fix and presumably needing their own change (they have a different shape: loop exits rather than the nested selection with a continuing arm):{{1,3},{2,4},{3,1},{4},{}},{{1,2},{3,2},{4},{4,1},{}},{{1,2},{4},{3,1},{4,2},{}}.For comparison, I had a competing branch for this issue and am withdrawing it: it fixed fewer graphs (257 of the 640) and the same estimated-words fuzz found two graphs that
mainstructurizes through the retry path but my branch throws on ({{1},{2,6},{4,3},{4,5},{6},{},{1,7},{8,1},{}}withblocks[7].estimatedSpirvWords = 1000, and one more).d59a9167is the one to carry.AI-assisted: yes.
AlxcBTW commentedon Oct 10, 2026
@husseinkad thank you, a 600,000-graph differential check is a much stronger test than mine, and withdrawing your branch for it is generous. The three shapes that still fail look like loop exits rather than this issue's case, and I'll look at them separately.
AI-assisted: yes (Claude Code).
orishamir commentedon Oct 10, 2026
Measured AlxcBTW#20 (d59a916) against
maina29ef59 with a random structured-program fuzzer (30,000 graphs throughStructurize, oracle =requireStructuredBranchesfromtests/Structurizer.cpp) and throughRecompilewith SPIRV-Tools validation. Harness and commands: https://github.com/orishamir/anyps5-cfgfuzz.mainand fix(shader): guard shared loop exit routing and preserve valid fallbacks #2471 fail on the same words withCFG shared merge splitting exceeded budget: originalBlocks=11 currentBlocks=55 splitBudget=44.main, test(agc): expect the signature rejection for a corrupted request #20, fix(shader): guard shared loop exit routing and preserve valid fallbacks #2471 and both, with 0 or 16 estimated words per block and as real shaders:These are the "arms whose global merge is outside the loop" class named above.
AI-assisted: yes (Claude Code).
AlxcBTW commentedon Oct 10, 2026
Follow-up for the five shapes in this thread that still end in the split budget: my two from fuzzing,
{{1},{2},{3,4},{2,5},{},{1}}and{{1},{2,3},{4,5},{},{3,6},{1},{2}}, and @husseinkad's three. The fix is AlxcBTW#24 (9fa7d06a, on top of #20'sd59a9167). Full fork CI is green.Cause. In each shape, an exit path leaves the loop construct before it reaches the loop's merge, the nearest common post-dominator of its exits. It either continues the enclosing loop or enters a block that is also reached from before the loop.
splitOneLoopMergeputs a forwarding block in front of the merge each round. The other path bypasses it, so the post-dominator never moves until the budget runs out.Fix. Such exits are routed through the existing
routeLoopExitsjunction, the same form already used for loops without a common post-dominator. This runs as a thirdstructurizepass that starts only when both existing passes throw. So every graphmainstructurizes today comes out byte-identical.Differential fuzz,
mainagainst the branch. I used a Windows port of @orishamir's structured-program generator plus random graphs. Each graph runs at 0 words and with one block at 1,000 words. The oracle checks exact post-dominators,requireStructuredBranches, header dominance and the original's execution paths, and runsspirv-val(SDK v2026.4) on a module built from the result.mainwhere it structurizesmainitself (530 random, 92 programs) and are unchanged.git merge-treewith fix(shader): guard shared loop exit routing and preserve valid fallbacks #2471 is clean, and the five shapes still structure with both applied.One caveat about #20 itself, found with
spirv-val. At 1,000 words,d59a9167turns 1 of 200,000 budget failures into an invalid output: a back edge to a non-header,1;2,3;4,5;3,1;4,6;3,7;5,3;with block 3 heavy. #24 inherits it. @husseinkad's 600k check had nospirv-val, so it couldn't see this.AI-assisted: yes
husseinkad commentedon Oct 10, 2026
Thank you @AlxcBTW for taking the three loop-exit shapes (and for being so quick on the earlier one). I'm wrapping up for today and not starting new work, so I haven't re-run my fuzz against #24 (
9fa7d06a). If it is useful when you or a maintainer want an independent regression check, my earlier method was: compileStructurizer.cppfrommainand from the fix into separate drivers, run the same 600,000 random reducible graphs (5 to 9 blocks) through both, and check every result withrequireStructuredBranches,requireExactPostDominatorsand a merge-dominated-by-header and closed-construct check, once with every block at 0estimatedSpirvWordsand once with one random block at 1000 so theprivatizeSharedReturnsretry path is exercised; the retry-path run is the one that found a regression in my own attempt.AI-assisted: yes.
orishamir commentedon Oct 10, 2026
Independent check of AlxcBTW#24 (
9fa7d06a), the same way as #20 above:Structurizer.cppandStructurizer.hppfrom the branch againstmaina29ef59 (ControlFlow unchanged since b5f5362), 30,000 random structured programs at 16 and 1,000 words per block and with one random 1,000-word block, plus 903 of them as wave64 compute shaders throughRecompilewith SPIRV-Tools validation. Harness: https://github.com/orishamir/anyps5-cfgfuzz.#24 alone, against
mainmainstructurizesrequireStructuredBranchesfailuresAgainst #20 alone it is the same picture (about 1,600 more fixed, 0 regressions). End to end on the 903-shader sample: 24 shaders newly valid, none lost, no new invalid SPIR-V. All five shapes and this issue's graph structurize.
One thing to look at:
verifyStructuredintests/ControlFlow.cpp. Five of the 24 newly structurized sampled shaders validate in SPIRV-Tools but fail that check withblock X of the selection at block Y branches to block Z outside the construct. @husseinkad's three shapes do the same as real programs; the two shapes you added asControlFlow.cppprograms pass. For{{1, 3}, {2, 4}, {3, 1}, {4}, {}},GraphBuilderbuilds{{1, 2}, {6}, {3, 4}, {6}, {1, 5}, {2}, {}}and #24's output is:spirv-valaccepts the exit to the enclosing selection's merge; the project's check does not, so these three could not be added asControlFlow.cppprograms as they are. Whether the check is stricter than needed or the exit should go through block 6 is your call;requireStructuredBranchesandverifySameExecutionspass on all five.#24 merged with #2471 (clean merge): 0 regressions against #2471, 1,846 to 2,104 more budget failures structurized, but 38 graphs at 1,000 words and 8 with one large block now pass the structurizer and fail
requireStructuredBranches, which #2471 alone rejected with the budget error; in the sample 2 of those produce invalid SPIR-V (exits the selection headed by X, but not via a structured exit). That is #2471's junction class from #2762, reached through #24's third pass, not a #24 defect on its own.I could not reproduce the #20 caveat shape with my layout (block 3 heavy: #20 and #24 refuse it with the clone-cost message rather than producing invalid output), so no data on that one.
AI-assisted: yes (Claude Code).
AlxcBTW commentedon Oct 10, 2026
@orishamir thanks for the check. I could reproduce the merge result, but the
requireStructuredBranchesfailures come from a different place than the third pass.#24 merged with #2471. I ran 30,000 programs from the structured-program generator at 16 and at 1,000 words per block, and with one block at 1,000. The merge structurizes some graphs that #2471 alone rejects, but with invalid output. They fall into two groups:
d59a9167+ #2471)requireStructuredBranchesand fail only inspirv-val. A block of a nested selection branches to the enclosing selection's merge (exits the selection headed by X, but not via a structured exit). Your two invalid SPIR-V shaders show that message.requireStructuredBranchesfailures come from the first two passes.d59a9167merged with fix(shader): guard shared loop exit routing and preserve valid fallbacks #2471, without fix: support SDK startup, linear framebuffers and indexed draws #24's commits, gives the same output on each of them. Most are route-variable conditionals left without a merge whose arms are neither the loop's merge nor its continue block, which is the rule Structurizer accepts a loop whose selection shares a tail block with a nested selection; the SPIR-V then fails validation #2762 is about. So I think your 38 and 8 belong to this group.0b56b7adon #24 now checks the third pass's result against the SPIR-V structured control-flow rules (2.11), with dominance taken over branch, merge and continue edges. If the result fails, the second pass's error is reported. Merged with #2471, the third-pass group ends in the same error as with #2471 alone, and nothing else changes. On #24 alone the output equals9fa7d06aon every graph: 100,000 random graphs and the 30,000 programs at each setting. The second group stays until #2762 is fixed. Non-GPU tests and the 253agc_drivertests (RTX 5070 Laptop) pass.verifyStructured. It is stricter than SPIR-V. SPIR-V does not let a nested selection exit to the enclosing selection's merge: a selection may only be left to its own merge or to the innermost loop's merge or continue target (2.11.3). The difference is which blocks belong to the construct. SPIR-V counts the blocks the header structurally dominates (2.11.2), and structural dominance treats merge and continue declarations as edges (2.2.5).constructBlocksusesgraph.Dominatesinstead. In your output, loop header 3 declares 7 as its merge, so selection 4 does not structurally dominate blocks 7 to 11. The branch 11 → 12 is therefore selection 0 leaving to its own merge.main's own output fails the check the same way. For example,{{1, 5}, {2, 3}, {5}, {5, 4}, {1}, {}}on a29ef59 gives:verifyStructuredreportsblock 6 of the selection at block 2 branches to block 7 outside the construct.requireStructuredBranchespasses, andspirv-valv2026.4 accepts the same CFG, while it rejects a direct exit from an inner selection to the outer merge. The helper fix needs structural dominators in the test, so I left it out of #24.AI-assisted: yes
orishamir commentedon Oct 10, 2026
Measured
0b56b7adthe same way as9fa7d06aabove (Structurizer.cpp/.hppagainstmain57f96fd, ControlFlow unchanged):9fa7d06aon all 30,000 programs at 16, 1,000 and 16 words with one 1,000-word block, class and detail, and identical classes on the 903-shader sample. The 17 sample outputs thatverifyStructuredrejects are the same 17 as before, all "branches to block X outside the construct"; 12 of them aremain's own output, and your{{1, 5}, {2, 3}, {5}, {5, 4}, {1}, {}}reproduces here (spirv-val accepts, the helper does not). So I agree the helper is stricter than 2.11, and those are not findings against fix: support SDK startup, linear framebuffers and indexed draws #24.requireStructuredBranchesfailures are exactly the graphsd59a9167+ fix(shader): guard shared loop exit routing and preserve valid fallbacks #2471 already produces, so they are the first-two-pass group, as you said. Rendering the 111 rejected graphs as shaders: on9fa7d06a+ fix(shader): guard shared loop exit routing and preserve valid fallbacks #2471, 59 fail spirv-val, 10 validate, 3 validate but fail the helper, and 39 end in a structurize error before the third pass; on0b56b7ad+ fix(shader): guard shared loop exit routing and preserve valid fallbacks #2471 the 59 are refused and the 10 + 3 are unchanged. On this sample the check removes every invalid output and loses no valid one.AI-assisted: yes (Claude Code Fable 5.1 xhigh)
AlxcBTW commentedon Oct 10, 2026
Thanks @orishamir, that settles it. On #24's own head,
0b56b7adremoves every invalid output from the third pass and loses no valid one, alone and merged with #2471. TherequireStructuredBranchesgroup belongs tod59a9167+ #2471 (the #2762 class) and needs its own fix. From my side #24 is ready to carry.AI-assisted: yes
orishamir commentedon Oct 10, 2026
Filed the helper strictness as #2960, with the
{{1, 5}, {2, 3}, {5}, {5, 4}, {1}, {}}reproducer, so it has a home outside #24.AI-assisted: yes (Claude Code Fable 5.1 xhigh)
mrk1987 commentedon Oct 10, 2026
@AlxcBTW replayed the original shader (
originalBlocks=161) in the full runtime, on Windows with an RTX 5090,main262c031 plus your #24 stack (d59a9167,9fa7d06a,0b56b7ad). I tested the whole stack, not #20 alone.CFG shared merge splitting exceeded budgetfailures in two runs, on two compute shaders (originalBlocks=161and162). The title stops at the 161-block one.agc_structurizerandagc_shader_control_flowpass.Correction: both runs (with and without the stack) also had #2471 applied to the structurizer, plus logging-only diagnostics. So the result is for #2471 together with your stack, not for your stack alone. The other local changes (decoder and driver) are outside the structurizer.
AI-assisted: yes (Claude Code).
AlxcBTW commentedon Oct 10, 2026
@mrk1987 thanks for running the full runtime with the original 161-block shader, and for the correction.
Since #2471 was on the structurizer in both runs, the result shows that #2471 plus the stack gets past those two shaders, but not which change does it. If you can, one run with the stack and #2471 reverted (logging diagnostics are fine to keep) would separate the two. SleglJan saw the same pattern on 007 in #2523, where the stack without #2471 took the 125-block stop-9 shader past the structurizer.
AI-assisted: yes (Claude Code)
xXPBSXx commentedon Oct 10, 2026
Another title that reaches this error: Resident Evil 4 (PPSA07411) on Windows. It gets there only after several earlier stops (see #2776 and #3136/#3143/#3144), and with the
register 0x292MSAA bit check relaxed locally to see what comes next (that relaxation is not proposed).The stop, on a current
mainplus those fixes (without #2471):Earlier in the same run another shader failed with
originalBlocks=209 currentBlocks=1045. So the split count lands at exactly 5x the original block count each time, which looks like the same non-terminating shape as in the reproduction rather than a budget that is merely too small. I have not run with #2471 applied, so I cannot say whether it helps here. I can do that run if useful.AI-assisted (Claude Code).
AlxcBTW commentedon Oct 10, 2026
@xXPBSXx yes, please, that run would help. #2471 at
a396b019carries the loop-exit commits from #24, and those cover the shapes in this thread that ended in the budget.About the 5x: that ratio comes from the budget itself, so it doesn't tell the shapes apart.
splitBudgetis 4xoriginalBlocks(864 = 4 × 216, 872 = 4 × 218), and the pass gives up once the block count reaches the original plus the budget (1080 = 216 + 864). Any shader that runs out of budget reports exactly 5x, whether its splitting never terminates or the budget is merely too small.If any of the three shaders still stops with #2471, could you attach the recompile request for it? Then I can replay it with
agc_shader_replayand see which shape it is without the game. One more thing: on graphs that fail anyway, #2471 is currently slower thanmain. orishamir measured that above, so a failing run will take longer to reach the error.AI-assisted: yes (Claude Code)
oneandonlydean commentedon Oct 11, 2026
#2941 (merged 10-10) fixes a split-budget loop in UE5 compute shaders of 135, 161, 162 and 272 blocks. Does your title still stop on current main?
mrk1987 commentedon Oct 11, 2026
@oneandonlydean @AlxcBTW On current
main(d7cfa72, which includes #2941 and #2974) the title no longer stops on these shaders. STAR WARS: Galactic Racer (PPSA28416), Windows 11, RTX 5090.The structurizer is unchanged from
main: neither #2471 nor the loop-exit commits from AlxcBTW's #20/#24 are applied. The local build does carry unrelated fixes that this title needs to get this far (#423 ported, #425, #2076, AlxcBTW's PR 25 masked-select change, and a local clamp for 4 GiB buffer views).Result: 2 of 2 runs show no
CFG shared merge splitting exceeded budget, down from 4-5 per run before. Both runs reach a later, unrelated stop: aDISPATCH_DIRECTwith the ordered-append modifier (ds_ordered_count).So AlxcBTW's question (does #2471 or the rest of the stack get past them) has a simpler answer for this title: #2941 on its own is enough.
AI-assisted: yes (Claude Code)
fix(recompiler): merge a loop selection where its arms meet in the it…
fix(recompiler): route loop exits that leave the loop before they meet
oneandonlydean commentedon Oct 11, 2026
Closing: fixed by #2941. @mrk1987 confirmed above on
d7cfa728: noCFG shared merge splitting exceeded budgetin 2 of 2 runs. Reopen if a shader still hits the budget on current main.test(recompiler): cover refused control flow and the forced structuri…
test(recompiler): cover refused control flow and the forced structuri…
test(recompiler): cover refused control flow and the forced structuri…