Skip to content

perf(shader): keep a specialized module's instructions in one arena - #7538

Merged
adriadam10 merged 1 commit into
boykopovar:mainfrom
milescoviello:perf/spirv-specialization-arena
Oct 19, 2026
Merged

adriadam10 merged 1 commit into
boykopovar:mainfrom
milescoviello:perf/spirv-specialization-arena

Conversation

@milescoviello

@milescoviello milescoviello commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

What

SpecializeSpirv parses the prepared module into Specialization::instructions, one std::vector<std::uint32_t> per instruction. A module of n instructions therefore made n allocations as it was read and n frees when the specialization ended. In a profile of a Release replay of 355 MXGP 26 (PPSA30839) shader requests, SpecializeSpirv was 7.9% of all samples on main (inclusive), and about a third of that (211 of 607 samples) was malloc and free.

Parsed instructions now take their words from a WordArena that the Specialization owns, through an ArenaAllocator (Instruction is std::vector<std::uint32_t, ArenaAllocator<std::uint32_t>>). The arena's first chunk is the size of the module's words, so parsing allocates about once, and the arena frees its chunks when the specialization ends. The arena is declared before instructions and constants, so it outlives them, and no instruction leaves the Specialization: Run copies the words into a plain std::vector. An allocator without an arena uses std::allocator as before. That covers Make's results and other temporaries, and assigning one to an instruction copies it into the arena. Nothing else changes, and the specialized module is the same.

I first wrote this with std::pmr::monotonic_buffer_resource. That was about as fast (21% instead of 24%). But Apple's libc++ marks std::pmr unavailable before macOS 14, and #7332's app bundle declares macOS 11 as its minimum, so this uses a plain allocator instead.

Tested

Ubuntu 24.04 (Linux 6.8), GCC 13.3, NVIDIA RTX 4070 Ti, driver 595.99.02.

  • ctest -R "^agc_|^shader_ir" on 6efb726: 710/710 pass (3 skipped: host import sparse, sparse dma-buf import, stencil ref export).
  • agc_shader_replay --spv (Release, ANYPS5_ENABLE_SPIRV_TOOLS off, ANYPS5_NO_SHADER_CACHE=1) on 466 shader requests I had dumped with APS5_DUMP_SHADERS: 355 from PPSA30839, 107 from PPSA31162 and 4 from another app. The replay writes the specialized module. All 462 .spv files (a few names repeat across the dumps) have the same SHA-256 on main 0d078f7 and with this PR, and the replay output is identical. This PR is on 6efb726; the 9 commits between the two don't touch core/shader/recompiler.

Specialization time, from a temporary timer around SpecializeSpirv that is not part of this PR. It is summed over the 319 calls of one replay of the 355 PPSA30839 requests, 3 runs per build, interleaved:

Run main 0d078f7 this PR
1 2254.3 ms 1691.7 ms
2 2317.1 ms 1794.6 ms
3 2280.6 ms 1712.4 ms

That is about 24% of the specialization time, or about 0.5 s of the set's roughly 25 s replay. Whole-replay times during these runs swung with another workload on the host (27.4 to 28.6 s for main), so I'm only claiming the specialization time.

Title level: no speed claim. Specialization runs when a draw needs a module for new pipeline state, so this shortens that wait, but I didn't measure it in a title.

tools/check_conventions.py --base upstream/main passes. tools/pr_overlap.py lists only #6176, which conflicts with main so it could not compare it. #6176 adds tables to the constructor one line above the parse loop this PR changes. #5315 changes removeUnusedConstants. Neither touches these lines.

Title capture

As the perf label asks, these are captures of one title and scene on one machine. MXGP26 (PPSA30839) ran from boot with APS5_TRACE_SECONDS=0:60, ANYPS5_NO_SHADER_CACHE=1 and a fresh run directory (cold AnyPS5 and NVIDIA caches), so the shaders compile inside the window. NVIDIA RTX 4070 Ti (595.99.02), Ubuntu 24.04, release libs, Xvfb display, no input. main dbac9fa was captured five times: before the PR captures, after them, and three more times in a row after that. Each PR capture and the first two main captures followed a rebuild of the release libraries; the last three main captures did not, and they came out lower. The change is main with this PR merged on top as a local merge commit (e449aef241c0). The time is the wall time of the Shader.PrepareArtifact slices on the shader prepare threads in each trace.

capture presents frame p50 / p90 artifacts prepared prepare time, total per artifact, mean / median
main 1 194 16.7 / 32.8 ms 935 87.3 s 93.4 / 27.7 ms
main 2 195 16.7 / 34.0 ms 956 91.4 s 95.6 / 29.6 ms
main 3 166 16.7 / 28.4 ms 961 82.1 s 85.4 / 27.3 ms
main 4 161 16.7 / 27.9 ms 946 77.6 s 82.1 / 24.7 ms
main 5 183 16.7 / 22.7 ms 939 78.4 s 83.5 / 22.8 ms
this PR 188 16.7 / 29.3 ms 947 97.5 s 103.0 / 28.9 ms

The five main captures span 17.7% in total prepare time, and the 13 prepare threads run in parallel with the title, so a title capture can't resolve a recompiler change of a few percent. The replay measurement above is this change's measurement. This PR's capture is above all five main captures in total prepare time. With one capture of the change I can't tell that apart from noise, and the replay measurement above points the other way. I can repeat the capture if that would help.

main 1 trace, digest, main 2 trace, digest, main 3 trace, digest, main 4 trace, digest, main 5 trace, digest, this PR trace, digest

{"commit":"dbac9fa15b61","dirty":false,"env":{"APS5_TRACE_SECONDS":"0:60"},"gpu":"NVIDIA GeForce RTX 4070 Ti","driver":"NVIDIA 595.99.02","os":"Linux 6.8.0-147-generic","resolution":"3840x2160","window":{"spec":"seconds 0:60","first_present":0,"ms":120148.301},"presents":194,"frame_ms":{"p50":16.686,"p90":32.765,"p99":2921.590,"max":112847.624},"gpu_busy_ms_per_present":6.483,"worker_busy_pct":{"queue worker 0x0":88.481,"queue worker 0x20":73.127},"top_phases_ms_per_frame":{"Driver.Packet":1000.862,"Driver.Packet.dispatch":503.561,"Driver.Dispatch":503.451,"Shader.PrepareArtifact":450.125,"Driver.Packet.memory_wait":424.003,"Vulkan.GraphicsPipeline":132.519,"Vulkan.GraphicsPipelineLibrary":126.497,"Vulkan.GraphicsPipelinePrebuild":77.312},"trace_dropped":false,"trace":"aps5-trace-dbac9fa15b61-3.json.gz"}
{"commit":"e449aef241c0","dirty":false,"env":{"APS5_TRACE_SECONDS":"0:60"},"gpu":"NVIDIA GeForce RTX 4070 Ti","driver":"NVIDIA 595.99.02","os":"Linux 6.8.0-147-generic","resolution":"3840x2160","window":{"spec":"seconds 0:60","first_present":0,"ms":118089.051},"presents":188,"frame_ms":{"p50":16.688,"p90":29.330,"p99":2332.068,"max":111625.858},"gpu_busy_ms_per_present":5.809,"worker_busy_pct":{"queue worker 0x0":88.437,"queue worker 0x20":71.533},"top_phases_ms_per_frame":{"Driver.Packet":1004.814,"Shader.PrepareArtifact":518.836,"Driver.Packet.dispatch":513.181,"Driver.Dispatch":513.126,"Driver.Packet.memory_wait":418.222,"Vulkan.GraphicsPipeline":135.265,"Vulkan.GraphicsPipelineLibrary":129.624,"Vulkan.GraphicsPipelinePrebuild":80.289},"trace_dropped":false,"trace":"aps5-trace-e449aef241c0-3.json.gz"}

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
  • Fixes: none
  • Unblocks: none
  • GPUs tested: NVIDIA GeForce RTX 4070 Ti, driver 595.99.02, Ubuntu 24.04 (Linux 6.8)
  • AI-assisted: yes (Claude Code)

🤖 Generated with Claude Code

Specialization parsed every instruction of the prepared module into
its own std::vector, so a module of n instructions made n allocations
when it was read and n frees when the specialization ended. Parsed
instructions now take their words from a WordArena the Specialization
owns, through an ArenaAllocator whose first chunk is the size of the
module's words, so parsing allocates about once and the arena frees
its chunks when the specialization ends. An allocator without an arena
(Make's results and other temporaries) uses std::allocator as before,
and assigning one to an instruction copies it into the arena. The
specialized module is unchanged.

This is a plain allocator rather than std::pmr because Apple's libc++
marks std::pmr unavailable before macOS 14.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added shader core/shader perf Performance change perf numbers missing perf pull request without a before and after summary line labels Oct 18, 2026
@github-actions

github-actions Bot commented Oct 19, 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 7922 core/shader/recompiler/SpirvBackend/src/SpirvSpecialization.cpp L16-28

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 8004 core/shader/recompiler/SpirvBackend/src/SpirvSpecialization.cpp

@adriadam10 adriadam10 self-assigned this Oct 19, 2026
@milescoviello

Copy link
Copy Markdown
Contributor Author

The title capture the perf label asks for is now in the description, with both summary lines, the traces and the digests: MXGP26 from boot, a cold shader cache, main dbac9fa against main + this PR (e449aef241c0). The five main captures span 17.7% in total shader prepare time, so the capture can't resolve this change, and the replay measurement in the description is still its measurement. This PR's capture is above all five main captures in total prepare time. With one capture of the change I can't tell that apart from noise, and the replay measurement above points the other way. I can repeat the capture if that would help.

AI-assisted: yes (Claude Code)

@github-actions github-actions Bot removed the perf numbers missing perf pull request without a before and after summary line label Oct 19, 2026
@adriadam10
adriadam10 merged commit f29546a into boykopovar:main Oct 19, 2026
11 of 12 checks passed
@adriadam10

Copy link
Copy Markdown
Collaborator

Checked the arena lifetime (declared before instructions/constants, outlives them), the allocator semantics for moves/assignments between instructions with different arenas (element-wise copy, correct), instructions = std::move(ordered) (keeps arena-backed words, arena still alive) and that Run copies out to a plain std::vector. Memory is bounded by the module size (geometric chunks, freed with the specialization). Output identical on 462 modules per the SHA-256 comparison; merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

perf Performance change shader core/shader

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants