Skip to content

perf(recompiler): iterate structurizer fixpoints in post-order and bound shared merge splitting growth - #5028

Merged
oneandonlydean merged 4 commits into
boykopovar:mainfrom
nikowolf123:perf/structurizer-attempt-budget
Oct 14, 2026
Merged

oneandonlydean merged 4 commits into
boykopovar:mainfrom
nikowolf123:perf/structurizer-attempt-budget

Conversation

@nikowolf123

@nikowolf123 nikowolf123 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

What

The structurizer's attempts can spend seconds to minutes on a small graph before they accept or refuse it. With the oracle described under Tested, about 1,150 of 200,000 random graphs of 2 to 32 blocks take more than 10 s, on main and with #4926 (1,142 and 1,149; the count depends on machine load), and some take more than 300 s. One 20-block graph takes 10.3 s. In a title, that time is spent while the shader is prepared.

A profile of 41 of these graphs puts 73% of the time in computeDominatorTree, 13% in iterationSelectionMerge, 6% in joinWithinIteration and 5% in mergeBesideReturns, all recomputed while shared merge splitting grows the graph. Every slow attempt ends at the split round budget (4 rounds per block at the start of splitting), in one of two ways:

  • 36 of the 41: each round routes a merge through a new junction and adds 13 blocks in the median attempt (an attempt averages 8 to 43 blocks per round), so the graph reaches 31 to 173 times its block count at the start of splitting (one went from 44 to 4,600 blocks).
  • 5 of the 41, the 20-block graph among them: each round adds one forwarder block, ending at 5 times the size.

In production code, this PR changes only Structurizer.cpp:

  • Dominators, post-dominators and the post-dominator fixpoints in mergeBesideReturns, iterationSelectionMerge and joinWithinIteration sweep the blocks in reverse post-order from the entry (dominators) or in post-order (the others), unreachable blocks last in id order. They swept in id order, but splitting appends its blocks at the end, so a sweep carried a set only one new block further. computePostDominators already used this order; analysisOrder replaces its inline copy. Each fixpoint starts from the full set and its equations are monotone, so every sweep order reaches the same greatest fixed point and no output changes. joinWithinIteration also tests membership with a bit vector instead of a linear search, and mergeBesideReturns inserts into its already sorted set.
  • splitSharedMergeBlocks refuses the attempt once the graph has more than 16 times its block count at the start of splitting, or more than 64 blocks when that is larger (CFG shared merge splitting exceeded growth budget). The budget counts blocks, not time. A refused attempt continues exactly as after the existing round budget: the next attempt, then the reach-flag fallback of feat(recompiler): structure every control-flow graph the structurizer refuses #4926. With the sweep-order changes alone, 360 of the 1,220 graphs that took more than 10 s in any run on main or feat(recompiler): structure every control-flow graph the structurizer refuses #4926 still do.

The growth budget is a heuristic. Succeeding attempts grew at most 8.6 times on these 200,000 graphs, 9.4 times on 200,000 graphs of another seed and 10.6 times on 12,000 graphs of up to 128 blocks. An attempt that would succeed only beyond 16 times now gives a later attempt's output or the fallback's, which is valid and equivalent but not the same. No such graph was found, but shaders are larger than these graphs.

Splitting that adds one block per round stays under the growth budget and still runs all its rounds. On 2,000 random graphs of up to 128 blocks, 9 still take more than 10 s with this PR, at most 63.5 s; with the sweep-order changes alone, 28 take more than 10 s and 13 more than 300 s. The technical debt entry of #4926 now records the budget and this limit.

Tested

WSL2 Ubuntu 26.04, GCC 15.2.0, configured like CI (Release, SPIRV-Tools ON, lavapipe), on top of #4926. No title.

In-tree:

  • agc_structurizer gains three cases. The 20-block graph and a 12-block loop whose 2000-word latch is too large to clone, so that splitting keeps adding junctions, must give structured branches, every original block once and the same executions as the input. The same loop with an unsupported exit, which keeps the reach-flag fallback out, must be refused at the growth budget. On feat(recompiler): structure every control-flow graph the structurizer refuses #4926 alone that case fails: it reports the round budget after the graph reached 676 blocks.
  • Built from the test file alone with g++ -O2, the test takes 0.4 s with this PR. On feat(recompiler): structure every control-flow graph the structurizer refuses #4926 alone it fails after 16 s (39 s with APS5_NO_PERF_ITERATION_SELECTION=1).
  • ctest -R "structurizer|control_flow": 8 of 8 pass in three runs. agc_structurizer takes 0.3 s, and under 1 s in each of its variants.
  • Full ctest in the CI configuration: 1014 tests, 0 failed. tools/check_skips.py flags only guest_video_out_high_frame_rate, which skips because the WSL display has no 120 Hz mode.
  • tools/check_test_registration.py passes. tools/check_conventions.py --base origin/main passes with both the branch's checker and main's.
  • Rebased onto main cac4e96 with feat(recompiler): structure every control-flow graph the structurizer refuses #4926: full ctest in the CI configuration, 1032 tests, 0 failed. Then onto 4bb2c7f, where only the technical debt file conflicted: the structurizer, control-flow and shader tests, 18 of 18.
  • Native Windows 11, WinLibs GCC 15.2.0 (SPIRV-Tools OFF): the structurizer, control-flow and shader tests, 18 of 18; agc_structurizer takes 0.5 s and at most 0.9 s in its variants.

Out of tree, with the oracle described in #4926 (graph contract, SPIRV-Tools on a skeleton module at SPIR-V 1.3, 1.4 and 1.6, and an exact equivalence decision), on the same 200,000 graphs (seed 1, 2 to 32 blocks, 10 s per graph), built against main and against this branch and run on the same machine at 16 jobs:

The same code, run earlier on other graphs: on 200,000 graphs of seed 2 (2 to 32 blocks), all 199,892 graphs that a build with the sweep-order changes alone finished within 60 s give the same outputs, and the slowest graph takes 2.3 s. On 2,000 graphs of up to 128 blocks, all 1,987 that build finished within 300 s give the same outputs.

A second check on graphs of seed 7, with this branch at ebcb1068c:

  • 80,000 graphs of 2 to 32 blocks, 20,000 for each weight setting of the oracle (every block 0 words, one block 1000 words, every block 1000 words, and the default mix per graph). The 32,606 graphs main accepts give byte-identical outputs, and so do the 79,632 graphs feat(recompiler): structure every control-flow graph the structurizer refuses #4926 finished within 10 s. None is refused or takes more than 10 s; main takes more than 10 s on 366. Two runs of 40,000 of these graphs at different job counts give identical outputs, and an AddressSanitizer build gives the same outputs on 5,000 of them without a report.
  • 2,000 graphs of up to 128 blocks (default and every-block-1000-word weights). The build without the growth budget finished 1,976 of them within 120 s, and all 1,976 give the same outputs as this PR. This PR finishes all 2,000; 5 take more than 10 s.

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: feat(recompiler): structure every control-flow graph the structurizer refuses #4926
  • Unblocks: none
  • GPUs tested: lavapipe (Mesa 26.0.8, llvmpipe) on WSL2 Ubuntu 26.04; NVIDIA GeForce RTX 5080, driver 616.92, Windows 11 for the structurizer, control-flow and shader tests
  • AI-assisted: yes. Written with Claude Code; I reviewed every line and ran the tests above.

🤖 Generated with Claude Code

@houcineBen

Copy link
Copy Markdown

A data point from a real shader for the case listed as remaining ("splitting that adds one block per round … still runs all its rounds").

vkQuake (LibreQuake) homebrew PPSA99010 on a RADV (Mesa 26.2) build: compute shader 0x20134f400 (1602 dwords, wave32, 8x8 threads, 155 blocks when splitting starts). Header 119 splits merge 151 again in every round, each round adding one forwarder in front of the previous one, until the round budget. agc_shader_replay --cfg on its dump, Windows 11, RTX 4060 Ti, WinLibs GCC 15.2.0:

Build Time
main 9ea7130 ~312 s
this PR (67bb149; 516e926 has the same commits) 18.4 s
this PR + the change below 0.7 s

The title waits 10 s for the dispatch, so 18.4 s still stops it.

The change (+15/−2 in Structurizer.cpp): splitOneSelectionMerge reports the header and merge it split, and splitSharedMergeBlocks fails the attempt with CFG shared merge splitting repeats after 16 consecutive rounds that split the same header and merge and add exactly one block. I checked with the check changed to only record: every attempt it fires in, 6 in agc_structurizer and 6 in this shader, ran on to exceeded budget anyway. So it only ends doomed attempts sooner, and the structured CFGs of all 12 vkQuake dumps we have are byte-identical to this PR's. A test case uses your 20-block forwarder graph with block 12 unsupported (to keep the reach-flag fallback out). It must be refused with the new reason, and on this PR alone it reports exceeded budget: originalBlocks=43 currentBlocks=216 splitBudget=172. Full ctest on top of this PR: 1082 pass, 7 skipped.

I'll open it as a follow-up with Depends on: #5028 once I can (I'm at the first-time author PR limit), unless you'd rather fold it in here.

nikowolf123 and others added 4 commits October 14, 2026 00:59
When every attempt of the structurizer ladder refuses a graph, the title
aborts. Rebuild such a graph from a hierarchical decomposition into
strongly connected components instead: each original block keeps one copy
and runs behind a guard on its own reach flag, and each component becomes
a loop with a single latch that repeats it while a back edge set its again
flag. Every edge sets the reach flag of its target, so exactly one flag
names the next original block and the blocks run in the input's order for
every branch decision. Irreducible and non-terminating graphs are handled
the same way, and no block is cloned.

The ladder runs first and its output is unchanged. The fallback only runs
when both attempt series refuse; if it cannot structure the graph, the
ladder's error is reported as before. Blocks that start the top-level
order unconditionally run unguarded, so the entry block's results reach
later blocks without a stale path. APS5_FORCE_STRUCTURIZER_FALLBACK=1
uses the construction for every supported graph, for tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…zer fallback

Structurize eight graphs the ladder refuses on main (a duplicate merge, a
region that wraps around to its dominator, the merge-splitting budget, a
conditional without a structured merge, an irreducible entry and three
loops that never exit) and the boykopovar#2642 two-latch loop, which the ladder
accepts. Each must give structured branches, every original block exactly
once and the same executions as the input for every branch decision.

Register agc_structurizer_forced_fallback and
agc_shader_control_flow_forced_fallback, which run the in-tree graphs and
RDNA programs, through Recompile and SPIR-V validation, with
APS5_FORCE_STRUCTURIZER_FALLBACK=1. Under that switch the split-kind
checks become checks that no block is cloned and a route variable is
used, and the nested-selection check names blocks by their original
instructions instead of their position.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…und shared merge splitting growth

The structurizer's attempts could spend more than 10 s on a graph of 20
blocks before they accepted or refused it; in a title that stalls shader
preparation. Nearly all of that time went to recomputing dominators and
post-dominator fixpoints while merge splitting grew the graph to
hundreds or thousands of blocks.

Dominators, post-dominators and the fixpoints in mergeBesideReturns,
iterationSelectionMerge and joinWithinIteration now sweep the blocks in
reverse post-order (dominators) or post-order (the others) instead of in
id order. Splitting appends its new blocks at the end, so id order needed
a sweep for almost every new block. Each fixpoint starts from the full
set and its equations are monotone, so every sweep order reaches the
same greatest fixed point and the result does not change.

Shared merge splitting also refuses an attempt once the graph has more
than 16 times its block count at the start of splitting (at least 64
blocks). Without it, splits that route through a new junction each round
grew some graphs past 4,000 blocks before the round budget ran out. A
refused attempt goes to the next attempt or to the reach-flag fallback,
as it does after the round budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dget

Structurize the 20-block graph whose merge splitting adds one forwarder
per round until its round budget runs out, and a 12-block loop whose
2000-word latch is too large to clone, so merge splitting keeps adding
junctions. Both must give structured branches, every original block once
and the same executions as the input.

Give the loop an unsupported exit, which keeps the reach-flag fallback
out, and require Structurize to report the growth budget refusal instead
of the round budget refusal it reached before after 676 blocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nikowolf123
nikowolf123 force-pushed the perf/structurizer-attempt-budget branch from 516e926 to 07d4b36 Compare October 13, 2026 23:07
@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 13, 2026
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Other open pull requests touch the same code. Merging one will break the other, or both implement the same thing. If this one builds on them, list them in Depends on.

Pull request Conflicting files (lines on main) Same exports Same new files
#4806 docs/dev/debt/shader.md L117
#5049 docs/dev/debt/shader.md L117
#5461 docs/dev/debt/shader.md L117
#5472 docs/dev/debt/shader.md L117

RINNECODER added a commit to RINNECODER/AnyPS5 that referenced this pull request Oct 14, 2026
…time CPU (#482)

* perf(shader): rewrite high-fan-out IR uses in linear time

Ported from upstream PR boykopovar#4035

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(shader): index specialization tables by SPIR-V id

Ported from upstream PR boykopovar#4772

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shader): honor disabled optimization during specialization

Ported from upstream PR boykopovar#4827

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(shader): discard helper functions unreachable after specialization

Ported from upstream PR boykopovar#5064

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shader): drop module constants nothing references after specialization

Ported from upstream PR boykopovar#5315

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shader): cap SPIR-V scalar replacement

Ported from upstream PR boykopovar#4288

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(recompiler): iterate structurizer fixpoints in post-order and bound shared merge splitting growth

Includes the stacked upstream commit that structurizes refused control flow
with reach flags, which this change builds on.

Ported from upstream PR boykopovar#5028

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(shader): emit image_bvh_intersect_ray as one shared function per variant

Ported from upstream PR boykopovar#5455

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(shader): avoid redundancy traversal map copies

Patches the bundled SPIRV-Tools redundancy elimination (only built with
ANYPS5_ENABLE_SPIRV_TOOLS=ON).

Ported from upstream PR boykopovar#5511

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(metal): accept a specialized BDA module that dropped one of its two BDA descriptors

Specialization removes descriptor variables the final module no longer
references. With helper functions now discarded after specialization, a
compute module can drop its page-table variable while keeping the fault
buffer, and the bridge then rejected the dispatch ("BDA ABI requires
page-table and fault-buffer descriptor metadata"). Require at least one of
the two roles instead of both; the module's own descriptors are still
checked against the prepared plan.

Not a port; needed by the port of upstream PR boykopovar#5064.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@nikowolf123

Copy link
Copy Markdown
Contributor Author

Closing this together with #4926, which it builds on, following the request there to hold off on pull requests that do not name a title needing the change while the queue is this long. The branch stays; if a title stops on refused control flow on current main, I will reopen with the title ID and the log line.

@oneandonlydean oneandonlydean self-assigned this Oct 14, 2026
@oneandonlydean

Copy link
Copy Markdown
Collaborator

Reopened with #4926; reviewing both now.

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

Labels

agc libSceAgc* build docs shader core/shader

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants