Skip to content

perf(agc): baseline shader recompilation on registration - #639

Merged
boykopovar merged 67 commits into
mainfrom
feat/no-ingame-shaders-recomp
Oct 8, 2026
Merged

boykopovar merged 67 commits into
mainfrom
feat/no-ingame-shaders-recomp

Conversation

@boykopovar

@boykopovar boykopovar commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

What

All game shaders pass through Driver::RegisterShader during game initialization. Recompiling them at this stage would allow draw/dispatch to use precompiled SPIR-V without compiling shaders during gameplay.

The current recompiler embeds resource parameters into shader code, so resource changes require recompilation. Even without resource changes, the initial compilation still occurs just before rendering during gameplay. Original shaders read these parameters at runtime. Recompiled shaders must do the same: fixed code, with resources supplied separately.

Compiled artifacts are separated from invocation data, and RDNA-to-SPIR-V compilation runs during shader registration and static ABI preparation. Draw/dispatch require prepared artifacts and fail if missing. Pipeline specialization and direct access for CPU-resolvable buffers are partially implemented; specialization validation and serialization have been added. Vertex fetch restoration and image/sampler optimization remain incomplete.

If successful, this will eliminate the last fundamental source of performance overhead compared to a real PS5.

Tested

There's no noticeable performance improvement on Dreaming Sarah. This may be because shader recompilation has never been a significant feature in this game.

Checklist

  • 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
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: none

@adriadam10

Copy link
Copy Markdown
Collaborator

Is there anything I can do to help with this without getting in your way? One idea: before you remove the image/sampler specialization, a test-only PR on main with execution tests that pin results across T#/S# variations (formats, dst_sel, mip/array ranges, clamp/border modes, filters), so you can check the runtime-descriptor version against them. If you would rather have something else, or a separate piece of the remaining work, tell me which and I'll stay off the rest. #473 is on standby until this lands.

@boykopovar

Copy link
Copy Markdown
Owner Author

Don't worry about changes to the main branch interfering with my progress. Keep up the good work. If my idea works, I'll resolve the conflicts myself and adapt everything.

@adriadam10

Copy link
Copy Markdown
Collaborator

Thanks! Then I'll leave this area to you: no new PRs from me on moving shader compilation to registration or on resource specialization, since your approach replaces it. I'll keep working on the rest as before.

@boykopovar

Copy link
Copy Markdown
Owner Author

Regarding #473, it's still not worth making any architecture changes that don't align with shader recompilation at the registration stage.

@Celegans12

Copy link
Copy Markdown
Contributor

Testing notes from PPSA26344 (Ghost of Yōtei v1.008) on the runtime buffer path, in case they help.

The pattern. Six compute shaders stop on main at GetBufferResource dword 0 is not a valid runtime value: 0x8000289300, 0x800022d100, 0x800028d800, 0x8000284e00, 0x80002a9600 and 0x80002a1f00.

  • They read a table of 192-byte records through a V# at [SRT+0x1b8]+0x20, then use the V#s stored in each record (+0x00, +0x10, +0xa0, +0xb0).
  • The record offset is either loop-carried (s48 * 0xc0) or taken from a tile-list entry.
  • Accesses through those V#s: buffer_load_ushort / ubyte / dword / dwordx4 (idxen), buffer_load_format_x, buffer_load_format_d16_xyzw (about 128 per shader), s_buffer_load, and buffer_store_byte / _d16_hi (glc).
  • On main, TakeGpuDescriptor routes a runtime V# to BDA only for 32-bit raw accesses, so the ushort load at pc 0x78c reaches GetHandle and throws.

Replay through this branch. I replayed the six captured requests through this branch's agc_shader_replay (531e76d), with the SRT and record memory reconstructed from values captured live. The replay needed a local test-only patch: req version 8 and memory injection.

