From 4ee79e522e8717c42d7ddc155747986e328413b2 Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:45:27 +0000 Subject: [PATCH 1/5] perf(agc): remember an evicting import request's budget refusal At a full host import budget with nothing evictable, a request that may evict tried again on every use, and each try walked every live import to add up what could be evicted. In a title's first scene at its 6400 MiB budget that was 37,627 refusals per present, each one a walk over about 4,400 imports, while the address-based builds kept every import held. Such a request now remembers its final refusal, the one with no smaller block left to try. Later requests for the same run, from the same range, are answered from that memory, still counted as refused and remembered, until an import is made or retired, the budget or the import window changes, the run's ranges change, or an import could have become evictable. An import becomes evictable only once the use count passes its last use by the 256-use protection, and a held one also needs a higher build floor, so the walk records the first such use among the imports it could not evict, held ones apart: the memory survives a new build while no held import can age out yet. Requests that may not evict keep their behaviour. --- .../Graphics/src/GuestBufferMemory.cpp | 56 +++++++- .../libSceAgcDriver/tests/BdaResources.cpp | 128 ++++++++++++++++++ docs/dev/debt/libSceAgcDriver.md | 2 +- 3 files changed, 178 insertions(+), 8 deletions(-) diff --git a/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp b/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp index 5a19619e3d97..5bc936dd8c21 100644 --- a/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp +++ b/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp @@ -168,6 +168,20 @@ 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 containing; + std::uint64_t window; + std::uint64_t floor; + std::uint64_t ready; + std::uint64_t readyHeld; +}; + // Imports persist across draws, keyed by allocation base, and are dropped when their allocation leaves // the registered set; a dropped import is destroyed once the recorded work that may read it completed. struct HostImports { @@ -184,6 +198,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 @@ -972,9 +987,14 @@ bool pointerHeld(const HostImports& state, const HostImport& entry) { return entry.lastUse >= 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) && entry.lastUse + protectedUses < state.useCounter; + return !pointerHeld(state, entry) && entry.lastUse + ProtectedUses < state.useCounter; +} + +bool evictingRefusalCurrent(const HostImports& state, const EvictingRefusal& refusal, std::uint64_t budget) { + return refusal.epoch == state.epoch && refusal.liveBytes == state.liveBytes && refusal.budget == 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) { @@ -1062,10 +1082,16 @@ bool importsStale(const Context& context, const HostImports& state); 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.bytes; + if (entry.base + entry.bytes > begin && entry.base < end) continue; + if (evictable(state, entry)) { + bytes += entry.bytes; + continue; + } + auto& horizon = pointerHeld(state, entry) ? readyHeld : ready; + horizon = std::min(horizon, entry.lastUse + ProtectedUses + 1); } return bytes; } @@ -1122,6 +1148,18 @@ const HostImport* importRunAt(const Context& context, HostImports& state, Import if (sameRun(failed->second, run)) return nullptr; failures.erase(failed); } + const auto budget = importBudget(context); + 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 containingAddress = containing != run.ranges.end() ? (*containing)->address : std::numeric_limits::max(); + if (const auto remembered = state.evictingRefusals.find({base, bytes}); evict && !joined && remembered != state.evictingRefusals.end()) { + if (remembered->second.containing == containingAddress && evictingRefusalCurrent(state, remembered->second, budget) && sameRun(remembered->second, run)) { + ++counts.refused; + counts.refusedBytes += bytes; + ++counts.remembered; + return nullptr; + } + state.evictingRefusals.erase(remembered); + } // The imports this one replaces overlap it; none may be held by a build, and the import must fit the budget (with // what may be evicted) before any of them is retired. auto first = state.imports.lower_bound(base); @@ -1135,9 +1173,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.bytes; } - const auto budget = importBudget(context); 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); @@ -1159,10 +1198,10 @@ const HostImport* importRunAt(const Context& context, HostImports& state, Import } std::fprintf(stderr, "[gpu] host import budget: %llu MiB live of %llu MiB (APS5_HOST_IMPORT_MIB); %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), 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 (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}); + if (evict && !joined) 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, containingAddress, importWindow.load(std::memory_order_relaxed), state.buildFloor, ready, readyHeld}); return nullptr; } for (auto it = first; it != state.imports.end() && it->first < base + bytes;) { @@ -1318,6 +1357,7 @@ void refreshImports(const Context& context, HostImports& state, const GuestAlloc state.failedJoins.clear(); state.failedBlocks.clear(); state.refusals.clear(); + state.evictingRefusals.clear(); state.sparseRunEnds.clear(); state.sparseRunJoins.clear(); state.device = context.device; @@ -1354,6 +1394,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* findImport(HostImports& state, std::uint64_t begin, std::uint64_t end) { @@ -2405,6 +2446,7 @@ void ClearHostImports(VkDevice device) { state.failedJoins.clear(); state.failedBlocks.clear(); state.refusals.clear(); + state.evictingRefusals.clear(); state.device = VK_NULL_HANDLE; state.refreshedGeneration = 0; ++state.epoch; diff --git a/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp b/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp index 78fb9d3ccb8b..f8471ef131e3 100644 --- a/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp @@ -1095,6 +1095,130 @@ 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"); + { + 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 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 spaceReadAcrossImportsTests(const Context& context, const BdaTestAccess& access) { constexpr std::size_t piece = 65536; constexpr std::size_t window = 2 * piece; @@ -2200,6 +2324,10 @@ void RunBdaResourceTests(const Context& context, const BdaTestAccess& access) { importBlockFailureTests(context, access); importBlockBudgetTests(context); importRefusalMemoryTests(context); + evictingRefusalMemoryTests(context); + evictingRefusalAgingTests(context); + evictingRefusalRangeTests(context); + evictingRefusalWindowTests(context); heapMirrorTests(context, access); importedHeapMirrorTests(context, access); importWatchTests(); diff --git a/docs/dev/debt/libSceAgcDriver.md b/docs/dev/debt/libSceAgcDriver.md index e8ad0b11db15..52129e78ab28 100644 --- a/docs/dev/debt/libSceAgcDriver.md +++ b/docs/dev/debt/libSceAgcDriver.md @@ -148,5 +148,5 @@ - [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. Minecraft (PPSA17221) registers the graphics event, never requests an interrupt in its releases, and waits on the event after every flip while holding its render lock, so without this it never presents a second frame. Whether the console raises the event from the flip itself or from an interrupt its flip sequence contains is not measured; a title that waits only on release interrupts sees one extra event per flip, with `data` counting it. -- [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 2 GiB (imports and type-3 mirrors share it; #167's rule). 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 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 and the 2 GiB reserve 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 2 GiB (imports and type-3 mirrors share it; #167's rule). 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 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 while no import was made or retired, the budget, 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. 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 and the 2 GiB reserve are choices, 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. From 219da69c3232856a4f6ec4d973ac575bace5b49b Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:18:33 +0000 Subject: [PATCH 2/5] perf(agc): keep an evicting refusal remembered while the budget shrinks The remembered refusal of a request that may evict expired on any budget change. A smaller budget can only keep the request refused, and #6479 moves the budget in 64 MiB steps once a second, so the memory now ends only when the budget grows, as #6479's stillRefused does. --- .../prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp | 2 +- core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp | 4 ++++ docs/dev/debt/libSceAgcDriver.md | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp b/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp index 5bc936dd8c21..bf26a6609379 100644 --- a/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp +++ b/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp @@ -994,7 +994,7 @@ bool evictable(const HostImports& state, const HostImport& entry) { } bool evictingRefusalCurrent(const HostImports& state, const EvictingRefusal& refusal, std::uint64_t budget) { - return refusal.epoch == state.epoch && refusal.liveBytes == state.liveBytes && refusal.budget == budget && refusal.window == importWindow.load(std::memory_order_relaxed) && state.useCounter < refusal.ready && (state.buildFloor <= refusal.floor || state.useCounter < refusal.readyHeld); + 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) { diff --git a/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp b/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp index f8471ef131e3..97b5489eeb99 100644 --- a/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp @@ -1118,6 +1118,10 @@ void evictingRefusalMemoryTests(const Context& context) { 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)); diff --git a/docs/dev/debt/libSceAgcDriver.md b/docs/dev/debt/libSceAgcDriver.md index 52129e78ab28..faef46dc349d 100644 --- a/docs/dev/debt/libSceAgcDriver.md +++ b/docs/dev/debt/libSceAgcDriver.md @@ -148,5 +148,5 @@ - [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. Minecraft (PPSA17221) registers the graphics event, never requests an interrupt in its releases, and waits on the event after every flip while holding its render lock, so without this it never presents a second frame. Whether the console raises the event from the flip itself or from an interrupt its flip sequence contains is not measured; a title that waits only on release interrupts sees one extra event per flip, with `data` counting it. -- [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 2 GiB (imports and type-3 mirrors share it; #167's rule). 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 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 while no import was made or retired, the budget, 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. 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 and the 2 GiB reserve 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 2 GiB (imports and type-3 mirrors share it; #167's rule). 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 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 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. 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 and the 2 GiB reserve are choices, 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. From cc42ab0198c038577fa8d1fd8255a22a31697050 Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:17:09 +0000 Subject: [PATCH 3/5] perf(agc): answer any range of a refused block from its evicting refusal An evicting refusal was remembered for the range holding the requested address and dropped by a request for another range of the same run, so requests landing in different ranges of one refused block, as uploads and accesses across a block of adjacent ranges do, scanned the imports every time. The memory now keeps the room the refusal left and answers any range of the run that is larger than it: such a range cannot step down to a block of its own either. A smaller range still recomputes, and a run that cannot step down at all is answered for every range. --- .../Graphics/src/GuestBufferMemory.cpp | 7 +++---- .../libSceAgcDriver/tests/BdaResources.cpp | 20 +++++++++++++++++++ docs/dev/debt/libSceAgcDriver.md | 2 +- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp b/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp index 7acb7e3c3c43..30919ed67c33 100644 --- a/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp +++ b/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp @@ -175,7 +175,7 @@ struct EvictingRefusal { std::uint64_t epoch; std::uint64_t liveBytes; std::uint64_t budget; - std::uint64_t containing; + std::uint64_t room; std::uint64_t window; std::uint64_t floor; std::uint64_t ready; @@ -1150,9 +1150,8 @@ const HostImport* importRunAt(const Context& context, HostImports& state, Import } const auto budget = importBudget(context); 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 containingAddress = containing != run.ranges.end() ? (*containing)->address : std::numeric_limits::max(); if (const auto remembered = state.evictingRefusals.find({base, bytes}); evict && !joined && remembered != state.evictingRefusals.end()) { - if (remembered->second.containing == containingAddress && evictingRefusalCurrent(state, remembered->second, budget) && sameRun(remembered->second, run)) { + if ((!stepDown || containing == run.ranges.end() || remembered->second.room < (*containing)->bytes) && evictingRefusalCurrent(state, remembered->second, budget) && sameRun(remembered->second, run)) { ++counts.refused; counts.refusedBytes += bytes; ++counts.remembered; @@ -1203,7 +1202,7 @@ const HostImport* importRunAt(const Context& context, HostImports& state, Import const auto room = budget + evictable > live ? budget + evictable - live : 0; 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}); - if (evict && !joined) 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, containingAddress, importWindow.load(std::memory_order_relaxed), state.buildFloor, ready, readyHeld}); + if (evict && !joined) 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}); return nullptr; } for (auto it = first; it != state.imports.end() && it->first < base + bytes;) { diff --git a/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp b/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp index 2d149b7b16d0..776a740cddc1 100644 --- a/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp @@ -1206,6 +1206,25 @@ void evictingRefusalRangeTests(const Context& context) { 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 evictingRefusalWindowTests(const Context& context) { PieceWindow window(context); constexpr auto piece = PieceWindow::Piece; @@ -2480,6 +2499,7 @@ void RunBdaResourceTests(const Context& context, const BdaTestAccess& access) { evictingRefusalAgingTests(context); evictingRefusalRangeTests(context); evictingRefusalWindowTests(context); + evictingRefusalBlockRangesTests(context); heapMirrorTests(context, access); importedHeapMirrorTests(context, access); importWatchTests(); diff --git a/docs/dev/debt/libSceAgcDriver.md b/docs/dev/debt/libSceAgcDriver.md index cea7af070325..48d1ede05e00 100644 --- a/docs/dev/debt/libSceAgcDriver.md +++ b/docs/dev/debt/libSceAgcDriver.md @@ -156,5 +156,5 @@ - [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. Minecraft (PPSA17221) registers the graphics event, never requests an interrupt in its releases, and waits on the event after every flip while holding its render lock, so without this it never presents a second frame. Whether the console raises the event from the flip itself or from an interrupt its flip sequence contains is not measured; a title that waits only on release interrupts sees one extra event per flip, with `data` counting it. -- [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 2 GiB (imports and type-3 mirrors share it; #167's rule). 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 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 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. 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 and the 2 GiB reserve 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 2 GiB (imports and type-3 mirrors share it; #167's rule). 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 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, for any range of it larger than the room the refusal left (a smaller one may still step down to a block of its own), 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. 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 and the 2 GiB reserve are choices, 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. From cceba477f6e0584aa8b854866d5481b16ab6b908 Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:18:34 +0000 Subject: [PATCH 4/5] perf(agc): remember an evicting join's budget refusal A join's budget refusal was never remembered, so a buffer bound across ranges that the budget could not take tried the join, with a scan of every import, at each upload's pre-pass. A join now remembers its final refusal like any other evicting request, under the same conditions. The memory keeps whether it was a join and answers only the same kind of request, since a join skips the held-import check and never steps down. --- .../Graphics/src/GuestBufferMemory.cpp | 7 +++-- .../libSceAgcDriver/tests/BdaResources.cpp | 28 +++++++++++++++++++ docs/dev/debt/libSceAgcDriver.md | 2 +- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp b/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp index 30919ed67c33..6729e2e216ea 100644 --- a/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp +++ b/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp @@ -180,6 +180,7 @@ struct EvictingRefusal { std::uint64_t floor; std::uint64_t ready; std::uint64_t readyHeld; + bool joined; }; // Imports persist across draws, keyed by allocation base, and are dropped when their allocation leaves @@ -1150,8 +1151,8 @@ const HostImport* importRunAt(const Context& context, HostImports& state, Import } const auto budget = importBudget(context); 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 && !joined && remembered != state.evictingRefusals.end()) { - if ((!stepDown || containing == run.ranges.end() || remembered->second.room < (*containing)->bytes) && evictingRefusalCurrent(state, remembered->second, budget) && sameRun(remembered->second, run)) { + if (const auto remembered = state.evictingRefusals.find({base, bytes}); evict && remembered != state.evictingRefusals.end()) { + if ((!stepDown || containing == run.ranges.end() || remembered->second.room < (*containing)->bytes) && remembered->second.joined == joined && evictingRefusalCurrent(state, remembered->second, budget) && sameRun(remembered->second, run)) { ++counts.refused; counts.refusedBytes += bytes; ++counts.remembered; @@ -1202,7 +1203,7 @@ const HostImport* importRunAt(const Context& context, HostImports& state, Import const auto room = budget + evictable > live ? budget + evictable - live : 0; 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}); - if (evict && !joined) 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}); + 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}); return nullptr; } for (auto it = first; it != state.imports.end() && it->first < base + bytes;) { diff --git a/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp b/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp index 776a740cddc1..2ba8c7361dbb 100644 --- a/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp @@ -1225,6 +1225,33 @@ void evictingRefusalBlockRangesTests(const Context& context) { 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 evictingRefusalWindowTests(const Context& context) { PieceWindow window(context); constexpr auto piece = PieceWindow::Piece; @@ -2500,6 +2527,7 @@ void RunBdaResourceTests(const Context& context, const BdaTestAccess& access) { evictingRefusalRangeTests(context); evictingRefusalWindowTests(context); evictingRefusalBlockRangesTests(context); + evictingRefusalJoinTests(context); heapMirrorTests(context, access); importedHeapMirrorTests(context, access); importWatchTests(); diff --git a/docs/dev/debt/libSceAgcDriver.md b/docs/dev/debt/libSceAgcDriver.md index 48d1ede05e00..ce7e42d03254 100644 --- a/docs/dev/debt/libSceAgcDriver.md +++ b/docs/dev/debt/libSceAgcDriver.md @@ -156,5 +156,5 @@ - [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. Minecraft (PPSA17221) registers the graphics event, never requests an interrupt in its releases, and waits on the event after every flip while holding its render lock, so without this it never presents a second frame. Whether the console raises the event from the flip itself or from an interrupt its flip sequence contains is not measured; a title that waits only on release interrupts sees one extra event per flip, with `data` counting it. -- [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 2 GiB (imports and type-3 mirrors share it; #167's rule). 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 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, for any range of it larger than the room the refusal left (a smaller one may still step down to a block of its own), 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. 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 and the 2 GiB reserve 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 2 GiB (imports and type-3 mirrors share it; #167's rule). 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 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, for any range of it larger than the room the refusal left (a smaller one may still step down to a block of its own), 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 and the 2 GiB reserve are choices, 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. From a48aa7693477e717b27620dc35b097c06d04b859 Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:34:53 +0000 Subject: [PATCH 5/5] perf(agc): step a remembered evicting refusal down without a new scan An evicting request whose block is refused but leaves room for its own range steps down to a smaller block, and only the last block it tried was remembered. Once an address-based build had stepped down inside a block, every later evicting request for the rest of that block scanned every import at the block's level before it reached the memory below. A refusal is now remembered before the request steps down, with the room it left. A remembered refusal answers with the same decision the scan would make: while the memory is current only a lower budget can change that room, so the request steps down to the same smaller block when its range fits in the room, and is refused otherwise. --- .../Graphics/src/GuestBufferMemory.cpp | 6 ++-- .../libSceAgcDriver/tests/BdaResources.cpp | 33 +++++++++++++++++++ docs/dev/debt/libSceAgcDriver.md | 2 +- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp b/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp index 6729e2e216ea..8577d921ef7e 100644 --- a/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp +++ b/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp @@ -1152,10 +1152,12 @@ const HostImport* importRunAt(const Context& context, HostImports& state, Import const auto budget = importBudget(context); 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 ((!stepDown || containing == run.ranges.end() || remembered->second.room < (*containing)->bytes) && remembered->second.joined == joined && evictingRefusalCurrent(state, remembered->second, budget) && sameRun(remembered->second, run)) { + 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); @@ -1201,9 +1203,9 @@ const HostImport* importRunAt(const Context& context, HostImports& state, Import std::fprintf(stderr, "[gpu] host import budget: %llu MiB live of %llu MiB (APS5_HOST_IMPORT_MIB); %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), static_cast(refused), static_cast(refusedBytes >> 20u), static_cast(remembered), static_cast(base), static_cast(bytes), ImportRequesterName(requester)); } 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}); - 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}); return nullptr; } for (auto it = first; it != state.imports.end() && it->first < base + bytes;) { diff --git a/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp b/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp index 2ba8c7361dbb..110011aa9cc4 100644 --- a/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/BdaResources.cpp @@ -1252,6 +1252,38 @@ void evictingRefusalJoinTests(const Context& context) { 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; @@ -2528,6 +2560,7 @@ void RunBdaResourceTests(const Context& context, const BdaTestAccess& access) { evictingRefusalWindowTests(context); evictingRefusalBlockRangesTests(context); evictingRefusalJoinTests(context); + evictingRefusalStepDownTests(context); heapMirrorTests(context, access); importedHeapMirrorTests(context, access); importWatchTests(); diff --git a/docs/dev/debt/libSceAgcDriver.md b/docs/dev/debt/libSceAgcDriver.md index ce7e42d03254..2ba0e8b0ef27 100644 --- a/docs/dev/debt/libSceAgcDriver.md +++ b/docs/dev/debt/libSceAgcDriver.md @@ -156,5 +156,5 @@ - [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. Minecraft (PPSA17221) registers the graphics event, never requests an interrupt in its releases, and waits on the event after every flip while holding its render lock, so without this it never presents a second frame. Whether the console raises the event from the flip itself or from an interrupt its flip sequence contains is not measured; a title that waits only on release interrupts sees one extra event per flip, with `data` counting it. -- [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 2 GiB (imports and type-3 mirrors share it; #167's rule). 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 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, for any range of it larger than the room the refusal left (a smaller one may still step down to a block of its own), 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 and the 2 GiB reserve 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 2 GiB (imports and type-3 mirrors share it; #167's rule). 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 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 and the 2 GiB reserve are choices, 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.