Skip to content

perf(agc): prepare pipelined draws on a thread pool - #6949

Closed
alfalfa-star wants to merge 1 commit into
boykopovar:mainfrom
alfalfa-star:perf/agc-draw-prep-pool
Closed

alfalfa-star wants to merge 1 commit into
boykopovar:mainfrom
alfalfa-star:perf/agc-draw-prep-pool

Conversation

@alfalfa-star

@alfalfa-star alfalfa-star commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

What

With pipelined draws, every queue-0 draw is still prepared on the packet worker: shader captures, resource materialization and draw-cache lookups run one draw at a time before the draw is queued for the committer. In Kena: Bridge of Spirits (PPSA01802) the forest scene issues around 30,000 draws per second, and this preparation is the CPU bottleneck that keeps the title far below 30 fps.

APS5_DRAW_PREP_THREADS=N (N > 0) prepares queue-0 draws on a pool of N threads:

  • Draws are handed to the pool in submission order and committed in that order by the existing committer.
  • Each prepared draw is checked against the memory written by the draws queued before it; if an earlier draw wrote something it read, its preparation is redone.
  • Draw-cache data-only hits keep their cache entry alive until commit and use the patched stage data like the single-threaded path.
  • The pool is pinned to the efficient cores (APS5_DRAW_PREP_AFFINITY_MASK overrides), and the committer to the queue workers' cores.

The pool is off by default; nothing changes unless APS5_DRAW_PREP_THREADS is set.

Tested

AMD Radeon RX 9070 XT (RADV GFX1201), Mesa 26.2.4, Arch Linux (kernel 7.2.9), Kena: Bridge of Spirits (PPSA01802).

  • ctest: no new failures (agc_driver_tessellation segfaults on main with and without this change).
  • Kena with APS5_DRAW_PREP_THREADS=4: the pool prepares about 300,000 draws per 10 s with ~20 in flight. Together with the other work in our local test stack it brings the forest scene to about 24 fps. A measurement of this change alone on top of main will follow in a comment.

Checklist

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

🤖 Generated with Claude Code

With APS5_DRAW_PREP_THREADS=N (N > 0), queue-0 draws are prepared on a
pool of N threads instead of on the packet worker: each draw's shader
captures, resource materialization and draw-cache lookups run on a pool
thread, the draws are enqueued in submission order, and each prepared
draw is validated against the writes of the draws before it, redoing the
preparation when an earlier draw wrote memory it read. Commits stay in
order on the draw committer. Data-only draw-cache hits keep their cache
entry alive until commit and use the patched stage data like the main
path. The pool is pinned to the efficient cores (APS5_DRAW_PREP_AFFINITY_MASK
overrides) and the committer to the queue workers' cores.

The pool is off by default (APS5_DRAW_PREP_THREADS unset or 0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 16, 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
PR 1896 core/libs/prx/libSceAgcDriver/Execution/include/Driver/Draw/DrawPipeline.hpp L39
PR 6208 core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp L34
PR 6341 core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/DrawPipeline.cpp L2-3, L119
PR 6501 core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp L175-176
PR 6616 core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp L175-176
PR 6914 core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/Draw.cpp L4
core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/DrawPipeline.cpp L68, L179-180
PR 6963 core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp L175-176
PR 7008 core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp L175-176

Some comparisons could not be completed because a pull request conflicts with the base branch. Review these shared files manually before concluding that there is no overlap.

Pull request Shared files not compared
PR 1341 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 1368 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 1395 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 2434 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 4339 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 4448 core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp
PR 4650 core/libs/prx/libSceAgcDriver/CMakeLists.txt
core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/Draw.cpp
PR 4891 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 5021 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 5115 core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp
PR 5371 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 5426 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 5431 core/libs/prx/libSceAgcDriver/CMakeLists.txt
core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/Draw.cpp
core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp
PR 5441 core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/Draw.cpp
PR 5499 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 5609 core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/Draw.cpp
core/shader/recompiler/Optimization/include/Optimization/SrtWalker/SrtEvaluator.hpp
PR 5925 core/libs/prx/libSceAgcDriver/CMakeLists.txt
core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/Draw.cpp
core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp
PR 6044 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6176 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6188 core/libs/prx/libSceAgcDriver/CMakeLists.txt
core/libs/prx/libSceAgcDriver/Execution/include/Driver/Draw/DrawPipeline.hpp
core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/Draw.cpp
core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/DrawPipeline.cpp
core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp
PR 6215 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6222 core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/Draw.cpp
PR 6259 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6320 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6373 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6412 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6451 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6503 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6535 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6538 core/libs/prx/libSceAgcDriver/Execution/include/Driver/Draw/DrawPipeline.hpp
core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/DrawPipeline.cpp
core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp
PR 6539 core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/Draw.cpp
core/shader/recompiler/Optimization/include/Optimization/SrtWalker/SrtEvaluator.hpp
PR 6683 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6718 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6747 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6808 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6937 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6946 core/libs/prx/libSceAgcDriver/CMakeLists.txt
core/libs/prx/libSceAgcDriver/Execution/include/Driver/Draw/DrawPipeline.hpp
core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/Draw.cpp
core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/DrawPipeline.cpp
core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp
PR 6947 core/libs/prx/libSceAgcDriver/CMakeLists.txt
core/libs/prx/libSceAgcDriver/Execution/include/Driver/Draw/DrawPipeline.hpp
core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/Draw.cpp
core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/DrawPipeline.cpp
core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp

@github-actions github-actions Bot added build shader core/shader agc libSceAgc* labels Oct 16, 2026

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

Off by default, but this is a concurrency design with correctness risks and no test or measurement yet.

  • const Submission* pooledSubmission = &submission; is used inside the pool job (materializeDrawStage(..., *pooledSubmission, ...)) after draw() has returned drawn(). Nothing in the diff shows what keeps that Submission alive until the job has run. If it is a stack or per-submit object, the job reads a dead object once the worker moves on. Say what guarantees the lifetime, or copy what the job needs into Pooled.
  • The pool threads are detach()ed from a leaked new DrawPrepPool() with no stop path. At AgcDriverShutdown a job can still be running against this (the Driver) and the device it captured. Shutdown has to WaitAll() before teardown, and the pool has to be stoppable.
  • The poolMiss/poolSeen block runs on every draw when the pool is on: eight atomics, a std::all_of over dataCandidates, the verify and trace probes, and a stderr line every 100k draws. It is diagnostic code in the hottest function. Please remove it or put it behind a profile switch.
  • Enqueue, Drain, PendingLabel and Overlaps now call WaitAll(), and so does every packet that is not in commutesWithPreparedDraws. Each of those collapses the pool back to the single-threaded path for that stretch. The [prep-pool] line already counts waitAlls; please post it, since it decides whether four threads buy anything in a title that interleaves labels or DMA with draws.
  • A conflict (WrittenBetween) redoes the whole preparation. That doubles the CPU cost on exactly the draws that depend on each other. Post the redos ratio from the same run.
  • The pool is pinned to the efficient cores by default on hybrid CPUs, and the description calls this preparation "the CPU bottleneck". Running the bottleneck on E-cores by default needs an A/B against APS5_DRAW_PREP_AFFINITY_MASK set to the P-cores. Also, "about 20 in flight" cannot come from APS5_DRAW_PREP_THREADS=4: maxInFlight is count * 3 = 12.
  • SrtEvaluator.hpp drops explicit from IrNode(IrValue*). That makes every IrValue* convert implicitly to IrNode across the recompiler, in a header that has nothing to do with this topic. Revert it, or say why the pool needs it.
  • No test. Ordering of commits, the redo on a write conflict and WaitAll at drain are all untested, and each is a place where a race gives wrong pictures instead of a crash. A test that runs two dependent draws (the second reads what the first writes) through the pool, once with the conflict and once without, would cover the redo.
  • Performance: "about 24 fps for the whole stack" and "a measurement of this change alone will follow" is not yet a report. Please add the summary lines of main and this PR with APS5_DRAW_PREP_THREADS unset and set, same scene in PPSA01802, no APS5_PROFILE_*.

@parl0v

parl0v commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Data point from a CPU-bound scene on Windows: RTX 3060 12 GB (NVIDIA 617.42), i5-12400F, Windows 10 19045, MinGW GCC Release. Minecraft Dungeons II (PPSA16064 01.100.100), title screen, no input, 200 s rows, APS5_PROFILE_DRAW=1 APS5_PROFILE_GPU=1; main bc3c0fc vs main + this PR, interleaved with five other PRs in one batch, two rounds. FPS is the mean of the last 10 [present] windows, GPU busy and idle gaps per frame over the last 8. This scene is CPU-bound on this machine (45-47 ms of idle gaps per frame).

libs FPS GPU busy idle gaps
main 6.70 / 6.60 105 / 106 ms 45 / 46 ms
+ this PR (e2d9ac8) 6.56 / 6.66 (-0.6 %) 106 / 104 ms 47 / 46 ms

Within the run-to-run spread (about ±1.5 % here), no regression, 0 fatal.

AI-assisted: yes (Claude Code)

@Raghav314

Copy link
Copy Markdown
Contributor

@alfalfa-star a data point from Hades II (PPSA36082), the level's item pickup UI, where queue worker 0's draw preparation is the limit. Linux, RTX 4090, 7800X3D, NVIDIA 615.78.08. All three builds are main 2fab88d + #5656 + #6632; this PR is a local cherry-pick of e2d9ac8 onto it (applies cleanly). APS5_PIPELINED_DRAWS=32 APS5_PIPELINE_DISPATCH=1 APS5_HOST_IMPORT_MIB=6784, warm shader cache, trace 70:10, one run each:

build presents in 10 s frame p50 / p99 ms queue worker 0 busy Driver.Draw ms per frame
base 499 19.9 / 25.3 93% 16.3
this PR, APS5_DRAW_PREP_THREADS=4 501 19.8 / 23.3 93% 16.0
#6946, APS5_DRAW_PIPELINE=1 824 12.0 / 14.9 84% 8.5

The pool's own line during the window:

[prep-pool] 10 s: 221002 jobs on 4 threads, avg 0.0 in flight; front waited for all 128551 times (724 ms), for room 1 ms; turn waits 24 ms; redos 0, front-read conflicts 0, skipped 0

So in this scene the jobs run one at a time: shader_compile_and_link on the worker drops from 12.3 to 9.1 ms per frame, but the worker waits for the pool to empty about 13,000 times a second, which takes the time back. No redos or skipped draws.

#6946 changes the same part of Driver::draw (it hands the second half of a draw to one finisher thread), so the two conflict there and in DrawPipeline and the packet filter; whichever lands second will need a rebase.

LLM assistance used.

@tibzejoker

Copy link
Copy Markdown
Contributor

GTA V (PPSA04264) data for this PR, as suggested by @teepean on #1203.

Setup. i9-13900K, RTX 4090 (NVIDIA 616.92), Windows 11, WinLibs GCC 15.2. Built from main 5f61879a9 plus #6488 and #6848 (GTA needs them), then this PR's e2d9ac88c cherry-picked. Story mode, same scene, warm shader cache. FPS is the frame counter delta from t=180 s to t=330 s; one run each.

Config FPS
pool off 16.96
APS5_DRAW_PREP_THREADS=4 17.15
APS5_DRAW_PREP_THREADS=2 16.53
4 threads + APS5_POOL_DATA_HITS=1 14.12
same, pinned to the P-cores (APS5_DRAW_PREP_AFFINITY_MASK=0xffff) 16.77

So nothing measurable on this title. The counters say why.

Default gate: most draws stay on the packet worker. [prep-pool-gate] reports eligible 782,337 and data entry 6,417,663: about 89 % of GTA's draws are draw-cache data-only hits. They aren't pooled, and each one goes through DrawPipeline::Enqueue, which calls WaitAll(). In Story the pool gets ~23k jobs per 10 s with 0.3 in flight on average, and the front waits for all of them ~20k times per 10 s. The pool is drained before nearly every pooled draw.

With APS5_POOL_DATA_HITS=1: ~165-190k jobs per 10 s, 7-8 in flight. But turn waits are about 16 s per 10 s (summed over 4 threads), room waits ~3 s, and ~30k full drains per 10 s are still there. On the default E-core pinning that is −17 %; on the P-cores it is back to the baseline.

My reading: on GTA, the ordered stage after preparation (the committer and the in-order turn) is what limits the frame, not preparation itself. Prepared draws queue up behind it. The 13900K's E-cores are also noticeably slower for this work than the P-cores, so the default affinity may hurt on hybrid Intel CPUs.

Happy to rerun with other settings or a newer head, or to capture a worker profile of the committer if that helps.

AI-assisted (Claude Code).

@alfalfa-star

Copy link
Copy Markdown
Contributor Author

Closing this. On Kena it doesn't pay for its risk, and your review found real problems. Details for anyone who picks up the idea (@tibzejoker, @teepean):

Measured on Kena (PPSA01802), forest title scene, no input. main f8b22ed + this PR, APS5_TRACE_SECONDS=100:30, three rounds with the pool off and on alternating. Linux, RX 9070 XT, RADV.

APS5_DRAW_PREP_THREADS presents in 30 s p50 frame ms
unset 158 / 227 / 195 179.6 / 131.5 / 153.2
4 206 / 214 / 216 145.2 / 139.4 / 138.2

The pool-off rows spread more than the difference. An earlier batch had pool off 210 / 239 against pool on 217 / 223. The [prep-pool] line (APS5_PROFILE_DRAW=1) shows why there is little to gain here: 505 jobs per 10 s, 0.5 in flight on average, the front waited for all 317 times (21 ms), 0 redos, 0 front-read conflicts. Most draws don't qualify for the pool, and labels or other non-commuting packets drain it before it fills.

Bug found while writing the test you asked for. Pool threads never set DrawPipeline::Active(), so queryPendingWrite and VariantValidation on a pool thread skipped the draw-pipeline check. A redone preparation then read memory that an earlier queued draw writes, before that draw ran. The test, behind a full draw pipeline, has draw A render a pixel that draw B reads as a scalar constant. It got 0 instead of the value A wrote. The fix sets the flag in the pool job, and the test then passes with one redo. The other points (submission pointer, a stoppable pool joined at shutdown, gate counters removed, report only with APS5_PROFILE_DRAW, no default E-core or committer pinning, explicit back on IrNode) are fixed on my branch too. I'm not pushing them, since the PR is closed.

Not fixed, and the reason to close rather than finish. The packet worker still decodes vertex tables and validates draw-cache hits against guest memory before a draw reaches the pool. Those reads don't see writes from earlier draws still preparing on the pool. A redo re-runs only the captures, with the worker's snapshots. Making that correct means running the whole draw on the pool from a copy of the queue state and redoing all of it on a conflict, as in @teepean's place-reserving design. Their Stray numbers suggest the committer becomes the limit once the worker isn't.

AI-assisted: yes (Claude Code)

@alfalfa-star

Copy link
Copy Markdown
Contributor Author

Thanks @Raghav314 and @tibzejoker. Your Hades II and GTA V numbers came in while I was measuring Kena, and they say the same thing: the pool holds 0.0–0.3 draws on average because the front drains it, and pooling the data-only hits moves the wait into the in-order commit. That, plus the ordering hole above, is why this is closed in favour of teepean's reserved-places design (perf/reserved-prep, linked on #1203).

AI-assisted: yes (Claude Code)

@teepean

teepean commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

A follow-up for anyone who lands here from the note above: the reserved-places design (perf/reserved-prep) didn't pay either. On Stray it lost about 14 % with #6501, and @tibzejoker's GTA V runs showed the same. In both titles the committer is the ceiling (busy 75–90 % of the frame with or without the pool), so preparing draws in parallel only adds waits. The numbers are on #1203. I've stopped working on it; cutting the committer's serial cost has paid instead (#5436).

AI-assisted: yes (text with Claude Code).

@alfalfa-star
alfalfa-star deleted the perf/agc-draw-prep-pool branch October 20, 2026 14:37
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.

6 participants