Skip to content

fix(shader): prove masked lanes unread for every select in one backward pass - #4331

Merged
oneandonlydean merged 1 commit into
boykopovar:mainfrom
oneandonlydean:up4/fix-3504-shader-prep-time
Oct 12, 2026
Merged

oneandonlydean merged 1 commit into
boykopovar:mainfrom
oneandonlydean:up4/fix-3504-shader-prep-time

Conversation

@oneandonlydean

@oneandonlydean oneandonlydean commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

What

Refs #3504. That regression came in with #3279, which I carried, so this is the fix for it. I do not have Stray's requests: @ThatsLeo, could you time 0x200f780000 and 0x200f7a0000 on this branch, and check that 0x2013010000 and the rest of your 43 still compile?

MaskedSelectEliminator walked forward from every select to see whether the lanes its mask excludes are read. Since fda4aa90 (bisected by @ThatsLeo) the walks pass explicit-LOD gathers, size queries and guarded image writes, so most of them cover the rest of the program. The cost is selects times program size.

The pass now computes the answer for all selects at once. For every value it keeps the set of masks under which the value is unread, built from its uses backwards:

  • a use guarded by a condition (a select's active operand, the other side of a LogicalAnd, an access with a trailing exec) contributes the masks that condition implies;
  • a lane-local use, a phi or an explicit-LOD sample adds the set of its own result;
  • any other use contributes nothing.

A select is dropped when its mask is in its own set. Values and conditions that depend on themselves through loop phis are solved per strongly connected component, starting from "every mask". Selects are settled users first, so a dropped select is already gone when the values it read are settled, as in the walk's reverse sweep.

The rules are the walk's: the same guarded uses, lane-local list, phi and loop conditions, ballot and mask-word forms, whole quad masks (#3391), and the selected execs, rebuilt mask words and guarded first-lane reads of #3739. A first-lane read passes the masks its read mask implies when its own result is unread outside that read mask; that is solved once per read, in the same way, with #3739's same-block rule for a loop-local read mask. Three things are gone: the step limit per walk, the 64-level depth of the implication check and the nesting depth of first-lane proofs. A select that the walk kept only because it ran into one of them is now dropped, which includes a first-lane read whose result only reaches itself around a loop. One case is refused instead of counted: a first-lane proof that needs a read mask of a kind (loop-local or not, whole-quad or not) that is already being solved. It is not a budget: no select is kept for lack of time.

Measured on a synthetic pixel shader of the reported shape (each unit compares, selects, gathers with LOD zero and writes a register under the exec; one image write at the end), Release, same machine:

Instructions main 7a84b22 This branch
4,007 0.05 s, 2.6 M values visited 0.001 s
32,007 4.0 s, 168 M 0.010 s
128,007 173.8 s, 2,688 M visited, 384 M implications 0.16 s

The visit and implication counts on main come from counters added locally. Both trees drop the same 16,000 selects in the last row.

Relation to #3550 and #4031: #3550 keeps the walk and stops proving when a per-shader budget is spent, so the selects it keeps depend on the multiple (1,602 of 9,232 dropped at 32x on 0x200f780000, and 0x2013010000 refused below 128x, per @ThatsLeo's tables in #3504). With this change there is no budget to pick, and #3550 should not be needed for #3504; if it goes in anyway it conflicts with this one. #4031 is independent: it is still needed for selects that no proof can drop (Palworld's 0x1b40e5c000, Minecraft Dungeons II), but not to make a budget safe.

Cost: the sets are explicit, so memory grows with values times the masks their guards imply. A chain of 8,000 nested kills takes 0.09 s and about 150 MB here (3.4 s on main).

Tested

Linux (CachyOS, GCC 16.2.1, Release, BUILD_TESTING=ON, ANYPS5_ENABLE_SPIRV_TOOLS=ON), RTX 4090 (NVIDIA 610.57.04). The commit was rebased over #3739 and carries its three rules, so everything in the first list was run again.

On main 8c4a295 plus this commit:

On main 436e310 with and without this commit (nothing under core/shader changed between 436e310 and 8c4a295):

  • 143 dumped recompile requests from two other titles replayed with agc_shader_replay --spv: every module is byte-identical to main (30,722,012 words), in 277 s against 282 s. These are the ones of my 163 that compile on this main; 7 more stop at the clip check of 9db923e on both trees. At least one of the 143 has first-lane reads.
  • 300,000 random programs (diamonds, nested loops, exec and mask-word phis, ballots, whole quad masks, selected execs, rebuilt mask words, first-lane reads, both wave sizes, guarded and unguarded accesses, samples and gathers) compared with main's walk with its limits lifted and a first-lane read that only reaches itself around a loop taken as unread: the programs after the pass are identical in all of them. 10 deliberate errors in the three rules from fix(shader): prove masked writes through guarded first-lane reads #3739 (the wrong arm of a selected exec, mask words matched on the low word only, a first-lane result taken as always unread, and so on) are each caught by the same comparison within 40,000 programs. The harness is not part of the commit.
  • A 2D title we test with, one scripted run on main and two with this commit (menu, new game, two rooms; 220 s): all 7 visual checkpoints pass on all three, 36.5, 33.8 and 39.1 fps in the menu, 37.7, 29.2 and 40.6 in gameplay (frames dumped on the CPU; other work was running on the machine during the slow one), no error line, no skipped draw, no Xid. Each run started with an empty shader cache and compiled 107 modules (3,251,533 words): the same 107 keys, every module byte-identical.

Before the rebase, with the commit as it was without #3739's rules:

Not tested: Stray itself and its requests (see above), Windows, AMD and Intel; the full ctest and the 3D title on the rebased commit.

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 in TechnicalDebt
  • 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
  • AI-assisted: yes (Claude Code)

@oneandonlydean oneandonlydean self-assigned this Oct 12, 2026
@github-actions github-actions Bot added needs rebase Conflicts with main, rebase needed shader core/shader agc libSceAgc* labels Oct 12, 2026
@ThatsLeo

Copy link
Copy Markdown
Contributor

@oneandonlydean measured on this PR's head dbad071a against main 436e310d, with offline replays of Stray's (PPSA02101 v01.002) requests on an Intel Core Ultra (Meteor Lake), Release:

Shader main This PR
0x200f780000 205 s 0.9 s (110 MB peak)
0x200f7a0000 458 s 1.7 s (173 MB peak)
0x2013010000 0.8 s, compiles 0.2 s, compiles

The two main timings ran side by side, so they are a little higher than my earlier single runs (141 s and 385 s on 8ec0e131).

I haven't run the title with it yet. That needs a rebuilt set, and I'll add it here if it shows anything.

AI-assisted: yes.

…rd pass

MaskedSelectEliminator walked forward from each select to see whether the
lanes its mask excludes are read. Since fda4aa9 the walks pass explicit-LOD
gathers, size queries and guarded image writes, so in a large pixel shader
most of them cover the rest of the program, and the cost grows with the
square of its size (boykopovar#3504).

The pass now computes, for every value, the set of masks under which it is
unread, from its uses backwards: a use guarded by a condition contributes the
masks that condition implies, a lane-local use adds what its result is unread
under, and any other use contributes nothing. A select is dropped when its
mask is in its own set. Loops are solved as strongly connected components.
The rules for guards, lane-local uses, loops, whole quad masks, selected
execs, rebuilt mask words and guarded first-lane reads are the ones the walk
applied. A first-lane read passes the masks its read mask implies when its
own result is unread outside that read mask, which is solved once per read in
the same way. The walk's step limit, its 64-level implication depth and its
first-lane nesting depth are gone, so a proof no longer depends on the order
of the questions.
@oneandonlydean
oneandonlydean force-pushed the up4/fix-3504-shader-prep-time branch from dbad071 to d37463e Compare October 12, 2026 08:29
@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 12, 2026
@oneandonlydean

Copy link
Copy Markdown
Collaborator Author

@ThatsLeo thanks, that is the number this needed. The branch is rebased onto main after #3739 (d37463e), and it was not a mechanical rebase: #3739's three rules (selected exec, rebuilt mask words, guarded first-lane reads) are now part of the backward pass. Could you rerun the 43 requests on that head, 0x2013010000 and 0x2012f90000 in particular?

@github-actions github-actions Bot added the docs label Oct 12, 2026
@ThatsLeo

Copy link
Copy Markdown
Contributor

@oneandonlydean on the rebased head d37463e7, the same offline replays of Stray's (PPSA02101 v01.002) requests on Intel Core Ultra (Meteor Lake), Release:

  • All 43 requests compile, and every module passes spirv-val --target-env vulkan1.3.
  • 0x200f780000 takes 0.9 s and 0x200f7a0000 1.8 s, as before the rebase.
  • 0x2013010000: recompiled: 231752 SPIR-V words, in 0.2 s.

0x2012f90000: I can't give you that one. The request I saved was truncated, and replay rejects it with RequestSerializer: truncated data. The shader is only prepared after the brightness calibration screen. Getting there on today's main takes #425, the stencil storage commit and #1077, and #1077 no longer merges (13 files conflict). With #425 and the stencil commit on top of this PR, Stray stops earlier, with color views of several array slices are unsupported. Its shape is the one in my comment on #2548: a SelectU32 on the lane's s_wqm bit. #4033 folds that bit, and #4033 is now on main.

AI-assisted: yes.

@AlxcBTW

AlxcBTW commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Independent soundness check of this branch with the masked-select fuzzer (agc_masked_select_fuzz): random wave programs with exec saved, narrowed, killed, revived and restored around diamonds and loops, run on a small lane interpreter before and after the pass. A select may only go if every observable effect stays the same (stores and loads under their exec, readfirstlane/readlane/ballot, branch decisions, plain uses). The fuzzer is not part of this PR. It is our local fuzzer commit (9c49eaf5c, a test-only file), which we also run against main as a long-running job.

Setup: this PR's head dbad071ac and its base 7a84b2226, each with the fuzzer commit and a local timing harness on top, Release, GCC 15.2 on Windows, CPU only. Current main conflicts with this PR in MaskedSelectEliminator.cpp (#3739), so the comparison is against the PR's own base.

Soundness, seeds 10,000,000 to 10,499,999, both wave sizes, 1,000,000 programs per tree:

programs changed by the pass selects removed skipped mismatches
this PR 1,000,000 784,881 2,108,049 0 0
base 1,000,000 784,881 2,108,109 0 0

No mismatch and no exception. The same fuzzer has run more than 19 M programs on main without a mismatch.

Same programs through both passes (240,000 programs, seeds 10,000,000 to 10,119,999): the IR after the pass is identical in all of them, and the instruction count is identical (26,390,091 on both sides). The removed counts differ in 1,325 programs (770 higher on this branch, 555 lower), with identical IR in every one of those, so that is only a difference in how the stat is counted.

Timing, CPU time on the same machine:

Not covered: the fuzz programs are too small to reach the old step and depth limits, so the extra selects dropped beyond them are not checked by this fuzzer. Your comparison against the walk with its limits lifted is the check for those.

AI-assisted: yes (Claude Code)

@fma-rc

fma-rc commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

I replayed 148 requests from two more titles with and without this PR. The tool's output is identical, including every SPIR-V word count, and the replay is a little faster with it.

Linux, GCC 16.2.1 Release, agc_shader_replay with ANYPS5_NO_SHADER_CACHE=1. "Without" is main 0ef9dc9, "with" is d37463e merged onto main 33ae282; between those two commits only the replay tool's --prepare option changed in the shader and driver code. Times are the median of three runs over the whole set.

Title requests recompiled without with
Kena (PPSA01746) 17 16 0.26 s 0.24 s
LEGO Horizon Adventures (PPSA14632) 131 131 3.37 s 3.13 s

Kena's one failure is the same on both: packed pixel ancillary input has an unsupported live use (used by SelectU32 1065353216 ?).

The 13 tests matching masked_select|shader_recompiler|agc_shader pass on an RTX 5080, a Radeon 610M (RADV) and lavapipe.

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.

4 participants