diff --git a/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp b/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp index 619a45d5dbfe..6549e853b535 100644 --- a/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp +++ b/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp @@ -170,6 +170,21 @@ struct ImportRefusal { std::uint64_t budget; }; +struct EvictingRefusal { + std::uint64_t base; + std::uint64_t bytes; + GuestAllocations::Mapped ranges; + std::uint64_t epoch; + std::uint64_t liveBytes; + std::uint64_t budget; + std::uint64_t room; + std::uint64_t window; + std::uint64_t floor; + std::uint64_t ready; + std::uint64_t readyHeld; + bool joined; +}; + #ifdef _WIN32 constexpr std::size_t settleBatchCount = 4; constexpr std::uint32_t settleBatchCopies = 256; @@ -225,6 +240,7 @@ struct HostImports { std::map sparseRunJoins; std::map, ImportFailure> failedBlocks; std::map, ImportRefusal> refusals; + std::map, EvictingRefusal> evictingRefusals; // Registry generation the imports were last reconciled with. std::uint64_t refreshedGeneration = 0; // Bumped whenever an import is dropped, so HostImport pointers taken under the lock earlier can be @@ -1425,9 +1441,14 @@ bool pointerHeld(const HostImports& state, const HostImport& entry) { return effectiveUse(state, entry) >= state.buildFloor; } +constexpr std::uint64_t ProtectedUses = 256; + bool evictable(const HostImports& state, const HostImport& entry) { - constexpr std::uint64_t protectedUses = 256; - return !pointerHeld(state, entry) && effectiveUse(state, entry) + protectedUses < state.useCounter; + return !pointerHeld(state, entry) && effectiveUse(state, entry) + ProtectedUses < state.useCounter; +} + +bool evictingRefusalCurrent(const HostImports& state, const EvictingRefusal& refusal, std::uint64_t budget) { + return refusal.epoch == state.epoch && refusal.liveBytes == state.liveBytes && budget <= refusal.budget && refusal.window == importWindow.load(std::memory_order_relaxed) && state.useCounter < refusal.ready && (state.buildFloor <= refusal.floor || state.useCounter < refusal.readyHeld); } std::uint64_t retireLeastRecent(const Context& context, HostImports& state, const GuestAllocations::Lease& lease, std::uint64_t wanted) { @@ -1564,10 +1585,16 @@ const HostImport* findImport(HostImports& state, std::uint64_t begin, std::uint6 const HostImport* importRunAt(const Context& context, HostImports& state, ImportRun run, bool joined, std::uint64_t address, const GuestAllocations::Lease& lease, std::uint64_t end, bool evict, ImportRequester requester); // The bytes retireLeastRecent could free outside [begin, end). -std::uint64_t evictableBytes(const HostImports& state, std::uint64_t begin, std::uint64_t end) { +std::uint64_t evictableBytes(const HostImports& state, std::uint64_t begin, std::uint64_t end, std::uint64_t& ready, std::uint64_t& readyHeld) { std::uint64_t bytes = 0; for (const auto& [base, entry] : state.imports) { - if ((entry.base + entry.bytes <= begin || entry.base >= end) && evictable(state, entry)) bytes += entry.pinnedBytes; + if (entry.base + entry.bytes > begin && entry.base < end) continue; + if (evictable(state, entry)) { + bytes += entry.pinnedBytes; + continue; + } + auto& horizon = pointerHeld(state, entry) ? readyHeld : ready; + horizon = std::min(horizon, effectiveUse(state, entry) + ProtectedUses + 1); } return bytes; } @@ -1657,6 +1684,19 @@ const HostImport* importRunAt(const Context& context, HostImports& state, Import failures.erase(failed); } dropTrimmed(state, base, base + bytes); + const auto budget = effectiveBudget(context, state); + const auto containing = std::find_if(run.ranges.begin(), run.ranges.end(), [address](const auto& range) { return address >= range->address && address < range->address + range->bytes; }); + if (const auto remembered = state.evictingRefusals.find({base, bytes}); evict && remembered != state.evictingRefusals.end()) { + if (remembered->second.joined == joined && evictingRefusalCurrent(state, remembered->second, budget) && sameRun(remembered->second, run)) { + ++counts.refused; + counts.refusedBytes += bytes; + ++counts.remembered; + const auto room = remembered->second.room - std::min(remembered->second.room, remembered->second.budget - budget); + if (stepDown && containing != run.ranges.end() && room >= (*containing)->bytes) return importAllocation(context, state, address, lease, requester, end, evict, std::min(bytes / 2, room)); + return nullptr; + } + state.evictingRefusals.erase(remembered); + } auto first = state.imports.lower_bound(base); if (first != state.imports.begin() && std::prev(first)->second.base + std::prev(first)->second.bytes > base) --first; std::uint64_t replaced = 0; @@ -1672,9 +1712,10 @@ const HostImport* importRunAt(const Context& context, HostImports& state, Import if (held(it->second)) return stepDown ? importAllocation(context, state, address, lease, requester, end, evict, bytes / 2) : nullptr; replaced += it->second.pinnedBytes; } - const auto budget = effectiveBudget(context, state); const auto live = state.liveBytes - replaced; - const auto evictable = evict && live + bytes > budget ? evictableBytes(state, base, base + bytes) : 0; + auto ready = std::numeric_limits::max(); + auto readyHeld = std::numeric_limits::max(); + const auto evictable = evict && live + bytes > budget ? evictableBytes(state, base, base + bytes, ready, readyHeld) : 0; if (live + bytes > budget + evictable) { // A refused import turns every later use of the range into CPU copies, so say so. static auto lastReport = std::chrono::steady_clock::now() - std::chrono::seconds(60); @@ -1696,8 +1737,8 @@ const HostImport* importRunAt(const Context& context, HostImports& state, Import } std::fprintf(stderr, "[gpu] host import budget: %llu MiB live of %llu MiB (%s); %llu imports (%llu MiB) refused so far, %llu of them remembered, last 0x%llx+0x%llx by %s\n", static_cast(state.liveBytes >> 20u), static_cast(budget >> 20u), budgetSource(context, state, budget).c_str(), static_cast(refused), static_cast(refusedBytes >> 20u), static_cast(remembered), static_cast(base), static_cast(bytes), ImportRequesterName(requester)); } - const auto containing = std::find_if(run.ranges.begin(), run.ranges.end(), [address](const auto& range) { return address >= range->address && address < range->address + range->bytes; }); const auto room = budget + evictable > live ? budget + evictable - live : 0; + if (evict) state.evictingRefusals.insert_or_assign(std::pair{base, bytes}, EvictingRefusal{base, bytes, GuestAllocations::Mapped(run.ranges.begin(), run.ranges.end()), state.epoch, state.liveBytes, budget, room, importWindow.load(std::memory_order_relaxed), state.buildFloor, ready, readyHeld, joined}); if (stepDown && containing != run.ranges.end() && room >= (*containing)->bytes) return importAllocation(context, state, address, lease, requester, end, evict, std::min(bytes / 2, room)); if (containing != run.ranges.end() && run.ranges.size() > 1) remember((*containing)->address, (*containing)->bytes, GuestAllocations::Mapped{*containing}); return nullptr; @@ -1939,6 +1980,7 @@ void refreshImports(const Context& context, HostImports& state, const GuestAlloc state.failedJoins.clear(); state.failedBlocks.clear(); state.refusals.clear(); + state.evictingRefusals.clear(); state.ceiling = 0; state.allocationFailures = 0; state.allocationFailed = false; @@ -1989,6 +2031,7 @@ void refreshImports(const Context& context, HostImports& state, const GuestAlloc for (auto it = state.failedJoins.begin(); it != state.failedJoins.end();) it = sameRun(it->second, joinedRun(lease, it->second.base, it->second.base + it->second.bytes)) ? std::next(it) : state.failedJoins.erase(it); for (auto it = state.failedBlocks.begin(); it != state.failedBlocks.end();) it = sameRun(it->second, exactRun(lease, it->first.first, it->first.second)) ? std::next(it) : state.failedBlocks.erase(it); for (auto it = state.refusals.begin(); it != state.refusals.end();) it = stillRefused(state, &it->second, budget) && sameRun(it->second, exactRun(lease, it->first.first, it->first.second)) ? std::next(it) : state.refusals.erase(it); + for (auto it = state.evictingRefusals.begin(); it != state.evictingRefusals.end();) it = evictingRefusalCurrent(state, it->second, budget) && sameRun(it->second, exactRun(lease, it->first.first, it->first.second)) ? std::next(it) : state.evictingRefusals.erase(it); } const HostImport* peekImport(const HostImports& state, std::uint64_t begin, std::uint64_t end) { @@ -3097,6 +3140,7 @@ void ClearHostImports(VkDevice device) { state.failedJoins.clear(); state.failedBlocks.clear(); state.refusals.clear(); + state.evictingRefusals.clear(); state.ceiling = 0; state.allocationFailures = 0; state.allocationFailed = false; diff --git a/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp b/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp index d14aec6c0893..920ca899f3b4 100644 --- a/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp @@ -1372,6 +1372,212 @@ void importRefusalMemoryTests(const Context& context) { ::operator delete(block, std::align_val_t{piece}); } +void evictingRefusalMemoryTests(const Context& context) { + constexpr std::size_t piece = 65536; + constexpr std::size_t filled = 32; + constexpr std::size_t count = filled + 3; + auto importing = context; + importing.hostImportAlignment = piece; + auto* block = static_cast(::operator new(2 * count * piece, std::align_val_t{piece})); + const auto at = [&](std::size_t index) { return block + 2 * index * piece; }; + const auto address = [&](std::size_t index) { return reinterpret_cast(at(index)); }; + ClearHostImports(importing.device); + { + GuestAllocations::Mutation mutation; + for (std::size_t index = 0; index < count; ++index) mutation.Add(at(index), piece, true, true, true); + } + const auto budget = HostImportLiveBytes() + filled * piece; + SetHostImportBudget(budget); + for (std::size_t index = 0; index < filled; ++index) Require(HostImportFor(importing, address(index), 16) != nullptr, "a piece inside the import budget was not imported"); + Require(HostImportLiveBytes() == budget, "the pieces did not fill the import budget"); + const auto counted = [] { return HostImportCounters().requesters[static_cast(ImportRequester::Access)]; }; + auto before = counted(); + for (int request = 0; request < 8; ++request) Require(HostImportFor(importing, address(filled), 16) == nullptr, "a piece over a full budget with nothing evictable was imported"); + auto after = counted(); + Require(after.refused - before.refused == 8 && after.remembered - before.remembered == 7, "8 evicting requests for one refused piece at a full budget with nothing evictable scanned the imports " + std::to_string(8 - (after.remembered - before.remembered)) + " times, not once"); + SetHostImportBudget(budget - piece); + before = counted(); + Require(HostImportFor(importing, address(filled), 16) == nullptr && counted().remembered == before.remembered + 1, "a remembered evicting refusal ended when the budget was lowered"); + SetHostImportBudget(budget); + { + GuestAllocations::Mutation mutation; + mutation.Remove(at(0)); + } + before = counted(); + Require(HostImportFor(importing, address(filled), 16) != nullptr && counted().remembered == before.remembered, "an evicting request was answered from its remembered refusal after an import retired"); + static_cast(HostImportFor(importing, address(filled + 1), 16)); + before = counted(); + Require(HostImportFor(importing, address(filled + 1), 16) == nullptr && counted().remembered == before.remembered + 1, "a second evicting request for a refused piece was not answered from memory"); + SetHostImportBudget(budget + piece); + before = counted(); + Require(HostImportFor(importing, address(filled + 1), 16) != nullptr && counted().remembered == before.remembered, "an evicting request was answered from its remembered refusal after the budget was raised"); + static_cast(HostImportFor(importing, address(filled + 2), 16)); + { + GuestBufferMemory upload(importing); + upload.Upload(false); + upload.WriteBack(); + } + before = counted(); + Require(HostImportFor(importing, address(filled + 2), 16) == nullptr && counted().remembered == before.remembered + 1, "a remembered evicting refusal ended when a build raised the floor while no import could age out of its protection yet"); + for (int use = 0; use < 200; ++use) Require(HostImportFor(importing, address(filled + 1), 16) != nullptr, "a live import was not found"); + before = counted(); + Require(HostImportFor(importing, address(filled + 2), 16) != nullptr && counted().remembered == before.remembered, "an evicting request was answered from its remembered refusal after the imports aged out of their protection"); + { + GuestAllocations::Mutation mutation; + for (std::size_t index = 1; index < count; ++index) mutation.Remove(at(index)); + } + ClearHostImports(importing.device); + SetHostImportBudget(0); + ::operator delete(block, std::align_val_t{piece}); +} + +void evictingRefusalAgingTests(const Context& context) { + constexpr std::size_t piece = 65536; + constexpr std::size_t filled = 16; + auto importing = context; + importing.hostImportAlignment = piece; + auto* block = static_cast(::operator new(2 * (filled + 1) * piece, std::align_val_t{piece})); + const auto at = [&](std::size_t index) { return block + 2 * index * piece; }; + const auto address = [&](std::size_t index) { return reinterpret_cast(at(index)); }; + ClearHostImports(importing.device); + { + GuestAllocations::Mutation mutation; + for (std::size_t index = 0; index <= filled; ++index) mutation.Add(at(index), piece, true, true, true); + } + SetHostImportBudget(HostImportLiveBytes() + filled * piece); + for (std::size_t index = 0; index < filled; ++index) Require(HostImportFor(importing, address(index), 16) != nullptr, "a piece inside the import budget was not imported"); + { + GuestBufferMemory upload(importing); + upload.Upload(false); + upload.WriteBack(); + } + const auto counted = [] { return HostImportCounters().requesters[static_cast(ImportRequester::Access)]; }; + static_cast(HostImportFor(importing, address(filled), 16)); + auto before = counted(); + Require(HostImportFor(importing, address(filled), 16) == nullptr && counted().remembered == before.remembered + 1, "a refusal while the imports were recent but no longer held by a build was not remembered"); + for (int use = 0; use < 200; ++use) Require(HostImportFor(importing, address(filled - 1), 16) != nullptr, "a live import was not found"); + before = counted(); + Require(HostImportFor(importing, address(filled), 16) != nullptr && counted().remembered == before.remembered, "an evicting request was answered from its remembered refusal after imports no build held aged out of their protection"); + { + GuestAllocations::Mutation mutation; + for (std::size_t index = 0; index <= filled; ++index) mutation.Remove(at(index)); + } + ClearHostImports(importing.device); + SetHostImportBudget(0); + ::operator delete(block, std::align_val_t{piece}); +} + +void evictingRefusalRangeTests(const Context& context) { + PieceWindow window(context); + constexpr auto piece = PieceWindow::Piece; + constexpr auto fillers = PieceWindow::Window / 2; + window.Add(0, 2 * piece); + window.Add(2 * piece, piece); + window.Add(3 * piece, piece); + for (std::size_t filler = 0; filler < 4; ++filler) { + window.Add(fillers + 2 * filler * piece, piece); + Require(HostImportFor(window.importing, window.Address(fillers + 2 * filler * piece), 16) != nullptr, "a filler piece inside the import budget was not imported"); + } + SetHostImportBudget(HostImportLiveBytes() + piece); + Require(HostImportFor(window.importing, window.Address(0), 16) == nullptr, "a 128 KiB range in a 256 KiB block over the budget was imported"); + Require(HostImportFor(window.importing, window.Address(2 * piece), 16) != nullptr && window.Block(2 * piece, piece), "a 64 KiB range of the same 256 KiB block was answered from the 128 KiB range's refusal instead of stepping down to its own import"); +} + +void evictingRefusalBlockRangesTests(const Context& context) { + PieceWindow window(context); + constexpr auto piece = PieceWindow::Piece; + constexpr auto fillers = PieceWindow::Window / 2; + window.AddPieces(0, 4 * piece); + for (std::size_t filler = 0; filler < 4; ++filler) { + window.Add(fillers + 2 * filler * piece, piece); + Require(HostImportFor(window.importing, window.Address(fillers + 2 * filler * piece), 16) != nullptr, "a filler piece inside the import budget was not imported"); + } + SetHostImportBudget(HostImportLiveBytes()); + const auto counted = [] { return HostImportCounters().requesters[static_cast(ImportRequester::Access)]; }; + const auto before = counted(); + for (int request = 0; request < 8; ++request) Require(HostImportFor(window.importing, window.Address(static_cast(request % 2) * piece), 16) == nullptr, "a 64 KiB range of a block over a full budget with nothing evictable was imported"); + const auto after = counted(); + Require(after.refused - before.refused == 8 && after.remembered - before.remembered == 7, "8 evicting requests alternating between two 64 KiB ranges of one refused 256 KiB block scanned the imports " + std::to_string(8 - (after.remembered - before.remembered)) + " times, not once"); + SetHostImportBudget(HostImportLiveBytes() + piece); + Require(HostImportFor(window.importing, window.Address(piece), 16) != nullptr && window.Block(piece, piece), "a 64 KiB range of the block was answered from the full budget's refusal instead of stepping down to its own import once the budget had room for it"); +} + +void evictingRefusalJoinTests(const Context& context) { + PieceWindow window(context); + constexpr auto piece = PieceWindow::Piece; + constexpr auto fillers = PieceWindow::Window / 2; + window.AddPieces(0, 3 * piece); + for (std::size_t filler = 0; filler < 4; ++filler) { + window.Add(fillers + 2 * filler * piece, piece); + Require(HostImportFor(window.importing, window.Address(fillers + 2 * filler * piece), 16) != nullptr, "a filler piece inside the import budget was not imported"); + } + SetHostImportBudget(HostImportLiveBytes()); + const auto upload = [&] { + GuestBufferMemory plain(window.importing); + plain.AddReadable(window.Address(0), 3 * piece); + plain.Upload(false); + plain.WriteBack(); + }; + const auto counted = [] { return HostImportCounters().requesters[static_cast(ImportRequester::Join)]; }; + const auto before = counted(); + for (int request = 0; request < 8; ++request) upload(); + const auto after = counted(); + Require(!HostImportCovers(window.importing, window.Address(0), 3 * piece), "a join of three 64 KiB ranges over a full budget with nothing evictable was imported"); + Require(after.refused - before.refused == 8 && after.remembered - before.remembered == 7, "8 uploads bound across three refused ranges scanned the imports for their join " + std::to_string(8 - (after.remembered - before.remembered)) + " times, not once"); + SetHostImportBudget(HostImportLiveBytes() + 3 * piece); + upload(); + Require(HostImportCovers(window.importing, window.Address(0), 3 * piece) && counted().made == after.made + 1, "the join was answered from the full budget's refusal after the budget grew"); +} + +void evictingRefusalStepDownTests(const Context& context) { + PieceWindow window(context); + constexpr auto piece = PieceWindow::Piece; + constexpr auto fillers = PieceWindow::Window / 2; + window.AddPieces(0, 4 * piece); + for (std::size_t filler = 0; filler < 4; ++filler) { + window.Add(fillers + 2 * filler * piece, piece); + Require(HostImportFor(window.importing, window.Address(fillers + 2 * filler * piece), 16) != nullptr, "a filler piece inside the import budget was not imported"); + } + SetHostImportBudget(HostImportLiveBytes() + 2 * piece); + { + GuestBufferMemory leased(window.importing); + leased.AcquireRegistered(); + leased.Upload(true); + leased.WriteBack(); + } + Require(window.Block(0, 2 * piece) && window.Serial(2 * piece) == 0, "the address-based build did not step down from the refused 256 KiB block to the 128 KiB block that fits"); + { + GuestBufferMemory upload(window.importing); + upload.Upload(false); + upload.WriteBack(); + } + const auto counted = [] { return HostImportCounters().requesters[static_cast(ImportRequester::Access)]; }; + const auto before = counted(); + for (int request = 0; request < 8; ++request) Require(HostImportFor(window.importing, window.Address(2 * piece), 16) == nullptr, "the rest of a block over a full budget with nothing evictable was imported"); + const auto after = counted(); + const auto scans = (after.refused - before.refused) - (after.remembered - before.remembered); + Require(after.refused - before.refused == 16 && scans == 1, "8 evicting requests that step down from a refused block scanned the imports " + std::to_string(scans) + " times, not once"); + SetHostImportBudget(HostImportLiveBytes() + piece); + Require(HostImportFor(window.importing, window.Address(2 * piece), 16) != nullptr && window.Block(2 * piece, piece), "a 64 KiB range was answered from a block's refusal remembered before the budget grew instead of stepping down to its own import"); +} + +void evictingRefusalWindowTests(const Context& context) { + PieceWindow window(context); + constexpr auto piece = PieceWindow::Piece; + constexpr auto fillers = PieceWindow::Window / 2; + SetHostImportWindow(0); + window.Add(0, piece); + window.Add(piece, piece); + for (std::size_t filler = 0; filler < 4; ++filler) { + window.Add(fillers + 2 * filler * piece, piece); + Require(HostImportFor(window.importing, window.Address(fillers + 2 * filler * piece), 16) != nullptr, "a filler piece inside the import budget was not imported"); + } + SetHostImportBudget(HostImportLiveBytes() + piece); + Require(HostImportFor(window.importing, window.Address(0), 16) == nullptr, "two adjacent 64 KiB ranges over the budget were imported without an import window"); + SetHostImportWindow(PieceWindow::Window); + Require(HostImportFor(window.importing, window.Address(0), 16) != nullptr && window.Block(0, piece), "a 64 KiB range was answered from a refusal made without the import window instead of stepping down once the window was set"); +} + void importDeviceBudgetTests() { constexpr std::uint64_t MiB = std::uint64_t{1} << 20u; constexpr std::uint64_t GiB = std::uint64_t{1} << 30u; @@ -3153,6 +3359,13 @@ void RunBdaResourceTests(const Context& context, const BdaTestAccess& access) { importBlockFailureTests(context, access); importBlockBudgetTests(context); importRefusalMemoryTests(context); + evictingRefusalMemoryTests(context); + evictingRefusalAgingTests(context); + evictingRefusalRangeTests(context); + evictingRefusalWindowTests(context); + evictingRefusalBlockRangesTests(context); + evictingRefusalJoinTests(context); + evictingRefusalStepDownTests(context); importDeviceBudgetTests(); importCeilingTests(context, access, false); importCeilingTests(context, access, true); diff --git a/docs/dev/debt/libSceAgcDriver.md b/docs/dev/debt/libSceAgcDriver.md index d3f226814599..5b94dab8644b 100644 --- a/docs/dev/debt/libSceAgcDriver.md +++ b/docs/dev/debt/libSceAgcDriver.md @@ -192,7 +192,7 @@ - [16-bit depth with stencil](../../../core/libs/prx/libSceAgcDriver/Graphics/src/DepthSurface.cpp) (libSceAgcDriver) is stored as `D24_UNORM_S8_UINT`, or `D32_SFLOAT_S8_UINT`, on a device without `D16_UNORM_S8_UINT` as an attachment. Depth comparisons, clears and the depth image see the wider format's precision, so results at the 16-bit quantization boundary can differ from the console; the constant depth bias is scaled by 256 for D24, and a non-zero constant on the D32 float fallback throws. Not measured on console hardware. - [Depth surfaces created over storage writes](../../../core/libs/prx/libSceAgcDriver/Graphics/src/DepthSurface.cpp) (libSceAgcDriver): a depth surface created without HTILE copies its depth plane from the storage image at the plane's address, a pending one first, when that image has the plane's extent, one level, a 2D view or a 2D array view (its layer 0 is copied) and a 16-bit format for a 16-bit plane or a 32-bit format for a 32-bit plane. A multisampled surface, a surface the device stores in another format, a surface with HTILE and a surface without such an image start from `DB_DEPTH_CLEAR`, and the stencil plane from `DB_STENCIL_CLEAR`; CPU writes to the plane's memory are not read. Not measured on the console, where the plane is that memory. - [Flip packets raise the graphics end-of-pipe event](../../../core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp) (libSceAgcDriver) - a flip delivers the queue's end-of-pipe event (`sceAgcDriverAddEqEvent`) once the work recorded before it completes, as a `RELEASE_MEM` with `INT_SEL` does, but only on a queue that has never delivered an interrupt from such a release: the queue is latched the first time a release interrupt reaches the event queue, and flips on that queue deliver nothing while its own release interrupts keep arriving. The latch is cleared when the queue has flipped five times without another interrupt from its own releases: the fifth flip delivers the event and unlatched the queue, so a title that waits on the flip event after latching recovers only if it submits further flips. The bound cannot be by time: the next release interrupt can arrive seconds later than a suppressed flip across loading stalls, and delivering an unexpected flip event on a queue that pairs flips with releases can crash; no fixed window short enough to unblock a waiting title keeps such a title alive. Whether the console raises the event from the flip itself or from an interrupt its flip sequence contains is not measured; nor is how the console behaves for a title that waits on the flip event and also raises release interrupts on the same queue. -- [Host import windows and budget](../../../core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp) (libSceAgcDriver): a registered range larger than 64 MiB is imported one 64 MiB window at a time for buffer and fill accesses; an access across window edges imports the cells it touches, grown to the union of the range's imports it overlaps. An address-based build (`AcquireRegistered`) imports the range whole and replaces its windows. Live imports stay under a budget: `APS5_HOST_IMPORT_MIB`, else the larger of 6 GiB and half the physical memory, at most the largest host heap less the larger of 2 GiB and half that heap (imports, type-3 mirrors and buffers and textures placed in host memory share it; on a 16 GiB machine whose host heap is half the memory, a 2 GiB reserve left too little for the rest). Where the device enables `VK_EXT_memory_budget`, that host heap's reported budget less its other usage and an eighth, in 64 MiB steps, read at most once a second, also bounds the default budget once an import's `vkAllocateMemory` has failed; a set `APS5_HOST_IMPORT_MIB` is never lowered by the report. The report's other usage includes the host-visible copies and heap mirrors that stand in for refused imports, so it grows with the refusals; the first time the report would lower the budget, `[gpu] host import budget: host heap ...` prints the heap's reported budget and usage and the live imports. An import's `vkAllocateMemory` failure within an eighth of the budget, or the third in a row, lowers the budget to the live imports until the device or the configured budget changes; an isolated failure further below only marks its range failed. Live imports are not capped by `maxMemoryAllocationCount`: NVIDIA's Windows driver reports 4096, while Village held 27,167 live imports without a failure. A new import beyond it retires the least recently used imports not used in the last 256 lookups; one that would not fit even then is refused before anything is retired, and imports made when a range is mapped never evict. A heap mirror, sparse or not, whose range has left the write watch is refreshed in full at each use, every block counted as written; a build plans its range again only once the import path takes the range. A request that may not evict (an address-based build, an import when a range is mapped) remembers a refused range or block, with the live bytes and the budget at the refusal, and is answered from that memory, still counted as refused, until live imports drop below those bytes, the budget changes or one of its ranges changes; a request that may evict always tries again. The shutdown line `[gpu] host imports at shutdown` gives the live, made, refused (and remembered) and failed counts per requester. The 64 MiB window, the 256-lookup protection, the 2 GiB reserve, the eighth, the 64 MiB steps and the three failures are choices, not measured. +- [Host import windows and budget](../../../core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp) (libSceAgcDriver): a registered range larger than 64 MiB is imported one 64 MiB window at a time for buffer and fill accesses; an access across window edges imports the cells it touches, grown to the union of the range's imports it overlaps. An address-based build (`AcquireRegistered`) imports the range whole and replaces its windows. Live imports stay under a budget: `APS5_HOST_IMPORT_MIB`, else the larger of 6 GiB and half the physical memory, at most the largest host heap less the larger of 2 GiB and half that heap (imports, type-3 mirrors and buffers and textures placed in host memory share it; on a 16 GiB machine whose host heap is half the memory, a 2 GiB reserve left too little for the rest). Where the device enables `VK_EXT_memory_budget`, that host heap's reported budget less its other usage and an eighth, in 64 MiB steps, read at most once a second, also bounds the default budget once an import's `vkAllocateMemory` has failed; a set `APS5_HOST_IMPORT_MIB` is never lowered by the report. The report's other usage includes the host-visible copies and heap mirrors that stand in for refused imports, so it grows with the refusals; the first time the report would lower the budget, `[gpu] host import budget: host heap ...` prints the heap's reported budget and usage and the live imports. An import's `vkAllocateMemory` failure within an eighth of the budget, or the third in a row, lowers the budget to the live imports until the device or the configured budget changes; an isolated failure further below only marks its range failed. Live imports are not capped by `maxMemoryAllocationCount`: NVIDIA's Windows driver reports 4096, while Village held 27,167 live imports without a failure. A new import beyond it retires the least recently used imports not used in the last 256 lookups; one that would not fit even then is refused before anything is retired, and imports made when a range is mapped never evict. A heap mirror, sparse or not, whose range has left the write watch is refreshed in full at each use, every block counted as written; a build plans its range again only once the import path takes the range. A request that may not evict (an address-based build, an import when a range is mapped) remembers a refused range or block, with the live bytes and the budget at the refusal, and is answered from that memory, still counted as refused, until live imports drop below those bytes, the budget changes or one of its ranges changes. A request that may evict remembers its refusal too, answering later requests for the same run from memory with the room the refusal left (a request whose range fits in that room steps down to a smaller block without scanning the imports again, and a refusal that stepped down is remembered for each block it tried), while no import was made or retired, the budget has not grown, the import window and the run are unchanged, and no import can have become evictable since: the use count stays below the first use at which an import that was neither evictable nor held could leave the 256-lookup protection, and either the build floor has not risen or no held import could leave it yet. A join remembers its refusal the same way, apart from other requests for the same run, so a buffer bound across refused ranges does not try the join again at every upload. The shutdown line `[gpu] host imports at shutdown` gives the live, made, refused (and remembered) and failed counts per requester. The 64 MiB window, the 256-lookup protection, the 2 GiB reserve, the eighth, the 64 MiB steps and the three failures are choices, not measured. - [Periodic pipeline cache saves skip busy intervals](../../../core/libs/prx/libSceAgcDriver/Graphics/src/PipelineCache.cpp) (libSceAgcDriver) - the saver thread snapshots the Vulkan pipeline cache only when no pipeline creation holds it (`TryLockForSnapshot`); otherwise it skips that 10 s interval. A blocking snapshot queued behind a long creation (an NVIDIA compile of a 1.5M-word compute pipeline runs for minutes) makes the writer-preferring `shared_mutex` hold every later pipeline creation on every queue until that compile ends, which froze Yōtei's (PPSA26344) loading at frame 797-799 for the whole compile. While creations overlap without a gap, no periodic save happens, so a crash in that stretch loses the pipelines built since the last save; `SaveNow` and the teardown save still wait for the lock. How often busy stretches skip saves in practice is not measured. - [Registration preparation at submit](../../../core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/Submission.cpp) (libSceAgcDriver) - `Driver::Submit` waits for every registration-time shader preparation queued before the call, not only those of the shaders the commands use, so a submission behind a long queue of unrelated preparations waits for all of them on the submitting thread. Waiting there instead of in the queue worker keeps the GPU side of a submission short: a title that sees a submission take seconds treats the GPU as hung (Goat Simulator 3, PPSA04159, then stops submitting and its garbage collector fails a 10 s timeout). On the console shaders are compiled ahead of time, so neither wait exists; how long a title tolerates a slow submit call is not known. - [Resource cache entries holding released textures](../../../core/libs/prx/libSceAgcDriver/Graphics/src/ShaderResources.cpp) (libSceAgcDriver) - a sampled texture the texture cache drops while something else holds it, or makes without caching (`admitTexture` refusing a first use), is marked released, and `ResourceCache::Insert` refuses descriptor sets that hold one and removes the entries that do. The removal waits for the next insert: until then `Find` still returns those entries and their textures stay alive. One process-wide flag starts the sweep, so while the sampled cache is over its budget every insert scans all entries (1,024 at start, up to 16,384 after growth, or `APS5_RESOURCE_CACHE_ENTRIES`, each with its textures); that cost is not measured. When `ReleaseTextureCaches` runs on device memory exhaustion, the sampled textures these entries hold are only marked released and are freed at the next insert, so the immediate allocation retry gets no memory back from them. A draw or dispatch whose textures the cache refused builds its descriptor sets again on each use for as long as the cache stays full; the count bounds (`APS5_RESOURCE_CACHE_ENTRIES`, `APS5_RESOURCE_CACHE_TABLE_ELEMENTS`) still bound nothing by bytes, and sampled textures made outside the texture cache (the disabled cache, depth-surface copies) are never marked. Measured on one title on one RTX 4090: with the default bounds, sampled textures held outside the cache peaked at 2,836 MiB (7,830 MiB before) and device-local heap 0 stayed under its 22.9 GB budget for the whole run (26,357 MiB before, which aborted with `VK_ERROR_OUT_OF_DEVICE_MEMORY` at the wolf scene). Whether the extra descriptor-set builds cost frame time elsewhere is not measured.