Merge pull request #3527 from Sanjaysrinivas/fix/agc-buffer-memory-recovery

fix(agc): reclaim idle buffers on allocation OOM
This commit is contained in:
Adrià 2026-10-11 23:30:03 +02:00 committed by GitHub
commit 039d21ead8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 256 additions and 13 deletions

View File

@ -61,12 +61,13 @@ public:
// The size a buffer for `bytes` is created with: its size class, or `bytes` itself when large.
static std::size_t Capacity(std::size_t bytes, VkMemoryPropertyFlags properties);
static VkBufferUsageFlags Usage(VkBufferUsageFlags usage, VkMemoryPropertyFlags properties);
VkDeviceMemory AllocateMemory(const VkMemoryAllocateInfo& allocation, const char* operation);
std::optional<BufferAllocation> Take(std::size_t bytes, VkBufferUsageFlags usage, VkMemoryPropertyFlags properties = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
void Put(const BufferAllocation& allocation) noexcept;
static bool SlabEligible(std::size_t capacity, VkDeviceSize alignment, VkDeviceSize size, VkDeviceSize atom);
static VkDeviceSize SlabBlockBytes(std::size_t capacity);
SlabSlot TakeSlot(const Context& context, std::uint32_t memoryType, std::size_t capacity, bool addressable);
void PutSlot(VkDeviceMemory memory, VkDeviceSize offset) noexcept;
VkDeviceSize PutSlot(VkDeviceMemory memory, VkDeviceSize offset) noexcept;
std::size_t SlabBlocks();
private:
@ -117,7 +118,8 @@ private:
static bool DeviceTierEnabled();
static bool DeviceTiered(VkMemoryPropertyFlags properties);
static std::size_t NextCapacity(std::size_t capacity);
void destroy(const BufferAllocation& allocation) noexcept;
VkDeviceSize destroy(const BufferAllocation& allocation) noexcept;
VkDeviceSize trim();
// Moves the tier's least recently used slot (the oldest front of its lists) to `evicted`; the
// caller destroys those after releasing the mutex, so builds taking buffers on other threads
// do not wait behind the Vulkan destroy calls. Nothing changes when the vector cannot grow.

View File

@ -41,6 +41,30 @@ BufferPool::~BufferPool() {
for (auto& [memory, block] : slabBlocks) freeBlock(*block);
}
VkDeviceMemory BufferPool::AllocateMemory(const VkMemoryAllocateInfo& allocation, const char* operation) {
VkDeviceMemory memory = VK_NULL_HANDLE;
auto result = allocateMemory(device, &allocation, nullptr, &memory);
const auto initialResult = result;
unsigned attempts = 1;
VkDeviceSize reclaimed = 0;
if (result == VK_ERROR_OUT_OF_DEVICE_MEMORY || result == VK_ERROR_OUT_OF_HOST_MEMORY) {
reclaimed = trim();
if (reclaimed != 0) {
memory = VK_NULL_HANDLE;
result = allocateMemory(device, &allocation, nullptr, &memory);
++attempts;
}
}
if (result == VK_SUCCESS) {
if (attempts == 2) std::fprintf(stderr, "[bufferpool] %s recovered: allocation=%llu reclaimed=%llu initialResult=%d attempts=%u\n", operation, static_cast<unsigned long long>(allocation.allocationSize), static_cast<unsigned long long>(reclaimed), static_cast<int>(initialResult), attempts);
return memory;
}
char details[512];
std::snprintf(details, sizeof(details), "%s: allocation=%llu memoryType=%u reclaimed=%llu initialResult=%d attempts=%u", operation, static_cast<unsigned long long>(allocation.allocationSize), allocation.memoryTypeIndex, static_cast<unsigned long long>(reclaimed), static_cast<int>(initialResult), attempts);
Check(result, details);
return VK_NULL_HANDLE;
}
bool BufferPool::SlabEligible(std::size_t capacity, VkDeviceSize alignment, VkDeviceSize size, VkDeviceSize atom) {
if (capacity >= classLimit || size > capacity || !std::has_single_bit(capacity)) return false;
const auto alignmentOk = alignment == 0 || (std::has_single_bit(alignment) && alignment <= capacity);
@ -80,7 +104,7 @@ SlabSlot BufferPool::TakeSlot(const Context& context, std::uint32_t memoryType,
if (addressable) allocation.pNext = &flags;
allocation.allocationSize = blockBytes;
allocation.memoryTypeIndex = memoryType;
Check(allocateMemory(device, &allocation, nullptr, &block->memory), "vkAllocateMemory buffer slab");
block->memory = AllocateMemory(allocation, "vkAllocateMemory buffer slab");
if ((context.memory.memoryTypes[memoryType].propertyFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) != 0) {
void* mapping = nullptr;
const auto mapped = mapMemory(device, block->memory, 0, VK_WHOLE_SIZE, 0, &mapping);
@ -105,26 +129,27 @@ void BufferPool::freeBlock(SlabBlock& block) noexcept {
freeMemory(device, block.memory, nullptr);
}
void BufferPool::PutSlot(VkDeviceMemory memory, VkDeviceSize offset) noexcept {
VkDeviceSize BufferPool::PutSlot(VkDeviceMemory memory, VkDeviceSize offset) noexcept {
std::unique_ptr<SlabBlock> released;
{
std::lock_guard lock(slabMutex);
const auto found = slabBlocks.find(memory);
if (found == slabBlocks.end()) return;
if (found == slabBlocks.end()) return 0;
auto& block = *found->second;
auto& slab = slabs[block.slab];
if (block.free.empty()) slab.available.push_back(&block);
block.free.push_back(static_cast<std::uint32_t>(offset / block.slotBytes));
if (--block.used != 0) return;
if (--block.used != 0) return 0;
if (slab.emptyBlocks == 0) {
++slab.emptyBlocks;
return;
return 0;
}
slab.available.erase(std::find(slab.available.begin(), slab.available.end(), &block));
released = std::move(found->second);
slabBlocks.erase(found);
}
freeBlock(*released);
return SlabBlockBytes(released->slotBytes);
}
std::size_t BufferPool::SlabBlocks() {
@ -150,16 +175,16 @@ VkDeviceSize BufferPool::DeviceBudget(const VkPhysicalDeviceMemoryProperties& me
return std::max<VkDeviceSize>(VkDeviceSize{512} << 20u, largest / 8u);
}
void BufferPool::destroy(const BufferAllocation& allocation) noexcept {
VkDeviceSize BufferPool::destroy(const BufferAllocation& allocation) noexcept {
if (allocation.slab) {
destroyBuffer(device, allocation.buffer, nullptr);
PutSlot(allocation.memory, allocation.offset);
return;
return PutSlot(allocation.memory, allocation.offset);
}
// Device-local allocations (see DeviceBuffer) are never mapped.
if (allocation.mapping != nullptr) unmap(device, allocation.memory);
destroyBuffer(device, allocation.buffer, nullptr);
freeMemory(device, allocation.memory, nullptr);
return allocation.allocationBytes;
}
bool BufferPool::DeviceTiered(VkMemoryPropertyFlags properties) {
@ -240,6 +265,43 @@ std::size_t BufferPool::MaxSlots() {
return slots;
}
VkDeviceSize BufferPool::trim() {
Tier retiredSmall;
Tier retiredLarge;
Tier retiredDevice;
{
std::lock_guard lock(mutex);
for (const auto& [tier, retired] : {std::pair{&smallTier, &retiredSmall}, std::pair{&largeTier, &retiredLarge}, std::pair{&deviceTier, &retiredDevice}}) {
tier->free.swap(retired->free);
tier->evictions += tier->slots;
tier->retainedBytes = 0;
tier->slots = 0;
}
}
VkDeviceSize bytes = 0;
for (const auto* tier : {&retiredSmall, &retiredLarge, &retiredDevice}) {
for (const auto& [key, slots] : tier->free) {
for (const auto& slot : slots) bytes += destroy(slot.allocation);
}
}
for (;;) {
std::unique_ptr<SlabBlock> released;
{
std::lock_guard lock(slabMutex);
const auto found = std::find_if(slabBlocks.begin(), slabBlocks.end(), [](const auto& entry) { return entry.second->used == 0; });
if (found == slabBlocks.end()) break;
auto& slab = slabs.at(found->second->slab);
slab.available.erase(std::find(slab.available.begin(), slab.available.end(), found->second.get()));
--slab.emptyBlocks;
released = std::move(found->second);
slabBlocks.erase(found);
}
freeBlock(*released);
bytes += SlabBlockBytes(released->slotBytes);
}
return bytes;
}
void BufferPool::Put(const BufferAllocation& allocation) noexcept {
// Evicted allocations are destroyed after the mutex is released (see evictOldest). The vector
// may throw on growth; a Put that cannot retain simply destroys, as noexcept requires.

View File

@ -103,7 +103,7 @@ Buffer::Buffer(const Context& context, std::size_t size, VkBufferUsageFlags usag
ready = true;
return;
}
Check(context.Function<PFN_vkAllocateMemory>("vkAllocateMemory")(context.device, &allocation, nullptr, &memory), "vkAllocateMemory buffer");
memory = cache->AllocateMemory(allocation, "vkAllocateMemory buffer");
Check(context.Function<PFN_vkBindBufferMemory>("vkBindBufferMemory")(context.device, buffer, memory, 0), "vkBindBufferMemory");
initializeAddress(usage);
if ((properties & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) != 0) Check(context.Function<PFN_vkMapMemory>("vkMapMemory")(context.device, memory, 0, VK_WHOLE_SIZE, 0, &mapping), "vkMapMemory");
@ -182,7 +182,7 @@ DeviceBuffer::DeviceBuffer(const Context& context, std::size_t size, VkBufferUsa
allocation.allocationSize = ClassAllocationBytes(requirements, capacity);
allocationBytes = allocation.allocationSize;
allocation.memoryTypeIndex = context.MemoryType(requirements.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
Check(context.Function<PFN_vkAllocateMemory>("vkAllocateMemory")(context.device, &allocation, nullptr, &memory), "vkAllocateMemory device buffer");
memory = cache->AllocateMemory(allocation, "vkAllocateMemory device buffer");
Check(context.Function<PFN_vkBindBufferMemory>("vkBindBufferMemory")(context.device, buffer, memory, 0), "vkBindBufferMemory device");
} catch (...) {
release();

View File

@ -4,6 +4,7 @@
#include <cstdint>
#include <cstring>
#include <cstdio>
#include <deque>
#include <map>
#include <memory>
#include <set>
@ -37,6 +38,11 @@ struct MockDevice {
VkDeviceSize liveBytes = 0;
std::map<VkDeviceMemory, VkDeviceSize> memoryBytes;
std::map<VkBuffer, std::pair<VkDeviceMemory, VkDeviceSize>> bound;
std::set<VkBuffer> liveBuffers;
std::deque<VkResult> results;
VkDeviceSize limit = 0;
VkResult pressureResult = VK_ERROR_OUT_OF_DEVICE_MEMORY;
std::uint64_t attempts = 0;
};
MockDevice mock;
@ -45,6 +51,7 @@ VKAPI_ATTR VkResult VKAPI_CALL mockCreateBuffer(VkDevice, const VkBufferCreateIn
*buffer = reinterpret_cast<VkBuffer>(static_cast<std::uintptr_t>(mock.next++));
mock.sizes[*buffer] = info->size;
mock.usages[*buffer] = info->usage;
mock.liveBuffers.insert(*buffer);
return VK_SUCCESS;
}
@ -53,6 +60,17 @@ VKAPI_ATTR void VKAPI_CALL mockGetBufferMemoryRequirements(VkDevice, VkBuffer bu
}
VKAPI_ATTR VkResult VKAPI_CALL mockAllocateMemory(VkDevice, const VkMemoryAllocateInfo* info, const VkAllocationCallbacks*, VkDeviceMemory* memory) {
++mock.attempts;
auto result = VK_SUCCESS;
if (!mock.results.empty()) {
result = mock.results.front();
mock.results.pop_front();
}
if (result == VK_SUCCESS && mock.limit != 0 && mock.liveBytes + info->allocationSize > mock.limit) result = mock.pressureResult;
if (result != VK_SUCCESS) {
*memory = reinterpret_cast<VkDeviceMemory>(std::uintptr_t{0xdeadbeef});
return result;
}
*memory = reinterpret_cast<VkDeviceMemory>(static_cast<std::uintptr_t>(mock.next++));
if (info->memoryTypeIndex == 1) mock.hostMemory[*memory].resize(info->allocationSize);
mock.memoryBytes[*memory] = info->allocationSize;
@ -74,11 +92,16 @@ VKAPI_ATTR VkResult VKAPI_CALL mockMapMemory(VkDevice, VkDeviceMemory memory, Vk
VKAPI_ATTR void VKAPI_CALL mockUnmapMemory(VkDevice, VkDeviceMemory) {}
VKAPI_ATTR void VKAPI_CALL mockDestroyBuffer(VkDevice, VkBuffer, const VkAllocationCallbacks*) {
VKAPI_ATTR void VKAPI_CALL mockDestroyBuffer(VkDevice, VkBuffer buffer, const VkAllocationCallbacks*) {
Expect(mock.liveBuffers.erase(buffer) == 1, "an unknown buffer was destroyed");
++mock.destroyedBuffers;
}
VKAPI_ATTR void VKAPI_CALL mockFreeMemory(VkDevice, VkDeviceMemory memory, const VkAllocationCallbacks*) {
if (!mock.memoryBytes.contains(memory)) {
Expect(false, "an undefined failed allocation output was freed");
return;
}
mock.liveBytes -= mock.memoryBytes.at(memory);
mock.memoryBytes.erase(memory);
mock.hostMemory.erase(memory);
@ -319,6 +342,149 @@ void Slabs() {
Expect(mock.liveBytes == 0, "slab blocks outlived their pool");
}
void AllocationRecovery(VkResult pressureResult, bool host) {
mock = MockDevice{};
{
auto context = mockContext();
const auto make = [&](std::size_t bytes) -> std::shared_ptr<void> {
if (host) return std::make_shared<Buffer>(context, bytes, VK_BUFFER_USAGE_TRANSFER_SRC_BIT);
return std::make_shared<DeviceBuffer>(context, bytes, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT);
};
auto active = make(2 * MiB);
const auto activeBuffer = *mock.liveBuffers.begin();
const auto activeMemory = mock.memoryBytes.begin()->first;
{ auto idle = make(2 * MiB); }
mock.limit = 6 * MiB;
mock.pressureResult = pressureResult;
const auto before = mock.attempts;
try {
auto recovered = make(4 * MiB);
Expect(mock.attempts == before + 2, "pool pressure did not trigger exactly one allocation retry");
Expect(mock.frees == 1 && mock.liveBytes == 6 * MiB, "recovery did not release the idle allocation");
Expect(mock.liveBuffers.contains(activeBuffer) && mock.memoryBytes.contains(activeMemory), "recovery destroyed an active allocation");
} catch (const std::exception& error) {
Expect(false, std::string("idle memory did not permit recovery: ") + error.what());
}
}
Expect(mock.liveBytes == 0 && mock.liveBuffers.empty(), "recovery leaked a resource");
}
void SlabAllocationRecovery() {
mock = MockDevice{};
{
auto context = mockContext();
Buffer active(context, 300, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT);
const auto activeMemory = mock.bound.at(active.Handle()).first;
{ DeviceBuffer idle(context, 2 * MiB, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); }
mock.limit = 4 * MiB;
const auto before = mock.attempts;
try {
Buffer recovered(context, 600, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT);
Expect(mock.attempts == before + 2 && mock.frees == 1, "a new slab block did not retry after reclaiming an idle allocation");
Expect(context.bufferPool->SlabBlocks() == 2 && mock.memoryBytes.contains(activeMemory), "slab allocation recovery discarded its live block");
} catch (const std::exception& error) {
Expect(false, std::string("slab allocation did not recover: ") + error.what());
}
}
Expect(mock.liveBytes == 0 && mock.liveBuffers.empty(), "slab recovery leaked a resource");
}
void ReclaimSlabs() {
mock = MockDevice{};
{
auto context = mockContext();
Buffer active(context, 300, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT);
const auto activeMemory = mock.bound.at(active.Handle()).first;
std::memset(active.Bytes().data(), 0x5a, active.Bytes().size());
{
Buffer sameBlock(context, 260, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT);
Buffer otherBlock(context, 600, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT);
}
mock.limit = 6 * MiB;
const auto before = mock.attempts;
try {
Buffer recovered(context, 4 * MiB, VK_BUFFER_USAGE_TRANSFER_SRC_BIT);
Expect(mock.attempts == before + 2 && mock.frees == 1, "reclaim did not free the unused slab block");
Expect(context.bufferPool->SlabBlocks() == 1 && mock.memoryBytes.contains(activeMemory), "reclaim freed a slab with a live slot");
Expect(std::all_of(active.Bytes().begin(), active.Bytes().end(), [](std::byte value) { return value == std::byte{0x5a}; }), "reclaim changed a live slab buffer");
} catch (const std::exception& error) {
Expect(false, std::string("empty slab did not permit recovery: ") + error.what());
}
}
Expect(mock.liveBytes == 0 && mock.liveBuffers.empty(), "slab reclaim leaked a resource");
mock = MockDevice{};
{
auto context = mockContext();
BufferPool pool(context);
const auto idle = pool.TakeSlot(context, 1, 512 * 1024, false);
pool.PutSlot(idle.memory, idle.offset);
mock.limit = 4 * MiB;
const auto before = mock.attempts;
try {
const auto recovered = pool.TakeSlot(context, 1, 256 * 1024, false);
Expect(mock.attempts == before + 2 && mock.frees == 1 && pool.SlabBlocks() == 1, "a spare slab without cached buffers was not reclaimed");
pool.PutSlot(recovered.memory, recovered.offset);
} catch (const std::exception& error) {
Expect(false, std::string("spare slab did not permit recovery: ") + error.what());
}
}
Expect(mock.liveBytes == 0, "spare slab recovery leaked device memory");
}
void AllocationFailure(VkResult initial, VkResult final, bool cached) {
mock = MockDevice{};
{
auto context = mockContext();
if (cached) { DeviceBuffer idle(context, 2 * MiB, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); }
mock.results = {initial, final};
const auto before = mock.attempts;
std::string failure;
try {
DeviceBuffer buffer(context, 4 * MiB, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT);
} catch (const std::exception& error) {
failure = error.what();
}
const bool reclaimed = cached && (initial == VK_ERROR_OUT_OF_DEVICE_MEMORY || initial == VK_ERROR_OUT_OF_HOST_MEMORY);
Expect(mock.attempts == before + (reclaimed ? 2 : 1), "allocation retries were not bounded or a non-memory failure was retried");
Expect(failure.find("Vulkan result " + std::to_string(reclaimed ? final : initial)) != std::string::npos, "allocation failure lost its final Vulkan result");
Expect(mock.liveBuffers.size() == (cached && !reclaimed ? 1u : 0u), "a failed allocation retained its buffer");
Expect(mock.liveBytes == (cached && !reclaimed ? 2 * MiB : 0), "a failed allocation mishandled cached memory");
}
Expect(mock.liveBytes == 0 && mock.liveBuffers.empty(), "failure cleanup leaked a resource");
}
void SuccessfulAllocationKeepsPool() {
mock = MockDevice{};
{
auto context = mockContext();
{ DeviceBuffer idle(context, 2 * MiB, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); }
DeviceBuffer buffer(context, 4 * MiB, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT);
Expect(mock.attempts == 2 && mock.frees == 0 && mock.liveBytes == 6 * MiB, "a successful first allocation reclaimed its cache");
}
Expect(mock.liveBytes == 0 && mock.liveBuffers.empty(), "normal allocation leaked a resource");
}
void PartialSlabDoesNotRetry() {
mock = MockDevice{};
{
auto context = mockContext();
Buffer active(context, 300, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT);
{ Buffer idle(context, 260, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); }
mock.limit = 2 * MiB;
const auto before = mock.attempts;
std::string failure;
try {
Buffer buffer(context, 2 * MiB, VK_BUFFER_USAGE_TRANSFER_SRC_BIT);
} catch (const std::exception& error) {
failure = error.what();
}
Expect(failure.find("Vulkan result -2") != std::string::npos, "a live slab's memory was treated as reclaimable");
Expect(mock.attempts == before + 1 && mock.frees == 0, "an allocation retried without freeing device memory");
Expect(mock.liveBuffers.size() == 1 && mock.liveBuffers.contains(active.Handle()) && context.bufferPool->SlabBlocks() == 1, "reclaim changed a live slab allocation");
}
Expect(mock.liveBytes == 0 && mock.liveBuffers.empty(), "partial slab failure leaked a resource");
}
}
int main() {
@ -330,6 +496,18 @@ int main() {
AddressAndHostUnchanged();
SmallDeviceClasses();
Slabs();
for (const auto result : {VK_ERROR_OUT_OF_DEVICE_MEMORY, VK_ERROR_OUT_OF_HOST_MEMORY}) {
AllocationRecovery(result, false);
AllocationRecovery(result, true);
AllocationFailure(result, result, true);
AllocationFailure(result, result, false);
}
SlabAllocationRecovery();
ReclaimSlabs();
AllocationFailure(VK_ERROR_DEVICE_LOST, VK_ERROR_DEVICE_LOST, true);
AllocationFailure(VK_ERROR_OUT_OF_DEVICE_MEMORY, VK_ERROR_DEVICE_LOST, true);
SuccessfulAllocationKeepsPool();
PartialSlabDoesNotRetry();
} catch (const std::exception& error) {
std::fprintf(stderr, "FAIL: %s\n", error.what());
return 1;

View File

@ -362,6 +362,7 @@ Throughout the project, every function at every stage either **does exactly what
- [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.
- [Separate alpha blend into 8_8 SWAP_ALT targets](../../core/libs/prx/libSceAgcDriver/Graphics/src/Pipeline.cpp) (libSceAgcDriver): the second channel of a two-channel target with `COMP_SWAP` 1 holds the alpha export, but Vulkan blends it with the color equation of the R8G8 attachment, so a draw whose alpha equation differs from its color equation is recorded twice, the first channel with the color equation and the second with the alpha factors and operation moved into the color equation. The vertex stages run twice, so a vertex shader that writes memory writes twice, and occlusion counts include both passes. Draws that write depth or stencil, pixel shaders that write memory and factors of the destination alpha throw. That the hardware blends the second channel with the alpha equation is taken from Mesa's `si_translate_colorswap` and radeonsi's use of it for luminance-alpha formats, not measured on hardware.
- [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.
- [AGC buffer allocations](../../core/libs/prx/libSceAgcDriver/Graphics/src/BufferPool.cpp) retry host/device out-of-memory once after reclaiming idle pool buffers and fully unused slab blocks. Live buffers and blocks with live slots are retained. Live images and other retained resources have no global memory bound; allocation failure still throws when reclaim cannot satisfy the request.
- [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
- [`APS5_THREAD_PRIORITY=rt`](../../core/libs/prx/libSceAgcDriver/Execution/src/ThreadPriority.cpp) (libSceAgcDriver) lowers the process's hard `RLIMIT_RTTIME` when it is above `APS5_THREAD_RTTIME_US` or rtkit's `RTTimeUSecMax`, because [rtkit](https://github.com/heftig/rtkit/blob/c295fa849f52b487be6433e69e08b46251950399/rtkit-daemon.c#L592-L637) (`verify_process_rttime`) reads the hard limit from `/proc/<pid>/limits` and refuses real-time scheduling when it is higher. An unprivileged process cannot raise the hard limit again, so it stays lowered for the process and its threads even when rtkit then refuses the request (burst limit, user or start-time checks) and the thread throws. rtkit's `MaxRealtimePriority` and `RTTimeUSecMax` are read first, so an unreachable rtkit or a priority above its maximum leaves the limit untouched. The lowered limit only matters for threads that later run under a real-time policy