mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-12 06:08:30 +09:00
[Fix] (DirectVulkan): make texture/renderbuffer GC reach every dead resource - name-deleted textures register via weak_from_this so first-sync-after-delete can no longer orphan a TextureResource, an orphan sweep makes GC authoritative over the resource map, dead-texture pruning moves to a frame-boundary gate (64 frames) so churn through clears/readbacks reclaims without draws, and dead renderbuffers age past frames-in-flight before their VkImage/view is destroyed instead of leaking until shutdown (or being freed while in flight)
This commit is contained in:
@@ -180,6 +180,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
|||||||
sampleCount = VK_SAMPLE_COUNT_1_BIT;
|
sampleCount = VK_SAMPLE_COUNT_1_BIT;
|
||||||
internalFormat = TextureInternalFormat::Unknown;
|
internalFormat = TextureInternalFormat::Unknown;
|
||||||
samples = 0;
|
samples = 0;
|
||||||
|
deadSinceFrame = kNeverObservedDead;
|
||||||
}
|
}
|
||||||
|
|
||||||
VkRenderPassManager::VkRenderPassManager(VkDevice device,
|
VkRenderPassManager::VkRenderPassManager(VkDevice device,
|
||||||
@@ -214,21 +215,38 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void VkRenderPassManager::CollectRenderbufferGarbage() {
|
void VkRenderPassManager::CollectRenderbufferGarbage() {
|
||||||
Vector<MG_State::GLState::RenderbufferObject*> deadRenderbuffers;
|
// Two-phase reclamation: a dead renderbuffer's VkImage may still be referenced by
|
||||||
deadRenderbuffers.reserve(m_renderbufferResources.size());
|
// command buffers submitted up to frames-in-flight frames ago (it was legally
|
||||||
for (auto& [renderbuffer, resource] : m_renderbufferResources) {
|
// 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);
|
||||||
|
for (auto it = m_renderbufferResources.begin(); it != m_renderbufferResources.end();) {
|
||||||
|
auto& resource = it->second;
|
||||||
const auto liveRenderbuffer = resource.renderbuffer.lock();
|
const auto liveRenderbuffer = resource.renderbuffer.lock();
|
||||||
if (!liveRenderbuffer || liveRenderbuffer.get() != renderbuffer) {
|
if (liveRenderbuffer && liveRenderbuffer.get() == it->first) {
|
||||||
deadRenderbuffers.emplace_back(renderbuffer);
|
resource.deadSinceFrame = RenderbufferResource::kNeverObservedDead;
|
||||||
|
++it;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
if (resource.deadSinceFrame == RenderbufferResource::kNeverObservedDead) {
|
||||||
|
resource.deadSinceFrame = m_frameCounter;
|
||||||
|
++it;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
for (auto* renderbuffer : deadRenderbuffers) {
|
if (m_frameCounter - resource.deadSinceFrame < retireAgeFrames) {
|
||||||
auto resourceIt = m_renderbufferResources.find(renderbuffer);
|
++it;
|
||||||
if (resourceIt != m_renderbufferResources.end()) {
|
continue;
|
||||||
resourceIt->second.Destroy(m_device, m_allocator);
|
|
||||||
m_renderbufferResources.erase(resourceIt);
|
|
||||||
}
|
}
|
||||||
m_pendingRenderbufferClears.erase(renderbuffer);
|
m_pendingRenderbufferClears.erase(it->first);
|
||||||
|
resource.Destroy(m_device, m_allocator);
|
||||||
|
it = m_renderbufferResources.erase(it);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +288,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
|||||||
resource.samples != renderbuffer->GetSamples();
|
resource.samples != renderbuffer->GetSamples();
|
||||||
if (!needsCreate) {
|
if (!needsCreate) {
|
||||||
resource.renderbuffer = renderbuffer;
|
resource.renderbuffer = renderbuffer;
|
||||||
|
// A new renderbuffer at a recycled address may adopt a compatible entry that
|
||||||
|
// was already stamped dead; it is alive again, so cancel the aging.
|
||||||
|
resource.deadSinceFrame = RenderbufferResource::kNeverObservedDead;
|
||||||
return &resource;
|
return &resource;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1178,6 +1199,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
|||||||
void VkRenderPassManager::OnPresent() {
|
void VkRenderPassManager::OnPresent() {
|
||||||
++m_frameCounter;
|
++m_frameCounter;
|
||||||
|
|
||||||
|
// Runs every frame boundary, ahead of the render-pass sweep gate below: the walk
|
||||||
|
// is O(#renderbuffer resources) — single digits in practice — and per-frame
|
||||||
|
// invocation keeps dead-resource reclaim latency at the aging bound instead of
|
||||||
|
// coupling it to renderbuffer *use* (the GetOrCreateRenderbufferResource call
|
||||||
|
// site never runs again once an app stops using renderbuffers).
|
||||||
|
CollectRenderbufferGarbage();
|
||||||
|
|
||||||
// Sweep occasionally; evict entries whose last use is far past every
|
// Sweep occasionally; evict entries whose last use is far past every
|
||||||
// in-flight frame so their VkRenderPass/VkFramebuffer can be destroyed
|
// in-flight frame so their VkRenderPass/VkFramebuffer can be destroyed
|
||||||
// safely (RenderPassEntry's destructor releases the handles).
|
// safely (RenderPassEntry's destructor releases the handles).
|
||||||
|
|||||||
@@ -230,6 +230,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
|||||||
|
|
||||||
public:
|
public:
|
||||||
struct RenderbufferResource {
|
struct RenderbufferResource {
|
||||||
|
// deadSinceFrame sentinel: the owning weak reference has not been observed
|
||||||
|
// expired. Dead resources age past every in-flight frame before Destroy
|
||||||
|
// (see CollectRenderbufferGarbage); the GPU may still reference the image
|
||||||
|
// for frames-in-flight frames after the GL object dies.
|
||||||
|
static constexpr Uint64 kNeverObservedDead = UINT64_MAX;
|
||||||
|
|
||||||
WeakPtr<MG_State::GLState::RenderbufferObject> renderbuffer;
|
WeakPtr<MG_State::GLState::RenderbufferObject> renderbuffer;
|
||||||
VkImage image = VK_NULL_HANDLE;
|
VkImage image = VK_NULL_HANDLE;
|
||||||
VmaAllocation allocation = nullptr;
|
VmaAllocation allocation = nullptr;
|
||||||
@@ -241,6 +247,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
|||||||
VkSampleCountFlagBits sampleCount = VK_SAMPLE_COUNT_1_BIT;
|
VkSampleCountFlagBits sampleCount = VK_SAMPLE_COUNT_1_BIT;
|
||||||
TextureInternalFormat internalFormat = TextureInternalFormat::Unknown;
|
TextureInternalFormat internalFormat = TextureInternalFormat::Unknown;
|
||||||
Int samples = 0;
|
Int samples = 0;
|
||||||
|
// m_frameCounter value at which the weak reference was first seen expired.
|
||||||
|
Uint64 deadSinceFrame = kNeverObservedDead;
|
||||||
|
|
||||||
void Destroy(VkDevice device, VmaAllocator allocator);
|
void Destroy(VkDevice device, VmaAllocator allocator);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -627,6 +627,19 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
|||||||
frameIndex, m_deferredViewReleases.size());
|
frameIndex, m_deferredViewReleases.size());
|
||||||
m_currentFrameIndex = frameIndex;
|
m_currentFrameIndex = frameIndex;
|
||||||
CollectDeferredReleases(frameIndex);
|
CollectDeferredReleases(frameIndex);
|
||||||
|
|
||||||
|
// Frame-boundary GC: every 64 frame boundaries (~1 s at 60 fps) bounds the reclaim
|
||||||
|
// latency for dead textures regardless of draw traffic — workloads that churn
|
||||||
|
// textures through clears/readbacks alone never reach the draw-gated
|
||||||
|
// CollectGarbage. Must run after CollectDeferredReleases above: the prune defers
|
||||||
|
// its releases into this frame's slot, which was just drained, so they are
|
||||||
|
// destroyed only after the slot's fence has been waited again one full frame-ring
|
||||||
|
// cycle from now (never while an in-flight frame may still reference them).
|
||||||
|
constexpr Uint32 kGcFrameInterval = 64;
|
||||||
|
++m_gcFrameCounter;
|
||||||
|
if (m_gcFrameCounter % kGcFrameInterval == 0) {
|
||||||
|
PruneDeadTextures();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void VkTextureManager::CollectAllDeferredReleases() {
|
void VkTextureManager::CollectAllDeferredReleases() {
|
||||||
@@ -709,9 +722,22 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
|||||||
// construction introduces a new identity. Doing this unconditionally made every
|
// construction introduces a new identity. Doing this unconditionally made every
|
||||||
// sampled-texture sync scan the entire alive-texture map per draw.
|
// sampled-texture sync scan the entire alive-texture map per draw.
|
||||||
if (aliveIt == m_aliveObjects.end()) {
|
if (aliveIt == m_aliveObjects.end()) {
|
||||||
|
WeakPtr<MG_State::GLState::ITextureObject> aliveTexture;
|
||||||
const auto& liveTexture = MG_State::pGLContext->GetTextureObject(texture.GetExternalIndex());
|
const auto& liveTexture = MG_State::pGLContext->GetTextureObject(texture.GetExternalIndex());
|
||||||
if (liveTexture && liveTexture.get() == &texture) {
|
if (liveTexture && liveTexture.get() == &texture) {
|
||||||
m_aliveObjects[identity] = WeakPtr<MG_State::GLState::ITextureObject>(liveTexture);
|
aliveTexture = liveTexture;
|
||||||
|
} else {
|
||||||
|
// The name lookup legally fails while the object is alive: the name was
|
||||||
|
// deleted with the texture still attached to an FBO (the attachment's
|
||||||
|
// SharedPtr keeps it alive), or the name was reused by a new texture, or
|
||||||
|
// this is a default texture object (name 0 lives outside the name map).
|
||||||
|
// Register through the object's own control block so the resource created
|
||||||
|
// below still participates in weak-expiry GC instead of becoming an
|
||||||
|
// orphan no reclamation path can reach until Shutdown.
|
||||||
|
aliveTexture = texture.weak_from_this();
|
||||||
|
}
|
||||||
|
if (!aliveTexture.expired()) {
|
||||||
|
m_aliveObjects[identity] = Move(aliveTexture);
|
||||||
PruneStaleTextureAliases(&texture);
|
PruneStaleTextureAliases(&texture);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1219,10 +1245,22 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SizeT VkTextureManager::CollectGarbage() {
|
SizeT VkTextureManager::CollectGarbage() {
|
||||||
|
// Draw-gated stagger (1 in 256 calls): keeps the per-draw cost at one counter
|
||||||
|
// bump. The guaranteed reclaim path is the frame-boundary prune in BeginFrame;
|
||||||
|
// this remains as a cheap assist so draw-heavy workloads reclaim sooner.
|
||||||
m_gcCounter++;
|
m_gcCounter++;
|
||||||
if (m_gcCounter != 0) {
|
if (m_gcCounter != 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
return PruneDeadTextures();
|
||||||
|
}
|
||||||
|
|
||||||
|
SizeT VkTextureManager::PruneDeadTextures() {
|
||||||
|
// Erasing entries would dangle the raw TextureResource pointers memoized for the
|
||||||
|
// current draw; every call path (BeginFrame, and CollectGarbage at the top of a
|
||||||
|
// freshly opened draw-sync scope) runs before any memo entry is recorded.
|
||||||
|
MOBILEGL_ASSERT(m_drawSyncedThisDraw.empty(),
|
||||||
|
"PruneDeadTextures: draw-sync memo holds raw resource pointers an erase would dangle");
|
||||||
|
|
||||||
Vector<MG_State::GLState::ITextureObject*> expiredTextures;
|
Vector<MG_State::GLState::ITextureObject*> expiredTextures;
|
||||||
expiredTextures.reserve(m_aliveObjects.size());
|
expiredTextures.reserve(m_aliveObjects.size());
|
||||||
@@ -1234,7 +1272,25 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
|||||||
for (auto* texture : expiredTextures) {
|
for (auto* texture : expiredTextures) {
|
||||||
PruneStaleTextureAliases(texture);
|
PruneStaleTextureAliases(texture);
|
||||||
}
|
}
|
||||||
return expiredTextures.size();
|
SizeT prunedCount = expiredTextures.size();
|
||||||
|
|
||||||
|
// Orphan sweep: after the pass above, m_aliveObjects holds only live entries.
|
||||||
|
// Registration in SyncTextureAndGetDescriptor cannot fail for a SharedPtr-owned
|
||||||
|
// texture (weak_from_this fallback), so a resource whose identity has no alive
|
||||||
|
// entry has no trackable owner: its GL-side object is gone, or was never
|
||||||
|
// shared-owned, in which case recreation on a later sync is the safe fallback.
|
||||||
|
// Destruction goes through the per-frame deferred queues, never immediate.
|
||||||
|
Vector<TextureIdentity> orphanIdentities;
|
||||||
|
for (auto it = m_textureResources.begin(); it != m_textureResources.end(); ++it) {
|
||||||
|
if (m_aliveObjects.find(it->first) == m_aliveObjects.end()) {
|
||||||
|
orphanIdentities.emplace_back(it->first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const auto& identity : orphanIdentities) {
|
||||||
|
EraseTrackedTexture(identity);
|
||||||
|
}
|
||||||
|
prunedCount += orphanIdentities.size();
|
||||||
|
return prunedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
Bool VkTextureManager::SyncTexture(MG_State::GLState::ITextureObject &texture,
|
Bool VkTextureManager::SyncTexture(MG_State::GLState::ITextureObject &texture,
|
||||||
|
|||||||
@@ -368,6 +368,7 @@ private:
|
|||||||
static TextureIdentity MakeTextureIdentity(MG_State::GLState::ITextureObject* texture);
|
static TextureIdentity MakeTextureIdentity(MG_State::GLState::ITextureObject* texture);
|
||||||
void EraseTrackedTexture(const TextureIdentity& identity);
|
void EraseTrackedTexture(const TextureIdentity& identity);
|
||||||
void PruneStaleTextureAliases(MG_State::GLState::ITextureObject* texture);
|
void PruneStaleTextureAliases(MG_State::GLState::ITextureObject* texture);
|
||||||
|
SizeT PruneDeadTextures();
|
||||||
|
|
||||||
VkDevice m_device = VK_NULL_HANDLE;
|
VkDevice m_device = VK_NULL_HANDLE;
|
||||||
VkPhysicalDevice m_physicalDevice = VK_NULL_HANDLE;
|
VkPhysicalDevice m_physicalDevice = VK_NULL_HANDLE;
|
||||||
@@ -377,6 +378,9 @@ private:
|
|||||||
Uint32 m_currentFrameIndex = 0;
|
Uint32 m_currentFrameIndex = 0;
|
||||||
|
|
||||||
Uint8 m_gcCounter = 0;
|
Uint8 m_gcCounter = 0;
|
||||||
|
// Frame-boundary GC gate: counts BeginFrame calls, not draws, so texture churn
|
||||||
|
// through non-draw paths (FBO clears, readbacks) still reaches the prune.
|
||||||
|
Uint32 m_gcFrameCounter = 0;
|
||||||
// Active only between BeginDrawSyncScope/EndDrawSyncScope; identities of
|
// Active only between BeginDrawSyncScope/EndDrawSyncScope; identities of
|
||||||
// textures already fully synced in the current draw (small N -> flat scan).
|
// textures already fully synced in the current draw (small N -> flat scan).
|
||||||
Bool m_drawSyncScopeActive = false;
|
Bool m_drawSyncScopeActive = false;
|
||||||
|
|||||||
@@ -15,7 +15,11 @@
|
|||||||
#include <MG_Util/Math/VectorTypes.h>
|
#include <MG_Util/Math/VectorTypes.h>
|
||||||
|
|
||||||
namespace MobileGL::MG_State::GLState {
|
namespace MobileGL::MG_State::GLState {
|
||||||
class ITextureObject {
|
// Texture objects are always SharedPtr-owned (TextureState creates every instance via
|
||||||
|
// MakeShared, including the per-target default objects). enable_shared_from_this lets
|
||||||
|
// backends that only receive a reference (e.g. syncing a name-deleted texture kept
|
||||||
|
// alive by an FBO attachment) still register a weak liveness reference for GC.
|
||||||
|
class ITextureObject : public std::enable_shared_from_this<ITextureObject> {
|
||||||
public:
|
public:
|
||||||
using TargetEnum = TextureTarget;
|
using TargetEnum = TextureTarget;
|
||||||
virtual ~ITextureObject() = default;
|
virtual ~ITextureObject() = default;
|
||||||
|
|||||||
Reference in New Issue
Block a user