Skip to content

perf(agc): reuse draw snapshots across draws that nothing wrote between - #5906

Merged
adriadam10 merged 3 commits into
boykopovar:mainfrom
TheAggie:perf/agc-draw-snapshot-reuse
Oct 14, 2026
Merged

adriadam10 merged 3 commits into
boykopovar:mainfrom
TheAggie:perf/agc-draw-snapshot-reuse

Conversation

@TheAggie

Copy link
Copy Markdown
Contributor

What

On Windows a draw copies every guest buffer it reads in place into a new snapshot, whether or not the bytes changed since the last draw. Host-pointer imports of the guest mapping are compared instead of write-watched there (#1441), so CollectWrites returns 0 for them, KeepDrawSnapshot never keeps the snapshot, and the next draw copies the range again. The debt entry for #1441 already notes that this disables snapshot reuse on excluded blocks.

In RetroArch's Flycast core (PPSA99169) on Windows, that is a 2 MiB vertex buffer copied for nearly every draw: about 12,500 copies and 16 GB a second in the Crazy Taxi attract demo. A local measurement found 99.95% of those copies byte-identical to the previous copy of the same range.

  • PrepareDrawBindings stamps a draw with the write tracker's generation, the collect epoch and the guest allocation registry's generation. The last snapshot of each address is kept with its stamp, and a draw with the same stamp and size reuses it.
  • The collect epoch moves at every submission, wait, drain and reap. A CPU store into unwatched memory is therefore picked up at the next ordering point, the bound docs/dev/ARCHITECTURE.md already gives unwatched memory and the collect memo gives watched memory. A store into watched memory moves the tracker generation and ends reuse at once.
  • When the stamp differs, the existing path runs unchanged (ReusableDrawSnapshot, then a new copy).
  • Draws that rebind a moved buffer from captured words (draw templates) reuse the buffer built for the same words, from a small ring of 8, instead of building it again.
  • The map of unwritten snapshots holds at most 4,096 addresses; at the limit every other entry is dropped.
  • APS5_VERIFY_DRAW_SNAPSHOTS=1 checks every reused snapshot against the slow path, or against guest memory where the slow path keeps nothing, and reports differences.
  • The debt line for fix(agc): compare Windows host imports after late driver writes #1441 now says snapshot reuse on excluded blocks is limited to one collect epoch instead of disabled.

No runtime switch turns it off; with the verify switch a difference is reported, not corrected.

Tested

Windows 11, NVIDIA GeForce RTX 5070 Ti, MinGW-w64 GCC 15.2.0, on main 63fb0cd92.

  • ctest: 1168 of 1185 pass, 15 skipped. guest_call_trace_abort and windows_backtrace_report fail, and fail the same way on plain main here. 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, they pass.
  • New test agc_draw_snapshot_epoch (agc_driver_recorder_tests --draw-snapshot-epoch-only), next to drawSnapshotPatchTests and on the same host-imported guest block, but run where imports are compared as well: a second draw in one collect epoch gets the same snapshot; after a store and a new epoch the next draw gets a new snapshot with the new bytes, and the draw after it reuses that one. Here imports are compared. With the epoch left out of the stamp, the test fails with "a draw snapshot does not hold the guest bytes of its draw".
  • RetroArch (PPSA99169), Flycast core, Crazy Taxi attract demo, ANYPS5_PRESENT_MODE=immediate, measured on main a14ae8e23 plus local changes outside this PR that the frontend needs to boot, unprofiled, APS5_TRACE_SECONDS=60:30: 202 presents in 30 s and audio at 5.8% of real time without this change, against 928 and 934 presents and 26.1% and 27.0% with it.
  • Same demo with APS5_VERIFY_DRAW_SNAPSHOTS=1: 1,800,000 reused snapshots checked against guest memory, 0 differing.
  • Linux is untested here; on Linux most ranges are write-watched, so the stamp mostly falls through to the existing path.

Related: #5609 contains this change as one of its commits; with this PR it depends on it instead. #5542 caches read-only snapshot copies of address-based builds by comparing bytes on each use; this PR covers draw inputs and compares nothing. #5900 (draft) moves watched vertex and index ranges from in-place binding to reused snapshot copies in InPlaceDrawInput; this PR is about the storage-buffer snapshots of PrepareDrawBindings on compared imports, a different path, and touches neither Draw.cpp nor the in-place rules.

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw (NotImplemented_nid_no_patch); silent stubs are listed in TechnicalDebt
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: none
  • AI-assisted: yes. Written with Claude Code under my direction.

🤖 Generated with Claude Code

@kjhq

kjhq commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Tested on Sifu (PPSA03001, Unreal Engine 4, #5908): this PR gets it past its loading screen.

Windows 11 (build 26300), NVIDIA GeForce RTX 5090 (610.88), MinGW GCC 15.2.0. main 8dfaafe with and without this PR. APS5_PROFILE_DRAW=1 plus local timers around PrepareDrawBindings only.

Without it, PrepareDrawBindings copies about 7.5 storage-buffer snapshots per draw, about 13 MiB, and almost none are reused (10 hits in 582,000 snapshots, about 1 TB copied in two minutes). That is about 0.5 ms per draw, out of the 0.6-0.9 ms the draw takes. Queue 0 manages about 1,000 draws and 2 submissions a second. The backlog stalls RenderThread until Unreal's watchdog fires (GameThread timed out waiting for RenderThread after 120.01 secs).

main main + #5906
draws recorded per 10 s on queue 0 8,700-11,600 19,000-30,000
average recorded draw 610-900 µs 243-398 µs
PrepareDrawBindings per draw about 480 µs about 12 µs
queue 0 submissions per 10 s 22 96-127
result loading screen freezes, watchdog fatal error at about 160 s title screen ("PRESS ANY BUTTON") reached, no watchdog in 200 s

The title screen runs at about 0.5 FPS; the remaining per-draw cost is elsewhere, and I'm following it up separately.

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

The Sifu numbers (PrepareDrawBindings from about 480 µs to about 12 µs per draw) and the verify run (1.8 M reused snapshots, 0 differing) make a strong case. I checked the interaction with GPU writes on main: an allocation whose range has pending GPU writes is skipped before the snapshot code (if (!direct || recorder.PendingWriteOverlaps(item.address, item.size)) continue;). The new UnwrittenDrawSnapshot lookup sits after that, so a buffer that a dispatch in the same submission writes still binds in place and is never served a stale copy.

One gap, in the moved-words ring:

const auto held = std::find_if(movedWordBuffers.begin(), movedWordBuffers.end(), [&](const MovedWordBuffer& entry) { return entry.allocation == index && entry.size == override->size && entry.words == override->words; });

The buffer built on a miss is override->words plus every dataPatches entry for that allocation (buffer->Bytes()[patch.byte] = patch.adjustment). The key compares only the allocation index, size and words, not the patches. Two draws with the same captured words and different adjustments would share the first draw's patched bytes. If the adjustments follow from the words alone, saying so (or asserting it under APS5_VERIFY_DRAW_SNAPSHOTS) settles it. Otherwise the patches, or a hash of them, belong in MovedWordBuffer's key. This path isn't covered by the verify switch, which only checks the UnwrittenDrawSnapshot answers.

Two small points:

  • The map is keyed by address and checks the size on lookup, so a range bound at one address with two sizes in alternating draws evicts itself on every draw. That costs speed, not correctness, so it's fine as is. Keying by (address, bytes) like drawSnapshots would avoid it.
  • The Tested section has the RetroArch run as presents and audio rate. Adding the aps5-summary.jsonl lines of main and of this change (the run already used APS5_TRACE_SECONDS) would put it in the Performance report form.

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

Checked on Linux (build + the 546 libSceAgcDriver tests, agc_draw_snapshot_epoch passes and fails without the change). The stamp logic and the moved-words ring look right; one thing to fix:

  • Recorder::unwrittenSnapshots (Recorder.cpp:2525) keeps a shared_ptr<Buffer> per address up to 4,096 entries with no byte limit, and an entry whose stamp no longer matches is never dropped until the same address is stored again. Each buffer can be up to the 64 MiB snapshot cap, so this holds device memory outside the 1 GiB DrawSnapshotBudget that bounds drawSnapshots. Please bound it by bytes (reuse the drawSnapshots budget/recency, or drop entries whose stamp is stale on lookup) and cover the limit in the test.

On @CnRJay's dataPatches question: they are a member of ShaderResources set at construction, so the same words always get the same patches within one object; the ring needs no extra key.

The conflict with main is only the #1441 line in docs/dev/debt/libSceAgcDriver.md; we can rebase it once the above is done.

TheAggie and others added 3 commits October 14, 2026 22:45
…hat nothing wrote between

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… again after a store

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

Co-Authored-By: Claude <noreply@anthropic.com>
@adriadam10
adriadam10 force-pushed the perf/agc-draw-snapshot-reuse branch from 0976e1d to b0001cb Compare October 14, 2026 20:52
@adriadam10

Copy link
Copy Markdown
Collaborator

Rebased onto current main (only the #1441 line in docs/dev/debt/libSceAgcDriver.md conflicted) and fixed the point from our review ourselves, in b0001cb:

  • unwrittenSnapshots now holds only entries of the current stamp: a lookup or note with a different stamp drops the others (the three generations only grow, so an entry under an older stamp can never match again).
  • It is bounded by DrawSnapshotBudget in bytes and by Recorder::UnwrittenSnapshotEntries (4,096) in entries; a note past either is not kept, replacing an address releases its old bytes.
  • unwrittenSnapshotTests (run by agc_draw_snapshot_cap) pins the byte budget, the entry cap, the replacement and the retirement on a newer stamp; removing either bound makes it fail.

Linux: the full agc* ctest set passes (558/559, the one failure is agc_driver_tessellation, which fails the same way on main here). The Windows build runs before the merge.

@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Other open pull requests touch the same code. Merging one will break the other, or both implement the same thing. If this one builds on them, list them in Depends on.

Pull request Conflicting files (lines on main) Same exports Same new files
#5892 docs/dev/debt/libSceAgcDriver.md L58
#5905 docs/dev/debt/libSceAgcDriver.md L58

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

The unbounded unwrittenSnapshots is fixed in b0001cb: retired on a stamp change and capped in bytes and entries, with a test. Windows build green (run 38635781892).

@adriadam10
adriadam10 merged commit 0ae1deb into boykopovar:main Oct 14, 2026
8 checks passed
@JAB-dev

JAB-dev commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Besiege (PPSA20313), main at 7003eda5d plus the draft that imports resident direct memory through a section alias on Windows, with and without this PR (b0001cb67), Windows 11, RTX 4070 SUPER, 1080p: title screen 21.5 / 21.5 -> 21.3 / 21.9 FPS (no change).

@fma-rc

fma-rc commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

The full no-argument agc_driver_recorder_tests run (it has no ctest entry since the revert of #5211, so CI does not see it) fails from this merge on with AGC graphics: second draw observed overwritten input (Recorder.cpp:1965). With the (9) hunk of 6bd076c applied so the run gets that far, on lavapipe:

ref full run
fa539dd (parent of the merge) passes
0ae1deb (this merge) fails there
4808d9c, 404075b, b9d9ec6 fails there

Same failure 3 of 3 on NVIDIA and RADV at b9d9ec6. The test memsets the element to 0x22 between the two PrepareDrawBindings calls; the second draw's snapshot still holds 0x11, the first draw's bytes, so it looks like the UnwrittenDrawSnapshot answer is used across that CPU write.

In a title I do not see it: Astro's Playroom on 6870cc2 with APS5_VERIFY_DRAW_SNAPSHOTS=1 checked 4.2 million fast answers with 0 differing. So either the test block is outside what the stamp covers, or the test needs updating; I have not worked out which.

@Dextroid17

Copy link
Copy Markdown
Contributor

On the question above (test block outside the stamp, or the test needs updating): on lavapipe (main eea428c, default unoptimized CMake build), with the (9) hunk of 6bd076c applied, adding GuestMemory::MarkWritten(element, elementBytes) after each of the two memsets makes second draw observed overwritten input pass 3 of 3. 11adb34, the commit before 98ce8af, passes the check and 98ce8af fails it, 2 of 2 each. Details, including a third unstamped write further on, are on #1532: #1532. I did not run it on NVIDIA, RADV or Windows, and it does not show the driver is right for an unstamped CPU write.

AI-assisted: yes

ivica00 added a commit to ivica00/AnyPS5 that referenced this pull request Oct 15, 2026
@meh7an meh7an mentioned this pull request Oct 15, 2026
11 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants