Skip to content

perf(agc): cheaper shader-memory captures per draw - #1193

Merged
adriadam10 merged 4 commits into
boykopovar:mainfrom
MichonGoddijn231849:perf/capture-plan-walk
Oct 15, 2026
Merged

adriadam10 merged 4 commits into
boykopovar:mainfrom
MichonGoddijn231849:perf/capture-plan-walk

Conversation

@MichonGoddijn231849

@MichonGoddijn231849 MichonGoddijn231849 commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

What

The per-draw capture of a shader's memory (the SRT walk and the guest pages it reads) cost about 15 µs per draw packet in GTA V Performance mode. It now costs about 6.6 µs.

  • The SRT evaluator memoizes per plan value in a reused, stamped per-thread array, instead of a hash table allocated and grown on every walk (evaluate 5.3 to 3.6 µs per capture).
  • ExtractPlan also stores the plan's values as 32-byte records with argument ids, and the walk evaluates that compact copy. The IR walk spent most of its time in cache misses (3.6 to 2.7 µs). The evaluator is a template over the node view, so both walks share every opcode's semantics, memo, cycle and failure rules. A plan that cannot be expressed compactly keeps the IR walk. APS5_VERIFY_SRT_COMPACT=1 walks both and throws on any difference.
  • ShaderMemory's valid/read/recent marks are arrays of 64-bit words scanned with countr_zero, instead of bitsets tested bit by bit (about 1 µs per draw packet).
  • Captured pages come from a per-thread pool and are found through a short vector with a last-page shortcut. A page that no recorded GPU work writes gets the flush hook a whole-page read would make, then has each 64-byte block copied when a word of it is first read (about 0.3 µs per packet). Pages with pending GPU writes, and unmapped pages, are read as before.
  • APS5_PROFILE_DRAW splits the capture time into SRT evaluation and materialization, and prints the [capture] line once per 10 s instead of every 500 captures (it cost about 2 µs per capture).
  • Lazy pages count as accessible (they were checked whole when made), a read looks up the code and header snapshots only when it can touch them, and the capture counters run only under APS5_PROFILE_DRAW (@Raghav314's review, his commit as co-author; on Hades II, RTX 4090: capture_resources 2.48 -> 2.14 ms per frame).
  • The disk cache stores the plan's denseValueIds and rebuilds the compact plan on load (ResourceMaterializer::CompactPlan); format 31.

Tested

Windows 11 Pro, AMD Ryzen 5 7600, NVIDIA GeForce RTX 5080 (driver 32.0.16.1074), 32 GB, MinGW-w64 GCC 15.2.0.

  • Based on main 872f523 (2026-10-14) on its own; it no longer depends on perf(agc): linear color targets in registered guest memory get a resident image #1191.
  • The whole agc_* and shader* suite passes on Windows: 504 tests, 1 skipped (agc_driver_stencil_ref_export). Not run here: the relinker's Python tests (my local Python does not find their helper modules) and guest_gameupdate.
  • Summary lines of main and of this PR (GTA V PPSA04264 Performance mode, the same scene, warm caches, traces attached): to follow in a comment.

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: none
  • Unblocks: none (speed)
  • GPUs tested: NVIDIA GeForce RTX 5080, driver 32.0.16.1074 (595.97), Windows 11 Pro
  • AI-assisted: yes. Written with Claude (Anthropic) under my direction; I reviewed and tested it.

🤖 Generated with Claude Code

@github-actions github-actions Bot added build shader core/shader agc libSceAgc* labels Oct 7, 2026
@github-actions github-actions Bot added the needs rebase Conflicts with main, rebase needed label Oct 8, 2026
@github-actions github-actions Bot added needs rebase Conflicts with main, rebase needed and removed needs rebase Conflicts with main, rebase needed labels Oct 8, 2026
@MichonGoddijn231849
MichonGoddijn231849 marked this pull request as draft October 9, 2026 08:32
@MichonGoddijn231849

Copy link
Copy Markdown
Contributor Author

Parked as a draft: the capture path changed a lot with #639 (captures now run against the prepared invocation), so this needs a rework rather than a rebase. I'll mark it ready again once it is ported.

@github-actions github-actions Bot added needs rebase Conflicts with main, rebase needed and removed needs rebase Conflicts with main, rebase needed labels Oct 14, 2026
@Raghav314

Copy link
Copy Markdown
Contributor

Thanks for taking the three read-path points in. One thing for Linux: the rebased head doesn't build there. ShaderDiskCache.cpp checks sizeof(IrResourcePlan) == 584 inside the __linux__ && __x86_64__ && __GLIBCXX__ block, and with the compact plan the struct is 712 bytes with libstdc++ (I checked with a sizeof probe on my machine: Linux, CachyOS, GCC). The encoder already stores denseValueIds and the compact plan is rebuilt on load, so I think only the number needs to change:

static_assert(sizeof(IrResourcePlan) == 712, "IrResourcePlan changed: update EncodePlan and DecodePlan");

With that it builds, and the agc_* suite passes here on main 98541c6 with #1193 merged in (alongside #494, #1201, #1202 and #5687, so not on its own).

LLM assistance used.

MichonGoddijn231849 and others added 4 commits October 15, 2026 11:11
The per-draw capture of a shader's memory (the SRT walk and the guest pages it reads) cost about
15 us per draw packet in GTA V Performance mode; it now costs about 6.6.

- The SRT evaluator memoizes per plan value in a thread's reused, stamped array instead of a hash
  table allocated and grown on every walk (evaluate 5.3 -> 3.6 us per capture).
- ExtractPlan also stores the plan's values as 32-byte records with argument ids, and the walk
  evaluates that compact copy: the IR walk spent most of its time in cache misses (3.6 -> 2.7 us).
  The evaluator is a template over the node view, so both walks share every opcode's semantics, memo,
  cycle and failure rules. A plan that cannot be expressed compactly keeps the IR walk.
  APS5_VERIFY_SRT_COMPACT=1 walks both and throws on any difference.
- ShaderMemory's valid/read/recent marks are arrays of 64-bit words scanned with countr_zero instead
  of bitsets tested bit by bit (about 1 us per draw packet).
- Captured pages come from a per-thread pool, are found through a short vector with a last-page
  shortcut, and a page no recorded GPU work writes gets the flush hook a whole-page read would make,
  then has each 64-byte block copied when a word of it is first read (about 0.3 us per packet).
  Pages with pending GPU writes and unmapped pages are read as before.
- APS5_PROFILE_DRAW splits the capture time into SRT evaluation and materialization, and prints the
  [capture] line once per 10 s instead of every 500 captures (it cost about 2 us per capture).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Gjon Gojcevic <7807870+gjonn@users.noreply.github.com>
…'t need

A lazy page was found accessible whole when it was made, and its blocks are
copied from guest memory on demand; accessible() no longer asks
GuestMemory::Accessible for it again on every word the SRT walk checks. A read
looks up the code and header snapshots only when it can touch them, and the
read and page counters run only under APS5_PROFILE_DRAW, where the [capture]
line prints them.

Raghav314 measured the three on Hades II (PPSA36082, RTX 4090, Linux):
capture_resources 2.48 -> 2.14 ms per frame, frame p50 12.34 -> 11.60 ms.

Co-Authored-By: Raghav314 <71544502+Raghav314@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…stdc++

Co-Authored-By: Claude <noreply@anthropic.com>
…luator on main

Co-Authored-By: Claude <noreply@anthropic.com>
@adriadam10
adriadam10 force-pushed the perf/capture-plan-walk branch from ce22992 to eacacc6 Compare October 15, 2026 09:17
@adriadam10

Copy link
Copy Markdown
Collaborator

Rebased onto current main (0afa6a6) and force-pushed; the previous head is kept at adriadam10/AnyPS5:backup/MichonGoddijn231849-perf-capture-plan-walk.

  • Conflicts with perf(shader): keep only evaluable branch conditions in resource plans #5181 resolved: IrValueArena and the compact plan structs live side by side in ResourcePlan.hpp; MayEvaluate is a static member of the templated evaluator; the materializer clones through the arena. Shader cache FormatVersion bumped to 36.
  • sizeof(IrResourcePlan) assert set to 712 (libstdc++, as Raghav measured); fieldCount stays 23, denseValueIds is encoded and CompactPlan runs on decode.
  • ShaderMemory::readWords adapted to main's WordMask (new SetRun instead of a std::bitset shift), and the MayEvaluate test calls the evaluator through Detail::IrNode.

Tested on Linux (RADV 660M, -DANYPS5_ENABLE_SPIRV_TOOLS=ON, APS5_VERIFY_SRT_COMPACT=1): shader/agc/recompiler ctest 588/589; the one failure is agc_driver_tessellation, which segfaults the same way on main on this GPU. Windows build dispatched next. No title runs on this machine, so the before/after summary lines Dean asked for still need someone with a game.

@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 15, 2026
@github-actions

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
#6032 core/shader/recompiler/ShaderDiskCache.hpp L16
#6053 core/shader/recompiler/Optimization/include/Optimization/SrtWalker/SrtEvaluator.hpp L78-80
core/shader/recompiler/Optimization/src/SrtWalker/SrtEvaluator.cpp L277
#6095 core/shader/recompiler/IntermediateRepresentation/include/IntermediateRepresentation/IrMetadata/ResourcePlan.hpp L132
core/shader/recompiler/Optimization/src/SrtWalker/SrtDescriptorEvaluation.cpp L7, L46-48, L203
core/shader/recompiler/ShaderDiskCache.cpp L148

@EagleBearerYS

Copy link
Copy Markdown
Contributor

One thing we noticed while porting #1201's version, in case it helps: since this merged, a shader-memory capture page that's marked lazy skips the multisample read guard.

Before (042eb7c), a page a capture first touched was read whole with GuestMemory::Read (ShaderMemory.cpp L111), which runs the read guard before its flush (GuestMemory.cpp L2004). MultisampleTarget.cpp installs that guard so that a CPU read of a live multisample target throws (L233-L234, L344).

On main now (1db84d5), the page only gets FlushGpuWrites and is marked lazy (ShaderMemory.cpp L191-L194), and each 64-byte block is later copied from guest memory with std::memcpy (L220-L223). So a capture whose first read lands on a live multisample target's memory now reads the guest bytes without that check.

We haven't seen a title hit it. Running the guard for the page when it's marked lazy would keep the check. Happy to send the small fix with a test if you'd like one.

EagleBearerYS added a commit to EagleBearerYS/AnyPS5 that referenced this pull request Oct 15, 2026
Brings the series onto main 4e9a2a8 (boykopovar#1193, boykopovar#1201, boykopovar#1202, boykopovar#6060,
boykopovar#6333 and the descriptor word tracking of 682b07e).

Resolutions:
- ShaderMemory: the column memo's supplied and spilled blocks now sit
  on boykopovar#1193's page vector, page pool and word masks. Pages are found by
  binary search, since a spilled block can add many pages, and a block
  placed into a lazily read page fills the rest of the 16-word blocks it
  touches, so a later lazy read never overwrites placed words.
- ResourceMaterializer and SharedValues: the selected-record path and
  the words index carry availableDwordMask, as the snapshot hash does.
  A selected record's dwords past the end of its buffer count as read
  zeros, which is what a scalar buffer load returns there, so a key past
  the heap still selects a null image without reading the heap.
- DescriptorCache: kept table sets live beside boykopovar#1201's recycled sets.
- Tests and CMake: both sides' tests are kept.
mrzacarias pushed a commit to mrzacarias/AnyPS5 that referenced this pull request Oct 15, 2026
Upstream main now carries the final versions of PRs boykopovar#1193, boykopovar#1201, boykopovar#1202, boykopovar#5216 and boykopovar#4465, so their
draft copies on this branch give way to main's: the snapshot ring, the pipeline identity memo, the
code-hash memo and APS5_RESIDENT_LINEAR_TARGETS are gone with them. The port of boykopovar#4943 (draw-cache
relocation across moved user-data pointers, not merged upstream) is dropped: the draw prefetch
replaces it.

Kept on top of main: the arm64 FEXCore runner and macOS libraries, the MoltenVK settings, rect-list
clip and cull distances, GPU-ordered in-place vertex inputs, the draw pipeline's spin-first locks and
lock-free overlap check, SharedVector binding flags, the result-memo admission, the draw prefetch
(APS5_DRAW_PREFETCH) and the 1D T# sample modes. New for arm64: fegetenv from the runner's guest
control words, and the direct-memory reuse map on macOS. On macOS the guest buffers keep one
descriptor array (no buffers_coherent or buffers_u8 aliases): MoltenVK aliases a descriptor array only
through Metal argument buffers, which the driver turns off, and Vampire Crawlers' first compute
pipeline failed to compile.
@MichonGoddijn231849

Copy link
Copy Markdown
Contributor Author

@EagleBearerYS you're right, thanks for catching it. On main the lazy page only gets FlushGpuWrites (ShaderMemory.cpp L191-L194) and its blocks are later copied with memcpy, so the read guard that GuestMemory::Read ran first is skipped for a capture's first touch of a page. Running the guard over the page when it is marked lazy keeps the check at no per-word cost. Yes please, send the fix with the test; I'll review it.

adriadam10 pushed a commit that referenced this pull request Oct 19, 2026
Brings the series onto main 4e9a2a8 (#1193, #1201, #1202, #6060,
#6333 and the descriptor word tracking of 682b07e).

Resolutions:
- ShaderMemory: the column memo's supplied and spilled blocks now sit
  on #1193's page vector, page pool and word masks. Pages are found by
  binary search, since a spilled block can add many pages, and a block
  placed into a lazily read page fills the rest of the 16-word blocks it
  touches, so a later lazy read never overwrites placed words.
- ResourceMaterializer and SharedValues: the selected-record path and
  the words index carry availableDwordMask, as the snapshot hash does.
  A selected record's dwords past the end of its buffer count as read
  zeros, which is what a scalar buffer load returns there, so a key past
  the heap still selects a null image without reading the heap.
- DescriptorCache: kept table sets live beside #1201's recycled sets.
- Tests and CMake: both sides' tests are kept.
adriadam10 pushed a commit that referenced this pull request Oct 19, 2026
Since #1193 (4651980) a capture copies a page in 64-byte blocks as it
reads them, instead of reading the whole page with GuestMemory::Read the
first time it touches it. The page now only gets FlushGpuWrites, which
runs the flush hook but not the read guard, so a capture over a
multisampled color target's memory, whose samples are only on the host,
read the stale guest bytes silently where it threw before.

ShaderMemory::page runs GuardRead over the page where the whole-page
read stood, before the flush, as Read does. The check stays once per
page a capture touches, as before #1193. The block copies need none: a
pooled page is reset and goes through page() again, and later reads of
a page were not checked again before #1193 either.

The test captures a shader whose SRT lies in a live 4x target's memory,
then one whose SRT lies just past it, then the first again on the
pooled page.
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.

5 participants