Skip to content

perf(agc): reuse cached draw stages when only buffer base addresses moved - #5609

Open
TheAggie wants to merge 14 commits into
boykopovar:mainfrom
TheAggie:perf/agc-draw-stage-relocation
Open

TheAggie wants to merge 14 commits into
boykopovar:mainfrom
TheAggie:perf/agc-draw-stage-relocation

Conversation

@TheAggie

@TheAggie TheAggie commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

What

Second step for #4054, stacked on #4943. That PR finds a cached draw when the user-data pointers moved and the data behind them is equal. A guest driver that keeps its buffer descriptors in a table reached through such a pointer (RADV's descriptor sets) still missed on every draw whose buffers moved, because the descriptor's base address is among the words a stage is validated against.

  • A stage whose captured words differ from the cached ones only in buffer base addresses is reused: the base words of the bound buffers are patched in the cached result, and the buffers themselves are read by the draw as usual, so their contents stay out of the match.
  • Cached stages are relocated one by one, keyed by their prepared shader, so a draw can take its vertex stage from one earlier draw and its pixel stage from another.
  • A patched stage replaces its donor, so the next draw with the same bases matches without another patch.
  • Reusing draw snapshots across draws that nothing wrote between was a commit of this PR; it landed on its own as perf(agc): reuse draw snapshots across draws that nothing wrote between #5906.
  • The second-sight admission (perf(agc): cache a draw key the second time it is seen #5216) counts a repeat of a stage's relocated contents as a second sighting, since a draw's stages are now matched one by one.
  • At their limit the stage donor and decode maps drop every other entry, not all of them.
  • The shader disk cache carries the new bufferBaseSlots field of a result, and its Linux-only size asserts expect the grown structs.
  • Two per-draw costs that showed up once the cache hit: the dynamic state of an open render pass is only set again when it changed, and AnyPendingOverlaps consults the pending image range index first. The state the filter compares against is kept per command buffer, in the recorder batch's open render pass: it is reset when a new pass starts and marked unknown after the back-face and second-channel splits and after stencil replay (which sets the stencil reference itself).
  • A stage that upstream's stage-data hits would verify (APS5_VERIFY_DRAW_DATA_HITS=1) is left to that verification instead of being reused.

APS5_NO_DRAW_RELOCATION=1 turns the relocation off, APS5_NO_DRAW_STATE_FILTER=1 the dynamic state filter.

Only the commits after #4943's head are this PR.

Tested

Windows 11, NVIDIA GeForce RTX 5070 Ti, MinGW-w64 GCC 15.2.0, rebased on main 940eba9a2.

  • ctest: 1270 of 1292 pass, 14 skipped. guest_call_trace_abort, windows_backtrace_report and agc_driver_store_over_untracked fail, and fail the same way on plain main here. agc_driver_prepared_shaders_debug failed once in a parallel run and passes alone. Three newer tests, agc_driver_prepared_graphics_prebuild_0/_1 and undeliverable_host_exception, fail under my test runner because it skips their environment changes and pass pattern; run with those, all three pass.
  • The size asserts in ShaderDiskCache.cpp only compile on Linux. I have no Linux build; I compiled the structs under MinGW libstdc++ on x86_64, where all asserted sizes match. agc_shader_disk_cache's result round trip now also sets and compares bufferBaseSlots.
  • New test agc_driver_draw_cache_moved_buffer: a pixel shader loads a buffer descriptor through a user-data pointer and reads its colour from that buffer. Once the draw has been seen twice, drawing it again with the descriptor's base changed, with the same or other contents, hits the cached draw. It fails with APS5_NO_DRAW_RELOCATION=1.
  • On main 940eba9a2, this PR and perf(agc): find cached draws across moved user-data pointers #4943 against the same build without them (both with the same local changes the frontend needs to boot, immediate present, audio muted, three alternating unprofiled runs): audio produced at 34.5%, 35.7% and 35.6% of real time without, against 45.7%, 44.1% and 42.8% with.
  • RetroArch (PPSA99169) with its Flycast core running Crazy Taxi's attract demo, measured before the rebase, on main 4430780bd (without perf(agc): cache a draw key the second time it is seen #5216) plus local changes outside this PR that the frontend needs to boot. perf(agc): find cached draws across moved user-data pointers #4943 alone against perf(agc): find cached draws across moved user-data pointers #4943 plus this PR, same local base, one thread, ANYPS5_PRESENT_MODE=immediate, two unprofiled runs each: audio produced at 43.6% and 43.4% of real time against 57.6% and 57.9%. One profiled run each: queue worker 41.5 µs against 30.7 µs per draw packet; stage captures per 10 s 92,728 in 185,141 draws against 14,859 in 238,439; draws with every stage reused 75% against 96.8%.
  • Same demo and base with perf(agc): cache a draw key the second time it is seen #5216 cherry-picked, two unprofiled runs each: the chain without the admission 59.1% and 58.8%; with perf(agc): cache a draw key the second time it is seen #5216 as merged 51.0% and 50.7%; with the admission change of these two PRs 58.8% and 58.5%.
  • Same demo with APS5_VERIFY_DRAW_ENTRIES=1, which captures every hit again and compares: 0 stages differed in every report (118,722 hits in the largest).

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: perf(agc): find cached draws across moved user-data pointers #4943
  • AI-assisted: yes. Written with Claude Code under my direction.

🤖 Generated with Claude Code

@CnRJay CnRJay left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per-stage relocation is a sensible next step after #4943. The verify mode (APS5_VERIFY_DRAW_ENTRIES=1, 0 differing stages) is good evidence for the demo. I found two correctness gaps that the demo probably doesn't reach, and one gap in the evidence.

1. The content hash stops at 64 words of each read. In relocatedContentKey, each (offset, words) read is hashed as count = std::min<std::size_t>(words, MaxContentWords). words is the length of a run that learnPendingRelocation derives from variant.runs, which follow the captured regions. A descriptor table reached through a relocatable base can easily be longer than 64 dwords; eight 8-dword T#s are already 64. Words past the first 64 are then never compared. A table that moved, with the same first 64 words and different descriptors after them, produces the same alias key and reuses the donor stage with stale descriptors. Either hash the whole run (in chunks of MaxContentWords), or ban the register in learnPendingRelocation when a run is longer than the limit, so such a stage stays on the capture path.

2. RecordedDrawState is one process-wide object with no lock. recordedDrawState() returns a function-local static that apply and setLibraryState read and write without synchronization. The recorded.commands == commands check only protects against a different command buffer when two command buffers are recorded one after the other. Suppose two queue workers record concurrently (the measurements say "one thread", so I assume multi-threaded recording is a supported mode). Thread A checks that recorded.commands is its own buffer, thread B then stores its own viewport and stencil values, and A compares its new values against B's. A then skips a vkCmdSet* whose value differs from what A's command buffer actually holds, so state set for one queue's draws leaks into another's. Keeping this state per command buffer (in the recorder's batch, next to commands) avoids both the race and the handle comparison. thread_local would also work if a command buffer is only ever recorded on one thread. Besides that, the filter looks right: Begin re-applies everything with known = false, and the two other graphics binds reset recorded.commands.

3. The MaxAliases limit clears both maps outright. drawStageDonors.clear() / drawDecodes.clear() at 16384 entries means a title that crosses the limit loses every donor at once. For several frames, every moved draw then goes back to full capture. That is exactly the stall pattern this PR is trying to remove, and it would show up as a periodic hitch rather than a lower average. An LRU, or evicting the oldest half, keeps the steady state. At least a counter in the profile output would make the cliff visible.

Evidence:

  • The per-packet times (41.5 → 30.7 µs) and capture counts come from profiled runs.
  • The unprofiled metric, audio produced as a share of real time, is a reasonable proxy for this demo. It is still not the aps5-summary.jsonl lines plus traces captured with no APS5_PROFILE_* switch that the Performance report form asks for.
  • The base also includes local changes outside the PR.

The summary lines of #4943 alone and of #4943 + this PR, on the same machine, would make the claim checkable. So would a run on a second title that doesn't use a RADV-style descriptor-set layout, which would show that the extra per-draw content hashing (CopyMapped plus the hash of every relocatable read) costs nothing where relocation never hits.

Also, since only the commits after #4943's head belong to this PR, Depends on: #4943 should be filled in the checklist, if it isn't already.

@github-actions github-actions Bot added the needs rebase Conflicts with main, rebase needed label Oct 14, 2026
0xcrstn added a commit to 0xcrstn/AnyPS5 that referenced this pull request Oct 14, 2026
…eir read ranges

Two kinds of draw snapshot were always copied whole.

A moved buffer is a read-only guest buffer of a draw that reuses a cached
resource build whose V# pointed elsewhere (MovedReadOnlyBuffers, the draw
relocation of boykopovar#5609): the snapshot copies the buffer at the draw's own
address and size instead of the build's. Its read ranges are relative to
the V# base, so they hold at the new address; they are now placed from
the draw's own binding and V# when the move is found and used like an
unmoved buffer's.

A formatted load (buffer_load_format_*, tbuffer_load_*) addresses its
element like any MUBUF/MTBUF load, index * stride + offset + instruction
offset + soffset (RDNA 2 ISA, buffer addressing), and reads its components
at byte offsets within one element of its data format, whose largest is
32_32_32_32 (16 bytes); SpirvRuntimeBuffer's formatted() reads each
component through the same address(). Such a load now reads up to 16
bytes from its offset instead of the whole buffer.

The "moved" and "formatted load" reasons are gone from the whole-copy
counts; the shader disk cache format goes to 32.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@adriadam10 adriadam10 self-assigned this Oct 14, 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.

Reviewed against the diff; two correctness problems, and the PR still needs a rebase once #4943 lands.

  • DrawRelocate.cpp relocatedContentKey: each read is hashed as min(words, MaxContentWords) (64). A descriptor table longer than 64 dwords that moved, with the same first 64 words and different descriptors after them, gets the same alias key and reuses the donor stage with stale descriptors. Hash the whole run in chunks, or stop treating the register as relocatable in learnPendingRelocation when a run is longer than the limit.
  • Pipeline.cpp recordedDrawState(): a function-local static with no lock, read and written by apply/setLibraryState. With two queue workers recording, the recorded.commands == commands check does not stop one worker comparing against the other's viewport/stencil values and skipping a vkCmdSet* its command buffer needs. Keep this state per command buffer (next to commands in the recorder's batch) or make it thread_local if recording is single-threaded; please say which holds.
  • MaxAliases: drawStageDonors.clear() / drawDecodes.clear() at 16384 drops every donor at once, so every moved draw goes back to full capture for a while. Evict the oldest part instead, or at least count it in the profile output.
  • Evidence: the 41.5 → 30.7 µs numbers come from profiled runs and the base has local changes outside the PR. The unprofiled aps5-summary.jsonl lines of #4943 alone vs #4943 + this PR, plus one title without a RADV-style descriptor layout showing the extra hashing costs nothing, would make it checkable.
  • The branch conflicts with main and is stacked on #4943, which is open and conflicting too; rebase after it merges.

Welcome back once these are addressed.

@TheAggie
TheAggie force-pushed the perf/agc-draw-stage-relocation branch from 8417747 to 00816c0 Compare October 14, 2026 12:55

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

Thanks for the update. The 64-word hash and the all-at-once eviction are fixed (chunked hashing, dropEveryOther with the evicted counter). One point from the last review is still open:

  • Pipeline.cpp:690 recordedDrawState() is still a function-local static with no lock. With two queue workers recording, the recorded.commands == commands check does not stop one worker comparing against the other's viewport/stencil values and skipping a vkCmdSet* its own command buffer needs. Keep the state per command buffer (next to commands in the recorder's batch), or make it thread_local if recording is single-threaded; please say which one holds.
  • The branch still conflicts with main and is stacked on #4943, which is open and conflicting. Please rebase once #4943 lands.

@TheAggie
TheAggie force-pushed the perf/agc-draw-stage-relocation branch from 00816c0 to d6abd55 Compare October 14, 2026 13:15
@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 14, 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.

Thanks for the rebase. The 64-word hash and eviction fixes are in; one point from the last review is still open:

  • Pipeline.cpp:690 recordedDrawState() is still a function-local static with no lock. With two queue workers recording, recorded.commands == commands does not stop one worker comparing against the other's viewport/stencil values and skipping a vkCmdSet* its own command buffer needs (and the writes race). Keep the state per command buffer (next to commands in the recorder's batch), or make it thread_local if recording is single-threaded; please say which one holds.
  • Still stacked on #4943, which is open; please rebase once it lands.

@TheAggie

Copy link
Copy Markdown
Contributor Author

@CnRJay thanks for the review. Rebased onto main 7181fe94d (on top of #4943's new head) and force-pushed.

  1. Content key cut off at 64 words. Fixed: a read is now hashed whole, 64 words at a time, with its offset and length mixed in. For the record, a cut-off key could not make a stale stage be reused, because a candidate is still compared word by word before it is taken; what it cost was two different buffers sharing a key and pushing each other out.

  2. RecordedDrawState is process-wide and unlocked. Not changed in this push. Recording into a command buffer already happens under the GPU mutex, so the state is not touched by two threads at once. thread_local would be wrong here: the queue worker and the committer record into the same command buffer in turn, and the filter has to see what the other one set. If you would rather not rely on that, I can move the state into the recorder object so its lifetime and its lock are the recorder's.

  3. Clearing a whole map at the limit. Fixed: at the limit every other entry is dropped instead of all of them, for the stage donors and the decodes here and the aliases in perf(agc): find cached draws across moved user-data pointers #4943, and the [draw-relocate] line reports how many were dropped.

  4. Evidence. Unprofiled captures with APS5_TRACE_SECONDS=60:30, Crazy Taxi attract demo in RetroArch/Flycast, ANYPS5_PRESENT_MODE=immediate, same machine, runs alternated. Base is main a14ae8e23 plus the local changes the frontend needs to boot (not in either PR). From aps5-summary.jsonl:

    build presents in 30 s GPU busy ms per present audio produced (of real time)
    base, neither PR 202 2.13 5.8%
    perf(agc): find cached draws across moved user-data pointers #4943 188, 194 1.29, 1.29 5.4%, 5.5%
    perf(agc): find cached draws across moved user-data pointers #4943 + this PR 1261, 1191 0.89, 0.92 36.6%, 36.0%

    I will be plain about the middle row: on current main perf(agc): find cached draws across moved user-data pointers #4943 alone does not move this workload. A profiled run shows it hits about 40% of draws, but on this base almost every draw copies a whole 2 MiB guest buffer into a snapshot (about 12,500 copies and 16 GB a second), and a cache hit still makes that copy. This PR reuses the snapshot when nothing wrote between draws (e92089d9f) and turns most draws into hits that skip the stage captures. I have not measured those two apart, so I can't say how the gain splits between them. The 27% to 38% in perf(agc): find cached draws across moved user-data pointers #4943's description is from the older base and says so; I will add this result there too.

    I have not yet run a title that does not go through RADV; none of my test titles uses Sony's own graphics driver, so that part is still owed.

The dependency is unchanged: this PR depends on #4943, and only the commits after that PR's head belong to it.

This push also fixes the Linux build that @Raghav314 reported on #4943: the size asserts in ShaderDiskCache.cpp and the result encoder now cover bufferBaseSlots.

AI-assisted (Claude Code).

@TheAggie

Copy link
Copy Markdown
Contributor Author

Restructured, no change in behaviour: the commit that reuses draw snapshots across draws nothing wrote between is now its own PR, #5906. It helps every Windows title, not only the relocation path here. On main alone it takes the Crazy Taxi demo from 5.8% to about 26.5% of real time, since on Windows each draw otherwise copies a 2 MiB buffer that is almost always unchanged.

This branch now carries #5906's two commits under its own, so it depends on #4943 and #5906. Since #5906 went up separately it has a gentler eviction (every other entry at the limit), a recorder-level test, and the debt line for #1441 updated. All three are in this branch too. Both branches are rebased onto main 84b01f9cd.

AI-assisted (Claude Code).

@JJenkx

JJenkx commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

I prepared a Linux current-main port of this existing PR (no replacement PR): commit 9592291d1afb7e52796092d2edf39fb9cf2ed5b1, branch JJenkx:codex/pr5609-current-main-port-20261024. Production base is main 5ed5427bace6512fec69caa706874e4c847f8865; your input head is 09238da8206e0aa70d8966c69f822c297da36308.

The 12 conflict files needed more than taking either side: the port retains main's DrawTail/DrawFinisher synchronization, mutex abstraction, mesh descriptor and user-data mask metadata, optimized-pipeline scheduling, and sample-order APIs. Main already has the pending-image index shortcut. The Linux result-size assertion grows from current main's 544 to 568 bytes for bufferBaseSlots, rather than reverting the other current ABI sizes. The new diagnostic switch now has its required environment row.

One additional integration issue was reproducible: the moved-buffer regression still failed with the current SRT tape, but passed with APS5_NO_SRT_TAPE=1. The port now carries read-instruction, source-lead and operand provenance through the tape, rolls it back on refusal, preserves non-forwarding dependencies through lowered operations, and checks these fields in the tape/interpreter verifier. The tape stays enabled. Existing data-hit tests' forced-recompilation expectations were updated where relocation now permits reuse; payload/output checks, verification mode and disabled-relocation controls remain.

On RX 6650 XT / RADV, Release, with ANYPS5_REQUIRE_VULKAN=1, ANYPS5_NO_SHADER_CACHE=1, and actual Khronos validation:

  • Exact candidate: 8/8 focused checks pass (moved-buffer, relocation, reuse, data-hit user words, finisher ordering, disk-cache codec, relocation pipeline, reuse entry verification). Identical-fixture main control 89ee2611fe5ccfdada4b8ed46afa61cd14edadba: 6/8 pass, with the two expected missing-reuse assertions failing.
  • Five further runs with APS5_VERIFY_SRT_TAPE=1 pass with no reported tape/interpreter differences, including the expanded metadata checks.
  • Pipeline, draw-data verification, and refresh modes pass. With relocation disabled, the data-hit fixture passes its old-behavior checks and the moved-buffer fixture fails specifically for missing reuse.
  • All physical GPU runs loaded the validation layer and report zero VUIDs; no skips. The disk-cache codec is CPU-only. Conventions and whitespace checks pass.

Full logs, exact pins, patch, negative controls and earlier failed-attempt evidence — SHA-256 73f1427961e11262f6685d53dd4acc1a513c82a412898a7d17e472541d777a15.

This is focused integration/correctness evidence, not a title FPS result, full-app build, or fresh Windows validation. FAR's cache traces motivated trying this port; a matched scene comparison remains to be done. The complete main-to-port patch is in the archive if useful for rebasing your branch. AI-assisted: Codex.

@EagleBearerYS

Copy link
Copy Markdown
Contributor

@JJenkx one note on the port (9592291), in case it's taken up: it changes the shader disk cache encoding (ShaderDiskCache.cpp now writes and reads bufferBaseSlots), but FormatVersion in ShaderDiskCache.hpp stays at main's 47. So caches written by main would be decoded with the new layout. It would need a version of its own.

@JJenkx

JJenkx commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

FAR (PPSA03766) same-scene measurement of the Linux port above is complete. This single pair does not establish a reliable FPS gain.

Both arms start from main 5ed5427bace6512fec69caa706874e4c847f8865 and include the same existing #6782/#6913 prerequisites needed for this title. Thus this compares a shared integration baseline with that integration plus this PR's current-main port, rather than vanilla main.

Arm Exact commit Presents/window FPS Frame p50 / p90 / p99 (ms) GPU busy/present (ms)
main 6d8e603390697291189daf7d76580d197c5aa9e8 85 / 10008.724 ms 8.493 114.783 / 134.222 / 149.962 55.275
candidate cfa83360da4ea1639dadbfe32d9c86274773ffca 88 / 10088.239 ms 8.723 112.525 / 127.275 / 153.497 53.55

The observed +2.7% FPS / lower median is small, p99 is slightly worse, and other-host CPU estimates differ (13.5% vs 10.2% of the 32-thread machine over the sampled trace intervals). Application CPU use is 2.20 vs 2.22 cores. I would not use this to claim a reproducible title speedup.

Protocol: separate private conversion/runtime/cache per arm; identical copied surface checkpoint, inputs and settings; first warm-up, then fresh measured process; official 250:10 trace with 1,048,576 event capacity; caches enabled, validation disabled for timing, actual PTYs. Both traces have clean distinct commit stamps, no dropped events and 5/5 sampled focus checks. All four runs closed normally, restored temporary kernel configuration/private writables, and left the immutable seed unchanged. Prior focused GPU correctness/layer evidence remains scoped separately in the earlier comment.

Directly compared both measured 270-second images: same surface swimmer and flooded-village camera/geometry/lighting. Candidate direct-window observations at 179/275 seconds show changing water/debris/pose; repeated warm XComposite captures alone were insufficient to assess animation. These extra readbacks were outside the timing window. Audio and new gameplay progression are unverified.

Inclusive emitted shader-stage tags suggest lower materialization time (4.30 → 3.46 ms/present) but higher capture time (1.62 → 2.25); the combined shader compile/link tag is 17.04 → 17.46. These overlap and are thresholded trace slices, not independent costs or counts of new SPIR-V compilations.

Exact baseline and candidate commits are available on JJenkx:validation/far5609-main-20261024 and JJenkx:validation/far5609-candidate-20261024. Both full traces, trace_digest reports, screenshots, source patches, receipts and qualified review, SHA-256 17a845f4aaab996b550268d8b926428907ae57d44db873bea241ff47a6e3f673. No new PR was opened.

Official summary lines, main then change:

{"commit":"6d8e60339069","dirty":false,"env":{"APS5_DRAW_CACHE_ENTRIES":"4096","APS5_HOST_IMPORT_MIB":"16384","APS5_PAD_INPUT_FILE":"<path>","APS5_PIPELINED_DRAWS":"1","APS5_PLAYGO_UNLISTED_CHUNKS":"absent","APS5_TRACE_EVENTS":"1048576","APS5_TRACE_SECONDS":"250:10"},"gpu":"AMD Radeon RX 6650 XT (RADV NAVI23)","driver":"radv Mesa 26.2.4-arch1.1","os":"Linux 7.2.9-arch1-1","cpu":"AMD Ryzen 9 7950X 16-Core Processor","cpu_threads":32,"memory_mib":127901,"title_id":"PPSA03766","title":"FAR: Changing Tides","content_version":"01.002.000","resolution":"1920x1080","window":{"spec":"seconds 250:10","first_present":2311,"ms":10008.724},"presents":85,"frame_ms":{"p50":114.783,"p90":134.222,"p99":149.962,"max":149.962},"gpu_busy_ms_per_present":55.275,"worker_busy_pct":{"queue worker 0x0":94.882,"queue worker 0x38":75.145},"top_phases_ms_per_frame":{"Driver.Packet":198.669,"Driver.Packet.memory_wait":80.392,"Driver.Packet.dispatch":49.004,"Driver.Packet.draw":48.359,"Driver.Draw":47.986,"Graphics.Draw":44.087,"Graphics.Draw.draw_and_resource_release":36.727,"Driver.Dispatch":31.671},"trace_dropped":false,"trace":"aps5-trace-6d8e60339069-1325165.json.gz"}
{"commit":"cfa83360da4e","dirty":false,"env":{"APS5_DRAW_CACHE_ENTRIES":"4096","APS5_HOST_IMPORT_MIB":"16384","APS5_PAD_INPUT_FILE":"<path>","APS5_PIPELINED_DRAWS":"1","APS5_PLAYGO_UNLISTED_CHUNKS":"absent","APS5_TRACE_EVENTS":"1048576","APS5_TRACE_SECONDS":"250:10"},"gpu":"AMD Radeon RX 6650 XT (RADV NAVI23)","driver":"radv Mesa 26.2.4-arch1.1","os":"Linux 7.2.9-arch1-1","cpu":"AMD Ryzen 9 7950X 16-Core Processor","cpu_threads":32,"memory_mib":127901,"title_id":"PPSA03766","title":"FAR: Changing Tides","content_version":"01.002.000","resolution":"1920x1080","window":{"spec":"seconds 250:10","first_present":2232,"ms":10088.239},"presents":88,"frame_ms":{"p50":112.525,"p90":127.275,"p99":153.497,"max":153.497},"gpu_busy_ms_per_present":53.55,"worker_busy_pct":{"queue worker 0x0":94.789,"queue worker 0x38":75.146},"top_phases_ms_per_frame":{"Driver.Packet":193.433,"Driver.Packet.memory_wait":77.781,"Driver.Packet.dispatch":49.223,"Driver.Packet.draw":46.692,"Driver.Draw":46.349,"Graphics.Draw":41.059,"Graphics.Draw.draw_and_resource_release":34.138,"Driver.Dispatch":32.008},"trace_dropped":false,"trace":"aps5-trace-cfa83360da4e-1412121.json.gz"}

AI-assisted: Codex.

@JJenkx

JJenkx commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

I refreshed the reference port as one commit on main cf528e78c6c8fd30696ccfca6c097f9828be2cf6: 682ce616, branch codex/pr5609-key-donors-codec48. This supersedes my earlier reference port 959. This is offered for adoption into this PR; no duplicate PR opened.

The per-stage donor index now stores cache keys/stage indices, resolves only live entries and touches the donor LRU, preserving upstream 4119611. Updating cached stage variants publishes a copy-on-write DrawEntry snapshot so existing readers retain immutable stage vectors. The port also retains current materialization-exception tracing and forwards tracing/subdraw arguments through secondary draws.

The codec correction flagged above is included: shader disk format 48, pipeline journal 11 because it embeds the changed EncodeResult/DecodeResult layout. PR #6959 independently uses those version numbers; the two branches have not been combined, so advance/reconcile versions and layout guards if either lands first.

Validation on RX 6650 XT / RADV Linux:

  • Same 13 focused groups against exact main plus identical new fixtures and against 682ce61, required Vulkan, shader caches disabled, actual Khronos layer insertion and SRT tape verification. Candidate 39/39 successful invocations; main 30 successful invocations plus three expected missing-feature failures (moved-buffer and pixel-stage reuse/refresh). No skips or VUIDs.
  • Retaining evicted donors makes the existing upstream eviction fixture fail. Reverting journal version 11 to 10 makes the journal fixture reject loading the previous format. Restoring either experiment restores the original AGC library SHA-256 1443838c857d310e30a654e454b534c3d8ae718b7146e0590c2289f95fffa0e7 and positive tests. The old-header test rewrites a current journal header; it does not use a main-generated old-payload file.
  • Full libs/relinker builds, public headers and conventions passed. Read-only merge-tree against fetched main f4aabbb122bcb5fabfe882798a9498a439de6e94 is clean; the tests above are on the explicitly stated cf528 base.
  • FAR PPSA03766 final private integration e2fe9653 (same core plus common fix(agc): enable independentBlend for color targets with different blend states #6782/fix(agc): allow chunked imports as vertex and index buffers #6913 prerequisites) reached the flooded-village checkpoint and exited cleanly with inputs/configuration restored. Its three compositor captures were identical, so that run does not verify frame progression. Earlier trace pairs had mismatched window focus/capture ambiguity and are excluded: no FPS gain established. Windows/NVIDIA have not been rerun.

Source patch, exact-head logs/JUnit, build receipts and negative controls — SHA-256 fd059d6ff2018d857beb169c31b7b392d9eb7345c75104f6c331ccbcc4b36ac6. No game binaries/assets/saves included.

AI-assisted: Codex. Original feature credit remains with TheAggie; eviction/LRU correction follows upstream 4119611 and the review feedback here.

@milescoviello

Copy link
Copy Markdown
Contributor

@TheAggie here's a retail data point for the case this PR handles, in case it helps the next step.

Metal Gear Solid 4 (PPSA18194, 1.4.1), first gameplay level. Built from main 99b8446 plus #11425's D16 store fix 894f617c6e (shader only), on Linux with an RTX 4070 Ti, APS5_PROFILE_DRAW=1 APS5_TRACE_DRAW_CACHE=1. Per 10 s:

  • About 15 presents per second.
  • The GPU is busy 7 ms. The driver thread is busy 7.4 s: 20.8 µs per draw, 8.2 µs of it materializing captures.
  • 362,070 draw-cache lookups, 100,535 hits.
  • 264,146 lookups find no entry. Of those, 251,294 differ only in user words or pointers. New registers account for 0, evicted entries for 9,992, keys never inserted for 2,860.
  • 260,796 data-only hits, but their stages are 15,083 reused and 506,507 compiled.
  • Relocation: 15,376 donors with moved user words per 100,000 alias lookups, with 131 shader sets learned.
  • Stage data hits: 0. No stage was inserted with data positions.

So nearly every draw moves its user-data pointers, and relocation by content recovers about 15% of them. I haven't checked whether the rest differ only in buffer base addresses, which is what this PR patches, or in other words as well. If it's useful, I can run MGS4 on a rebased head of this PR and post the same counters.

AI-assisted: yes (Claude Code)

@B-Howell

Copy link
Copy Markdown
Contributor

Data point from Astro's Playroom (PPSA01325, 1.905), CPU Plaza, Windows 11, RTX 4080 SUPER. This is a case the PR does not reach.

Measured (one run each, APS5_TRACE_DRAW_CACHE=1, per 10 s):

main cccdaa2734* cccdaa2734* + cherry-pick of @JJenkx's port 682ce61680
lookups ~355k ~358k
register-key hits 0.2–0.5 % 0.2 %
data-only hits: stages reused / compiled ~262k / ~447k ~266k / ~452k

* Both builds have the direct-memory constant from #12192, because plain main does not boot this title. The cherry-pick needed conflict resolutions:

  • PipelineJournal format 13;
  • ShaderDiskCache FormatVersion 49 and the Linux size assert at 576;
  • kept main's _addressDepth/_descriptorBytes beside the port's _nested in SrtEvaluator/SrtTape.

So this is not the PR's head.

Why (from a local diagnostic counter; the interpretation is AI-generated): about 93 % of the data-only candidates that fail stop on a user word that the SRT walk reads as a value and that the stage also forwards as shader data. That word is the first of a 4-word group and changes on every draw. It looks like the base of a buffer descriptor that the title holds inline in user SGPRs. The relocation here moves descriptors reached through a pointer (asBase & ~asValue), so these words are never relocatable. If the bufferBaseSlots patching could also cover a V# whose words come straight from user data, it would apply to this title too.

AI-assisted (Claude Code).

@JJenkx

JJenkx commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Profile data showing the case this PR targets, from a current tree: ASTRONEER PPSA27623, intro scene, Linux, RX 6650 XT/RADV, on #8091 1c7f19e716 (based on main 801d5b9bc1). This doesn't test this PR, which doesn't apply to that tree (it conflicts in DrawCache.hpp, DispatchCache.hpp and Driver.hpp).

APS5_TRACE_DRAW_CACHE=1, a typical 10 s interval:

306005 lookups, 380 hits (0.1%), 0 stage misses, 305625 without an entry, 305617 data-only hits (stages: 47017 reused, 564217 compiled); no entry: … user words/pointers only 305621 … ; register-key miss component changes: stable registers 8779, user data 305624

Other intervals are within a few percent. So 99.9% of lookups miss only because user-data words or pointers changed, and about 92% of their stages are materialized again.

In a 25 s APS5_TRACE_SECONDS window of the same scene, the frame takes 81.6 ms (p50). Driver.Draw.shader_compile_and_link costs 9.1 ms per frame on queue worker 0, spread over 86,765 events averaging 0.03 ms. The GPU is busy 51.8 ms per present.

So I'd expect at most about 9 ms per frame from this kind of reuse in this scene, and less if the GPU is the limit. I haven't separated which user words are buffer base addresses and which are values. Once the branch is rebased onto current main, I can run a matched pair here (merge base vs head, same scene, draw-cache counters plus a frame-time trace).

@QRMOE

QRMOE commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

@TheAggie a data point for this PR from RESIDENT EVIL requiem (PPSA31246). I ported its commits onto a local branch of main to test it, and these are the things I ran into.

Porting onto current main

  • 18acdbf and f2ed5c6 conflict with what main changed after perf(agc): find cached draws across moved user-data pointers #4943:

    I kept this PR's per-stage donors, carried meshIndexDescriptor and userDataMask into cloneVariant, and dropped the donor LRU touch from 2b862fb, since donors no longer live in the draw cache. c70b729 is already on main.

  • SrtTape: main now evaluates descriptor sources through a precompiled tape (SrtTape::Run in SrtDescriptorEvaluation.cpp). The tape records userDataUse->reads, but not readInstructions, sourceLeads or operandReads. So noteBufferBaseWords finds no lead, and agc_driver_draw_cache_moved_buffer gets 0 hits on current main. With APS5_NO_SRT_TAPE=1 it passes. As a stopgap I skipped the tape while runtime.userDataUse is set. A real fix would have the tape record the same three things, operandReads being the hard one.

  • With that, 11 of the 12 draw-cache tests pass with the validation layer and 0 VUID- errors. The exception is agc_driver_draw_cache_relocation_evicted from fix(agc): let draw relocation aliases name a cached key, not hold its entry #9396: here a stage donor outlives its evicted entry, which that test forbids.

  • Ordering in DrawCapture.cpp: recordStageDataPositions, from main's stage-data hits, runs before tagBufferWords, and tagBufferWords returns on any variant that already has dataPositions. So variants that stage-data hits handle are never tagged.

requiem

  • The draw cache gets 0 hits in its first level on main. Per frame, about 95–99% of draws repeat their shape key, but 0% repeat their full key, because the user-data pointers move every frame.

  • With this PR and perf(agc): cache CPU-path indirect draws behind APS5_DRAW_CACHE_INDIRECT #9243 (APS5_DRAW_CACHE_INDIRECT=1), hits rise to about 1–2% of lookups during loading.

  • Of about 70,000 inserted stage variants:

    • 84% have no bufferBaseWords (no single-lead buffer);
    • 10% are skipped by the dataPositions ordering above;
    • 6% are tagged.

    So in this title, most of what changes behind the moved pointers isn't a buffer base address this PR can patch.

I can share the port branch if a rebase would help.

@fogydes

fogydes commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Verdict

stale+rebase

Findings

  1. [verified] Merge conflicts against current origin/main (0a2cda921d): git merge-tree --write-tree origin/main 09238da8206e0aa70d8966c69f822c297da36308 fails with exit code 1. The branch is 13,707 commits behind main and conflicts across multiple files:
    • core/libs/prx/libSceAgcDriver/Execution/include/Driver/Dispatch/DispatchCache.hpp
    • core/libs/prx/libSceAgcDriver/Execution/include/Driver/Draw/DrawCache.hpp
    • core/libs/prx/libSceAgcDriver/Execution/include/Driver/Driver.hpp
    • core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/Draw.cpp
    • core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/DrawCapture.cpp
    • core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/DrawRelocate.cpp
    • core/libs/prx/libSceAgcDriver/Graphics/include/Pipeline.hpp
  2. [verified] Conventions check passed: python tools/check_conventions.py --base 244f41ed24de04038f2a8ddbc0bbe7252ac82cd6 in a scratch worktree succeeds with 0 findings and 0 errors.
  3. [read-only] Missing speed claim artifacts: The PR is titled perf(agc): ... and makes specific benchmark and timing claims ("audio produced at 34.5%... against 45.7%...", "queue worker 41.5 µs against 30.7 µs..."). Per AGENTS.md, "A speed claim shows tools/trace_digest.py summaries of main and of the change from the same machine and scene" (including the two aps5-summary.jsonl lines). Neither the summary lines nor trace_digest.py digests are provided in the PR description.
  4. [read-only] Vulkan validation layer comparison requirement: Per AGENTS.md, AGC changes require running the same focused tests on main and the branch with ANYPS5_REQUIRE_VULKAN=1 ANYPS5_NO_SHADER_CACHE=1 VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation and comparing logs to verify no new VUID- validation errors are logged. This comparison is not present in the PR description.
  5. [read-only] Missing PR checklist lines: The PR checklist omits three required template lines from .github/pull_request_template.md:
    • - [ ] Fixes: <!-- issue it closes as Fixes #N, or none -->
    • - [ ] Unblocks: <!-- title ID and Game bring-up discussion, e.g. PPSA01325 #3232, or none -->
    • - [ ] GPUs tested: <!-- for AGC, shader recompiler or VideoOut changes: vendor, model, driver and OS of each GPU the GPU tests ran on; otherwise n/a -->
      Per AGENTS.md, all checklist lines must be retained and answered (GPUs tested is required for AGC changes).
  6. [author-reported] Author reports tests on Windows 11 with MinGW-w64 GCC 15.2.0 on NVIDIA RTX 5070 Ti, including new unit test agc_driver_draw_cache_moved_buffer and RetroArch (PPSA99169) Flycast core measurements.

Freshness envelope

  • Head: 09238da8206e0aa70d8966c69f822c297da36308
  • Base: 244f41ed24de04038f2a8ddbc0bbe7252ac82cd6
  • Behind origin/main: 13,707 commits
  • Merge-tree: exit 1 (conflicts in DispatchCache.hpp, DrawCache.hpp, Driver.hpp, Draw.cpp, DrawCapture.cpp, DrawRelocate.cpp, Pipeline.hpp)

Cross-PR ledger

  • No other open PR currently touches these relocated draw cache files.

@Tehshortbus

Copy link
Copy Markdown
Contributor

@TheAggie another title with the case this PR targets: ARMORED CORE VI FIRES OF RUBICON (PPSA06774, 01.009.000), first mission. This doesn't test this PR, which doesn't merge with current main (the conflicts are the logic ones adriadam10 listed).

Build main da026c7065, CachyOS Linux 7.3.0, Ryzen 7 5800X, RTX 4070 SUPER (NVIDIA 615.78.08), warm caches (pipeline journal replayed 451 pipelines at start), default settings, standing still at one spot.

Frame time (APS5_PROFILE_DRAW=1 APS5_PROFILE_GPU=1, tools/trace_digest.py, 4 intervals of 10 s):

  • 9.0 fps, frame p50 110.9 ms, GPU busy 48.4 ms per present.
  • Queue worker 0 busy 97 %; Driver.Packet 108 ms per frame, of it Shader.DrawStage 45 ms (capture_resources 20 ms).
  • About 17,000 draws per second at 40 to 44 µs each: capture 13.4, recompile 10.4 to 10.6, lock wait 5.5 to 8.8, decode 3.3, key/lookup/validate 3.3 to 3.5.

Draw cache (APS5_TRACE_DRAW_CACHE=1, three 10 s intervals in the same spot):

per 10 s
lookups 174,021 to 187,551
hits 1.2 to 2.0 %
without an entry 169,593 to 183,948
of those, user words/pointers only 156,759 to 170,358
of those, new registers 6,232 to 6,933
data-only hits: stages reused / compiled 47,195 to 53,234 / 297,229 to 315,406
register-key miss components: user data / stable registers / auxiliary 169,593 / 36,379 / 13,949 (first interval)

So almost every miss differs only in user data, and the data-only path still compiles about 86 % of the stages it looks at.

Other PRs on the same scene (same build plus one PR, one run each, standing still):

Run-to-run spread at the same spot was about 10 %.

Not measured (AI-generated interpretation): this matches what QRMOE found on PPSA31246, user-data pointers that move on every draw. I haven't checked whether the moved words here are single buffer bases that this PR's bufferBaseWords could patch, or inline descriptors like B-Howell found on PPSA01325.

AI-assisted: yes (Claude Code)

@ntamas94

Copy link
Copy Markdown
Contributor

A real-game case this directly addresses, as motivation.

Crysis Remastered (CUSA18659) on AnyPS5 is CPU-bound at ~7-9 FPS, and the per-draw prepare dominates. Profiled with APS5_PROFILE_DRAW=1 on a steady scene (per 10 s): 113,481 draw packets at 70.8 µs each — capture 33.0, recompile 17.5, key/lookup/validate 9.0, lock wait 4.0 µs. The whole-draw cache almost never hits: 110,316 of 113,365 lookups are "no entry", only 2,349 hits.

The cause is the one this PR targets. The draw key (RegisterStateKey's exact key, DrawRegisterKey.cpp) mixes the raw user-data SGPR values — which include the constant/vertex-buffer pointers the game allocates from a ring each frame. So the key changes every frame even though the shaders and render state don't, and every draw misses and re-prepares. The shape key in the same function (register fingerprints only, no user-data values) is stable across frames, which is why "reuse a cached draw stage when only buffer base addresses moved" is the right lever.

We ruled out table size: with APS5_DRAW_CACHE_ENTRIES=65536 the table grew to 62,176 entries with 0 evictions, yet 99,402 of 103,830 lookups were still "no entry" (hits only 2,349 → 2,990). So it is key churn from the moving pointers, not eviction.

(We did not test this PR on Crysis directly — it runs on an older-main integration branch, and cherry-picking the stack onto it conflicts heavily in the core draw path.)

@ntamas94

Copy link
Copy Markdown
Contributor

A data point for this PR from a PS4 title, Crysis Remastered (CUSA18659, GCN guest shaders): it hits this PR's case on most of its draws, and a local stand-in for it is where most of the speedup comes from.

The case.

  • Each draw's vertex buffer table sits in a ring and is passed in VS s[2:3]. The relocation from perf(agc): find cached draws across moved user-data pointers #4943 follows the moved pointer.
  • The table holds the draw's vertex buffer V#s. Their bases differ between otherwise equal draws while the table's other words stay equal. So the relocated vertex stage misses on a captured word that is a V# base, which is what this PR patches.
  • In the compiled stage that V# shows up twice: in the GuestBuffers element and in the FlattenedSrt copy of the table. Both need patching.

Numbers.

  • Setup: level "Contact", the same scripted 360 s run each time, last 10 s report, about 1,190 draws per frame. Run on a local branch, since the title does not reach the level on plain main yet.
  • Rows:
hits captures / 10 s µs per draw packet presents / 10 s
neither 2.4% 193,496 71.7 94
#13610 13.5% 204,272 66.8 100
#13610 + table V# bases 50.3% 128,846 59.8 110

APS5_VERIFY_DRAW_DATA_HITS=1 APS5_VERIFY_DRAW_ENTRIES=1 over a 420 s run of the third row: 49,328 hits captured again, 0 stages differed.

Three things this title needed, in case they apply to this PR's version:

  1. Only the base words can be matched. perf(agc): bound vertex-indexed guest buffers by the draw's vertex range #9897-style vertex bounds trim the record count of the GuestBuffers element. So only words 0 and 1 of the table's V# equal the element, and only the base word can be patched.
  2. Range check against captured runs. A V# whose range contains a captured run must not be patched: the stage read through it at capture time. The verifier caught this once in about 9,000 hits before the check was added.
  3. Split runs in the stage-data verifier. Relocation splits a run by the user word it hangs from. The stage-data verifier then compared split against unsplit runs over the same words, so it reported a false mismatch. Comparing merged runs fixes it.

The PS4 itself does not move these words between consecutive frames of a settled scene. On a PS4 Pro (FW 10.71) two consecutive flips of the same spot had identical VS and PS user data for all 1,158 draws. When the ring cycles, the fetch code and the table contents mostly change too (11 of 507 fetch subroutines and 15 of 1,439 tables kept their contents). So relocation matters while the scene changes. The PC runs above are scripted and the scene keeps changing: their draw count per frame varies between about 1,100 and 1,560.

Happy to send the stand-in, or to help with the rebase if that is useful.

@Amoenus

Amoenus commented Oct 31, 2026

Copy link
Copy Markdown
Contributor

@TheAggie I’m preparing a diagnostic-only change that shares materializeCapture with this PR: fork commit 1f5e95683bd3. It logs the last already-recorded read address/user-data origin when ResourceMaterializer::Materialize throws, then rethrows the original exception. It adds no guest reads, recovery path or success logging. This helped classify Sonic’s current capture stop; it does not fix that stop or implement buffer relocation.

Your head 09238da8206e adds noteBufferBaseWords after the same two materialization calls. I propose keeping that work intact and submitting this diagnostic separately, refreshing whichever branch lands second. The logger would wrap only materialization; your post-success bookkeeping would remain outside it.

Exact-head qualification and limits: 17 positive processes across main/change (including both physical GPU vendors), plus a test-only control failing specifically on missing context. The official overlap audit remains unresolved, and I have not opened a new upstream PR. Would that separate scope/order work for you?

AI-assisted: yes (Codex); author responsible.

@santhoshkumar-dev

Copy link
Copy Markdown
Contributor

Windows/SRT tape integration feedback from Another Crab's Treasure (PPSA20423, 02.000.000), on a local rebase/extension of this work onto pinned main 5fb1747fa7a4. This is not a qualification of the unmodified PR against current main.

Windows 11 10.0.26200, Ryzen 5 5600X, RX 6700 XT, AMD Vulkan 26.8.1, WinLibs GCC 15.2.0 r7, Release.

The tape needs to preserve readInstructions, sourceLeads and operandReads for buffer-base tagging. The local origin port now handles cached reads consumed later as operands and operations lowered away at tape roots; direct-read leads follow the interpreter's selection rules. Refused walks restore the metadata vectors. The moved-buffer canary passes with the tape enabled. CPU regressions distinguish forwarding, arithmetic use and first-lane use, and fail before the corresponding repairs.

Additional integration findings:

  • relocation_evicted passed on baseline and failed on the initial local perf(agc): reuse cached draw stages when only buffer base addresses moved #5609 stack: a donor could outlive its cache entry. Weak donor variants tied to a live owner key, with owner retention on validated hits, restored both the evicted and retained-donor cases.
  • I included the captured-buffer exclusion reported by ntamas94. A CPU contract regression fails without the range check and passes with it, including byte and strided descriptors above 4 GiB.
  • Composing descriptor patches with a previously pushed value needs to preserve the earlier effective push data. A staged-patch regression exposed and checks this transition for push and stored shader data.
  • Full-build verification needs to pair captured words with guest addresses across equivalent split/reordered capture ranges. Word values, bindings and effective push constants remain checked.

The current local extension also applies eligible reuse through the shape-matched/data-only path, including runtime-only pushed user words and the existing pure scalar-data refresh mechanism. The final mixed-input regression changes a memory value and a shader input together, checks the rendered color, and requires reuse in normal mode.

Twelve distinct GPU cases plus three CPU contracts pass on this GPU. Vulkan is required, the shader cache disabled, Khronos validation confirmed loaded, and the compared logs have no new VUID IDs. The shader-memory capture/validation/replay suite also passes. A visually confirmed beach run with both APS5_VERIFY_DRAW_ENTRIES=1 and APS5_VERIFY_DRAW_DATA_HITS=1 completed without a reported mismatch. Only this AMD GPU was tested locally.

Normal warm-scene screening of the final local composition was 11.21 FPS on baseline versus 10.73 FPS on the candidate (one run per arm; last five eligible 10-second intervals, individual frame durations, identical visually confirmed view and settings, hashes matched). This establishes no FPS benefit; the earlier buffer/table-only screen was 10.56 versus 10.57 FPS. I am sharing functional/integration evidence, not claiming that this PR or the local extension fixes this title's performance. The original runtime is restored. Game bring-up #8239.

Local SRT tape origin-port diff against pinned main 5fb1747
diff --git a/core/shader/recompiler/Optimization/src/SrtWalker/SrtTape.cpp b/core/shader/recompiler/Optimization/src/SrtWalker/SrtTape.cpp
index ddda8b1fc3..916f96519e 100644
--- a/core/shader/recompiler/Optimization/src/SrtWalker/SrtTape.cpp
+++ b/core/shader/recompiler/Optimization/src/SrtWalker/SrtTape.cpp
@@ -325,10 +325,13 @@ struct SrtTape::Frame {
     std::uint8_t* states;
     bool nullRoot = false;
     std::uint32_t addressDepth = 0;
+    std::uint32_t operandDepth = 0;
     std::size_t descriptorBytes = 0;
 };
 
 bool SrtTape::evaluate(Frame& frame, std::uint32_t index, std::uint64_t& result) const {
+    const auto& node = nodes[index];
+    if (frame.operandDepth > 0 && frame.runtime.userDataUse != nullptr && (node.op == Op::Read || node.op == Op::ReadConstBuffer)) frame.runtime.userDataUse->operandReads.push_back(node.source);
     const auto state = frame.states[index];
     if (state == 1) {
         result = frame.values[index];
@@ -336,7 +339,10 @@ bool SrtTape::evaluate(Frame& frame, std::uint32_t index, std::uint64_t& result)
     }
     if (state == 2) return false;
     std::uint64_t value = 0;
-    if (!compute(frame, nodes[index], value)) {
+    ++frame.operandDepth;
+    const bool computed = compute(frame, node, value);
+    --frame.operandDepth;
+    if (!computed) {
         frame.states[index] = 2;
         return false;
     }
@@ -438,7 +444,10 @@ bool SrtTape::compute(Frame& frame, const Node& node, std::uint64_t& result) con
                 frame.nullRoot = true;
                 return false;
             }
-            if (frame.runtime.userDataUse != nullptr) frame.runtime.userDataUse->reads.emplace_back(address, origin);
+            if (frame.runtime.userDataUse != nullptr) {
+                frame.runtime.userDataUse->reads.emplace_back(address, origin);
+                frame.runtime.userDataUse->readInstructions.push_back(node.source);
+            }
             if (node.loaded && frame.runtime.accessible != nullptr && !frame.runtime.accessible(frame.runtime.userContext, address, sizeof(std::uint32_t))) return false;
             if (auto* trace = frame.runtime.readTrace; trace != nullptr) {
                 if (node.source == trace->leaf) trace->leaves.emplace_back(trace->leafSlot, address);
@@ -556,6 +565,9 @@ bool SrtTape::Run(const IrResourcePlan& plan, const SrtRuntime& runtime, std::ve
     const auto baseBefore = use != nullptr ? use->asBase : 0;
     const auto valueBefore = use != nullptr ? use->asValue : 0;
     const auto useReadsBefore = use != nullptr ? use->reads.size() : 0;
+    const auto useReadInstructionsBefore = use != nullptr ? use->readInstructions.size() : 0;
+    const auto useOperandReadsBefore = use != nullptr ? use->operandReads.size() : 0;
+    const auto useSourceLeadsBefore = use != nullptr ? use->sourceLeads.size() : 0;
     const auto refuse = [&] {
         if (trace != nullptr) {
             trace->leaves.resize(leavesBefore);
@@ -566,6 +578,9 @@ bool SrtTape::Run(const IrResourcePlan& plan, const SrtRuntime& runtime, std::ve
             use->asBase = baseBefore;
             use->asValue = valueBefore;
             use->reads.resize(useReadsBefore);
+            use->readInstructions.resize(useReadInstructionsBefore);
+            use->operandReads.resize(useOperandReadsBefore);
+            use->sourceLeads.resize(useSourceLeadsBefore);
         }
         return false;
     };
@@ -585,6 +600,19 @@ bool SrtTape::Run(const IrResourcePlan& plan, const SrtRuntime& runtime, std::ve
         std::fill_n(states, nodes.size(), std::uint8_t {0});
     }
     Frame frame {runtime, values, states};
+    const auto noteErasedOperand = [&](IrValue* expression, std::uint32_t root) {
+        if (use == nullptr || root == NoNode || (nodes[root].op != Op::Read && nodes[root].op != Op::ReadConstBuffer) || nodes[root].source == nullptr || expression == nullptr) return;
+        auto* value = expression->Resolve();
+        for (;;) {
+            if ((value->Opcode() == IrOpcode::BitCastU32F32 || value->Opcode() == IrOpcode::BitCastF32U32) && value->ArgumentCount() == 1) value = value->Argument(0)->Resolve();
+            else if (value->Opcode() == IrOpcode::ReadConst && value->ArgumentCount() == 2) {
+                auto* slot = value->Argument(1)->Resolve();
+                if (!slot->HasImmediate() || slot->Type() != IrType::U32 || slot->ImmediateU32() >= plan.srtReads.size() || plan.srtReads[slot->ImmediateU32()].value == nullptr) break;
+                value = plan.srtReads[slot->ImmediateU32()].value->Resolve();
+            } else break;
+        }
+        if (value != nodes[root].source) use->operandReads.push_back(nodes[root].source);
+    };
 
     struct Scratch {
         std::uint32_t attempt = 0;
@@ -639,6 +667,7 @@ bool SrtTape::Run(const IrResourcePlan& plan, const SrtRuntime& runtime, std::ve
             for (const auto image : block.images) active[imageBase + image] = 1u;
             for (const auto slot : block.flatSlots) activeFlat[slot] = 1u;
             std::uint64_t condition = 0;
+            if (block.condition != nullptr && runtime.readSpecializationMemory != nullptr) noteErasedOperand(block.condition, conditionRoots[index]);
             const bool cleanEvaluable = block.condition != nullptr && runtime.readSpecializationMemory != nullptr && conditionRoots[index] != NoNode && evaluate(frame, conditionRoots[index], condition);
             if (frame.nullRoot) return refuse();
             if (cleanEvaluable) {
@@ -661,12 +690,21 @@ bool SrtTape::Run(const IrResourcePlan& plan, const SrtRuntime& runtime, std::ve
             for (std::uint32_t dword = 0; dword < source.dwordCount; ++dword) {
                 std::uint64_t word = 0;
                 frame.descriptorBytes = source.dwordCount > 1u ? static_cast<std::size_t>(source.dwordCount - dword) * sizeof(std::uint32_t) : 0u;
+                noteErasedOperand(source.dwords[dword], sourceDwords[sourceIndex][dword]);
                 const bool ok = evaluate(frame, sourceDwords[sourceIndex][dword], word);
                 frame.descriptorBytes = 0u;
                 if (!ok) return refuse();
                 value.dwords[dword] = static_cast<std::uint32_t>(word);
             }
         }
+        if (use != nullptr && source.dwordCount != 0 && source.dwords[0] != nullptr) {
+            auto* lead = source.dwords[0]->Resolve();
+            if (!lead->HasImmediate() && lead->Opcode() == IrOpcode::ReadConst && lead->ArgumentCount() == 2) {
+                auto* slot = lead->Argument(1)->Resolve();
+                if (slot->HasImmediate() && slot->Type() == IrType::U32 && slot->ImmediateU32() < plan.srtReads.size() && plan.srtReads[slot->ImmediateU32()].value != nullptr) lead = plan.srtReads[slot->ImmediateU32()].value->Resolve();
+            }
+            if (!lead->HasImmediate() && IsRawRead(plan, *lead)) use->sourceLeads.emplace_back(sourceIndex, lead);
+        }
         evaluated.push_back(value);
     }
     auto& flattened = scratch.flattened;
@@ -681,6 +719,7 @@ bool SrtTape::Run(const IrResourcePlan& plan, const SrtRuntime& runtime, std::ve
             trace->leafSlot = read.flatOffset;
         }
         std::uint64_t word = 0;
+        noteErasedOperand(read.value, flatRoots[slot]);
         const bool ok = read.flatOffset < flattened.size() && evaluate(frame, flatRoots[slot], word);
         if (pure) trace->leaf = nullptr;
         if (!ok) return refuse();

AI-assisted implementation and testing: yes, Codex, continuing the ZCode handover. No game files or assets attached.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agc libSceAgc* build docs needs rebase Conflicts with main, rebase needed perf numbers missing perf pull request without a before and after summary line perf Performance change shader core/shader

Projects

None yet

Development

Successfully merging this pull request may close these issues.