[Perf] (MG_State): stop paying two atomic refcounts for every glBindVertexArray

perf annotate put 94% of VertexArrayState::Bind's 10.5% self time on the
two lock-prefixed shared_ptr refcount RMWs each bind performs. The bound
VAO is now stored as a slot index into m_vertexArrays - no SharedPtr
copy, no atomics on the bind path. The lifetime invariant (the bound
object is kept alive by its slot; any cold path that clobbers a bound
slot - delete-while-bound including slot 0, create-over-bound-slot -
detaches the old object into m_boundDetached so GetBoundVertexArray
keeps answering with it) is enforced in MarkVertexArrayForDeletion /
CreateVertexArrayObject rather than assumed, and documented at the
change. Out-of-range binds and null slots keep their exact old
semantics.

VertexArrayObject also gains two opaque config-version-guarded backend
aux memo words, letting a backend answer "same vertex-input layout?"
from the frontend object instead of chasing its own cold cache entry.

After this change the frontend Bind drops out of the DirectVulkan draw
profile entirely (11.5% -> 0.5%). Measured jointly with the two backend
rounds that land on top: quiet-box 6-round order-alternating A/B,
all nine cases, no case worse than noise on either backend. Unit tests
421/421.
This commit is contained in:
BZLZHH
2026-08-06 13:16:02 -04:00
parent d0aae85da2
commit f8069c0624
3 changed files with 103 additions and 12 deletions
@@ -149,6 +149,25 @@ namespace MobileGL {
m_backendStateMemoVersion = m_configVersion;
}
// Backend-owned aux memo: two opaque VALUE words (no pointee, so unlike the
// state memo above they need no eviction-epoch guard), valid while the config
// version matches. They live next to m_configVersion, which every per-draw
// path already loads, so a backend can re-read small derived facts about this
// VAO's configuration (e.g. a layout hash and attribute masks) without
// chasing into its own cache's heap entry - that chase is a guaranteed cache
// miss when an app cycles hundreds of VAOs per frame.
Bool GetBackendAuxMemo(Uint64& outAux0, Uint64& outAux1) const {
if (m_backendAuxMemoVersion != m_configVersion) return false;
outAux0 = m_backendAuxMemo0;
outAux1 = m_backendAuxMemo1;
return true;
}
void SetBackendAuxMemo(Uint64 aux0, Uint64 aux1) const {
m_backendAuxMemo0 = aux0;
m_backendAuxMemo1 = aux1;
m_backendAuxMemoVersion = m_configVersion;
}
private:
void BumpAttributeFormatVersion(Uint index);
void BumpAttributeBufferVersion(Uint index);
@@ -185,6 +204,9 @@ namespace MobileGL {
mutable const void* m_backendStateMemo = nullptr;
mutable Uint64 m_backendStateMemoEpoch = 0;
mutable Uint32 m_backendStateMemoVersion = ~0u;
mutable Uint64 m_backendAuxMemo0 = 0;
mutable Uint64 m_backendAuxMemo1 = 0;
mutable Uint32 m_backendAuxMemoVersion = ~0u;
};
} // namespace GLState
} // namespace MG_State
@@ -9,20 +9,26 @@
#include "VertexArrayState.h"
namespace MobileGL::MG_State::GLState {
namespace {
// Shared "nothing bound" answer for GetBoundVertexArray/GetVertexArrayObject.
// Function-local statics carry a guard check per access; this one is
// constant-initialized and lives on the hot per-draw path.
const SharedPtr<VertexArrayObject> kNullVertexArrayObject = nullptr;
} // namespace
VertexArrayState::VertexArrayState() : m_indexGenerator(1024, 1) {
// Generate default VAO at index 0, which is not valid in core profile, but still remains for
// compatibility reasons.
m_indexGenerator.Insert(0);
auto defaultVAO = MakeShared<VertexArrayObject>(0);
m_vertexArrays.push_back(defaultVAO);
m_boundVertexArray = defaultVAO;
m_boundIndex = 0;
}
const SharedPtr<VertexArrayObject>& VertexArrayState::GetVertexArrayObject(Uint index) {
if (index >= m_vertexArrays.size()) {
// FIXME: report a GL error here
static SharedPtr<VertexArrayObject> nullVertexArrayObject = nullptr;
return nullVertexArrayObject;
return kNullVertexArrayObject;
}
return m_vertexArrays[index];
@@ -34,11 +40,20 @@ namespace MobileGL::MG_State::GLState {
}
void VertexArrayState::Bind(Uint index) {
const auto& vertexArray = GetVertexArrayObject(index);
// Re-binding the already-current VAO is a per-batch habit of Blaze3D-style renderers;
// skip the shared_ptr store (two atomic refcount ops) when nothing changes.
if (vertexArray == m_boundVertexArray) return;
m_boundVertexArray = vertexArray;
// Per-draw-batch hot path (Blaze3D-style renderers rebind a different VAO before
// every draw): store only the slot index - no SharedPtr copy, no refcount atomics.
// The bound object's lifetime is guaranteed by its slot (see the invariant note on
// m_boundIndex in the header).
if (m_boundDetached) [[unlikely]] {
// A cold path displaced the previously bound object out of its slot; this Bind
// supersedes it, exactly like the old SharedPtr member being overwritten.
m_boundDetached = nullptr;
}
// Match the previous semantics exactly: binding an out-of-range name, or a name
// whose slot holds no object, left the old SharedPtr member null - resolve that
// NOW, so a slot created later does not silently become bound.
m_boundIndex =
(index < m_vertexArrays.size() && m_vertexArrays[index] != nullptr) ? index : kUnboundIndex;
}
const SharedPtr<VertexArrayObject>& VertexArrayState::CreateVertexArrayObject(Uint index) {
@@ -48,14 +63,36 @@ namespace MobileGL::MG_State::GLState {
m_vertexArrays.resize(index + 1, nullptr);
}
auto& vao = m_vertexArrays[index];
if (index == m_boundIndex && vao != nullptr && !m_boundDetached) {
// Replacing the bound slot's live object: keep the OLD object alive and bound
// (that is what the previous SharedPtr member provided) until the next Bind.
// Unreachable through the GL entry points today - bind-time creation only fills
// empty slots and generated names are never in use - but the invariant is
// enforced here, not assumed.
m_boundDetached = std::move(vao);
}
vao = MakeShared<VertexArrayObject>(index);
return vao;
}
void VertexArrayState::MarkVertexArrayForDeletion(Uint index) {
if (m_indexGenerator.IsValid(index)) {
if (m_boundVertexArray && m_boundVertexArray->GetExternalIndex() == index) {
m_boundVertexArray = GetVertexArrayObject(0);
// "Deleting the bound VAO rebinds the default VAO" needs the same answer the old
// SharedPtr compare gave: either the live bound slot is the one being deleted, or
// the bound object is a detached one that carries this external index.
const Bool deletingBound = m_boundDetached
? m_boundDetached->GetExternalIndex() == index
: (m_boundIndex == index && m_boundIndex != kUnboundIndex);
if (deletingBound) {
m_boundDetached = nullptr;
m_boundIndex = 0; // the default VAO's slot always exists
if (index == 0) {
// Deleting slot 0 while it is bound (unreachable via the GL entry
// points, which filter name 0): the old SharedPtr member kept the
// object alive and bound across the slot null-out below; detach it
// to preserve that.
m_boundDetached = m_vertexArrays[0];
}
}
if (ValidateVertexArrayObject(index)) {
@@ -76,7 +113,16 @@ namespace MobileGL::MG_State::GLState {
}
const SharedPtr<VertexArrayObject>& VertexArrayState::GetBoundVertexArray() {
return m_boundVertexArray;
// NOTE: like GetVertexArrayObject, the returned reference is a slot reference and
// must not be held across CreateVertexArrayObject (vector growth) - existing
// callers bind/create first and only then take the reference.
if (m_boundDetached) [[unlikely]] {
return m_boundDetached;
}
if (m_boundIndex < m_vertexArrays.size()) {
return m_vertexArrays[m_boundIndex];
}
return kNullVertexArrayObject;
}
Vector<SharedPtr<VertexArrayObject>>& VertexArrayState::GetAllVertexArrays() {
@@ -29,9 +29,32 @@ namespace MobileGL {
Vector<SharedPtr<VertexArrayObject>>& GetAllVertexArrays();
private:
// "Nothing bound" (an out-of-range or never-created name was bound). Distinct from
// being bound to a live slot so that a slot filled AFTER such a bind does not
// retroactively become the bound VAO.
static constexpr Uint kUnboundIndex = ~static_cast<Uint>(0);
Vector<SharedPtr<VertexArrayObject>> m_vertexArrays;
IndexGenerator<Uint> m_indexGenerator;
SharedPtr<VertexArrayObject> m_boundVertexArray;
// The bound VAO is represented as an INDEX into m_vertexArrays, not as an owning
// SharedPtr copy. Chunk-style renderers rebind a different VAO before every draw,
// and the SharedPtr store this replaces cost two atomic refcount ops per bind -
// the single largest line of a vanilla-Minecraft draw profile (the lock-prefixed
// refcount RMWs serialize the store buffer in the middle of command recording).
//
// LIFETIME INVARIANT this relies on (and which the cold paths below enforce
// rather than assume): the object GetBoundVertexArray() refers to is kept alive
// by its own slot in m_vertexArrays. Every path that clears or replaces a slot
// either (a) rebinds index 0 first when it targets the bound slot
// (MarkVertexArrayForDeletion), or (b) detaches the displaced object into
// m_boundDetached (CreateVertexArrayObject), which then owns it and keeps
// GetBoundVertexArray() answering with the OLD object - exactly what the previous
// SharedPtr member did - until the next Bind drops it.
Uint m_boundIndex = 0;
// Cold-path ownership backstop, see above. Null in the steady state; Bind clears
// it (one predictable branch on the hot path).
SharedPtr<VertexArrayObject> m_boundDetached;
};
} // namespace GLState
} // namespace MG_State