Skip to content

perf(agc): find cached draws across moved user-data pointers - #4943

Merged
adriadam10 merged 6 commits into
boykopovar:mainfrom
TheAggie:perf/agc-draw-relocation-main
Oct 16, 2026
Merged

adriadam10 merged 6 commits into
boykopovar:mainfrom
TheAggie:perf/agc-draw-relocation-main

Conversation

@TheAggie

@TheAggie TheAggie commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

What

Addresses #4054: a guest driver that puts per-draw pointers in the user data (RADV does, for its descriptor sets and push constants) misses the draw cache on almost every draw, because the register key and the entry's memory runs both contain the pointer values.

  • The recompiler's SRT evaluator records, for each raw read, the user-data word its address is based on, and marks a word that is also used as a plain value. The result names the user-data word behind each shader-data dword.
  • The driver learns per shader set which user-data words are only read through. A draw that differs from a cached one only in those words looks up an alias key built from the contents behind the pointers instead of the pointers themselves.
  • On an alias hit the cached variant's runs are shifted by the pointer delta and validated by value, as an ordinary hit is, and the shader-data words that forward a pointer are patched.
  • A word that is used as a value anywhere, a pointer whose delta does not fit the runs, or contents that differ, all fall back to the normal miss path.
  • main now caches a draw the second time its key is seen (perf(agc): cache a draw key the second time it is seen #5216). With moving pointers the exact key never repeats, so a repeat of the alias key counts as a second sighting too, and one draw of a shader set that has not been learned yet is admitted so the pointer words can be learned from it. Alias admission is limited: a shader set gets two such admissions on probation, and after that only one whose relocated entries actually hit (validated hits, not donors found) is admitted through its alias. Without this, aliases that come up exactly twice each inserted an entry nobody used (the D2R hit drop in the review).
  • A title whose draws never match this way should not pay for the lookup: the alias key is built only when the exact key misses, and a shader set whose alias lookups hit less than 1 time in 16 over 1,024 tries is left alone for its next 16,384 draws before it is tried again. A shader set that does hit builds the alias with the key, as one pass.
  • The contents behind a pointer are hashed whole, 64 words at a time. When the alias map reaches its limit every other entry is dropped, not all of them, and the [draw-relocate] line counts the dropped ones.
  • The shader disk cache carries the new shaderDataUserWords field of a result, and its Linux-only size asserts expect the grown structs.
  • The alias key is built on main's incremental register fingerprint: RegisterStateKey gets an optional shapeKey out-parameter, and the user words are walked by one shared helper.

APS5_NO_DRAW_RELOCATION=1 turns it off.

Related: #494 (draft) reuses a shape's decode and stages when only user data differs; this PR covers the case where the user data are pointers and the data behind them is unchanged or moved. They touch the same lookup and would need a rebase against each other.

This is the first part. Later steps I have locally and would send separately, each with its own numbers: relocating stages independently, reusing a stage when only buffer base addresses moved, and reusing draw snapshots.

Tested

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

  • ctest: 1269 of 1291 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. 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 31 asserted sizes match, including the ones this PR does not touch. agc_shader_disk_cache's result round trip now sets shaderDataUserWords and compares it after decoding; it fails if the decoder drops them.
  • New test agc_driver_draw_cache_relocation: a pixel shader loads its colour through a user-data pointer. The same contents drawn from eight addresses hit the cached draw from the fifth draw on (two draws teach the shader set, two more are the first and second sighting of the contents); other contents do not hit; an address seen before still hits. It fails with APS5_NO_DRAW_RELOCATION=1. A second case draws 12 aliases that each come up exactly twice, between draws of a repeated one: at most 2 of them are cached and the repeated draw keeps all its hits (with the old admission rule all 12 were cached; the case fails on it).
  • 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. Same build, relocation off against on, two unprofiled runs each: audio produced at 26.9% and 27.0% of real time against 38.5% and 39.1%.
  • On main a14ae8e23 plus the same local changes) the same demo does not get faster with this PR alone: audio at 5.4% and 5.5% against 5.8% without it, unprofiled. About 40% of draws hit, but each draw on this base copies a 2 MiB guest buffer into a snapshot whether it hits or not, and that copy dominates. perf(agc): reuse cached draw stages when only buffer base addresses moved #5609 on top reaches 36%; see the numbers there.
  • On main 940eba9a2, the same demo with this PR and perf(agc): reuse cached draw stages when only buffer base addresses moved #5609 on top 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.
  • Old base, one profiled run each: queue worker 71.2 µs against 48.4 µs per draw packet; stage captures per 10 s 200,444 against 71,430; 72,924 of 100,001 alias lookups hit.
  • Same demo with APS5_VERIFY_DRAW_ENTRIES=1, which captures every hit again and compares: 0 stages differed in every report (88,797 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: none (perf(agc): reuse cached draw stages when only buffer base addresses moved #5609 builds on this PR and lands after it)
  • AI-assisted: yes. Written with Claude Code under my direction.

🤖 Generated with Claude Code

@xXPBSXx

xXPBSXx commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Windows test (MinGW GCC 15.2, Release, Windows 10, RTX 4080 SUPER). Built on main 534451426 together with six other open GPU PRs (their own commits applied, no merge conflicts), so only the tests that belong to this PR say something about it:

  • tests/execution/DrawCacheRelocation.cpp, which ctest runs as agc_driver_draw_cache_relocation: 3 of 3 runs passed.

Not tested: no game run. The batch was built once, not per PR; nothing failed, so no bisect was needed.

AI-assisted (Claude Code).

@TideOPL

TideOPL commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Tested on Minecraft Dungeons II (PPSA16064) gameplay: Windows 11, RTX 5070, cherry-picked onto our MCD2 build (main + the import-join PRs). No change for this title; data in case it helps the later steps:

  • Draw cache: about 38,000 lookups per 10 s, about 37,800 with no entry and about 170 hits, with and without this PR.
  • [draw-relocate] (report threshold lowered locally): 8,238 alias lookups over the whole run, 1 donor with moved user words, 0 refusals, 0 hits; 263 shader sets learned.
  • A local diagnostic that hashes the draw registers without the user-data words finds about 3,970 distinct shapes over 200,000 draws, and no repeat of a shape has equal user data. The words that change are mostly pointer pairs (low dwords like c9ccc9a0 -> caf3c9a0, same low 16 bits: a per-frame ring) in PS u24/u26 and VS u28/u24/u12, plus a few small values (PS u22: 0x280 -> 0x610).

So the data behind the pointers likely differs per draw (per-draw constants), which this first step does not cover. "Reusing a stage when only buffer base addresses moved" sounds like what this title needs; happy to test it.

AI-assisted (Claude Code).

@LeandroLP23

Copy link
Copy Markdown
Contributor

No measurable change on AMD in two light 2D catalog titles, the same result as @TideOPL's Minecraft Dungeons II run.

Setup: Windows 11 (26300), Radeon 860M (RDNA 3.5, AMD proprietary driver 24.30.58.08), WinLibs GCC 15.2.0, Release, 1920x1080. Base is main 5d50886ee + #5081, which these titles need so their UI renders at all. Each PR head was merged onto that base, with a clean merge. Catalog releases relinked with --windows, one warm-up run per build, then APS5_TRACE_SECONDS=20:10 with no other APS5_* switch. Three runs per build, interleaved (base, #4878, #4943, base, ...) so that laptop thermal drift hits every build alike. Median frame_ms.p50, with the per-run range:

Scene base + #4878 + #4943
Jelly5 (PPSA99505), server connection screen 65.4 ms (63.3-65.5) 64.9 ms (63.6-66.1, 2 traces; the third run's window didn't close before exit) 65.5 ms (60.9-66.1)
EVO Player (PPSA99039), home screen 72.5 ms (66.9-77.6) 76.5 ms (75.5-77.2) 77.2 ms (76.3-77.7)

In every build the queue worker is busy only 35-37 % of the window, and gpu_busy_ms_per_present is 2.9-8.1 ms. Driver.Packet.execute is 16-17 ms per frame in all three. These EVO-engine titles are bound by their own CPU side: each frame they wait on a fence with usleep(1000) and fill the 8 MB scanout from the CPU. So neither PR has much to remove here, and the EVO Player difference is inside the base's own spread. I'm posting it as a no-change data point on AMD; it isn't a regression claim. Summary lines and traces are available if you want them attached.

@github-actions github-actions Bot added the needs rebase Conflicts with main, rebase needed label Oct 13, 2026
@JAB-dev

JAB-dev commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Besiege (PPSA20313), this branch (df467c2b1) against its merge base 2a55cfe1e, Windows 11, RTX 4070 SUPER, 1080p, three runs each (the base lost one capture):

  • base: 16.8, 16.6 FPS; this branch: 15.9, 16.2, 16.2 FPS (about 3-5% lower)

The relocation does not take effect on this title. Over 10 s the draw cache sees about 75,000 lookups with 74,000 "no entry" and 927 hits, and your counters read [draw-relocate] 123013 alias lookups: 2 donors with moved user words, 0 refusals, 3 hits through a relocated entry; 59 shader sets learned, 12202 aliases held. So the keys that differ in Besiege are not the ones your alias path joins.

@JAB-dev

JAB-dev commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Data for why the relocation does not fire on Besiege (PPSA20313, main at a0f5916 plus #5364, local build that prints the old and new user-data words for the draws whose only changed key group is the user-data registers):

In steady state 70% of the misses are exactly that case, and in the first 40 sampled draws a single word changes in 39 of them (the 40th printed no changed word): the second stage's user word 8 (register 0x94, index 8 of the 0x8c group). It goes up by exactly +176 each time (1dacb3e0 -> 1dacb490 -> 1dacb540 -> ..., 39 of 39), with a jump now and then where draws in between were cache hits. No other user word differs. So the title walks a ring of 176-byte per-draw constant slots, and the draws are otherwise identical (same shaders, same registers).

@TheAggie

Copy link
Copy Markdown
Contributor Author

Thanks for the three data points. I've rebased the branch onto current main and force-pushed; what changed:

@JAB-dev that 3-5% on Besiege was the alias lookup running for every draw with nothing to find (your counters show 2 donors in 123,013 lookups). Two changes for that case:

  • The alias key is now built only when the exact key misses, not for every draw.
  • Each shader set is on trial: when fewer than 1 in 16 of its alias lookups hit over 1,024 tries, the relocation is skipped for that shader set's next 16,384 draws, then tried again.

I don't have Besiege, so I can't confirm it closes the gap there. If you can run the new head against the same base, the [draw-relocate] line should show far fewer alias lookups.

Your second comment explains why nothing matches: one user word walking a ring of 176-byte per-draw constant slots, with everything else equal. Whether that can be reused depends on how the shader reaches the slot. If it binds the slot as a buffer, #5609 covers it: only the base address moves and the contents stay out of the match. If it reads the constants through the pointer during the capture, the contents differ every draw and neither PR can match; that case needs the constants refreshed in a cached stage rather than compared, which is closer to what #494 does for user data, and I haven't done it here. A run of #5609's head on Besiege would tell which it is.

The rebase also had to deal with #5216 (a draw is cached the second time its key is seen). With moving pointers the exact key never repeats, so nothing was cached and the relocation had nothing to reuse. A repeat of the alias key now counts as a second sighting too. On the Flycast workload, with the follow-up applied: 59% of real time without the admission, 51% with #5216 as merged, 59% with this change.

@TideOPL your shape analysis is the case the next step is for. A pointer pair that moves through a per-frame ring with different data behind it each draw can't hit here, since this PR only matches when the contents behind the pointers are equal. The follow-up keeps a cached stage when the only words that differ are buffer base addresses the shader binds as buffers, so the per-draw constants stay out of the match. It is up as #5609, stacked on this one.

@LeandroLP23 thanks for the AMD numbers; no change is what I'd expect on titles that don't put pointers in the user data.

AI-assisted (Claude Code).

@Raghav314

Copy link
Copy Markdown
Contributor

I ran this with #5609 on top on my machine (Linux, CachyOS, GCC, RTX 4090, NVIDIA 615.78.08), merged with main a5f493f, and tried it on Hades II (PPSA36082). Two things, and some numbers.

The Linux build fails. ShaderDiskCache.cpp has layout asserts that only exist on Linux x86-64 with libstdc++, so a Windows build never sees them:

ShaderDiskCache.cpp:82: static assertion failed: RecompileResult changed: update EncodeResult and DecodeResult   (368 == 320)
ShaderDiskCache.cpp:97: static assertion failed: BindingAllocationResult changed: update the allocation encoder   (200 == 152)

shaderDataUserWords (5d8dff9, here) and bufferBaseSlots (54c1f16, #5609) are what grew them. Nothing is lost on disk, since the cached variant only stores the CompiledBindingLayout and both fields are rebuilt by DescriptorBindingBuilder::Populate on every capture. So it is only the asserts, plus EncodeResult/DecodeResult not carrying the two fields. What I used locally:

static_assert(sizeof(RecompileResult) == 368, ...);
static_assert(sizeof(BindingAllocationResult) == 200, ...);

// EncodeResult, after workgroupMemoryDwords
writer.Values(std::span<const std::uint32_t>(result.shaderDataUserWords));
writer.List(result.bufferBaseSlots, [](Writer& out, const std::array<std::uint32_t, 3>& slot) { out.Values(std::span<const std::uint32_t>(slot)); });

// DecodeResult, after workgroupMemoryDwords
reader.Values(result.shaderDataUserWords);
reader.List(result.bufferBaseSlots, 12, [](Reader& in, std::array<std::uint32_t, 3>& slot) {
    std::vector<std::uint32_t> words;
    in.Values(words);
    if (words.size() != slot.size()) in.Fail();
    else std::copy(words.begin(), words.end(), slot.begin());
});

With that, the full ctest passes except agc_driver_bc_block_copy, which fails the same way on plain main here.

The [draw-relocate] line never printed in Hades. It is only reached from inside if (!userPatches.empty()), once lookups >= 100000, and Hades rarely gets there. I added a local counter line printed with the [draw-cache] report every 10 s to see what happens.

Hades II, hub, standing still (APS5_PROFILE_DRAW=1, per 10 s):

  • APS5_VERIFY_DRAW_ENTRIES=1 APS5_VERIFY_REGISTER_KEYS=1 APS5_VERIFY_DRAW_SNAPSHOTS=1: 0 stages differed (about 49k hits captured again per 10 s), and the picture looks right.
  • About 241k draws keyed. About 129k of them (54%) were skipped by the trial back-off (shader sets under 1 hit in 16). About 61k tried an alias lookup: about 26k found no donor decode, about 35k relocated every stage, and about 13k of those became hits. The rest differed in contents after the move.
  • So in Hades the per-sprite pointer mostly points at different data, not the same data somewhere else, and relocation turns about 5% of the draws into hits. I did not run an A/B with APS5_NO_DRAW_RELOCATION=1, so I have no frame-time number for the change itself.

LLM assistance used.

TheAggie and others added 2 commits October 15, 2026 09:53
A pixel shader loads its colour through a user-data pointer. Drawn from a third address with the same contents it hits the cached draw; with other contents it does not; the addresses seen before still hit exactly.

AI-assisted (Claude Code).

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
giljakoki pushed a commit to giljakoki/AnyPS5-macos that referenced this pull request Oct 15, 2026
Unity (Outer Wilds) changes per-draw constant-buffer pointers in its
user-data SGPRs: 57975 of 57981 entry-less draws matched an earlier
draw's key but for those words, so the cache hit ~8% and every draw paid
capture + decode + recompile.

Port of boykopovar#4943 onto this fork's draw cache:
- the SRT evaluator records which user words are only a raw read's base
  (pointers it dereferences) and which it evaluates as values, and every
  read with the word it went through; results name the user word behind
  each forwarded shader-data dword
- per shader set (program addresses + resources) the driver learns its
  pointer words and, beyond boykopovar#4943, its free words (never read by the walk,
  at most forwarded into shader data); an exact-key miss looks up an alias
  key over the contents behind the pointers instead of their values
- on an alias hit the cached stages are copied with their runs shifted by
  the pointer delta (validated by value as any hit) and the forwarded
  shader-data words patched; anything else falls back to the miss path
- the base word of a dereference still counts as read for the capture
  memo; stages with vertex fetch decode reads or poisoned SRT reads stay
  out; a shader set hitting < 1/16 over 1024 alias lookups idles 16384
- [draw-relocate] line under APS5_PROFILE_DRAW; APS5_NO_DRAW_RELOCATION=1
  turns it off

Test agc_driver_draw_cache_relocation(_off): moved pointers to equal
contents hit from the second draw and draw the right colour, other
contents miss; a run-time user word that changes per draw hits and draws
the patched value. Both fail with APS5_NO_DRAW_RELOCATION=1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@TheAggie
TheAggie force-pushed the perf/agc-draw-relocation-main branch from 895d654 to 2ea25c5 Compare October 15, 2026 16:09
@TheAggie

Copy link
Copy Markdown
Contributor Author

@adriadam10 thanks for the review. Both branches are rebased onto main 940eba9 (#4943 is now 5 commits, #5609 is 8 on top of it, without #5906's two commits, which are merged). The three points:

Performance of the stack against current main. Flycast demo (Crazy Taxi attract mode), RetroArch PPSA99169, the same local support commits on both sides (the emulator needs them to run RetroArch; they are identical in both builds), immediate present, audio muted, alternating runs:

run main + support (audio) + #4943 + #5609 (audio)
1 34.5% 45.7%
2 35.7% 44.1%
3 35.6% 42.8%

Audio produced as a share of real time, unprofiled, about 9 points higher with the stack in every alternating pair.

Draw-cache hits on D2R. You were right about the admission. An exact key was admitted at its first sight whenever its alias had been seen, whether or not that shader set's relocations ever hit. That admission exists so a shader set can get a first entry to relocate from: after an alias-admitted insert, the next draw with the same alias hits through relocation and is never inserted. So the cost falls on aliases that come up exactly twice. Each such pair inserts an entry that becomes a donor nobody uses. The rule now:

  • a shader set gets two probation admissions through its alias;
  • after that, alias admission needs relocation hits, and trial.hits now counts only hits that validated through a relocated entry, not donors found;
  • once the trial decides the set is unproductive (fewer than 1 in 16 over 1,024 lookups), the alias isn't even computed (unchanged).

The new case in agc_driver_draw_cache_relocation (UnrelocatedAliasesStayOut) draws 12 aliases that each appear exactly twice, interleaved with a repeated draw. With the old rule it inserts 12 entries; with the new one at most 2, and the repeated draw keeps all 12 of its hits. The test fails on the old rule (checked by restoring it). I haven't got D2R to confirm the hit count on a real title; @mrfelek, if you can rerun with this head, the [draw-cache] inserts/evictions lines would show it.

Disk-cache round trip. agc_shader_disk_cache's result round trip now sets shaderDataUserWords and compares it after decoding (it fails if the decoder drops the words). The static_assert sizes were re-checked after the rebase: I compiled the structs with MinGW's libstdc++ on x86_64, where all 31 asserted sizes match, including the ones this branch doesn't touch. RecompileResult is 496 and BindingAllocationResult 184 on #4943, and 520/208 on #5609.

AI-assisted (Claude Code)

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

With APS5_VERIFY_DRAW_ENTRIES=1 every hit is captured again, so drawHit is false and no relocated hit counted for its shader set's trial, which judged every set unproductive and turned the relocation off under the very mode meant to check it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@milescoviello

Copy link
Copy Markdown
Contributor

Linux results, since the description has no Linux build. Ubuntu 24.04, GCC 13.3, Release, RTX 4070 Ti, NVIDIA 595.99, 244f41ed2 merged onto main 5d9dcc4 (clean merge).

  • Build. It builds, including the Linux-only size asserts in ShaderDiskCache.cpp.
  • Tests. agc_shader_disk_cache and agc_driver_draw_cache_relocation pass 3 of 3. The 469 tests matching ^agc_driver_|^agc_vertex_barrier|recompiler|shader_cache|request_serializer|spirv give 468 passes. agc_driver_store_over_untracked fails the same way on main here (test(agc): keep waiting for the neighbour copy case's result #6385).
  • A title where it doesn't engage yet. Super Meat Boy 3D (PPSA31162) is Unreal and misses the draw cache almost entirely on main. With APS5_TRACE_DRAW_CACHE, about 10,000 lookups per 10 s find no entry, and about 9,700 of those are user words/pointers only. The data-only path then compiles about 18,000 to 25,000 stages per 10 s, against 1,000 to 7,000 reused. With this PR (warm caches, after one warm-up run), the same counters look the same: 2.3% hits, user words/pointers only 9,714, 18,682 stages compiled against 1,086 reused. There is no [draw-relocate] line in 150 s, so it made fewer than 100,000 alias lookups. Presents per 10 s vary too much between runs on this title to show a difference either way. So these draws don't take the relocated path. Maybe their user words are also used as values, or the shader sets aren't learned. I haven't looked into which.

AI-assisted: yes

@LuisAguilarDev

Copy link
Copy Markdown
Contributor

Field data from PPSA08587 (Dragon Ball Xenoverse 2), in case it helps the relocation path: in its heavy gameplay scenes, this PR finds no donors, and the scene stays at 6-12 fps.

Setup: Windows 11, i5-11400, RTX 3070 Ti (NVIDIA 610.88). Build: main 5d9dcc4 + #6539 (which includes this PR at 244f41e) + the title's own unrelated fixes. Run with APS5_PROFILE_DRAW=1 APS5_TRACE_DRAW_CACHE=1 APS5_DRAW_CACHE_ENTRIES=32768.

The miss is in user data

Each frame has ~1,300 draws at ~65 µs each on the queue worker; the worker is ~90% busy and the GPU ~35%. 10 s window:

[draw-cache] 88436 lookups (10 s): 75097 no entry, 13339 validated ... 6829 hits
[draw-cache] register-key miss component changes (10 s): stable registers 6575, user data 74853, auxiliary registers 0, device 0, first shader set 1, unchanged components 47
[draw] driver phases (10 s, 109864 draw packets, 105059 drawn, 176120 captures), us per packet: ... decode 7.4 ... capture 16.2 ... recompile 19.1 ... key/lookup/validate 6.8 ... lock wait 7.1 ..., total 69.8

[draw-relocate] never printed, because it only reports once userPatches is non-empty.

Where the relocation path stops

To find out, I added counters at each exit of the relocation path. They are local only and don't change behaviour. One line per 10 s in the heavy scene, with this PR as is:

[draw-relocate-gates] (10 s): misses: no relocation learned 1, identity idle 80950, aliases built 2867, aliases unreadable 2470;
  learn: key not inserted 76321, learned 2063, no relocatable words 7903, words banned 0;
  capture stages: eligible 6619, ineligible (vertex fetch or decode reads) 14017;
  relocate: no donor decode 2867, no donor stage 0, refused 0, relocated 0; admit: by alias 0, probation spent 61
  • 94% of misses skip relocation because the identity is idle. The trial (1/16 hit share over 1024 lookups) never becomes productive.
  • Every alias lookup fails at drawDecodes.find(aliases.structure). No donor is ever learned, because learnPendingRelocation needs the pending key to have been inserted, and admitDrawKey admits only repeated keys, which these never are.

Same scene, with probation and idle bypassed

I then bypassed both, locally: a key whose alias structure was seen before is admitted, and the trial never goes idle. I also set APS5_NO_DRAW_STAGE_DATA_HITS=1, so tagBufferWords runs. Stage data hits were 0 in this title anyway. Result:

[draw-relocate-gates] (10 s): misses: no relocation learned 4, identity idle 0, aliases built 30313, aliases unreadable 36672;
  learn: key not inserted 62702, learned 940, no relocatable words 3347, words banned 0;
  capture stages: eligible 2450, ineligible (vertex fetch or decode reads) 5539;
  relocate: no donor decode 30301, no donor stage 22, refused 0, relocated 0;
  admit: by alias 0, probation spent 845, alias structure repeated 880 / new 29434, admitted by structure 58; stage donors found 2 / missing 22
[draw-relocate] 106640 alias lookups: 11 donors with moved user words, 0 refusals, 8 hits through a relocated entry

The probation and idle gates were not the root cause:

  • The alias structure itself is new on 97% of draws (29434 new vs. 880 repeated). About 70% of captured stages fail eligibility at DrawCapture.cpp (decodeReads[i].empty() && resourcesNum == 0), because they have vertex fetch. My guess is that their per-draw user words then enter other at their raw value, so the structure is unique per draw.
  • More than half of the alias computations come out unreadable (36672). A relocatable word is 0, or its reads are unmapped.

So for this title, the gain would come from making stages with vertex fetch relocatable, when their fetch descriptors are reached through the moved pointer, and from looking into why so many aliases are unreadable. I can run a build from a branch or share the counter patch if that helps; the full logs are available too.

The analysis was done with AI assistance (Claude Code).

@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. Checked against our last review:

  • Stack numbers on main 940eba9a2 (audio 34.5-35.7% → 42.8-45.7%) answer the performance point; the gain needs #5609 on top, so the two should land back to back.
  • Alias admission now stops after two probation admissions unless relocated hits validate. UnrelocatedAliasesStayOut fails when the probation condition is forced to always admit, and passes with the PR as is.
  • agc_shader_disk_cache now round-trips shaderDataUserWords.

On this head merged onto main, the Agc/AgcDriver scope builds and agc_shader_disk_cache and agc_driver_draw_cache_relocation pass here on Linux; thanks @milescoviello for the independent Linux run. Not verified by us: the D2R hit counts on this head (@mrfelek's last run was on the earlier one), and Windows runtime behaviour beyond CI. Merging once the full Build is green.

@adriadam10
adriadam10 merged commit f8d4ce3 into boykopovar:main Oct 16, 2026
8 checks passed
oneandonlydean pushed a commit that referenced this pull request Oct 17, 2026
relocateDrawEntry copied a cached variant field by field and left out
meshIndexDescriptor, which the variant gained in #5791 before #4943 merged.
lookupDraw compares a mesh stage's descriptor with the draw's index buffer
words before validating anything else, and no draw has an all-zero one, so
every relocated mesh-path stage was refused as a differing front stage. With
no hits the shader set's relocation trial stopped admitting new aliases too.

The copy now takes the donor's descriptor, so a relocated mesh stage matches
the same index buffer the donor was built for and misses on any other.

agc_driver_mesh_draw_relocation draws a mesh-path triangle whose vertex
buffer descriptor is loaded through a user-data pointer, at eight addresses
with the same contents: the last four now hit the cached draw (before: four
differing front stages, no hit).

AI-assisted: yes
Flamefusion added a commit to Flamefusion/AnyPS5 that referenced this pull request Oct 18, 2026
A draw that misses on its own register key and is served by a relocated
draw entry (relocateDrawEntry, boykopovar#4943) never got a draw recipe:

- relocateDrawEntry builds a fresh DrawEntry with per-draw copies of the
  donor's variants. That entry is not stored in drawCache under the draw's
  key.
- Driver::draw still passes the draw's own key to findDrawRecipe and, after
  recording, to attachDrawRecipe. Both look the key up in drawCache, find
  nothing and return, so a recipe built while recording such a draw is
  dropped and the next relocated draw of the same shape records in full
  again ("attaches 0" with APS5_TRACE_DRAW_CACHE).
- The recipe records also compare stage identity against the per-draw
  copies, which are new objects on every draw, so even a stored record
  could never match.

Changes:

- DrawEntry and DispatchVariant gain a donor pointer. relocateDrawEntry
  sets it on the relocated entry and on every copied variant, always to
  the original cached entry/variant (a copy of a copy points at the
  root), so the chain never grows.
- Driver::draw keys the recipe stages on the donor variant when a matched
  variant is a relocation copy, and hands the donor entry to
  findDrawRecipe/attachDrawRecipe (also through commitDraw on the
  pipelined path). With a donor, both read and update the donor entry's
  recipe list directly instead of looking the key up; without one they
  behave as before.
- DrawWithRecipe rebuilds the push constant block from the current shaders
  and patches it through the recorded resources when the recipe has push
  stages, instead of replaying the bytes captured with the recipe. A
  relocated draw moves user words that end up in push constants, so the
  captured block would carry the first draw's values.

Draws that are not relocated take the same path as before: their matched
variants have no donor and recipe lookup still goes through drawCache.

Measured on Hades (PPSA03355, discussion boykopovar#3234), where nearly every sprite
draw is served through a relocated entry: lookups and attaches now
succeed, but Graphics::Draw builds a recipe for only ~15 of ~100,000 draws
per 10 s because the draws' ShaderResources are not Reusable(), so the
frame rate does not change yet. The fix is what lets recipes apply to
relocated draws once that is addressed.
adriadam10 pushed a commit that referenced this pull request Oct 19, 2026
Resolves #4943's draw relocation against this branch: tagVariantRuns, which now builds a draw variant's runs and words, also records the captured words it shares with a table's supplied blocks, per region as before. A variant whose runs relocation splits keeps sharing for the draw cache's word compare, and skips the vouched validation, which already requires one run per region.
adriadam10 pushed a commit to Flamefusion/AnyPS5 that referenced this pull request Oct 19, 2026
A draw that misses on its own register key and is served by a relocated
draw entry (relocateDrawEntry, boykopovar#4943) never got a draw recipe:

- relocateDrawEntry builds a fresh DrawEntry with per-draw copies of the
  donor's variants. That entry is not stored in drawCache under the draw's
  key.
- Driver::draw still passes the draw's own key to findDrawRecipe and, after
  recording, to attachDrawRecipe. Both look the key up in drawCache, find
  nothing and return, so a recipe built while recording such a draw is
  dropped and the next relocated draw of the same shape records in full
  again ("attaches 0" with APS5_TRACE_DRAW_CACHE).
- The recipe records also compare stage identity against the per-draw
  copies, which are new objects on every draw, so even a stored record
  could never match.

Changes:

- DrawEntry and DispatchVariant gain a donor pointer. relocateDrawEntry
  sets it on the relocated entry and on every copied variant, always to
  the original cached entry/variant (a copy of a copy points at the
  root), so the chain never grows.
- Driver::draw keys the recipe stages on the donor variant when a matched
  variant is a relocation copy, and hands the donor entry to
  findDrawRecipe/attachDrawRecipe (also through commitDraw on the
  pipelined path). With a donor, both read and update the donor entry's
  recipe list directly instead of looking the key up; without one they
  behave as before.
- DrawWithRecipe rebuilds the push constant block from the current shaders
  and patches it through the recorded resources when the recipe has push
  stages, instead of replaying the bytes captured with the recipe. A
  relocated draw moves user words that end up in push constants, so the
  captured block would carry the first draw's values.

Draws that are not relocated take the same path as before: their matched
variants have no donor and recipe lookup still goes through drawCache.

Measured on Hades (PPSA03355, discussion boykopovar#3234), where nearly every sprite
draw is served through a relocated entry: lookups and attaches now
succeed, but Graphics::Draw builds a recipe for only ~15 of ~100,000 draws
per 10 s because the draws' ShaderResources are not Reusable(), so the
frame rate does not change yet. The fix is what lets recipes apply to
relocated draws once that is addressed.
Flamefusion added a commit to Flamefusion/AnyPS5 that referenced this pull request Oct 20, 2026
A draw that misses on its own register key and is served by a relocated
draw entry (relocateDrawEntry, boykopovar#4943) never got a draw recipe:

- relocateDrawEntry builds a fresh DrawEntry with per-draw copies of the
  donor's variants. That entry is not stored in drawCache under the draw's
  key.
- Driver::draw still passes the draw's own key to findDrawRecipe and, after
  recording, to attachDrawRecipe. Both look the key up in drawCache, find
  nothing and return, so a recipe built while recording such a draw is
  dropped and the next relocated draw of the same shape records in full
  again ("attaches 0" with APS5_TRACE_DRAW_CACHE).
- The recipe records also compare stage identity against the per-draw
  copies, which are new objects on every draw, so even a stored record
  could never match.

Changes:

- DrawEntry and DispatchVariant gain a donor pointer. relocateDrawEntry
  sets it on the relocated entry and on every copied variant, always to
  the original cached entry/variant (a copy of a copy points at the
  root), so the chain never grows.
- Driver::draw keys the recipe stages on the donor variant when a matched
  variant is a relocation copy, and hands the donor entry to
  findDrawRecipe/attachDrawRecipe (also through commitDraw on the
  pipelined path). With a donor, both read and update the donor entry's
  recipe list directly instead of looking the key up; without one they
  behave as before.
- DrawWithRecipe rebuilds the push constant block from the current shaders
  and patches it through the recorded resources when the recipe has push
  stages, instead of replaying the bytes captured with the recipe. A
  relocated draw moves user words that end up in push constants, so the
  captured block would carry the first draw's values.

Draws that are not relocated take the same path as before: their matched
variants have no donor and recipe lookup still goes through drawCache.

Measured on Hades (PPSA03355, discussion boykopovar#3234), where nearly every sprite
draw is served through a relocated entry: lookups and attaches now
succeed, but Graphics::Draw builds a recipe for only ~15 of ~100,000 draws
per 10 s because the draws' ShaderResources are not Reusable(), so the
frame rate does not change yet. The fix is what lets recipes apply to
relocated draws once that is addressed.
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.