Skip to content

perf(agc): build the pipelines of a submission ahead on a compile pool - #5925

Open
adriadam10 wants to merge 3 commits into
boykopovar:mainfrom
adriadam10:perf/agc-submit-lookahead
Open

adriadam10 wants to merge 3 commits into
boykopovar:mainfrom
adriadam10:perf/agc-submit-lookahead

Conversation

@adriadam10

Copy link
Copy Markdown
Collaborator

What

On the first run of a title, the queue worker finds the pipelines of a submission already built instead of creating them under GuestMemory::GpuMutex at each draw and dispatch.

  • Driver::resolveGroupAhead (DispatchAhead.cpp), which resolved only the dispatch groups of the compute queues, now also walks the graphics queue and the draws. At the start of every submission the worker walks the packets with a scratch QueueState, decodes every draw and dispatch whose draw-cache or dispatch-cache key is new (the materialized stage results go to the recompile cache, which serves the worker again at the packet) and hands their pipeline builds to a pool of hardware_concurrency() / 2 threads the VulkanDevice owns (PipelineLookaheadPool). Driver::draw gets the same resolve mode Driver::dispatch has: it stops before lockForDraw and hands the state and the stage results over.
  • A graphics pipeline is built through Graphics::BuildDrawPipelineAhead, the derivation Graphics::Draw makes (masked blend states and proxied targets, now shared with Draw as pipelineStateOf, the vertex input layout, the described set layouts and the attachment layout a recorded draw uses) ending in CachedPipeline; a compute pipeline through VulkanDevice::ReplayComputePipeline, the journal replay's hook. Both find-or-insert: a worker that reaches the packet first builds the pipeline itself and the pool's copy is dropped. Nothing waits for the pool.
  • On the graphics queue the walk reads memory as it is at the submission's start, continues past waits, conditional ranges and memory-writing packets, and adopts nothing: a stale decode costs a pipeline that is never bound. On the compute queues the walk keeps its group-capture rules and adopts the resolved dispatches as before (APS5_GROUP_CAPTURE_ALL_QUEUES=1 adopts on the graphics queue too, as before). The walk now also executes SET_*_REG_INDIRECT packets from their submit-time register lists instead of stopping at them.
  • The pool is cancelled and drained before the device is replaced (PrepareForReplacement) and at its teardown, before the journal replay, following the replay's Cancel/Wait pattern.
  • APS5_NO_SUBMIT_LOOKAHEAD=1 disables it; APS5_SUBMIT_LOOKAHEAD_THREADS sets the pool's threads (0 disables it too); APS5_PROFILE_DRAW=1 prints a [lookahead] line for the walk (per queue, with the [groupcap] line) and one for the pool at teardown.

Depends on #5875: the stable identities (RecompileResult::Identity), the find-or-insert stores, the builds outside the store mutexes in CachedPipeline/LinkPipelineFromLibraries and VulkanDevice::createComputePipeline/ReplayComputePipeline are what the pool builds through. The diff on top of #5875 is 18 files; review the last commit only.

Open pull requests near this change, checked before writing it: #4897 (compute pipelines created in PrepareDispatch before the GPU lock, with a futures map that waits on a concurrent build) and #5526 (PrewarmComputePipeline, the same idea) build the compute pipeline on the worker thread at the dispatch's own preparation; #5441 (PrewarmPipeline, draft) builds the graphics pipeline on the worker thread right before lockForDraw; #5115 (draft) prepares deferred shaders at SET_SH_REG. This pull request implements none of their functions: the builds run on a pool ahead of the packets, from a decode of the whole submission, through the #5875 entry points; the one shared step, deriving the pipeline inputs of a draw from its state and stages, is extracted from Graphics::Draw itself (pipelineStateOf) and used by Draw. If #5441 lands first, the pool task can call its PrewarmPipeline instead. #5747, #5481 and #5477 touch resolveGroupAhead by a few lines each (the indirect-argument stop, NOP skipping, COPY_DATA registers) and will need a trivial rebase on either side.

Tested

Linux (Ubuntu 24.04, GCC 13.3), lavapipe (Mesa 25.2.8, LLVM 20), 4 CPU threads, pool of 2 threads. No title or homebrew run: the hardware this is for (NVIDIA, #4897 measured 0.28 to 0.59 pipelines/s with 3 threads; the 5 to 9 s stalls of #5441) is not available here.

  • New agc_submit_lookahead (Vulkan mock, agc_driver_graphics_tests --submit-lookahead): the pool builds a draw's pipeline and runs a compute build ahead; the worker's own derivation of the same draw then hits the store and creates nothing; a configuration the worker built first is found present by the pool and not rebuilt; a failed build is counted and the pool goes on; cancelling with 15 builds queued behind a blocking one drops the 15, accepts nothing afterwards, and leaks no Vulkan object (mock.live == 0 after ClearCachedPipelines).

  • New agc_driver_submit_lookahead and agc_driver_submit_lookahead_off (lavapipe, the same binary with APS5_NO_SUBMIT_LOOKAHEAD=1): one DCB with a WAIT_REG_MEM on a CPU label, a draw into a linear target (a synchronous draw), a draw into a tiled resident target (a recorded draw) and a dispatch. With the lookahead on, while the worker waits on the label the walk decodes the two draws and the dispatch and the pool builds the three pipelines (two store misses, DispatchPipelineBuilds unchanged); after the label the worker's draws hit the store twice and its dispatch builds nothing; a repeated submission is skipped as seen. With it off, the walk never runs and the worker builds the three pipelines itself. Both variants check the same images (every texel of both targets, in both layouts) and the dispatch's output, so the lookahead changes nothing in what is drawn.

  • agc_driver_submit_lookahead_tests --benchmark N CHAIN (not registered): N new pixel programs (CHAIN multiply-add steps on a user-data word, so the module is not folded) drawn into a tiled resident target and N new compute programs in one DCB, after a warm-up submission that creates the device. Wall time from submit to idle, ANYPS5_NO_SHADER_CACHE=1 APS5_NO_PIPELINE_JOURNAL=1 MESA_SHADER_CACHE_DISABLE=true, two runs each:

    configurations pixel chain lookahead on lookahead off
    24 draws + 24 dispatches 200 steps 2555 ms, 2315 ms 3319 ms, 2851 ms
    64 draws + 64 dispatches 200 steps 5760 ms 6361 ms
    24 draws + 24 dispatches 0 steps 1455 ms, 1379 ms 1383 ms, 1416 ms
    64 draws + 64 dispatches 0 steps 3729 ms 3553 ms

    With pipelines that take time to build, the submission completes 9 to 23 % sooner, with every draw and dispatch finding its pipeline built by the pool (0 built by the worker). With trivial pipelines it is neutral at 24 and 5 % slower at 64: on lavapipe the pool's compile threads share the four cores with the rasterizer threads, which a GPU does not. The walk itself (the decode and recompile of the new configurations, 0.9 to 2.3 s on the worker at 200 steps) is work the worker did at the packets before; it now runs before the first packet.

  • Validation layer (VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation) on the lavapipe test with the lookahead on and off: the same two VUIDs in both runs, both already in the base (VUID-VkGraphicsPipelineCreateInfo-pLibraries-06681 on the fast link of the pipeline libraries, 12 times, and VUID-VkDeviceCreateInfo-pNext-pNext from the layer of Vulkan headers 1.3.275 not knowing a newer device feature struct, 6 times); no VUID the lookahead adds.

  • ctest -R '^(agc_|shader_|guest_shader_)': 539 tests, all pass. agc_driver_draw_cache_reuse and agc_driver_draw_cache_reuse_off now run with APS5_NO_SUBMIT_LOOKAHEAD=1: they assert the exact draw-cache lookup counts of three draws, which the walk's own decode of the same draws doubles (6 lookups instead of 3), so they keep testing the worker's draws alone. tools/check_conventions.py --base upstream/main and tools/check_test_registration.py build pass.

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: perf(agc): journal the pipelines of a run and build them ahead on the next one #5875
  • Unblocks: none
  • GPUs tested: lavapipe (Mesa 25.2.8, Ubuntu 24.04) for the benchmark and validation; AMD Radeon 660M, RADV, Mesa 26.2.4, Linux 7.2 for the libSceAgc + libSceAgcDriver ctest scope on the current head (577/578; agc_driver_tessellation segfaults the same way on main)
  • AI-assisted: yes

adriadam10 and others added 3 commits October 14, 2026 17:56
… next one

A graphics pipeline the store builds or a compute pipeline a dispatch builds is
appended to pipelines.journal next to the shader cache entries, as the
materialized result of each stage (the specialized SPIR-V, bindings and push
constants) plus the recipe the pipeline was built from: the stages'
identities, vertex input layout, descriptor set layout keys and the state
fields the Pipeline constructor reads. When a VulkanDevice is created with a
window (a headless device waits for its first draw or dispatch, since the
window's device usually replaces it first) the journal is loaded and its
pipelines are built on a pool of threads while the title loads, so the first
draw or dispatch of a configuration seen on an earlier run finds its pipeline
resident instead of compiling it on the queue worker.

The pipeline stores key on RecompileResult::Identity, a hash of the module
words, specialization constants, subgroup width and bindings that is the same
on every run (SharedSpirv::ContentHash, computed once per shared module),
rather than on the process-lifetime variant ids; a generated stage is keyed
like any other. CachedPipeline builds outside the store's mutex and the
library parts and link are built outside the library store's mutex, so replay
threads and queue workers build in parallel; a concurrent build of the same
key keeps the first inserted. A replayed pipeline enters the store at its
least recently used end and a full store takes none, and its optimized relink
waits for the first draw that binds it, so the title's own pipelines come
first in both. The compute pipeline creation moves into
VulkanDevice::createComputePipeline, shared by dispatches and the replay. The
store key also covers the color component mapping, the depth target's sample
count and a depth bias clamp, which the constructor reads.

The file names the device it was written for (the properties the driver's
pipeline cache file is named by) and starts over for another; a torn tail is
cut at a record boundary; two runs sharing the directory take turns on a lock
file and keep each other's records; a read failure never starts the file
over. A device cancels its replay before it is replaced or destroyed, and a
replaced device destroys its pipelines then rather than at the driver's stop.

APS5_NO_PIPELINE_JOURNAL=1 disables it; APS5_PIPELINE_JOURNAL_THREADS,
APS5_PIPELINE_JOURNAL_ENTRIES (the first recorded), APS5_PIPELINE_STORE_ENTRIES
and APS5_PIPELINE_JOURNAL_MAX_MIB bound it; APS5_PROFILE_DRAW prints a
[pipeline-journal] summary. APS5_VERIFY_SET_LAYOUTS=1 now also checks a
dispatch's set layout against the one described from its shader. The lavapipe
test suite, which shares one cache directory, runs with the journal off except
for the journal's own tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6hw3kRAuQdfcU9YCZgaoK
…a pipeline

boykopovar#5861 added both to the pipeline state and its store key; the recipe dropped them, so a
replayed pipeline keyed another configuration.

Co-Authored-By: Claude <noreply@anthropic.com>
On the first run of a title, every draw and dispatch of a new configuration
created its Vulkan pipeline on the queue worker, under GuestMemory::GpuMutex.
Driver::resolveGroupAhead, which resolved only the dispatch groups of the
compute queues, now also walks the graphics queue and the draws: at the start
of a submission the worker walks its packets with a scratch QueueState,
decodes every draw and dispatch whose draw-cache or dispatch-cache key is new
(the materialized stage results go to the recompile cache, which serves the
worker again at the packet) and hands their pipeline builds to a pool of
hardware_concurrency() / 2 threads the VulkanDevice owns
(PipelineLookaheadPool). Driver::draw gets the resolve mode Driver::dispatch
has: it stops before the GPU lock and hands over the state and the stages.

A graphics pipeline is built through Graphics::BuildDrawPipelineAhead, the
derivation Draw makes (the masked blend states and proxied targets, now shared
with Draw as pipelineStateOf, the vertex input layout, the described set
layouts and the attachment layout of a recorded draw) ending in
CachedPipeline; a compute pipeline through VulkanDevice::ReplayComputePipeline,
the journal replay's hook. Both find-or-insert: a worker that reaches the
packet first builds the pipeline itself and the pool's copy is dropped; the
worker never waits for the pool. On the graphics queue the walk reads memory
as it is at the submission's start, continues past waits, conditional ranges
and memory-writing packets, and adopts nothing: a stale decode costs a
pipeline that is never bound. On the compute queues the walk keeps its
group-capture rules and adopts the resolved dispatches as before; it now also
executes SET_*_REG_INDIRECT packets from their submit-time register lists. The
pool is cancelled and drained before the device is replaced and at its
teardown, before the journal replay.

APS5_NO_SUBMIT_LOOKAHEAD=1 disables it; APS5_SUBMIT_LOOKAHEAD_THREADS sets
the pool's threads; APS5_PROFILE_DRAW prints a [lookahead] line per queue and
one for the pool. On lavapipe, a submission of 24 draws and 24 dispatches of
new configurations with pixel shaders of 200 instructions completes in
2315 to 2555 ms instead of 2851 to 3319 ms, 64 of each in 5760 ms instead of
6361 ms, with every pipeline found built by the pool; with trivial pipelines it
is neutral.

Co-Authored-By: Claude <noreply@anthropic.com>
@fma-rc

fma-rc commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

I ran Astro's Playroom (PPSA01325) on Linux with an RTX 5080 (NVIDIA 615.78.08): main e34c479 against the same commit with this PR (3339ddd, which includes PR 5875). Caches were warm, the scene was the busy gameplay of the first level, and I ran three alternating pairs. The captures of both builds show the same scene.

main with this PR
FPS over 20 s, three pairs 6.83 / 6.88 / 6.95 4.53 / 4.38 / 5.57
frame p50, trace 140:15, second pair 146.3 ms 224.2 ms

GPU busy per present is the same (50.8 vs 51.2 ms). With APS5_PROFILE_DRAW=1, queue 0 decodes about 140,000 draws ahead per 10 s, and that takes about 2.8 s of the driver thread. Only 1,000 to 5,000 of them are skipped as seen before, so here the decode runs ahead on the thread that already limits the frame. Loading got faster cold (level reached at 109 s against 145 s), and was the same warm (58 against 55 s).

pipeline_journal, submit_lookahead and draw_cache_reuse pass on NVIDIA, RADV and lavapipe. APS5_SUBMIT_LOOKAHEAD_THREADS and APS5_NO_SUBMIT_LOOKAHEAD are environment switches.

@fdfawef324

Copy link
Copy Markdown
Contributor

Windows Server 2025, RTX 3060 (NVIDIA 591.86), WinLibs GCC 15.2.0 r7, Release, head 3339ddd. This head has the thread_local key in CachedPipeline from #5875 (details), and it shows here too:

  • prx_thread_local_destructors fails and names CachedPipeline.
  • Under gdb (Windows debug heap), runs stop with Critical error detected c0000374 in libSceAgcDriver.prx's run_dtor_list as a thread exits:
    • agc_driver_submit_lookahead_tests: 10 of 10 (thread queue worker 0x0)
    • the same with APS5_NO_SUBMIT_LOOKAHEAD=1: 9 of 10
    • agc_driver_pipeline_journal_tests: 10 of 10

The lookahead is not needed for it, so the fix belongs in #5875 and this branch needs it after a rebase. Without a debugger, ctest --repeat until-fail:10 passes 10 of 10 for agc_submit_lookahead, agc_driver_submit_lookahead{,_off}, agc_pipeline_journal, agc_driver_pipeline_journal, agc_pipeline_cache and agc_pipeline_library{,_off,_fast_only}.

Not tested: a title, Linux, and the HostThreadLocal change on this head (I tried it only on #5875's head).

AI-assisted: yes

@LeandroLP23

Copy link
Copy Markdown
Contributor

PS5 FSR4 Showcase (#6118) on AMD, as @MarcZX100 suggested. The first run still gives up at stage=fsr4, but the cache warms much faster. With this PR and #5875 the title gets past stage=fsr4 on the fourth run. With both turned off, it is still stuck there after nine.

Windows 11 (26300), Radeon 860M (RDNA 3.5, driver 32.0.13058.8001), WinLibs GCC 15.2.0, Release. Catalog 01.000.010, relinked with --windows --trap-sdk-syscall-stubs, APS5_PROFILE_DRAW=1.

Tree. This PR conflicts with current main in seven files, so I took its head 3339ddd3a as it is. On top I applied only the main changes the title needs: #5648 (VCC buffer tuples), #5163 (out-of-range sceAgcInit versions) and #5477 (COPY_DATA register source). Without the last two, the title stops at vkCreateDevice ... = -3 and at the COPY_DATA throw. Both sides below use that same build. "Off" sets APS5_NO_SUBMIT_LOOKAHEAD=1 APS5_NO_PIPELINE_JOURNAL=1.

Each side starts with a new run folder and an empty %LOCALAPPDATA%\AMD\VkCache, then reuses the folder:

Run Lookahead + journal Both off
1 (cold) vkQueueSubmit = -4 before chapter 0 same
2 stage=fsr4 after_ms=4523, cache saved 3,773 KiB stage=fsr4 after_ms=4522, 1,446 KiB
3 stage=fsr4, 4,138 KiB stage=fsr4, 1,990 KiB
4 past stage=fsr4, stops at stage=compose after_ms=712 (#6270's stop) stage=fsr4, 2,186 KiB
5–9 (not needed) stage=fsr4 after_ms=4523 every time
  • What the lookahead changes: in chapter 0 the large compute programs are now handed to the pool together ([dispatch] creating a pipeline for 794502 SPIR-V words (program 0x0) and the rest at once). On main the queue worker builds them one by one.
  • Why the first run still fails: each build takes 10–66 s on this GPU (AGC: PS5 FSR4 Showcase (PPSA99011) gives up in its first FSR4 pass while the queue worker builds 130k-800k-word compute pipelines (up to 66 s each on AMD) #6118), so even in parallel none of them finishes inside the title's ~4.5 s budget.
  • Why the cache warms faster: the title exits 7–12 s after it gives up. In that time the pool has finished several pipelines and they're saved, while the worker alone finishes at most one.
  • Journal replay: at start it rebuilt 1–3 compute pipelines per run (replay on 8 threads: 0 graphics and 3 compute built).

Not tested: this PR on current main (because of the conflicts), the full CTest suite, NVIDIA.

AI-assisted: yes (Claude Code).

@adriadam10

Copy link
Copy Markdown
Collaborator Author

Not merging this head:

  • @fma-rc's run on Astro's Playroom (RTX 5080, warm caches) shows the cost: 6.9 → 4.8 FPS. The walk decodes every draw of every submission on the queue worker (DispatchAhead.cpp draw branch), including when warm. That worker already limits the frame, and only 1-5k of ~140k draws per 10 s are skipped as seen. The next head will keep the walk off the worker's critical path once nothing new is found: either it stops walking a queue after submissions that hand nothing to the pool, or the decode moves off the worker. A test will pin that a warm submission is not decoded ahead. The before/after on the same scene will be in the description. @fma-rc, a rerun of that scene on the new head would settle it.
  • perf(agc): journal the pipelines of a run and build them ahead on the next one #5875 is merged (squashed), so the branch conflicts in 13 files. The new head will be only the lookahead commit, rebased onto main.
  • @fdfawef324: the thread_local destructor in CachedPipeline is gone from main (the merged perf(agc): journal the pipelines of a run and build them ahead on the next one #5875 keeps only trivial thread_local bools), so the rebase drops it from this branch.
  • @LeandroLP23: thanks, the FSR4 Showcase result (past stage=fsr4 on the fourth run) is the cold-start case this is for. It has to stay that way after the change above.

@Amoenus

Amoenus commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

@adriadam10 following the #5648 reference in @LeandroLP23's FSR4 report: our buffer-tuple fix is merged as 23dcb82499. The independent FSR4 title/cache observations are Leandro's.

Two source-based coverage suggestions for the rebased head:

  • Our native #5648 fixture recompiles and calls VulkanDevice::Dispatch directly. Its earlier RTX5080/Radeon results therefore do not exercise Driver::resolveGroupAhead. Routing a representative MUBUF/MTBUF s104:s105:VCC_LO:VCC_HI case through the submission fixture, with lookahead on/off and identical output/bounds checks, would cover the specific prerequisite the FSR4 tester needed.
  • For the planned warm regression, consider warm → a new configuration → warm again. Pin the absence of ahead-decode work on the queue worker for a warm submission, while the later new configuration still becomes eligible for prebuilding. The current repeated-submission assertion checks seen counts and unchanged pool.enqueued; it can pass while the worker still walks/derives keys. This complements your stated fix and catches a queue being permanently disabled after it first becomes warm.

We have a Windows11 Enterprise26200 RTX5080 (610.62/32.0.16.1062) and Radeon(TM) Graphics (32.0.21036.18) available for the updated head. A useful hardware comparison would keep correctness checks separate from cold pipeline-build time and warm worker/frame cost, with the same scene and cache setup. Our current Sonic replay ends during startup, so it supplies no steady-gameplay performance result for this PR.

This is source review of 3339ddd3a, not a fresh build, GPU run or performance measurement of #5925. The author has already identified the warm-cache regression and rebase work; this adds test coverage suggestions rather than repeating those findings. AI-assisted: yes, Codex.

@LeandroLP23

Copy link
Copy Markdown
Contributor

I tried the FSR4 Showcase (PPSA99011) cold A/B on this head for the warm-up question in #6118, but the head is too old for that title. Setup: 3339ddd with #5648 (23dcb82) cherry-picked, since it doesn't merge into current main. Radeon 860M, driver 32.0.13058.8001, Windows 11, relinked with --trap-sdk-syscall-stubs.

Every run stops 4 s in with FSR4_SHOWCASE_ERROR vkCreateDevice(physical, &di, NULL, &device) = -3, with and without APS5_NO_SUBMIT_LOOKAHEAD=1. That is before any pipeline is built, so it says nothing about this change. Current main gets past this point on the same machine.

I'll rerun the comparison once the branch is rebased: cold, lookahead on and off, counting runs until all 12 chapters play. On main that took 34 runs.

AI-assisted: yes (Claude Code)

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.

5 participants