[Fix] (DirectVulkan): harden the leak-fix round after adversarial review - pipeline memo now drops at every command-buffer boundary (a flush-loop-memoized pipeline could age out and be destroyed while its submission was in flight), mid-frame drains no longer rewind the arena or advance the cache-aging clocks in presenting apps (gated to every 8th drain since the last Present, so readback/fence-heavy frames neither churn conversions nor shrink the 1024-boundary retire window), render-pass eviction notifies the pipeline cache once per sweep batch instead of once per dying pass, descriptor pools use FREE_DESCRIPTOR_SET_BIT so a destroyed layout's cached sets are freed back and credited instead of abandoning pool slots (the live-layout age sweep that could orphan slots is removed - layout destruction is the sole purge path), and renderbuffer respecify parks the old backing for aged destruction instead of destroying it while possibly in flight

This commit is contained in:
2026-07-27 22:51:26 -04:00
parent d076c29146
commit 8a0a8a0274
8 changed files with 207 additions and 117 deletions
@@ -8,6 +8,7 @@
#include "PipelineFactory.h"
#include <algorithm>
namespace MobileGL::MG_Backend::DirectVulkan {
static const char* PrimitiveTopologyToString(VkPrimitiveTopology topology) {
@@ -297,10 +298,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return evicted;
}
Uint32 PipelineFactory::EvictByRenderPass(VkRenderPass renderPass) {
Uint32 PipelineFactory::EvictByRenderPasses(const Vector<VkRenderPass>& renderPasses) {
if (renderPasses.empty() || m_cache.empty()) {
return 0;
}
// Sorted-batch membership test keeps a mass eviction (shader-pack switch,
// dimension exit) at one O(cache * log batch) scan instead of one full scan
// per dying pass.
Vector<VkRenderPass> sortedPasses = renderPasses;
std::sort(sortedPasses.begin(), sortedPasses.end());
Uint32 evicted = 0;
for (auto it = m_cache.begin(); it != m_cache.end();) {
if (it->second.renderPass == renderPass) {
if (std::binary_search(sortedPasses.begin(), sortedPasses.end(), it->second.renderPass)) {
if (it->second.pipeline != VK_NULL_HANDLE) {
vkDestroyPipeline(m_device, it->second.pipeline, nullptr);
}
@@ -311,8 +320,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
if (evicted > 0) {
MGLOG_D("PipelineFactory::EvictByRenderPass: evicted %u pipelines for destroyed render pass",
evicted);
MGLOG_D("PipelineFactory::EvictByRenderPasses: evicted %u pipelines for %zu destroyed render passes",
evicted, sortedPasses.size());
}
return evicted;
}
@@ -70,15 +70,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// VkRenderPassManager::OnPresent's sweep. Returns the number of pipelines
// destroyed so the caller can drop any memoized VkPipeline handle.
Uint32 OnFrameBoundary();
// Destroys every cached pipeline hashed on `renderPass`. Only safe when the
// caller guarantees GPU idleness for them - the render-pass manager calls this
// (via the renderer) for passes its own >1024-boundary-idle sweep just evicted,
// and a pipeline hashed on that handle is only ever bound by draws that also
// hit the render-pass entry. Also closes the handle-recycling hazard: a
// recycled VkRenderPass value must never serve a stale pipeline. Returns the
// number destroyed (callers invalidate memos when non-zero); linear scan is
// fine, evictions are rare.
Uint32 EvictByRenderPass(VkRenderPass renderPass);
// Destroys every cached pipeline hashed on one of `renderPasses`. Only safe
// when the caller guarantees GPU idleness for them - the render-pass manager
// calls this (via the renderer) for passes its own >1024-boundary-idle sweep
// just evicted, and a pipeline hashed on those handles is only ever bound by
// draws that also hit the render-pass entries. Also closes the handle-recycling
// hazard: a recycled VkRenderPass value must never serve a stale pipeline.
// Batched: one cache scan regardless of how many passes died in the sweep.
// Returns the number destroyed (callers invalidate memos when non-zero).
Uint32 EvictByRenderPasses(const Vector<VkRenderPass>& renderPasses);
// Destroys every cached pipeline built from the program with content hash
// `programHash`. Called from the ProgramFactory eviction path, which proves the
// same >1024-boundary idleness (the program's pipelines are only bound by draws
@@ -171,7 +171,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
m_programFactory = nullptr;
m_device = VK_NULL_HANDLE;
m_minDynamicOffsetAlignment = 1;
m_frameCounter = 0;
m_frameCount = 0;
m_maxBindings = 0;
m_setsPerFrame = 0;
@@ -216,49 +215,33 @@ namespace MobileGL::MG_Backend::DirectVulkan {
SizeT purgedSets = 0;
for (auto& frame : m_frames) {
const auto it = frame.descriptorSetCacheByLayout.find(descriptorSetLayout);
if (it != frame.descriptorSetCacheByLayout.end()) {
purgedSets += it->second.sets.size();
frame.descriptorSetCacheByLayout.erase(it);
if (it == frame.descriptorSetCacheByLayout.end()) {
continue;
}
// Free the sets back to their pools and credit the bucket accounting, so
// program churn recycles pool capacity instead of abandoning the slots.
// GPU-safe: the layout only dies after >1024 idle frame boundaries, so no
// in-flight command buffer references these sets.
for (const auto& cached : it->second.sets) {
if (cached.set == VK_NULL_HANDLE) {
continue;
}
vkFreeDescriptorSets(m_device, cached.pool, 1, &cached.set);
const auto bucket = std::find_if(
frame.descriptorPools.begin(), frame.descriptorPools.end(),
[&cached](const DescriptorPoolBucket& candidate) { return candidate.handle == cached.pool; });
if (bucket != frame.descriptorPools.end() && bucket->allocatedSets > 0) {
--bucket->allocatedSets;
}
}
purgedSets += it->second.sets.size();
frame.descriptorSetCacheByLayout.erase(it);
}
if (purgedSets > 0) {
// The per-draw reuse memo folds the layout handle into its signature; drop
// it so a recycled handle value cannot revive a purged set mid-frame.
m_hasLastDescriptor = false;
MGLOG_D("UniformDescriptorBinder: purged %zu descriptor sets for destroyed layout", purgedSets);
}
}
void UniformManager::OnFrameBoundary() {
++m_frameCounter;
// Sweep cadence and retire age mirror VkRenderPassManager::OnPresent. Only the
// CPU-side tracking is reclaimed here: the sets' pool slots stay occupied until
// Shutdown destroys the pools (no FREE_DESCRIPTOR_SET_BIT, no mid-life pool
// reset), exactly as they would had the entry been kept. What the sweep buys is
// that an idle layout's tracking vectors stop accumulating, and that a
// destroyed-then-recycled layout handle finds no stale entry to hit.
constexpr Uint64 kSweepInterval = 256;
constexpr Uint64 kRetireAgeFrames = 1024;
if ((m_frameCounter % kSweepInterval) != 0) {
return;
}
SizeT purgedSets = 0;
for (auto& frame : m_frames) {
for (auto it = frame.descriptorSetCacheByLayout.begin();
it != frame.descriptorSetCacheByLayout.end();) {
if (m_frameCounter - it->second.lastUsedFrame > kRetireAgeFrames) {
purgedSets += it->second.sets.size();
it = frame.descriptorSetCacheByLayout.erase(it);
} else {
++it;
}
}
}
if (purgedSets > 0) {
m_hasLastDescriptor = false;
MGLOG_D("UniformDescriptorBinder::OnFrameBoundary: dropped %zu idle descriptor sets", purgedSets);
MGLOG_D("UniformDescriptorBinder: freed %zu descriptor sets for destroyed layout", purgedSets);
}
}
@@ -962,6 +945,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkDescriptorPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
// FREE_DESCRIPTOR_SET_BIT lets a destroyed layout's cached sets be freed back
// (OnDescriptorSetLayoutDestroyed) so program churn recycles pool capacity.
// The cost is on set allocation only, which happens when a layout's per-frame
// cache grows - never on the per-draw reuse path.
poolInfo.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT;
poolInfo.maxSets = maxSets;
poolInfo.poolSizeCount = static_cast<Uint32>(std::size(poolSizes));
poolInfo.pPoolSizes = poolSizes;
@@ -1040,12 +1028,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkDescriptorSet& outDescriptorSet) {
auto& frame = m_frames[frameIndex];
auto& cache = frame.descriptorSetCacheByLayout[programObj.descriptorSetLayout];
// Every layout used in a frame is acquired at least once (the per-draw
// descriptor-reuse memo starts each frame invalidated and folds the layout
// into its signature), so an actively-used entry is stamped every frame.
cache.lastUsedFrame = m_frameCounter;
if (cache.cursor < cache.sets.size()) {
outDescriptorSet = cache.sets[cache.cursor++];
outDescriptorSet = cache.sets[cache.cursor++].set;
} else {
VkResult allocResult = AllocateDescriptorSetsFromActivePool(frameIndex, programObj, outDescriptorSet);
if (allocResult == VK_ERROR_OUT_OF_POOL_MEMORY || allocResult == VK_ERROR_FRAGMENTED_POOL) {
@@ -1059,7 +1043,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return allocResult;
}
cache.sets.push_back(outDescriptorSet);
// The successful allocation came from the bucket the alloc helper left
// active; record it so a layout-destroyed purge can free the set back.
cache.sets.push_back({outDescriptorSet, frame.descriptorPools[frame.activeDescriptorPoolIndex].handle});
++cache.cursor;
MGLOG_D("UniformDescriptorBinder: cached descriptor set count for frame=%u grew to %zu", frameIndex,
cache.sets.size());
@@ -41,16 +41,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
void BeginFrame(Uint32 frameIndex);
// A ProgramFactory eviction just destroyed this layout: purge every frame
// slot's cached descriptor sets for it, so a recycled handle value can never
// stale-hit sets written for the dead layout's bindings. The sets themselves
// are only dropped, never freed - the frame pools are created without
// FREE_DESCRIPTOR_SET_BIT and are never reset mid-life, so their pool slots
// stay occupied until Shutdown destroys the pools wholesale.
// stale-hit sets written for the dead layout's bindings. The sets are
// vkFreeDescriptorSets'd back to their pools (created with
// FREE_DESCRIPTOR_SET_BIT) and the pool accounting is credited, so program
// churn recycles pool capacity instead of abandoning it. GPU-safe: the layout
// only dies after >1024 idle frame boundaries, so no in-flight command buffer
// references its sets. This is the only eviction path for the per-layout
// caches - a live layout's entry must never be purged (its sets would be
// unreachable pool slots), so there is deliberately no age-based sweep here.
void OnDescriptorSetLayoutDestroyed(VkDescriptorSetLayout descriptorSetLayout);
// Frame boundary hook: ages the per-layout descriptor-set caches and drops
// long-unused entries (mirroring VkRenderPassManager::OnPresent's sweep), so a
// shader-churn session's peak layout population does not pin its tracking
// vectors forever. Same pool-slot caveat as above.
void OnFrameBoundary();
Bool CollectSampledTextures(const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj,
Vector<MG_State::GLState::ITextureObject*>& outTextures);
@@ -77,12 +76,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Uint32 allocatedSets = 0;
};
// A cached descriptor set together with the pool it was allocated from, so a
// layout-destroyed purge can vkFreeDescriptorSets it back and credit the
// owning bucket's accounting.
struct CachedDescriptorSet {
VkDescriptorSet set = VK_NULL_HANDLE;
VkDescriptorPool pool = VK_NULL_HANDLE;
};
struct DescriptorSetCacheEntry {
Vector<VkDescriptorSet> sets;
Vector<CachedDescriptorSet> sets;
Uint32 cursor = 0;
// Frame-boundary counter value of the last AcquireDescriptorSet hit; drives
// cache eviction (see OnFrameBoundary).
Uint64 lastUsedFrame = 0;
};
struct FrameResources {
@@ -146,9 +150,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Vector<FrameResources> m_frames;
VkDeviceSize m_minDynamicOffsetAlignment = 1;
// Monotonic frame-boundary counter (bumped in OnFrameBoundary) for
// descriptor-set cache aging.
Uint64 m_frameCounter = 0;
Uint32 m_frameCount = 0;
Uint32 m_maxBindings = 0;
Uint32 m_setsPerFrame = 0;
@@ -207,6 +207,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
resource.Destroy(m_device, m_allocator);
}
m_renderbufferResources.clear();
CollectDeferredRenderbufferReleases(/*destroyAll=*/true); // caller guarantees device idle
m_pendingRenderbufferClears.clear();
RenderPassEntry::s_textureResourcesScratch.clear();
s_activeRenderPass = {};
@@ -214,19 +215,55 @@ namespace MobileGL::MG_Backend::DirectVulkan {
m_rpFastValid = false;
}
Uint64 VkRenderPassManager::RetireAgeFrames() const {
// MaxFramesInFlight + 2 covers the frame ring plus one boundary for the
// recording-to-submit gap and one because OnPresent runs ahead of Present's
// fence wait; the floor of 8 keeps a margin over the default ring of 3 while
// still releasing multi-MB attachment memory promptly (the render-pass cache's
// 1024-frame retirement would pin it for no additional safety).
return std::max<Uint64>(8, static_cast<Uint64>(m_config.MaxFramesInFlight) + 2);
}
void VkRenderPassManager::DeferRenderbufferBackingRelease(RenderbufferResource& resource) {
// The superseded backing may still be referenced by in-flight command buffers
// (glRenderbufferStorage can respecify a renderbuffer drawn this very frame),
// so it is parked and destroyed only after RetireAgeFrames() boundaries.
if (resource.image == VK_NULL_HANDLE && resource.view == VK_NULL_HANDLE) {
return;
}
m_deferredRenderbufferReleases.push_back({resource.image, resource.allocation, resource.view, m_frameCounter});
resource.image = VK_NULL_HANDLE;
resource.allocation = nullptr;
resource.view = VK_NULL_HANDLE;
}
void VkRenderPassManager::CollectDeferredRenderbufferReleases(Bool destroyAll) {
if (m_deferredRenderbufferReleases.empty()) {
return;
}
const Uint64 retireAgeFrames = RetireAgeFrames();
std::erase_if(m_deferredRenderbufferReleases, [&](DeferredRenderbufferRelease& release) {
if (!destroyAll && m_frameCounter - release.deferredAtFrame < retireAgeFrames) {
return false;
}
if (release.view != VK_NULL_HANDLE) {
vkDestroyImageView(m_device, release.view, nullptr);
}
if (release.image != VK_NULL_HANDLE) {
vmaDestroyImage(m_allocator, release.image, release.allocation);
}
return true;
});
}
void VkRenderPassManager::CollectRenderbufferGarbage() {
// Two-phase reclamation: a dead renderbuffer's VkImage may still be referenced by
// command buffers submitted up to frames-in-flight frames ago (it was legally
// attached and drawn right up to its deletion), so the first observation of an
// expired weak reference only stamps the current frame counter; Destroy runs once
// enough frame boundaries have passed that the stamping frame's submission fence
// has provably been waited. MaxFramesInFlight + 2 covers the frame ring plus one
// boundary for the recording-to-submit gap and one because OnPresent runs ahead
// of Present's fence wait; the floor of 8 keeps a margin over the default ring of
// 3 while still releasing multi-MB attachment memory promptly (the render-pass
// cache's 1024-frame retirement would pin it for no additional safety).
const Uint64 retireAgeFrames =
std::max<Uint64>(8, static_cast<Uint64>(m_config.MaxFramesInFlight) + 2);
// has provably been waited (see RetireAgeFrames).
const Uint64 retireAgeFrames = RetireAgeFrames();
for (auto it = m_renderbufferResources.begin(); it != m_renderbufferResources.end();) {
auto& resource = it->second;
const auto liveRenderbuffer = resource.renderbuffer.lock();
@@ -294,6 +331,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return &resource;
}
// Respecify: park the old backing for aged destruction instead of destroying
// inline - it may still be referenced by in-flight command buffers.
DeferRenderbufferBackingRelease(resource);
resource.Destroy(m_device, m_allocator);
resource.renderbuffer = renderbuffer;
@@ -1205,6 +1245,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// coupling it to renderbuffer *use* (the GetOrCreateRenderbufferResource call
// site never runs again once an app stops using renderbuffers).
CollectRenderbufferGarbage();
CollectDeferredRenderbufferReleases(/*destroyAll=*/false);
// Sweep occasionally; evict entries whose last use is far past every
// in-flight frame so their VkRenderPass/VkFramebuffer can be destroyed
@@ -1215,6 +1256,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return;
}
// Collect the dying handles and notify once after the loop: pipelines hashed
// on them share the entries' >kRetireAgeFrames idleness (they are only bound
// by draws that hit those entries), so the observer may destroy them
// immediately - and a single batched notification costs one pipeline-cache
// scan instead of one per evicted pass.
Vector<VkRenderPass> destroyedRenderPasses;
const Uint64 activeHash = s_hasActiveRenderPass ? s_activeRenderPass.hash : 0;
for (auto it = m_renderPasses.begin(); it != m_renderPasses.end();) {
const Bool isActive = s_hasActiveRenderPass && it->first == activeHash;
@@ -1222,17 +1269,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (m_rpFastValid && m_rpFastRenderPassHash == it->first) {
m_rpFastValid = false;
}
// Notify while the handle is still alive: pipelines hashed on it share
// the entry's >kRetireAgeFrames idleness (they are only bound by draws
// that hit this entry), so the observer may destroy them immediately.
if (m_evictionObserver != nullptr) {
m_evictionObserver->OnRenderPassDestroyed(it->second.renderPass);
}
destroyedRenderPasses.push_back(it->second.renderPass);
it = m_renderPasses.erase(it);
} else {
++it;
}
}
if (!destroyedRenderPasses.empty() && m_evictionObserver != nullptr) {
m_evictionObserver->OnRenderPassesDestroyed(destroyedRenderPasses);
}
}
Bool VkRenderPassManager::BeginRenderPass(VkCommandBuffer commandBuffer, RenderPassEntry& renderPassEntry) {
@@ -158,16 +158,20 @@ namespace MobileGL::MG_Backend::DirectVulkan {
public:
using HashType = Uint64;
// Notified from the OnPresent sweep for each aged-out entry, BEFORE the entry
// destructor destroys its VkRenderPass: pipelines are hashed on the raw handle,
// and once destroyed the value may be recycled for an incompatible pass, so
// dependent caches must purge everything keyed on it in the same step. The
// wholesale paths (Shutdown/RecreateSwapchain) do not notify - their callers
// already drop every pipeline outright.
// Notified once per OnPresent sweep with every aged-out entry's VkRenderPass
// value: pipelines are hashed on the raw handle, and once destroyed the value
// may be recycled for an incompatible pass, so dependent caches must purge
// everything keyed on them before any new pass can be created (the sweep and
// the notification run back-to-back with no creation in between; observers
// compare the values, never dereference them). Batched so a mass-idle cohort
// (shader-pack switch, dimension exit) costs the observer one pipeline-cache
// scan, not one per dying pass. The wholesale paths
// (Shutdown/RecreateSwapchain) do not notify - their callers already drop
// every pipeline outright.
class IEvictionObserver {
public:
virtual ~IEvictionObserver() = default;
virtual void OnRenderPassDestroyed(VkRenderPass renderPass) = 0;
virtual void OnRenderPassesDestroyed(const Vector<VkRenderPass>& renderPasses) = 0;
};
VkRenderPassManager(VkDevice device,
@@ -266,12 +270,28 @@ namespace MobileGL::MG_Backend::DirectVulkan {
ClearAttachmentPayload payload{};
};
// A superseded renderbuffer backing (glRenderbufferStorage respecify) parked
// until enough frame boundaries have passed that no in-flight command buffer
// can still reference it; destroyed in OnPresent (see RetireAgeFrames).
struct DeferredRenderbufferRelease {
VkImage image = VK_NULL_HANDLE;
VmaAllocation allocation = nullptr;
VkImageView view = VK_NULL_HANDLE;
Uint64 deferredAtFrame = 0;
};
UnorderedMap<MG_State::GLState::RenderbufferObject*, RenderbufferResource> m_renderbufferResources;
UnorderedMap<MG_State::GLState::RenderbufferObject*, PendingRenderbufferClear> m_pendingRenderbufferClears;
Vector<DeferredRenderbufferRelease> m_deferredRenderbufferReleases;
Bool HasPendingRenderbufferClear(
const MG_State::GLState::FramebufferAttachmentObject& attachment) const;
void CollectRenderbufferGarbage();
// Frame-boundary margin after which a resource last referenced by a retired
// GL object (or superseded backing) is provably past every in-flight frame.
Uint64 RetireAgeFrames() const;
void DeferRenderbufferBackingRelease(RenderbufferResource& resource);
void CollectDeferredRenderbufferReleases(Bool destroyAll);
static inline XXH64_state_t* m_hashState = XXH64_createState();
static inline ActiveRenderPassInfo s_activeRenderPass{};
@@ -7228,11 +7228,10 @@ void main() {
}
// Every submission is complete and nothing recorded references the
// per-frame transients, so the drains Present's tail performs are safe
// here too. Raise the buffer manager's completed floor first: the
// serial inference (frameSerial - frameCount) is only justified by
// Present's slot-fence cadence, and the extra BeginFrame below would
// otherwise inflate it past reality.
// per-frame transients. Pure-reclaim work runs on every drain: it only
// releases memory that is provably dead, never invalidates anything a
// later draw would have to rebuild. Raise the buffer manager's
// completed floor first so busy-tracking reflects the proven idleness.
m_bufferManager.NotifyDeviceIdle();
const Uint32 frameIndex = m_frameContext.GetCurrentFrameIndex();
@@ -7242,23 +7241,38 @@ void main() {
}
if (m_textureManager) {
m_textureManager->CollectAllDeferredReleases();
m_textureManager->BeginFrame(frameIndex);
}
m_bufferManager.CollectAllDeferredReleases();
// Descriptor cursors rewind on every drain (the pre-drain readback path
// already did exactly this), keeping fence/readback loops' set usage bounded.
if (m_uniformManager) {
m_uniformManager->BeginFrame(frameIndex);
}
// Frame-boundary-equivalent work - transient arena rewind (which invalidates
// the conversion cache) and the cache-aging clocks - is gated to every 8th
// drain since the last Present: a presenting app's mid-frame readbacks/waits
// must neither force re-conversion/re-upload churn for the rest of the frame
// nor multiply the aging rate (which would shrink the 1024-boundary retire
// window and thrash periodically-used pipelines/programs), while present-less
// loops still rewind the arena and age their caches every 8 iterations -
// bounded by 8 iterations' transient usage.
++m_drainsSinceLastPresent;
if ((m_drainsSinceLastPresent % 8) != 0) {
return true;
}
if (m_textureManager) {
m_textureManager->BeginFrame(frameIndex);
}
m_bufferManager.BeginFrame(frameIndex);
// The cached conversion slices point into the transient arena the
// BeginFrame above just rewound; drop them together.
m_convertedVertexStreams.clear();
if (m_uniformManager) {
m_uniformManager->BeginFrame(frameIndex);
}
if (m_renderPassManager) {
m_renderPassManager->OnPresent();
}
// Present-less loops cross frame boundaries here, so the content-addressed
// caches age on the same cadence as Present's tail. The pipeline memo can
// survive across these boundaries (no per-frame reset on this path), so it
// must drop whenever the sweep destroys anything.
// The pipeline memo can survive across these boundaries (no per-frame reset
// on this path), so it must drop whenever the sweep destroys anything.
if (m_programFactory) {
m_programFactory->OnFrameBoundary();
}
@@ -7266,9 +7280,6 @@ void main() {
m_lastPipelineValid = false;
m_lastPipelineResult = VK_NULL_HANDLE;
}
if (m_uniformManager) {
m_uniformManager->OnFrameBoundary();
}
if (m_vertexInputStateFactory) {
m_vertexInputStateFactory->OnFrameBoundary();
}
@@ -7366,6 +7377,14 @@ void main() {
return false;
}
// Command-buffer boundary: the pipeline memo must not survive it, or a
// pipeline bound only through memo hits is never re-stamped in the factory
// cache and the aging sweep could destroy it while the flushed submission
// still references it. Mirrors the drops at the readback and Present
// boundaries; costs one full pipeline lookup on the next draw.
m_lastPipelineValid = false;
m_lastPipelineResult = VK_NULL_HANDLE;
// The submitted command buffer may still be executing; recording must
// restart on a fresh one. If none can be allocated, fall back to
// draining this submission so reusing the buffer stays legal.
@@ -7530,16 +7549,20 @@ void main() {
MOBILEGL_ASSERT(m_imageIndexAcquired < m_swapchainObject.GetImageCount(),
"Present, acquired image index out of range");
m_renderPassManager->OnPresent();
// A real presented frame is the canonical aging cadence; mid-frame drains
// count against this and only age when presents stop coming.
m_drainsSinceLastPresent = 0;
// Age the content-addressed caches on the same frame-boundary cadence. Each
// keeps its own internal 256-sweep gate, so the per-frame cost is one counter
// increment and compare per cache; entries used by this frame's still-
// unsubmitted recording were stamped this boundary and can never age out.
// unsubmitted recording were stamped this boundary (every command-buffer
// boundary drops the pipeline memo, so the first draw of each recording
// performs a real, stamping lookup) and can never age out.
m_programFactory->OnFrameBoundary();
if (m_pipelineFactory->OnFrameBoundary() > 0) {
m_lastPipelineValid = false; // an aged-out pipeline may still be memoized
m_lastPipelineResult = VK_NULL_HANDLE;
}
m_uniformManager->OnFrameBoundary();
m_vertexInputStateFactory->OnFrameBoundary();
m_samplerManager->OnFrameBoundary();
auto& frame = m_frameContext.GetCurrent();
@@ -8574,15 +8597,15 @@ void main() {
m_computePipelines.clear();
}
void VulkanRenderer::OnRenderPassDestroyed(VkRenderPass renderPass) {
void VulkanRenderer::OnRenderPassesDestroyed(const Vector<VkRenderPass>& renderPasses) {
if (m_pipelineFactory == nullptr) {
return;
}
// The render-pass sweep's >1024-boundary idle guarantee covers these pipelines
// too (they are only bound by draws that hit the dying entry), so the factory
// too (they are only bound by draws that hit the dying entries), so the factory
// destroys them immediately. The memo must drop as well: it can hand out a
// cached handle without touching the factory.
if (m_pipelineFactory->EvictByRenderPass(renderPass) > 0) {
if (m_pipelineFactory->EvictByRenderPasses(renderPasses) > 0) {
m_lastPipelineValid = false;
m_lastPipelineResult = VK_NULL_HANDLE;
}
@@ -134,11 +134,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// recording, before any render pass.
void OnFrameCommandRecordingBegan(VkCommandBuffer commandBuffer) override;
// VkRenderPassManager::IEvictionObserver: the render-pass aging sweep is
// destroying a VkRenderPass; evict every graphics pipeline hashed on the
// VkRenderPassManager::IEvictionObserver: the render-pass aging sweep just
// destroyed these VkRenderPasses; evict every graphics pipeline hashed on a
// dying handle (they share its >1024-boundary idleness, so immediate
// destruction is safe) and drop the last-pipeline memo if any went.
void OnRenderPassDestroyed(VkRenderPass renderPass) override;
void OnRenderPassesDestroyed(const Vector<VkRenderPass>& renderPasses) override;
// ProgramFactory::IEvictionObserver: an aged-out program entry was
// destroyed; evict its compute pipeline and graphics pipelines (same
@@ -376,6 +376,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Vector<VkFence> m_freeSubmitFences;
Uint64 m_submitCounter = 0;
Uint64 m_completedSubmitCounter = 0;
// Drains since the last Present, gating the drain's frame-boundary-equivalent
// work (arena rewind + cache aging): a presenting app's mid-frame
// readbacks/waits must neither churn the transient caches nor accelerate the
// aging clocks, while present-less loops still cross a boundary every few
// iterations. Reset in Present.
Uint32 m_drainsSinceLastPresent = 0;
NativeWindowType m_window = 0;
void* m_platformDisplay = nullptr;