Skip to content

fix(recompiler): split image reads whose descriptor is a web of SRT Phis - #1274

Merged
adriadam10 merged 3 commits into
boykopovar:mainfrom
CnRJay:fix/recompiler-descriptor-phi-web
Oct 9, 2026
Merged

adriadam10 merged 3 commits into
boykopovar:mainfrom
CnRJay:fix/recompiler-descriptor-phi-web

Conversation

@CnRJay

@CnRJay CnRJay commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

What

#414 splits an image read whose T# or S# is a Phi of SRT reads in one block. A descriptor chosen inside a loop or through a chain of branches reaches the read as a web of Phis, for example Phi(first, Phi(first, second)) across a loop back edge. The plan then throws GetImageResource dword 0 is not a valid runtime value; chain: Phi(...Phi(...)).

When the one-block split does not apply, SplitDescriptorWeb now takes over:

  • WebSelector walks all split descriptor dwords together, as one tuple, back through the Phi web. It handles one Phi block at a time, memoized on the tuple, so loop back edges close into a cycle. It stops at 512 tuples and 64 arms.
  • Every tuple without Phis is a leaf. A leaf whose dwords can all be recomputed (SRT reads at an immediate offset, user data, uniform arithmetic) is an arm, and equivalent leaves share one arm. It builds a parallel U32 selector Phi web that carries the arm index.
  • The read is copied once per arm, each copy with its own memory entry, and the copies' results are selected per component with IEqual32(selector, arm) and SelectU32.
  • If any leaf cannot be recomputed, or holds a program-counter value (GetShaderBase arithmetic, which no descriptor holds), the read is left alone and throws as on main. The selector Phis built so far are discarded. Nothing is folded or guessed.

SplitDescriptorPhi keeps #414's one-block path (now SplitDescriptorEdges) and calls the web only where that path used to give up. The copy-and-select emission is shared between the two paths (ReplaceWithArmCopies).

Not covered: a web whose dwords are Phis of different blocks at the same step, such as a join that reloads only part of the T# while the rest comes from an earlier join's Phi. It throws as before.

UFC 6 (PPSA23566) is not fixed by this PR alone. In compute shader 0x363c84000, two s_setpc jump tables are indexed by the same clamped light type, min(type, 32). Entry 32 of the first table jumps to the join without loading a T#, and entry 32 of the second jumps straight to the two samples at 0x808. So for a light type of 32 or more the samples read whatever the T# registers hold: the second table's address arithmetic from the previous loop iteration, or a saved compare mask. Nothing in the shader rules that path out; only the title's data (no light type ≥ 32) does. Those leaves therefore throw here. Handling them exactly needs a fault raised at run time when the read executes through such an arm, which could build on the deferred-fault buffer from #759. That would be a follow-up.

Test (verifyDescriptorPhis in agc_shader_memory_tests):

  • A compute loop samples at the loop header through Phi(first, Phi(first, second)). The plan has two images, the SPIR-V samples twice, and the snapshot holds both T#s.
  • The same loop, with T# dword 6 overwritten before the loop by the program counter (s_getpc_b64), by EXEC, or by a v_cmp mask through VCC, throws.
  • With T# dwords 4-7 never written before the loop, it plans a third image. SsaPass::MakeInitialValue gives a never-written register the value 0, so the planner sees a constant, as it does for every unwritten SGPR read.
  • 63 if-then reloads of distinct T#s plan 64 images, and 64 reloads throw (arm limit).
  • Joins that reload either T# dword 7 or dwords 0-6 plan their 4 reachable T#s with 8 joins. With 260 joins they pass 512 tuples and throw (state limit). The state count grows by about 2 per join, so the 4096 of the first version would have needed a 23k-dword program that takes minutes in the front end. 512 leaves room for the deepest web seen in UFC 6 (about 35 Phi blocks per read).
  • fix(recompiler): split image reads whose T# or S# is a Phi of SRT reads #414's dynamic-offset case is still rejected.

Tested

Windows 11 Pro, NVIDIA GeForce RTX 5080, MinGW-w64 GCC 15.2.0, ANYPS5_ENABLE_SPIRV_TOOLS=ON.

  • ctest: 400/400 pass.
  • With ResourceTracker.cpp reverted to main, agc_shader_memory fails at the loop row with GetImageResource dword 0 is not a valid runtime value; chain: Phi(ReadConst(GetSrtResource, Immediate), Phi(ReadConst(GetSrtResource, Immediate), ReadConst(GetSrtResource, Immediate))).
  • Without the program-counter check, the s_getpc_b64 row plans a third image from the program counter.
  • UFC 6: I replayed its recompile requests offline and dumped the IR of 0x363c84000 for the analysis above. Not run on the title. With this version 0x363c84000 still throws, by design.

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. Written with Claude (Anthropic) under my direction; I reviewed and tested it.

🤖 Generated with Claude Code

SplitDescriptorPhi (boykopovar#414) handles a T# or S# that is a Phi of SRT reads in
one block. A descriptor picked inside a loop through a jump table reaches
the read as a chain of Phis rooted at the loop header, so the plan threw
"GetImageResource dword 0 is not a valid runtime value".

When the one-block split does not apply, walk the descriptor dwords
together through the Phi web, give each distinct SRT descriptor an arm,
build a U32 selector Phi web carrying the arm index, and select between one
copy of the read per arm. Paths whose dwords hold a program-counter, EXEC
or ballot value fold onto the first arm (listed in TechnicalDebt); any
other descriptor that cannot be recomputed still throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added shader core/shader docs agc libSceAgc* labels Oct 7, 2026

@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.

Checked: build + ctest green locally (436/436) and CI green. The new verifyDescriptorPhis loop cases fail with ResourceTracker.cpp reverted to main and pass with it, so they test the change. A few things before this can land:

  • ResourceTracker.cpp:793-795: a leaf that carries a program-counter, EXEC, ballot or undefined value is folded onto arm 0. That turns a draw that threw on main into one that returns the first descriptor's result when the assumption ("that path never reaches the read") is wrong, which is the plausible-but-wrong case the exact-or-throw rule rejects (#333 #436 #469). The TechnicalDebt line records it but nothing checks it. Please scope the fold to what you can show is a non-sampling path (for example the leaf's edge only comes from blocks where the read is not reached under the structurized EXEC mask), and throw for any other control-value leaf. If you can't show that statically, say what in the shader guarantees it.
  • The PR body says the final version (folding only control values) wasn't rerun on the title; the earlier version that ran to the intro folded every unrecomputable leaf and ran together with #293. Please rerun the final version, or add a Linux test that reproduces the jump-table shape with the saved-exec-ballot registers, which is what a game log alone can't give us.
  • Test coverage: only the s_getpc_b64 variant is pinned. Add rows for EXEC, ballot and undefined-register leaves (what you fold must be on purpose, not by accident), and a case that hits the 64-arm and 4096-state limits and still throws.

Happy to look again once these are addressed.

CnRJay and others added 2 commits October 8, 2026 14:02
…criptors

A path whose T# or S# dwords hold a program-counter, EXEC or compare-mask
value is no longer folded onto the first descriptor: the web split gives up
and the read throws as on main. Cache the invariant-Phi resolution per web,
lower the state limit to 512 and test the program-counter, EXEC and
compare-mask rows, unwritten registers, and the arm and state limits.

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

@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.

Checked the new head: build + ctest green on top of current main (550/550), and agc_shader_memory fails with ResourceTracker.cpp reverted, so the tests cover the change. CI is green.

The three points from the earlier review are addressed: leaves with a program-counter, EXEC or compare-mask value (and any leaf that can't be recomputed) now throw as on main instead of folding onto arm 0, the tests pin those cases plus the unwritten-register one, and the 64-arm and 512-state limits are tested to still throw.

Merging.

@adriadam10
adriadam10 merged commit dfa8b43 into boykopovar:main Oct 9, 2026
6 checks passed
@CnRJay
CnRJay deleted the fix/recompiler-descriptor-phi-web branch October 10, 2026 17:33
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.

2 participants