Two things showed up.

  1. Module size.
    • word3 is not an immediate, so formattedDispatch inlines all 77 formats at every formatted access.
    • Each module that compiles is about 56.7-56.9M SPIR-V words (227 MB), with 256 OpSwitches of 77 cases and 1.36M blocks. The title's other shaders are 17k-273k words.
    • I did not try to build a pipeline from one.
    • One SPIR-V function per (format, components, d16) called from the switch, or a switch on the format class, would keep it bounded.
  2. RuntimeImageMode (ResourceMaterializer.cpp:552) throws "image descriptor is incompatible with the static runtime image interface" for the placeholder T#s the title binds at P+0x84..0xe4.
    • These are 1x1 format-56 TYPE 9 (2D) T#s, sampled with image_sample_lz dim:3D.
    • With the captured memory, three of the five hit this. With those T#s nulled, all five recompile.

Not verified. The byte stores through these runtime V#s need write access in the BDA table. As far as I can see, AddressRanges (GuestBufferMemory.cpp:2306) grants it only to imported writable allocations or to ranges in writes, and the driver never sees a runtime V#'s range.

Context. At this point in the title the record table is empty (num_records 0, count 0), so the loop runs zero times on hardware. When the six dispatches are skipped, the title goes on to present frames. Happy to re-run these shaders with the title after a rebase.

@Celegans12

Copy link
Copy Markdown
Contributor

Two things from Ghost of Yōtei (PPSA26344) for this branch: a registration check at cf2bf8e, and the sub-dword buffer stores behind most of its 1.3 fps.

I ran PrepareShader the way RegisterShader calls it (zero user data, code and header only) over the 126 compute programs the title dispatched in a run today: 103 prepare, 23 throw. Only ProcessShutdown is caught on the way out of sceAgcCreateShader, so I'd expect the 23 to stop the title at registration, though I haven't run it on this branch. 16 of them dispatch fine on our integration build.

  1. Five need support main already has: the SDWA selectors from feat(shader): accept every SDWA selector on VOP2 integer ops #509 (not feat(shader): accept every SDWA selector on v_mov_b32 #751, as I wrote last time), the vcc global base from feat(shader): accept vcc as the scalar base address of global instructions #750 and the DPP16 row_share controls from feat(shader): implement the DPP16 row_share controls #660.
  2. Seven hit "shader image heap capacity exceeded". Each runtime image joins the group of every mode it might take, so a sampled group gets 18-20 images (capacity 16) or a storage group 5-7 (capacity 4).
  3. Eleven stop at a T# the tracker can't resolve, among them a waterfall (0x800037f200), a T#/S# behind a pointer in a record (0x8000333c00) and a loop over 0x1b8-byte records (0x80003cab00). Are these shapes planned here?
  4. Seven modules exceed the id bound of 4,194,303: 0x8000261500 and five record-table shaders from before (9.97M-12.36M, still the 77-format inline), and 0x8000390600. 0x8000305900 prepares into a valid module; I haven't run it.

On main, the light-culling shader (0x800036e100) takes about 589 ms of GPU time per dispatch, nearly all in compare-exchange loops for its 8- and 16-bit format stores, each an atomic over PCIe on host-imported memory. Plain 8/16-bit stores through aliased views take 28 ms, and the boot logos go from 1.3 to 5.6 fps with identical frames. Here runtime V#s store single bytes, but this shader's V#s take the direct path, where writeBuffer still merges sub-dword fields with AtomicUpdate. I have a small PR for main ready (buffers_u8/buffers_u16 views; odd addresses and devices without 16-bit storage keep the loop). Should I open it to land first, for you to keep or port, or hold it for you to fold in?

Two smaller questions. 0x80001e5f00 stores to tables it reads: is the bindless storage table refusal meant to stay? And 0x80002c4500's S# is unnormalized with wrap, which #799 still refuses (2391 skipped dispatches), but all 26 dispatches I dumped sample inside its 1408x1408 T#, so a clamp-to-edge sampler with a deferred fault outside it would be exact. On #822 that flag would be a ResourceSpecialization bit, which goes away here. Would a PipelineSpecialization entry suit you, and which FaultReason value? #757 adds ImageTable = 8, this branch InvalidDescriptor = 8.

Details
  • Rebase: 0x800022d100 (SDWA), 0x80001e5f00 (global_load_dword with vcc base, pc 0x4a9c), 0x80003b1700, 0x80003c5900, 0x80003dc800 (DPP).
  • Heap: 0x8000253100, 0x8000265d00, 0x80002c4500, 0x80002e1b00, 0x8000368d00, 0x80003cc000, 0x80003cfa00.
  • T#, others: 0x80001d5700, 0x80001fd400, 0x800024de00, 0x800027c200, 0x8000316900, 0x800031e600, 0x8000364b00, 0x80003aa700.
  • 0x800036e100 here: 41 spec constants, 7716 OpAtomicCompareExchange.
  • 0x80002c4500 S# 00008000 00fff000 05000000 00000000; u 0.5-719.5, v 96.5-1055.5.
  • Sub-dword A/B, RTX 3080 Laptop, GPU and draw profiling on: 13-14 to 46-57 presents per 10 s, GPU busy per present ~686 to ~110 ms, 67 frame dumps pixel-identical. Offline replay: 578-593 to 29-31 ms, written buffers byte-identical. A plain read-modify-write loses bytes (tiles share dwords), so it needs real narrow stores. About 4.5M loops per dispatch (138 per group).

MichonGoddijn231849 added a commit to MichonGoddijn231849/AnyPS5-fork that referenced this pull request Oct 9, 2026
…t, recoverable GPU out-of-memory

Large registered ranges are imported in windows; imports carry a use counter, and the least
recently used ones that no build still holds are retired when the import budget or the
driver runs out (a failed vkAllocateMemory is retried after trimming the buffer pool and
retiring imports). Built on main's import ownership: a window is reused only while the
registered Range containing it still owns it, and dma-buf imports keep their own unwatch rule.
ClearHostImports now uncounts the imports it destroys. agc_driver_host_import_windows covers
window reuse, retirement of a remapped range's window and the counter.

Rebased onto main a8b032b by CouchRiv (the conflicts with boykopovar#639 resolved; built and tested on Linux with an RTX 4070 Ti and RADV, and on Windows with an RTX 5080).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: CouchRiv <270190108+CouchRiv@users.noreply.github.com>
MichonGoddijn231849 added a commit to MichonGoddijn231849/AnyPS5-fork that referenced this pull request Oct 9, 2026
…code and stages

The draw key now has a shape: the key without the user words the stages read.
A draw whose key misses but whose shape has an entry copies that entry's decode,
re-reads only the user words (and merged-stage pointers), and reuses each
compiled stage whose captured reads still hold, compiling the rest.

APS5_NO_DRAW_DATA_HITS=1 disables it; APS5_VERIFY_DRAW_DATA_HITS=1 also decodes
and compiles in full and throws when the results differ.

A shape keeps its previous entries and only points at its newest key, so
draws of one shape that alternate between stable user words keep their own
key hits. The shape is passed only when data hits are on, so
APS5_NO_DRAW_DATA_HITS=1 keeps the previous behaviour exactly.

Rebased onto main a8b032b by CouchRiv (the conflicts with boykopovar#639 resolved; built and tested on Linux with an RTX 4070 Ti and RADV, and on Windows with an RTX 5080).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: CouchRiv <270190108+CouchRiv@users.noreply.github.com>
MichonGoddijn231849 added a commit to MichonGoddijn231849/AnyPS5-fork that referenced this pull request Oct 10, 2026
… apertures

Main's StoreAddressU32x2..x4 (one BDA lookup per wide store, b958425) bypassed the aperture routing:
a flat_store_dwordx2 into the shared aperture went to BDA at 0x8000000000000000 and LDS kept its old
value. The wide store now stores each component through the aperture like the 32-bit store, and keeps
the single BDA lookup outside the apertures.

The aperture test's V# word 3 is 0x31016fac (OOB_SELECT 3, raw), as in main's other execution tests:
with OOB_SELECT 0 its output buffer read as out of range after boykopovar#639, which was the failure reported on
four drivers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
adriadam10 pushed a commit to MichonGoddijn231849/AnyPS5-fork that referenced this pull request Oct 10, 2026
… apertures

Main's StoreAddressU32x2..x4 (one BDA lookup per wide store, b958425) bypassed the aperture routing:
a flat_store_dwordx2 into the shared aperture went to BDA at 0x8000000000000000 and LDS kept its old
value. The wide store now stores each component through the aperture like the 32-bit store, and keeps
the single BDA lookup outside the apertures.

The aperture test's V# word 3 is 0x31016fac (OOB_SELECT 3, raw), as in main's other execution tests:
with OOB_SELECT 0 its output buffer read as out of range after boykopovar#639, which was the failure reported on
four drivers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MichonGoddijn231849 added a commit to MichonGoddijn231849/AnyPS5-fork that referenced this pull request Oct 13, 2026
…code and stages

The draw key now has a shape: the key without the user words the stages read.
A draw whose key misses but whose shape has an entry copies that entry's decode,
re-reads only the user words (and merged-stage pointers), and reuses each
compiled stage whose captured reads still hold, compiling the rest.

APS5_NO_DRAW_DATA_HITS=1 disables it; APS5_VERIFY_DRAW_DATA_HITS=1 also decodes
and compiles in full and throws when the results differ.

A shape keeps its previous entries and only points at its newest key, so
draws of one shape that alternate between stable user words keep their own
key hits. The shape is passed only when data hits are on, so
APS5_NO_DRAW_DATA_HITS=1 keeps the previous behaviour exactly.

Rebased onto main a8b032b by CouchRiv (the conflicts with boykopovar#639 resolved; built and tested on Linux with an RTX 4070 Ti and RADV, and on Windows with an RTX 5080), and onto main 6ac63bf by gjonn (main's free-function decoder with its static-ABI mode, its incremental register key as the shape key).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: CouchRiv <270190108+CouchRiv@users.noreply.github.com>
Co-Authored-By: Gjon Gojcevic <7807870+gjonn@users.noreply.github.com>
RINNECODER added a commit to RINNECODER/AnyPS5 that referenced this pull request Oct 13, 2026
* fix(shader): keep each BDA page-table walk to one inlined copy per read

RADV inlines every SPIR-V function, DontInline included, so every
get_bda_pointer call site becomes its own copy of the page-table search,
and NIR's copy propagation of variables grows with each copy. The lookup
now keeps its search bounds in phis and records its header and range
faults from one call site each, picking the first failing check; a byte
read and the byte fallback of a span read loop over one lookup instead of
unrolling one per byte or dword.

(cherry picked from commit 2d3a970)

Ported from upstream PR boykopovar#3655

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

* fix(shader): decode a GPU-selected formatted load from a format table

A formatted load whose V# is selected on the GPU emitted all 77 format
cases in its shared function, and drivers that inline every function,
like RADV, copied all of them into each call site. The load now reads the
format's component count, widths, byte and bit offsets and type from a
constant table and runs one path for its four components, with the
existing conversion of each (type, width) pair selected at run time.
Stores and loads through a prepared V# keep the format switch.

(cherry picked from commit 369928d)

Ported from upstream PR boykopovar#3656

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

* test(shader): pin GPU-selected formatted loads for every buffer format

agc_driver_gpu_buffer_format loads the V#'s records and word3 from memory
and moves them into SGPRs with v_readfirstlane, so the recompiler sees a
GPU-selected descriptor and the load goes through the format table. It
checks buffer_load_format x/xy/xyz/xyzw and d16_xyzw on two lanes for
formats 1-77, selectors 0/1/4-7 on 1-, 2- and 4-component formats, loads
with a component past NUM_RECORDS, and that formats 0 and 78 stop the
invocation before its stores.

The expected values were captured on the format switch (2d3a970) and
are identical with the table.

(cherry picked from commit 6aaae03)

Ported from upstream PR boykopovar#3656

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

* perf(shader): try the last resolved range first in BDA lookups

Every access by guest address calls a lookup that binary-searches the
BDA range table: about 11 dependent steps over ~1200 ranges, 34 table
loads per access. Since boykopovar#639 every buffer access goes through it.

The lookups (read, probe, write and atomic) now keep the last range a
search of the invocation resolved in four private variables: guest
begin and end, device base and permissions. An access inside that range
with the wanted permissions returns base + (address - begin) at once.
Anything else (another range, a missing permission, an unmapped,
zero-byte or wrapping access) takes the full search with every check
and fault it records today, and a successful search refills the cache.
The variables only hold what the table says, and no shader writes the
table, so the result is the search's own answer.

On one title's busiest scene (Windows, discrete GPU), GPU time of the draws 59.5 -> 35 ms per frame.

APS5_NO_BDA_LOOKUP_CACHE=1 emits the lookups without the cache. The
switch is part of the shader disk cache key.

(cherry picked from commit 443583a)

Ported from upstream PR boykopovar#2837

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

* perf(shader): read adjacent BDA dword loads of one block through one span read

A scalar load of several dwords (s_load_dwordx8) becomes one
LoadAddressU32 per dword, and each took its own span read: a probe of
the page table and a fallback that looks every byte up. Loads in one
block with equal address operands and offsets within 16 bytes of the
first now share its span read. Instructions that write memory end a
group; the address operands are compared by content, since each load
carries its own GetAddressResource and SelectU32 values.

A title's main-menu pixel shader goes from 136 span reads
to 34. AMD's Windows compiler inlines the read helpers at every call,
and did not finish that shader before.

(cherry picked from commit 4577633)

Ported from upstream PR boykopovar#4931

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

* fix(shader): read GPU-selected V#s of s_buffer_load as SMEM does

An s_buffer_load_dword* whose V# the recompiler reads at run time (a
GPU-selected V#, for example one built with v_readfirstlane) went
through the vector buffer descriptor checks. A nonzero TYPE in word 3
made it return 0 and record an invalid-descriptor fault, and the base
was used with its two low bits, so a base off a dword boundary read
shifted bytes.

SMEM uses only the base, stride and num_records of the V#, and the two
low bits of the base and of the final address are ignored (AMD RDNA2
ISA guide, 30 November 2020, section 7.1 Table 27 SBASE and section
7.2.1 "Reads using Buffer Constant"). The capture-time evaluator
already follows both rules, and the bound path already drops the two
low bits. For scalar buffer loads the runtime path now masks the base
and skips the TYPE check; vector, formatted and atomic accesses keep
it.

agc_driver_scalar_buffer_gpu_descriptor runs s_buffer_load_dword and
s_buffer_load_dwordx2 through such a V# with the base 0 to 3 bytes off
a dword and TYPE 0 to 3, in wave32 and wave64.

(cherry picked from commit 86fdb52)

Ported from upstream PR boykopovar#5161

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

* perf(recompiler): read the buffer stride and presence at run time in compute modules

(cherry picked from commit 573ba3b)

Ported from upstream PR boykopovar#5353

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

---------

Co-authored-by: EdouardSence <103503538+EdouardSence@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: DotDebian <quentin.waguet@hotmail.fr>
Co-authored-by: poonasor <rickypoon89@icloud.com>
Co-authored-by: Niko <nikobilger2000@gmail.com>
Co-authored-by: Alex Collini <pipodi93@gmail.com>
adriadam10 pushed a commit to MichonGoddijn231849/AnyPS5-fork that referenced this pull request Oct 14, 2026
…code and stages

The draw key now has a shape: the key without the user words the stages read.
A draw whose key misses but whose shape has an entry copies that entry's decode,
re-reads only the user words (and merged-stage pointers), and reuses each
compiled stage whose captured reads still hold, compiling the rest.

APS5_NO_DRAW_DATA_HITS=1 disables it; APS5_VERIFY_DRAW_DATA_HITS=1 also decodes
and compiles in full and throws when the results differ.

A shape keeps its previous entries and only points at its newest key, so
draws of one shape that alternate between stable user words keep their own
key hits. The shape is passed only when data hits are on, so
APS5_NO_DRAW_DATA_HITS=1 keeps the previous behaviour exactly.

Rebased onto main a8b032b by CouchRiv (the conflicts with boykopovar#639 resolved; built and tested on Linux with an RTX 4070 Ti and RADV, and on Windows with an RTX 5080), and onto main 6ac63bf by gjonn (main's free-function decoder with its static-ABI mode, its incremental register key as the shape key).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: CouchRiv <270190108+CouchRiv@users.noreply.github.com>
Co-Authored-By: Gjon Gojcevic <7807870+gjonn@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants