perf(agc): relink library pipelines with link-time optimization in the background

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Adrià Franch 2026-10-10 12:38:59 +02:00
parent 197ffac972
commit 8e73f6e73e
8 changed files with 108 additions and 9 deletions

View File

@ -662,8 +662,10 @@ if(BUILD_TESTING)
add_test(NAME agc_pipeline_library_off COMMAND agc_driver_recorder_tests --pipeline-library-only)
set_tests_properties(agc_pipeline_library agc_pipeline_library_off PROPERTIES SKIP_RETURN_CODE 77)
set_tests_properties(agc_pipeline_library_off PROPERTIES ENVIRONMENT "APS5_NO_GPL=1")
add_test(NAME agc_pipeline_library_fast_only COMMAND agc_driver_recorder_tests --pipeline-library-only)
set_tests_properties(agc_pipeline_library_fast_only PROPERTIES ENVIRONMENT "APS5_NO_PIPELINE_LTO=1" SKIP_RETURN_CODE 77)
if(WIN32)
set_tests_properties(agc_pipeline_library agc_pipeline_library_off PROPERTIES ENVIRONMENT_MODIFICATION
set_tests_properties(agc_pipeline_library agc_pipeline_library_off agc_pipeline_library_fast_only PROPERTIES ENVIRONMENT_MODIFICATION
"PATH=path_list_prepend:$<TARGET_FILE_DIR:libc>")
endif()
set_tests_properties(agc_pipeline_cache PROPERTIES SKIP_RETURN_CODE 77)

View File

@ -2,6 +2,7 @@
#define CORE_LIBS_PRX_LIBSCEAGCDRIVER_GRAPHICS_INCLUDE_PIPELINE_HPP
#include "prx/libSceAgcDriver/Graphics/include/ShaderResources.hpp"
#include "prx/libSceAgcDriver/Graphics/include/PipelineLibrary.hpp"
#include <set>
namespace AgcDriver::Graphics {
@ -51,6 +52,7 @@ public:
// construction: the attachment formats alone decide).
void Continue(VkCommandBuffer commands, const State& state) const;
bool SplitsFaces() const { return backFaces != VK_NULL_HANDLE; }
bool Optimized() const { return optimized != nullptr && optimized->handle.load() != VK_NULL_HANDLE; }
void ContinueBackFaces(VkCommandBuffer commands, const State& state) const;
void PushConstants(VkCommandBuffer commands, VkShaderStageFlags stages, std::span<const std::byte, PipelinePushConstantBytes> bytes) const;
// Forgets the Vulkan objects without destroying them: for entries of a device that is already gone.
@ -76,6 +78,7 @@ private:
bool depthBounds = false;
bool depthBias = false;
bool libraries = false;
std::shared_ptr<OptimizedPipeline> optimized;
bool dynamicRendering = false;
VkFormat depthFormat = VK_FORMAT_UNDEFINED;
VkImageLayout attachmentLayout = VK_IMAGE_LAYOUT_UNDEFINED;

View File

@ -3,6 +3,8 @@
#include "prx/libSceAgcDriver/Graphics/include/Context.hpp"
#include <array>
#include <atomic>
#include <memory>
#include <cstddef>
#include <cstdint>
#include <span>
@ -21,13 +23,24 @@ struct PipelineLibraryKeys {
std::span<const VkDynamicState> PipelineLibraryDynamicStates();
VkPipeline LinkPipelineFromLibraries(const Context& context, const VkGraphicsPipelineCreateInfo& info, const VkPipelineRenderingCreateInfoKHR& rendering, const VkPipelineLayoutCreateInfo& layout, const PipelineLibraryKeys& keys);
struct OptimizedPipeline {
VkDevice device = VK_NULL_HANDLE;
PFN_vkDestroyPipeline destroy = nullptr;
std::atomic<VkPipeline> handle{VK_NULL_HANDLE};
std::atomic<bool> released{false};
void Release() noexcept;
};
VkPipeline LinkPipelineFromLibraries(const Context& context, const VkGraphicsPipelineCreateInfo& info, const VkPipelineRenderingCreateInfoKHR& rendering, const VkPipelineLayoutCreateInfo& layout, const PipelineLibraryKeys& keys, std::shared_ptr<OptimizedPipeline>* optimized = nullptr);
void WaitForOptimizedPipelines(VkDevice device);
void ClearPipelineLibraries(VkDevice device);
struct PipelineLibraryCounters {
std::array<std::uint64_t, 4> built{};
std::uint64_t linked = 0;
std::uint64_t optimized = 0;
};
PipelineLibraryCounters PipelineLibraryCountersOf(VkDevice device);

View File

@ -369,7 +369,7 @@ Pipeline::Pipeline(const Context& context, const State& state, const VertexInput
pipelineInfo.renderPass = renderPass;
if (dynamicRendering) pipelineInfo.pNext = &rendering;
timing.Mark("modules_and_state");
if (libraries) pipeline = LinkPipelineFromLibraries(context, pipelineInfo, rendering, layoutInfo, keys);
if (libraries) pipeline = LinkPipelineFromLibraries(context, pipelineInfo, rendering, layoutInfo, keys, &optimized);
else Check(context.Function<PFN_vkCreateGraphicsPipelines>("vkCreateGraphicsPipelines")(context.device, context.pipelineCache, 1, &pipelineInfo, nullptr, &pipeline), "vkCreateGraphicsPipelines");
if (state.depthBiasPerFace) {
raster.cullMode = VK_CULL_MODE_FRONT_BIT;
@ -391,6 +391,8 @@ Pipeline::~Pipeline() {
void Pipeline::release() noexcept {
framebuffers.clear();
if (optimized != nullptr) optimized->Release();
optimized.reset();
if (pipeline) context.Function<PFN_vkDestroyPipeline>("vkDestroyPipeline")(context.device, pipeline, nullptr);
if (backFaces) context.Function<PFN_vkDestroyPipeline>("vkDestroyPipeline")(context.device, backFaces, nullptr);
if (renderPass) context.Function<PFN_vkDestroyRenderPass>("vkDestroyRenderPass")(context.device, renderPass, nullptr);
@ -406,6 +408,8 @@ void Pipeline::release() noexcept {
}
void Pipeline::Abandon() noexcept {
if (optimized != nullptr) optimized->released.store(true);
optimized.reset();
for (auto& entry : framebuffers) entry.framebuffer->Abandon();
framebuffers.clear();
pipeline = VK_NULL_HANDLE;
@ -500,7 +504,8 @@ void Pipeline::Begin(VkCommandBuffer commands, const Framebuffer& framebuffer, V
}
void Pipeline::Continue(VkCommandBuffer commands, const State& state) const {
context.Resolved(&DeviceFunctions::cmdBindPipeline, "vkCmdBindPipeline")(commands, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline);
const auto best = optimized != nullptr ? optimized->handle.load() : VK_NULL_HANDLE;
context.Resolved(&DeviceFunctions::cmdBindPipeline, "vkCmdBindPipeline")(commands, VK_PIPELINE_BIND_POINT_GRAPHICS, best != VK_NULL_HANDLE ? best : pipeline);
context.Resolved(&DeviceFunctions::cmdSetViewport, "vkCmdSetViewport")(commands, 0, 1, &state.viewport);
context.Resolved(&DeviceFunctions::cmdSetScissor, "vkCmdSetScissor")(commands, 0, 1, &state.scissor);
if (libraries) {

View File

@ -1,6 +1,11 @@
#include "prx/libSceAgcDriver/Graphics/include/PipelineLibrary.hpp"
#include "prx/libSceAgcDriver/Execution/include/PerformanceTimer.hpp"
#include <array>
#include <condition_variable>
#include <cstdlib>
#include <deque>
#include <functional>
#include <thread>
#include <map>
#include <mutex>
#include <string_view>
@ -40,6 +45,11 @@ struct DeviceLibraries {
struct LibraryStore {
std::mutex mutex;
std::map<VkDevice, DeviceLibraries> devices;
std::condition_variable idle;
std::condition_variable work;
std::deque<std::pair<VkDevice, std::function<void()>>> jobs;
std::map<VkDevice, std::size_t> pending;
bool started = false;
};
LibraryStore& Libraries() {
@ -47,6 +57,29 @@ LibraryStore& Libraries() {
return *store;
}
void runJobs(LibraryStore& store) {
std::unique_lock lock(store.mutex);
for (;;) {
store.work.wait(lock, [&] { return !store.jobs.empty(); });
auto [device, job] = std::move(store.jobs.front());
store.jobs.pop_front();
lock.unlock();
job();
lock.lock();
if (--store.pending[device] == 0) store.idle.notify_all();
}
}
void enqueue(LibraryStore& store, VkDevice device, std::function<void()> job) {
if (!store.started) {
std::thread(runJobs, std::ref(store)).detach();
store.started = true;
}
++store.pending[device];
store.jobs.emplace_back(device, std::move(job));
store.work.notify_one();
}
Key Combined(const Key& part, const Key& context) {
Key key;
const auto append = [&](const Key& value) {
@ -66,7 +99,7 @@ std::span<const VkDynamicState> PipelineLibraryDynamicStates() {
return dynamicStates;
}
VkPipeline LinkPipelineFromLibraries(const Context& context, const VkGraphicsPipelineCreateInfo& info, const VkPipelineRenderingCreateInfoKHR& rendering, const VkPipelineLayoutCreateInfo& layout, const PipelineLibraryKeys& keys) {
VkPipeline LinkPipelineFromLibraries(const Context& context, const VkGraphicsPipelineCreateInfo& info, const VkPipelineRenderingCreateInfoKHR& rendering, const VkPipelineLayoutCreateInfo& layout, const PipelineLibraryKeys& keys, std::shared_ptr<OptimizedPipeline>* optimized) {
Require(info.renderPass == VK_NULL_HANDLE && info.pDynamicState != nullptr, "pipeline library parts need dynamic rendering and the library dynamic states");
auto& store = Libraries();
std::lock_guard lock(store.mutex);
@ -115,7 +148,7 @@ VkPipeline LinkPipelineFromLibraries(const Context& context, const VkGraphicsPip
VkGraphicsPipelineLibraryCreateInfoEXT library{VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_LIBRARY_CREATE_INFO_EXT, flags[part] == VK_GRAPHICS_PIPELINE_LIBRARY_FRAGMENT_OUTPUT_INTERFACE_BIT_EXT ? &rendering : &shaderRendering};
library.flags = flags[part];
VkGraphicsPipelineCreateInfo create{VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO, &library};
create.flags = VK_PIPELINE_CREATE_LIBRARY_BIT_KHR;
create.flags = VK_PIPELINE_CREATE_LIBRARY_BIT_KHR | VK_PIPELINE_CREATE_RETAIN_LINK_TIME_OPTIMIZATION_INFO_BIT_EXT;
create.pDynamicState = info.pDynamicState;
create.layout = pipelineLayout->second;
switch (flags[part]) {
@ -158,12 +191,51 @@ VkPipeline LinkPipelineFromLibraries(const Context& context, const VkGraphicsPip
VkPipeline pipeline = VK_NULL_HANDLE;
Check(context.Function<PFN_vkCreateGraphicsPipelines>("vkCreateGraphicsPipelines")(context.device, context.pipelineCache, 1, &create, nullptr, &pipeline), "vkCreateGraphicsPipelines link");
++device.counters.linked;
static const bool optimize = std::getenv("APS5_NO_PIPELINE_LTO") == nullptr;
if (optimize && optimized != nullptr) {
auto slot = std::make_shared<OptimizedPipeline>();
slot->device = context.device;
slot->destroy = device.destroyPipeline;
*optimized = slot;
const auto createPipelines = context.Function<PFN_vkCreateGraphicsPipelines>("vkCreateGraphicsPipelines");
const auto cache = context.pipelineCache;
const auto pipelineLayout = info.layout;
enqueue(store, context.device, [slot, createPipelines, cache, pipelineLayout, libraries, &store, devicePointer = &device] {
if (slot->released.load()) return;
VkPipelineLibraryCreateInfoKHR linked{VK_STRUCTURE_TYPE_PIPELINE_LIBRARY_CREATE_INFO_KHR};
linked.libraryCount = static_cast<std::uint32_t>(libraries.size());
linked.pLibraries = libraries.data();
VkGraphicsPipelineCreateInfo create{VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO, &linked};
create.flags = VK_PIPELINE_CREATE_LINK_TIME_OPTIMIZATION_BIT_EXT;
create.layout = pipelineLayout;
VkPipeline result = VK_NULL_HANDLE;
if (createPipelines(slot->device, cache, 1, &create, nullptr, &result) != VK_SUCCESS) return;
slot->handle.store(result);
if (slot->released.load()) {
if (const auto handle = slot->handle.exchange(VK_NULL_HANDLE)) slot->destroy(slot->device, handle, nullptr);
}
std::lock_guard lock(store.mutex);
++devicePointer->counters.optimized;
});
}
return pipeline;
}
void OptimizedPipeline::Release() noexcept {
released.store(true);
if (const auto pipeline = handle.exchange(VK_NULL_HANDLE)) destroy(device, pipeline, nullptr);
}
void WaitForOptimizedPipelines(VkDevice device) {
auto& store = Libraries();
std::unique_lock lock(store.mutex);
store.idle.wait(lock, [&] { return store.pending[device] == 0; });
}
void ClearPipelineLibraries(VkDevice device) {
auto& store = Libraries();
std::lock_guard lock(store.mutex);
std::unique_lock lock(store.mutex);
store.idle.wait(lock, [&] { return store.pending[device] == 0; });
const auto found = store.devices.find(device);
if (found == store.devices.end()) return;
auto& libraries = found->second;

View File

@ -3830,6 +3830,10 @@ void pipelineLibraryTests(const Device& device) {
state.colors.front().format = VK_FORMAT_R16G16B16A16_SFLOAT;
static_cast<void>(lookup());
Require(counters().built == std::array<std::uint64_t, 4>{2, 2, 3, 3}, "another target format rebuilt more than the fragment output library");
WaitForOptimizedPipelines(context.device);
const auto lto = std::getenv("APS5_NO_PIPELINE_LTO") == nullptr;
Require(counters().optimized == (lto ? counters().linked : 0u), "a linked pipeline was not optimized in the background");
Require(first->Optimized() == lto && culled->Optimized() == lto, "a pipeline did not switch to its optimized link");
ClearCachedPipelines(context.device);
Require(counters().linked == 0, "clearing the pipelines kept the device's libraries");
std::cout << "Pipeline library reuse tests passed\n";

View File

@ -70,7 +70,7 @@ flowchart LR
- A compute shader whose LDS plus its lock dword exceeds the host's `maxComputeSharedMemorySize` is not given a `Workgroup` array. `Recompile` sets `ShaderInfo::sharedMemoryBytes` for it and the SPIR-V backend addresses the LDS in a coherent storage buffer at descriptor set 1 (`WorkgroupMemoryDescriptorSet`), with a slice of `RecompileResult::workgroupMemoryDwords` per workgroup, indexed by `WorkgroupID` and `NumWorkGroups`. `VulkanDevice` keeps one such buffer, sized from the dispatch dimensions, and binds it after the resource set; programs that fit keep using shared memory.
- `Driver::RegisterShader` splits shader preparation in two. `PlanRegistered` runs on the registering guest thread: it validates the registers and entry point, decodes the code and builds the recompile request, so invalid registrations still fail there. The RDNA to IR translation and resource plan (`PrepareShader`) run on a background pool of `hardware_concurrency() / 2` detached threads (at least 2, 64 MiB stacks).
- The snapshot is published as pending. `SourceHandleFor`, `InvocationFor` and `ShaderPreparationTransaction::Edit` / `Read` wait for the pending job before reading `PreparedShaders`, and rethrow its failure. `APS5_SYNC_SHADER_PREPARE=1` prepares on the registering thread instead.
- A draw's `Graphics::Pipeline` comes from `CachedPipeline`. When the device has `VK_EXT_graphics_pipeline_library` with fast linking, `VK_EXT_extended_dynamic_state` and `VK_KHR_dynamic_rendering`, draws render with `vkCmdBeginRenderingKHR` instead of render passes and the pipeline is linked from four libraries (vertex input, pre-rasterization shaders, fragment shader, fragment output) kept per device in [`PipelineLibrary.cpp`](../../core/libs/prx/libSceAgcDriver/Graphics/src/PipelineLibrary.cpp). A library built for one pipeline is reused by the next: another blend, vertex layout or target format builds only that part (the shader libraries name no attachment format), and cull mode, front face, depth and stencil state are dynamic, so changing them only links. Push constants then use a 256-byte block with a 128-byte slot per side: the pre-rasterization stages stack from byte 0, the pixel shader always starts at byte 128 (`StagePushOffset`), so its library does not depend on how much push data its vertex shader uses. Pipelines with per-face depth bias, rect lists, geometry shaders or a stage without a variant id are created whole, still with dynamic rendering; devices without the extensions (or with `APS5_NO_GPL=1`) keep render passes and whole pipelines.
- A draw's `Graphics::Pipeline` comes from `CachedPipeline`. When the device has `VK_EXT_graphics_pipeline_library` with fast linking, `VK_EXT_extended_dynamic_state` and `VK_KHR_dynamic_rendering`, draws render with `vkCmdBeginRenderingKHR` instead of render passes and the pipeline is linked from four libraries (vertex input, pre-rasterization shaders, fragment shader, fragment output) kept per device in [`PipelineLibrary.cpp`](../../core/libs/prx/libSceAgcDriver/Graphics/src/PipelineLibrary.cpp). A library built for one pipeline is reused by the next: another blend, vertex layout or target format builds only that part (the shader libraries name no attachment format), and cull mode, front face, depth and stencil state are dynamic, so changing them only links. The fast link is what the draw waits for; a background thread then links the same libraries with link-time optimization and the pipeline binds that one from the next draw on. Push constants then use a 256-byte block with a 128-byte slot per side: the pre-rasterization stages stack from byte 0, the pixel shader always starts at byte 128 (`StagePushOffset`), so its library does not depend on how much push data its vertex shader uses. Pipelines with per-face depth bias, rect lists, geometry shaders or a stage without a variant id are created whole, still with dynamic rendering; devices without the extensions (or with `APS5_NO_GPL=1`) keep render passes and whole pipelines.
### Shader MODE register access

View File

@ -291,7 +291,7 @@ Throughout the project, every function at every stage either **does exactly what
- On Linux, [direct memory](../../core/libs/prx/libkernel/DirectMemory/DirectMemory.cpp) lives in one sparse memfd at file offset = physical address, so the [GPU imports](../../core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp) can share one udmabuf buffer object per 64 MiB of it. Released direct memory is not punched out of the file, because a live import of its chunk pins the pages and the GPU would keep reading the old ones: its pages stay committed (the process keeps the high-water mark of the direct memory it used) and are zeroed when the range is allocated again.
- [Per-face depth bias](../../core/libs/prx/libSceAgcDriver/Graphics/src/Draw.cpp) (libSceAgcDriver): Vulkan has one depth bias per draw, so a draw that rasterizes both faces with different front and back bias (PA_SU_SC_MODE_CNTL, PA_SU_POLY_OFFSET_FRONT/BACK_*) is recorded twice, front faces with the back faces culled and then back faces with the front faces culled. The vertex stages run twice, so a vertex shader that writes memory writes twice, and all front faces land before all back faces instead of in primitive order, which differs only for blending or stencil updates that depend on the order.
- [Compute LDS beyond the device's shared-memory limit](../../core/libs/prx/libSceAgcDriver/Execution/src/VulkanDevice.cpp) (libSceAgcDriver) lives in a coherent storage buffer with one slice of `LDS bytes / 4 + 1` dwords per workgroup (the last dword is the atomic lock), shared by all such dispatches and grown in 1 MiB steps up to `maxStorageBufferRange`; a dispatch whose slices do not fit throws. It is slower than shared memory and its cost was not measured. An indirect dispatch of such a program always reads its group counts on the CPU. Its results are pinned against the in-limit semantics in `agc_driver_lds_beyond_device_limit`, not measured on hardware.
- [Graphics pipeline libraries](../../core/libs/prx/libSceAgcDriver/Graphics/src/PipelineLibrary.cpp) (libSceAgcDriver) are fast-linked only, never relinked with `VK_PIPELINE_CREATE_LINK_TIME_OPTIMIZATION_BIT_EXT`. A vertex stage then keeps the outputs its fragment stage does not read: on an RTX 4090 (NVIDIA 615.71) 13 of 15 PPSA36082 vertex shaders paired with a pixel shader that reads none of them used up to 68 registers instead of 38 and a binary up to 21 % larger, while pairs that read every output compiled to the same code as a whole pipeline (#2242).
- [Graphics pipeline libraries](../../core/libs/prx/libSceAgcDriver/Graphics/src/PipelineLibrary.cpp) (libSceAgcDriver): a pipeline is fast-linked at the draw and relinked with `VK_PIPELINE_CREATE_LINK_TIME_OPTIMIZATION_BIT_EXT` on a background thread, and binds the optimized one from the next draw on. Draws recorded before that use the fast link, whose vertex stage keeps the outputs the fragment stage does not read: on RADV (Radeon 660M, Mesa 26.2.4) 346 of 483 Astro's Playroom vertex shaders paired with a fragment stage that reads none of them used more VGPRs (median 8 more, up to 2.5 times), and on an RTX 4090 13 of 15 PPSA36082 vertex shaders up to 68 instead of 38 (#2242). The optimized link compiles to the same code as a whole pipeline
- [sceAvPlayerGetVideoData](../../core/libs/prx/libSceAvPlayer/src/Source.cpp) (libSceAvPlayer) in sync mode `SCE_AVPLAYER_AV_SYNC_MODE_NONE` skips queued frames that the audio clock has already passed while the title pulls audio, so a title that requests video slower than the film's frame rate keeps the picture with the sound. Whether the console hands out every frame in this mode is unknown. PPSA12544 requests 7-19 frames per second for a 30 fps cinematic
- [libSceHttp2 asynchronous requests](../../core/libs/prx/libSceHttp2/Export.cpp) (libSceHttp2) never reach the network: `sceHttp2SendRequestAsync` accepts the request, queues a completion whose result is the library's network error and triggers the user event named by the kqueue option (equeue at offset 0, event id at 8, user data at 16, layout inferred from PPSA17221); `sceHttp2WaitAsync` returns that completion. The completion's `event_type` value is unknown and left 0. PPSA17221 completes its request object only after `sceHttp2SendRequestAsync` returns, and its handler for a synchronous failure calls `shared_from_this` before that, so a send must not fail synchronously. A kqueue option or user event that is missing, an extra option, and a wait with no completed operation throw
- [system](../../core/libs/prx/libkernel/System/src/Process.cpp) (libkernel) runs no command. It follows FreeBSD 11's `lib/libc/stdlib/system.c` for a sandbox without `/bin/sh`: a null command returns 1 unconditionally, as that version does, and any command returns the wait status of a child that could not exec the shell and called `_exit(127)` (`0x7f00`). Whether the console's application sandbox has no shell, and whether `vfork` succeeds there (FreeBSD returns -1 when it fails), is unverified