Skip to content

perf(agc): cache a draw key the second time it is seen - #5216

Merged
oneandonlydean merged 1 commit into
boykopovar:mainfrom
oneandonlydean:up4/draw-key-second-sight
Oct 13, 2026
Merged

oneandonlydean merged 1 commit into
boykopovar:mainfrom
oneandonlydean:up4/draw-key-second-sight

Conversation

@oneandonlydean

Copy link
Copy Markdown
Collaborator

What

A draw whose key has no entry in the draw cache captures its stages, builds a variant per stage (a copy of every captured region) and inserts a draw entry. A key that never comes back pays for the variants and the entry for nothing, and its entry pushes a live one out of the 4096-entry cache. Draws whose user data changes every frame are such keys: in a level of PPSA21564 almost no lookup finds an entry (152,112 lookups, 90 hits per 10 s), and cacheDrawStages (the variant copies and the insert) is 4.3% of the queue-0 worker's samples, before the frees of the evicted entries.

The driver now remembers the keys it has seen once (DrawKeySightings): 16,384 keys in four-way buckets, four times the cache's size. A key gets its entry at its second sight. A key that already has an entry still takes new variants at once. A full bucket replaces its oldest key, so keys that share a bucket don't push each other out unless more than four keys of that bucket come between two sights of one of them.

The cost is one more full draw for a key that does repeat: its third draw is the first hit, not its second. What a draw records is unchanged; only when an entry is made changes.

This is the idea of #3985, which was closed. Its table was direct-mapped, so two recurring keys in one slot evicted each other and were never cached; the buckets here are the fix discussed there. It was closed for no frame-time gain in a menu where few keys repeat; the scenes below where keys don't repeat do gain.

Tested

Linux (CachyOS, kernel 7.2, GCC 16.2, RTX 4090, NVIDIA 610.57.04), Release, BUILD_TESTING=ON.

  • agc_driver_draw_cache_reuse and agc_driver_draw_cache_reuse_off now draw three times and require 3 lookups, 2 without an entry and 1 insert. With only that change to the test on main both fail: the three draws made 3 draw-cache lookups (1 without an entry), expected 3 lookups with 2 absent. The same test runs a one-bucket table: two keys drawn in turn are both admitted at their second sight, and a full bucket replaces its oldest key.
  • ctest -R "agc|shader": 477 of 477 on this branch (main 075169f).
  • python3 tools/check_conventions.py --base origin/main passes.
  • A 2D title: boot, menu and gameplay to 220 s, all reference frames match, no skipped draws.

PPSA21564, main 2a2a3dc against the same commit with this change, same build options, warm shader and pipeline caches (warm-up runs not counted), runs alternated on an otherwise idle machine. Title and menu are frames per second over fixed windows. The levels advance a fixed step per frame below 30 fps, so they are milliseconds per present over the same level frame indices.

scene main this
title, fps 23.15, 23.35, 23.6 23.65, 23.8, 24.0
menu, fps 20.6, 20.9, 21.5 20.25, 21.1, 21.4
first level, frames 400 to 1200, ms 28.5, 28.6, 29.0 28.2, 27.7, 30.9
crowd in the first level, frames 1400 to 2000, ms 78.7, 76.7, 77.3 73.0, 72.8, 73.0
a later level, frames 100 to 380, ms (APS5_PROFILE_DRAW=1) 401.4, 403.2 371.4, 369.3

In that later level, with the profile on, key, lookup and validate fall from 4.2 to 1.6 us per draw packet.

Summary lines of APS5_TRACE_SECONDS=90:20 in the same later level, without APS5_PROFILE_DRAW: main first, this change second (p50 400.5 to 372.2 ms per present). The builds were made from exported trees, so commit reads unknown. Both traces filled the per-thread event buffer (trace_dropped); the frame times come from the presents and are complete. APS5_LOOP_GUARD and APS5_DUMP_FRAMES are set by my test harness on both sides; the other three variables it sets are not read by main. The trace files are not attached (the PR was opened from the command line); I can add them.

{"commit":"unknown","dirty":null,"env":{"APS5_DUMP_FRAMES":"6000","APS5_INEXACT_SINGLE_LANE":"500597a00","APS5_LOOP_GUARD":"2000","APS5_PAD_PRESS":"","APS5_TOLERATE_DISPATCH_FAILURES":"1","APS5_TRACE_DIR":"<path>","APS5_TRACE_SECONDS":"90:20","APS5_TWO_LANE":"500597a00"},"gpu":"NVIDIA GeForce RTX 4090","driver":"NVIDIA 610.57.04","os":"Linux 7.2.3-1-cachyos","resolution":"3840x2160","window":{"spec":"seconds 90:20","first_present":1595,"ms":20037.491},"presents":50,"frame_ms":{"p50":400.485,"p90":415.494,"p99":431.600,"max":431.600},"gpu_busy_ms_per_present":118.908,"worker_busy_pct":{"queue worker 0x0":99.374,"queue worker 0x40":98.126,"queue worker 0x48":97.957,"queue worker 0x46":0.438},"top_phases_ms_per_frame":{"Driver.Packet":1185.391,"Driver.Packet.memory_wait":776.389,"Driver.Packet.draw":356.213,"Driver.Draw":355.285,"Driver.Draw.shader_compile_and_link":168.414,"Driver.Draw.draw_and_resource_release":153.188,"Graphics.Draw":152.218,"Shader.DrawStage":143.506},"trace_dropped":true,"trace":"aps5-trace-unknown-4117737.json.gz"}
{"commit":"unknown","dirty":null,"env":{"APS5_DUMP_FRAMES":"6000","APS5_INEXACT_SINGLE_LANE":"500597a00","APS5_LOOP_GUARD":"2000","APS5_PAD_PRESS":"","APS5_TOLERATE_DISPATCH_FAILURES":"1","APS5_TRACE_DIR":"<path>","APS5_TRACE_SECONDS":"90:20","APS5_TWO_LANE":"500597a00"},"gpu":"NVIDIA GeForce RTX 4090","driver":"NVIDIA 610.57.04","os":"Linux 7.2.3-1-cachyos","resolution":"3840x2160","window":{"spec":"seconds 90:20","first_present":1592,"ms":20131.935},"presents":54,"frame_ms":{"p50":372.186,"p90":381.003,"p99":409.912,"max":409.912},"gpu_busy_ms_per_present":114.064,"worker_busy_pct":{"queue worker 0x0":99.285,"queue worker 0x40":98.232,"queue worker 0x48":98.188,"queue worker 0x46":0.469},"top_phases_ms_per_frame":{"Driver.Packet":1103.855,"Driver.Packet.memory_wait":723.301,"Driver.Packet.draw":328.267,"Driver.Draw":327.265,"Driver.Draw.draw_and_resource_release":148.226,"Graphics.Draw":147.226,"Driver.Draw.shader_compile_and_link":145.765,"Shader.DrawStage":137.433},"trace_dropped":true,"trace":"aps5-trace-unknown-4111272.json.gz"}

Not tested: Windows, AMD.

Checklist

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

A draw whose key has no entry captured its stages, built a variant per
stage (a copy of every captured region) and inserted a draw entry. A key
that never comes back paid for the variants and the entry for nothing,
and its entry pushed a live one out of the cache. Draws whose user data
changes every frame are such keys.

The driver now remembers the keys it has seen once, in a table of
four-way buckets holding four times the cache's size in keys. A key gets
its entry at its second sight; a key that already has an entry still
takes new variants at once. A full bucket replaces its oldest key, so
keys that share a bucket do not push each other out until more than four
of them come between two sights of one.

Test: agc_driver_draw_cache_reuse draws three times. The first two draws
find no entry, one entry is inserted, and the third draw hits it. A
sighting table of one bucket keeps two keys drawn in turn and replaces
its oldest key when full.
@mrfelek

mrfelek commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Data point from another title: Diablo II: Resurrected (PPSA03912), Linux, RTX 3080 Ti, in game. The draw cache hits about 0.1% there (46087 lookups per 10 s, 43 validated), the same pattern as described here. I ran this PR together with #5407 and #4947 (not isolated); no abort, and the window-title FPS went from about 2.4 to about 3.6 on average, noisy because the run swaps. Details on #5407.

AI-assisted; the runs are from my machine.

@oneandonlydean

Copy link
Copy Markdown
Collaborator Author

Holding this for review and a few more runs before it merges. What would help:

  • This PR alone on main against main, same scene, a few alternating runs each: frame time and the [draw] cache line (lookups, hits, validated). Runs combined with other PRs can't show what this one does.
  • Titles other than PPSA21564, especially ones with many repeated draws, and any visual difference against main (frame dumps or screenshots at the same point). A wrong hit would draw with stale state, so a picture that differs matters more than the fps.
  • AMD and Intel as well as NVIDIA, and Windows as well as Linux.

@mrfelek

mrfelek commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

A/B for this PR alone, as requested above. Diablo II: Resurrected (PPSA03912, 01.039.000), Linux, RTX 3080 Ti (NVIDIA 615.71.09), RAM-limited machine (31 GB, the run swaps), APS5_HOST_IMPORT_MIB=16384, APS5_THREAD_PRIORITY=high.

run presents per 10 s GPU busy per frame draw-cache hits of about 100k lookups per 10 s
A1 / A2 / A3 36.6 / 36.9 / 36.0 156-158 ms 91 / 94 / 161
B1 / B2 / B3 38.0 / 40.9 / 39.0 156-158 ms 211 / 227 / 216

Mean 36.5 on A and 39.3 on B, about 8% more presents, and the two sets do not overlap. Within a run the windows are steady (within one present). The GPU busy time is the same, and the CPU idle gap per frame goes from about 120 ms to about 100 ms, so the gain is small. The hit rate stays at about 0.2% (the title's user data changes on almost every draw), so this PR saves the insert and eviction work and nothing more there.

One thing to weigh: the title segfaulted while loading in 2 of the 5 launches with this PR (B1 and B2, both fine on retry) and 0 of 3 without it. Both crashes were a lookup in a hash table with a null table pointer inside the game's own code (same function, offsets 0x1b9d787 and 0x1b9d7a9 in the relinked executable), which looks like a race between game threads at load and not the driver, and the title has crashed at load before on builds without this PR. Five launches is too few to rule this PR out, so I would not read more into it than that.

AI-assisted; the runs are from my machine and I can attach the [draw-cache] and [present] lines of each run.

mrzacarias pushed a commit to mrzacarias/AnyPS5 that referenced this pull request Oct 13, 2026
…t is seen) into fex-upstream

Admission is skipped while APS5_DRAW_RELOCATION is on: relocated draws must be cached at first sight to be found under their moved pointers.
@bcortezf

Copy link
Copy Markdown

Isolated A/B of this PR against main, same machine and scene, alternating runs, warm caches. PPSA30528 (Red Dead Redemption), gameplay after the intro, Windows 11, RTX 4070 SUPER (driver 616.64), Ryzen 7 7800X3D, 3840x2160, APS5_TRACE_SECONDS=100:5.

  main this PR
frame p50, FPS 2.57, 2.46 2.76, 2.77

About +10%. No aborts. On top of #494 + #1193 + #1202 it is neutral: 6.33 / 5.72 / 7.64 FPS without it, 6.28 / 6.07 / 7.67 with it. It merges with #494 with three adjacent-line conflicts (Driver.hpp, DrawCache.cpp, DrawCapture.cpp): both insertDrawEntry signatures kept, and admitDrawKey(drawKey) added to the cacheDrawStages condition. I compared frame times only, no screenshot diff.

@oneandonlydean
oneandonlydean merged commit bfc1063 into boykopovar:main Oct 13, 2026
9 checks passed
adriadam10 added a commit to MichonGoddijn231849/AnyPS5-fork that referenced this pull request Oct 14, 2026
Since boykopovar#5216 a key gets its entry at its second sight, so a shape whose
user words change on every draw never got an entry and its data-only hits
never started. A key whose shape was seen before but has no entry is now
admitted at its first sight. A shape that already has an entry serves
data-only hits from it, and its new keys are still admitted at their
second sight, so per-draw keys don't churn the cache. Shapes are
remembered in a second sightings table of the same size.

agc_driver_draw_cache_reuse draws three times with new pixel user words
over a cached shape (three data-only hits, the vertex stages reused, no
insert), then three keys of a new shape (the second one is admitted), and
runs again with APS5_VERIFY_DRAW_DATA_HITS=1.

Co-Authored-By: Claude <noreply@anthropic.com>
mrzacarias pushed a commit to mrzacarias/AnyPS5 that referenced this pull request Oct 15, 2026
Upstream main now carries the final versions of PRs boykopovar#1193, boykopovar#1201, boykopovar#1202, boykopovar#5216 and boykopovar#4465, so their
draft copies on this branch give way to main's: the snapshot ring, the pipeline identity memo, the
code-hash memo and APS5_RESIDENT_LINEAR_TARGETS are gone with them. The port of boykopovar#4943 (draw-cache
relocation across moved user-data pointers, not merged upstream) is dropped: the draw prefetch
replaces it.

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

Labels

agc libSceAgc*

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants