Skip to content

perf(recompiler): drop exec selects whose inactive lanes nobody reads - #611

Merged
oneandonlydean merged 2 commits into
boykopovar:mainfrom
oneandonlydean:perf/recompiler-masked-select-elim
Oct 5, 2026
Merged

oneandonlydean merged 2 commits into
boykopovar:mainfrom
oneandonlydean:perf/recompiler-masked-select-elim

Conversation

@oneandonlydean

Copy link
Copy Markdown
Collaborator

What

Every VGPR write under EXEC becomes r = Select(exec, new, old). In SSA the register's previous value then stays live until the next write, even when the guest used the register as a temporary. NGG mesh shaders at 255 registers keep many dead values alive this way, and on NVIDIA the driver spills them to local memory (up to 784 bytes per thread in PPSA21564).

MaskedSelectEliminator (new Optimization pass, after the readlane pass and before the SRT walk, then DCE) replaces r with new when no lane where m is false can observe r. It walks every value derived from r:

  • passes through lane-local pure ops, phis, and samples the emitter performs with an explicit LOD;
  • a path ends at the true operand of a select, or an operand of a LogicalAnd, whose condition implies m. That condition is m itself, a LogicalAnd containing it, or a thread bit rebuilt from a ballot of such a condition;
  • second commit: a path also ends at a non-active operand of a buffer or LDS load or store, or SetAttribute, whose active operand implies m;
  • anything else keeps the select: cross-lane ops, ballots, readfirstlane, DPP, permutes, atomics, image ops, implicit-LOD pixel samples, branch conditions, the false operand of any select, and any value in a loop block.

Removal goes latest first, with DCE between rounds, so a chain of writes to one temporary collapses.

Why it is exact:

  • Where m is true, r equals new, so every derived value is unchanged.
  • Where m is false, only derived values change, and each path from them ends in a select, an AND or a guarded access that ignores that lane. The emitter wraps those stores and exports in if (active) and returns 0 for those loads outside it.
  • The select and everything it reaches must sit outside loops, so each runs at most once per invocation and sees the same m.
  • The FP adds, multiplies and FMAs are emitted NoContraction, so removing a select between them can't change rounding.

Tested

  • Linux (CachyOS, x86-64, GCC): full build with BUILD_TESTING=ON; ctest passes (240/240).
  • New agc_masked_select (22 cases):
    • the write read under its own, a narrower and a wider exec;
    • chain collapse;
    • cross-lane reads, ballots, and a branch condition derived from the write;
    • exec rebuilt from a ballot, full and partial wave;
    • phis;
    • writes in and reads inside loops;
    • implicit- and explicit-LOD samples;
    • guarded stores, loads and exports under the same and a wider exec;
    • the value used as an exec;
    • atomics.
  • The GPU execution tests compile their programs through the pass and still match. With a temporary counter (not committed), 63 of 228 programs lost 206 selects in total.
  • Local memory per thread, offline. These are my dumped PPSA21564 requests that current main can still read (80 distinct programs), recompiled with main and with this branch, then put through pipeline executable statistics (NVIDIA 610.57). Registers / local bytes:
program stage main this PR
500149c00, 500173100 MS 255 / 784 255 / 624
50000ab00, 500054d00 MS 255 / 752 255 / 704
5005dbb00, 500599900 CS 128 / 16 128 / 0
500604a00 CS 168 / 16 168 / 0
50087fb00 FS 96 / 80 96 / 64
5005ee100, 5006dd200 CS 168, 167 / 0 128 / 0
sum over the 80 programs 3,872 B 3,392 B

The rest of the mesh spill is real pressure: with every exec select removed (wrong code, measured only as a bound), 500149c00 still needs 448 B.

  • On a larger set of older dumps (151 programs), a few pixel shaders went from 115 to 126 registers with 0 B to 96 registers with 48 to 96 B. That is the driver choosing occupancy once pressure drops. The game FPS below shows no cost.
  • PPSA21564, on my integration branch, both this PR and the follow-up for per-invocation LDS:
    • Pipelines created through the intro, from the traced local memory per thread:

      MS spilling / bytes FS spilling / bytes
      before 28 / 11,696 52 / 5,504
      after 24 / 9,552 40 / 2,096

      The worst mesh shaders went from 720 B to 704 B and from 672 B to 528 B. About 2.3 KB of the FS drop is the LDS follow-up (3 pipelines at 864 B now at 96 B).

    • FPS is unchanged within noise. Two passing runs of each:

      intro title menu level
      before 44.7, 44.5 48.2, 48.9 46.8, 48.2 31.4, 30.6
      this PR 45.7, 47.0 49.5, 49.6 48.3, 47.8 32.3, 30.5
      with the LDS follow-up 45.4, 44.7 47.7, 49.6 46.2, 48.0 30.4, 30.3
    • My visual regression check passes; 0 GPU faults (Xid 31/109) in the 11 runs of these builds.

  • Checked against fix(shader): wave-wide exec and vcc branch conditions #608: the two merge cleanly, and ctest passes with both (241/241).

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

@github-actions

github-actions Bot commented Oct 5, 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
#327 core/libs/prx/libSceAgcDriver/CMakeLists.txt L501

Every VGPR write under EXEC is translated to Select(exec, new, old), so
in SSA each register's previous value stays live until the next write,
even when the guest only used the register as a temporary. In NGG mesh
shaders at 255 registers that keeps dead values alive and the driver
spills them to local memory.

MaskedSelectEliminator (after SSA, folding and the readlane pass)
replaces r = Select(m, new, old) by new when no lane where m is false
can observe r:
- every value derived from r is either a lane-local pure op or a phi,
  and their uses are checked the same way;
- or the derived value reaches the true operand of a select, or an
  operand of a LogicalAnd, whose condition implies m: m itself, a
  LogicalAnd containing m, or a thread bit rebuilt from a ballot of
  such a condition (also through ANDs of the mask words);
- anything else (cross-lane ops, ballots, readfirstlane, DPP, swizzle,
  permutes, stores, exports, atomics, branch references, implicit-LOD
  pixel samples, the false operand of any select) keeps the select.
The select and every value it reaches must be in blocks outside loops,
so each of them runs at most once per invocation and every use sees the
same instance of m. Selects whose condition is the full wave
((0xffffffff >> (lane & 31)) & 1) are replaced in any block.

Exact: in lanes where m is true r equals new, so every derived value is
unchanged there; in lanes where m is false only derived values change,
and each path from them ends in a select or AND that discards those
lanes. The selects are removed latest first and dead code is removed
between rounds, so chains of writes to one register collapse.
…lect

A register written under EXEC is usually consumed by an LDS or buffer
access or an export under the same EXEC. MaskedSelectEliminator kept
the select in that case, which leaves the NGG mesh shaders where they
were.

The emitter performs buffer and LDS loads and stores (8 to 128 bits,
GPU-selected V# included) and SetAttribute only in lanes whose active
operand is true: stores and exports sit in an if on it, loads return 0
outside it. Every other operand is read only in those lanes. So such an
access whose active operand implies the select's condition reads the
derived value only where it equals the original, and it now ends the
search like a masked select does. The active operand itself, atomics,
append/consume, scratch and address-space accesses and image accesses
still keep the select.
@oneandonlydean
oneandonlydean force-pushed the perf/recompiler-masked-select-elim branch from 81c8543 to 60c6552 Compare October 5, 2026 05:13
@github-actions github-actions Bot added build shader core/shader agc libSceAgc* labels Oct 5, 2026
@oneandonlydean
oneandonlydean merged commit fbd6ccb into boykopovar:main Oct 5, 2026
3 checks passed
oneandonlydean added a commit to oneandonlydean/AnyPS5 that referenced this pull request Oct 5, 2026
Takes upstream's merged versions of our boykopovar#586 boykopovar#611 boykopovar#612 boykopovar#505 boykopovar#490 boykopovar#619
boykopovar#503 boykopovar#627 boykopovar#628 boykopovar#618 and drops astrobot's local copies, plus gate-batch-3
(boykopovar#469 boykopovar#500 boykopovar#504 boykopovar#509 boykopovar#510 boykopovar#514 boykopovar#525 boykopovar#526 boykopovar#528), round o/p (boykopovar#617 boykopovar#620
boykopovar#621 boykopovar#622 boykopovar#623 boykopovar#624 boykopovar#501 boykopovar#508 boykopovar#629 boykopovar#630 boykopovar#631).

Conflicts:
- SpirvAnalysis.hpp/.cpp, SpirvBufferAccess.cpp: upstream's boykopovar#586/boykopovar#612.
  The 64-bit LDS atomics keep the full array (f27d6e5 no longer needed).
- SpirvMemoryInstructions.cpp: upstream's constant slot plus offset;
  astrobot's split-wave append/consume kept.
- GuestTextureResource.cpp, TextureTiling.cpp and their tests: upstream's
  boykopovar#490/boykopovar#503 (astrobot had no other change there).
- MaskedSelect.cpp test, ShaderMemory.cpp LDS test: upstream's.
- State.cpp: upstream's boykopovar#505 export count and boykopovar#469 null pixel program;
  astrobot's depth export rejection and depth target model kept.
- Pipeline.cpp: upstream's pipeline key; astrobot's depth attachment and
  blend check (a trailing unused attachment stays valid, as
  colorGapPipelineTests expects).
- Draw.cpp, Recorder.cpp, ShaderResources.cpp: astrobot's (renderPassKey
  with depth, moved sample functions, residency maintenance), which
  already hold the merged boykopovar#505/boykopovar#628/boykopovar#490 changes.
- ShaderInputState.cpp: both register sets.
- State.hpp: the duplicate exportIndex member from the auto-merge removed.
- CMakeLists.txt: astrobot's test list (a superset of upstream's).
- tests/Graphics.cpp: upstream's CompactedExportTests replaces astrobot's
  inline copy; tests/Recorder.cpp: upstream's viewPastLastMipTests added.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agc libSceAgc* build shader core/shader

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant