[Perf] (MG_Backend, MG_State): stop re-resolving texture unit bindings on every draw

DirectGLES re-derived the whole texture binding state for every draw: for each
touched unit, two alias-resolution passes over all binding slots, then a third
walk to unbind native targets nothing claimed, then the sampler. With the
Minecraft-shaped bench that was 13.2% of the render thread in BindCurrentTextures
alone, plus 4.6% in SyncNeccessaryTextures deciding which textures to consider.
The answer is identical across a whole terrain batch.

The resolution is now memoised, and what makes replaying it as a no-op legitimate
is that the memo does not merely trust a key: it compares the backend's own bound
texture shadow against the one resolution left behind. Every path that binds a
texture behind this function's back already maintains that shadow - the scratch
bind an upload does on the temp unit, CopyTexSubImage2D and GenerateMipmap
binding on the active unit, the glBindTextures fast path, the scrub a backend
texture performs when it is destroyed or respecified - so a memcmp catches all of
them without having to enumerate them. On top of that the key covers the texture
bind generation, the program that arbitrates aliased targets (pointer, lifetime
id, backend state version, link status), and the ES context generation.

Two invalidation sources had no signal at all and needed one. Mipmap completeness
decides whether a texture is bound in the first place, and it moves with texture
shape and with the effective sampler's filter - so a sampling-resolution
generation now moves with both, routed through single choke points
(TextureObjectBase::BumpShapeVersion, SamplerObject::BumpVersion) so a future
bump site cannot forget it. A texture context id was needed because both
generations restart at zero in a new GLContext, which can land on the old heap
address.

This also closes a pre-existing hole rather than working around it:
glDeleteSamplers unbinds the sampler from every unit straight through
TextureUnit::SetSamplerObject, bypassing the touch bookkeeping, so that setter now
bumps the bind generation on a real change. The sampler bind step itself stays
outside the memo and runs every draw - the program's raw-depth-fetch substitution
rewrites unit samplers immediately afterwards, so a memo there could never hit.

ns per draw, DriverBench on a GTX 1660 SUPER (native / Espryt):
mc_vanilla_draw 253 / 2037->1315, mc_ubo_range 202 / 1684->955,
mc_sodium_multidraw 739 / 3939->3150. Espryt goes from 8.3x to 4.7x the native
driver on the per-draw uniform-range case. Magma is unaffected (the MG_State
additions are counter bumps), and no case regressed.

Unit tests 421/421.
This commit is contained in:
BZLZHH
2026-08-06 07:41:10 -04:00
parent 62dea3bea4
commit 49aab57f03
10 changed files with 294 additions and 45 deletions
+179 -23
View File
@@ -813,6 +813,33 @@ namespace MobileGL::MG_Backend::DirectGLES {
return backendObj;
}
// Work list behind SyncNeccessaryTextures' per-draw unit walk. WHICH textures the
// touched units hold is a pure function of the unit bindings, so the GLContext identity
// (a never-reused id, not the heap address a recreated context can land on again), the
// texture bind generation and the touched-unit high-water mark are a complete key -
// WHAT each entry then has to do is still decided per draw by the version compares
// inside the sync calls, which is why texture content, shape and parameter changes need
// no key here.
//
// Entries borrow, they never own. `slot` points at the binding slot's shared_ptr, whose
// address is fixed for the context's lifetime (TextureState holds the unit array by
// value) and whose VALUE cannot change without bumping the bind generation. `backend` is
// the registry's object for the texture in that slot; the registry only erases an entry
// once the frontend texture has expired, and a frontend texture cannot expire while a
// slot the key covers still holds a reference to it. Holding either side by shared_ptr
// instead would keep dead frontend textures alive and defeat the registry's
// weak-reference GC.
struct UnitTextureSyncEntry {
const SharedPtr<MG_State::GLState::ITextureObject>* slot = nullptr;
BackendTextureObject* backend = nullptr;
};
static Vector<UnitTextureSyncEntry> g_unitTextureSyncList;
static Bool g_unitTextureSyncListValid = false;
static Uint64 g_unitTextureSyncListContextId = 0;
static Uint64 g_unitTextureSyncListBindGeneration = 0;
static Int g_unitTextureSyncListMaxUnit = -1;
static Uint g_unitTextureSyncListContextGeneration = 0;
void SyncNeccessaryTextures() {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
@@ -826,16 +853,37 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Units past the frontend's high-water mark have provably-empty slots.
const Int maxTouchedUnit = MG_State::pGLContext->GetMaxTouchedTextureUnit();
for (Int index = 0; index <= maxTouchedUnit; ++index) {
auto& unit = MG_State::pGLContext->GetTextureUnitObject(index);
for (const auto& bindingSlot : unit.GetAllBindingSlots()) {
auto& textureObject = bindingSlot.GetBoundObject();
// An image-less default texture (name 0) is the slot's initial / "unbound"
// state; it has nothing to sync, so skip it as cheaply as the old null slot.
if (textureObject && !MG_State::GLState::IsUndefinedDefaultTexture(textureObject.get())) {
SyncTextureObjectToBackend(textureObject);
const Uint64 bindGeneration = MG_State::pGLContext->GetTextureBindGeneration();
if (g_unitTextureSyncListValid &&
g_unitTextureSyncListContextId == MG_State::pGLContext->GetTextureContextId() &&
g_unitTextureSyncListBindGeneration == bindGeneration &&
g_unitTextureSyncListMaxUnit == maxTouchedUnit &&
g_unitTextureSyncListContextGeneration == g_textureContextGeneration) {
for (const auto& entry : g_unitTextureSyncList) {
entry.backend->SyncTextureParamsToBackend(*entry.slot);
entry.backend->SyncBuiltinSamplerToBackend(*entry.slot);
entry.backend->SyncMipmapsToBackend(*entry.slot);
}
} else {
g_unitTextureSyncListValid = false;
g_unitTextureSyncList.clear();
for (Int index = 0; index <= maxTouchedUnit; ++index) {
auto& unit = MG_State::pGLContext->GetTextureUnitObject(index);
for (const auto& bindingSlot : unit.GetAllBindingSlots()) {
auto& textureObject = bindingSlot.GetBoundObject();
// An image-less default texture (name 0) is the slot's initial / "unbound"
// state; it has nothing to sync, so skip it as cheaply as the old null slot.
if (textureObject && !MG_State::GLState::IsUndefinedDefaultTexture(textureObject.get())) {
g_unitTextureSyncList.push_back(
{&textureObject, SyncTextureObjectToBackend(textureObject).get()});
}
}
}
g_unitTextureSyncListContextId = MG_State::pGLContext->GetTextureContextId();
g_unitTextureSyncListBindGeneration = bindGeneration;
g_unitTextureSyncListMaxUnit = maxTouchedUnit;
g_unitTextureSyncListContextGeneration = g_textureContextGeneration;
g_unitTextureSyncListValid = true;
}
const auto& currentFBO =
@@ -1532,21 +1580,19 @@ namespace MobileGL::MG_Backend::DirectGLES {
XfbImpl::StartPendingTransformFeedback();
}
// Rebinds every frontend texture unit's textures (and sampler objects) on the
// backend context. Needed before draws AND compute dispatches: content syncs
// (SyncTextureObjectToBackend) bind scratch textures on the active unit as a
// side effect, so unit bindings must be re-established afterwards or shaders
// sample whatever texture the last sync left behind (e.g. Flywheel's depth
// pyramid downsample reading a stale unit-0 binding instead of the depth
// attachment).
void BindCurrentTextures() {
// Resolves every frontend texture unit's textures onto the backend context. Returns
// false when the resolution could not be completed from the state it read - a bound
// texture that has no backend object yet is skipped, and a later draw would bind it
// without any of the memo keys below moving - so the caller must not memoise it.
static Bool ResolveAndBindUnitTextures(const SharedPtr<MG_State::GLState::ProgramObject>& currentProgram,
Int maxTouchedUnit) {
#ifdef TRACY_ENABLE
ZoneScopedNC("BindCurrentTextures", TRACY_ZONECOLOR_BACKEND);
ZoneScopedNC("ResolveAndBindUnitTextures", TRACY_ZONECOLOR_BACKEND);
#endif
Bool fullyResolved = true;
// Frontend target the current program samples at a given unit; resolves an
// aliased native binding when two real textures compete for it (see below).
// Only consulted on a conflict, so the ordinary unit costs nothing.
const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw();
const auto sampledTargetForUnit = [&currentProgram](Int unit) {
if (!currentProgram || !currentProgram->GetLinkStatus()) {
return TextureTarget::Unknown;
@@ -1562,8 +1608,6 @@ namespace MobileGL::MG_Backend::DirectGLES {
return TextureTarget::Unknown;
};
// Units past the frontend's high-water mark have provably-empty slots.
const Int maxTouchedUnit = MG_State::pGLContext->GetMaxTouchedTextureUnit();
for (Int unit = 0; unit <= maxTouchedUnit; ++unit) {
auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);
Array<Bool, (SizeT)TextureTarget::TextureTargetCount> boundBackendTargets{};
@@ -1628,7 +1672,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Bind texture object
const auto& backendTextureIt = TextureImpl::g_backendTextureObjects.find(textureObject.get());
if (backendTextureIt == TextureImpl::g_backendTextureObjects.end()) continue;
if (backendTextureIt == TextureImpl::g_backendTextureObjects.end()) {
fullyResolved = false;
continue;
}
backendTextureIt->second->Bind(targetGL, unit);
boundBackendTargets[backendTargetIndex] = true;
@@ -1655,9 +1702,19 @@ namespace MobileGL::MG_Backend::DirectGLES {
TextureImpl::UnbindTexture(unit, targetGL);
}
}
}
return fullyResolved;
}
// Bind sampler object if necessary
const auto& samplerObject = textureUnit.GetSamplerObject();
// Puts each touched unit's frontend sampler object on the backend unit. Deliberately
// NOT part of the memo below: BindCurrentProgramWithResources rewrites the sampler of
// every unit its program samples right after this runs (raw-depth-fetch substitution,
// per-program sampler objects), so the sampler shadow this leaves behind is not what
// the next call would find - a memo keyed on it could never hit. The step is a pointer
// compare per unit in the common case, far below the resolution cost the memo removes.
static void BindCurrentUnitSamplers(Int maxTouchedUnit) {
for (Int unit = 0; unit <= maxTouchedUnit; ++unit) {
const auto& samplerObject = MG_State::pGLContext->GetTextureUnitObject(unit).GetSamplerObject();
if (samplerObject) {
const auto& backendSamplerIt = SamplerImpl::g_backendSamplerObjects.find(samplerObject.get());
if (backendSamplerIt != SamplerImpl::g_backendSamplerObjects.end()) {
@@ -1672,6 +1729,105 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
}
// Memo of the resolved per-unit TEXTURE bindings, so a steady-state draw loop stops
// re-deriving an answer nothing has invalidated (a Minecraft frame issues thousands of
// draws that touch none of the inputs below, and the resolution walks every binding slot
// of every touched unit three times).
//
// ResolveAndBindUnitTextures is a pure function of:
// * the GLContext identity - a never-reused id, since the counters below restart at 0 in
// a new context and a recreated one can land on the old heap address;
// * its texture bind generation - glBindTexture, glBindTextureUnit, glBindTextures,
// glBindSampler, the delete-unbind in MarkTextureObjectForDeletion, the sampler swap in
// TextureUnit::SetSamplerObject (which is how glDeleteSamplers unbinds), and a default
// texture gaining or losing an image all bump it;
// * the touched-unit high-water mark (units above it have provably-empty slots);
// * the program that arbitrates two real textures aliased onto one native target -
// identity, plus the lifetime id because a freed program can be replaced at the same
// address, plus the backend-state version which moves on every relink and on every
// sampler-uniform unit assignment, plus the link status;
// * the sampling-resolution generation - any texture shape change or any sampler
// parameter change, i.e. everything mipmap-completeness is computed from, and
// completeness is what decides whether a texture is bound at all;
// * the ES context generation, because the backend texture ids and the driver's own
// binding state die with the context.
//
// A matching key only says the ANSWER is unchanged; replaying it as "do nothing" also
// requires the bindings it established to still be on the driver. Rather than enumerate
// every writer, the memo keeps the binding shadow it left and compares it: that shadow is
// already the authority every redundant-bind filter in this backend trusts, and every path
// that puts a texture on a unit behind this function's back maintains it - the upload
// path's scratch bind on the temp unit (BackendTextureObject::Bind out of
// SyncMipmapsToBackend), CopyTexSubImage2D and GenerateMipmap binding on the active unit,
// the glBindTextures fast path, and the self-scrub a BackendTextureObject performs when it
// is destroyed or respecified.
struct ResolvedTextureBindingMemo {
Bool valid = false;
Uint64 glContextId = 0;
Int maxTouchedUnit = -1;
Uint64 bindGeneration = 0;
Uint64 samplingResolutionGeneration = 0;
const void* program = nullptr;
Uint64 programLifetimeId = 0;
Uint32 programBackendStateVersion = 0;
Bool programLinked = false;
Uint contextGeneration = 0;
decltype(TextureImpl::g_boundTexturesCache) boundTextures{};
};
static ResolvedTextureBindingMemo g_resolvedTextureBindingMemo;
// Rebinds every frontend texture unit's textures (and sampler objects) on the
// backend context. Needed before draws AND compute dispatches: content syncs
// (SyncTextureObjectToBackend) bind scratch textures on the active unit as a
// side effect, so unit bindings must be re-established afterwards or shaders
// sample whatever texture the last sync left behind (e.g. Flywheel's depth
// pyramid downsample reading a stale unit-0 binding instead of the depth
// attachment).
void BindCurrentTextures() {
#ifdef TRACY_ENABLE
ZoneScopedNC("BindCurrentTextures", TRACY_ZONECOLOR_BACKEND);
#endif
const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw();
// Units past the frontend's high-water mark have provably-empty slots.
const Int maxTouchedUnit = MG_State::pGLContext->GetMaxTouchedTextureUnit();
auto& memo = g_resolvedTextureBindingMemo;
const SizeT shadowBytes =
static_cast<SizeT>(maxTouchedUnit + 1) * sizeof(TextureImpl::g_boundTexturesCache[0]);
const Bool keysMatch = memo.valid && memo.glContextId == MG_State::pGLContext->GetTextureContextId() &&
memo.maxTouchedUnit == maxTouchedUnit &&
memo.bindGeneration == MG_State::pGLContext->GetTextureBindGeneration() &&
memo.samplingResolutionGeneration ==
MG_State::pGLContext->GetSamplingResolutionGeneration() &&
memo.program == static_cast<const void*>(currentProgram.get()) &&
memo.programLifetimeId == (currentProgram ? currentProgram->GetLifetimeId() : 0) &&
memo.programBackendStateVersion ==
(currentProgram ? currentProgram->GetBackendStateVersion() : 0) &&
memo.programLinked == (currentProgram && currentProgram->GetLinkStatus()) &&
memo.contextGeneration == TextureImpl::g_textureContextGeneration;
// Short-circuited: the shadow compare is only meaningful once the key (and with it the
// snapshotted row count) matches.
if (!keysMatch || std::memcmp(memo.boundTextures.data(), TextureImpl::g_boundTexturesCache.data(),
shadowBytes) != 0) {
memo.valid = false;
if (ResolveAndBindUnitTextures(currentProgram, maxTouchedUnit)) {
memo.glContextId = MG_State::pGLContext->GetTextureContextId();
memo.maxTouchedUnit = maxTouchedUnit;
memo.bindGeneration = MG_State::pGLContext->GetTextureBindGeneration();
memo.samplingResolutionGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration();
memo.program = currentProgram.get();
memo.programLifetimeId = currentProgram ? currentProgram->GetLifetimeId() : 0;
memo.programBackendStateVersion = currentProgram ? currentProgram->GetBackendStateVersion() : 0;
memo.programLinked = currentProgram && currentProgram->GetLinkStatus();
memo.contextGeneration = TextureImpl::g_textureContextGeneration;
std::memcpy(memo.boundTextures.data(), TextureImpl::g_boundTexturesCache.data(), shadowBytes);
memo.valid = true;
}
}
BindCurrentUnitSamplers(maxTouchedUnit);
}
// Binds the current program's backend object and re-establishes its per-program
// resources: global UBO contents, uniform-block bindings, and sampler uniform
// units (layout(binding=N) qualifiers are stripped from transpiled ESSL, so the