mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-11 13:48:30 +09:00
[Fix, Perf, Test] (MG_State, MG_Impl/GLImpl, MG_Test, MG_IntegrationTest): a pipeline composite mirrors only the uniforms a stage was written to, and survives a sampler or block rebinding
This commit is contained in:
@@ -384,23 +384,49 @@ namespace MobileGL::MG_State {
|
||||
// Location-by-location so that arrays are carried across whole, and via the padded
|
||||
// storage span so a mat3's std140 column padding travels with it.
|
||||
//
|
||||
// KNOWN LIMIT, inherent to flattening rather than to this copy: SSO gives each stage
|
||||
// program its own storage for a uniform, so two stage programs may declare the same
|
||||
// name and hold different values - but the composite is one link and has one slot for
|
||||
// it. RefreshCompositeUniforms walks the stages in order, so the last graphics stage
|
||||
// that declares the name wins, including when it is only holding the zero default and
|
||||
// an earlier stage held a written value. Fixing it properly means mirroring only the
|
||||
// uniforms a program has actually been written to, which wants a per-location dirty
|
||||
// set on ProgramObject.
|
||||
// WHICH uniforms: exactly the ones `source` has been WRITTEN to since its last link
|
||||
// (ProgramObject's per-location dirty set), and that restriction is a correctness fix
|
||||
// as much as it is the reason this is cheap.
|
||||
//
|
||||
// SSO gives each stage program its own storage for a uniform, so two stage programs
|
||||
// may declare the same name and hold different values - but the composite is one link
|
||||
// with one slot for it, and RefreshCompositeUniforms walks the stages in order. When
|
||||
// every active uniform was copied unconditionally, the LAST graphics stage that merely
|
||||
// DECLARED a name won, even while holding nothing but GL's zero default, and an
|
||||
// earlier stage's written value was overwritten with zeros on the way to the draw. The
|
||||
// shared-header idiom - the same `uniform mat4 u_mvp` declared in the VS and the FS,
|
||||
// written through glActiveShaderProgram(pipe, vs) - rendered nothing because of it.
|
||||
// Copying only written uniforms makes that case, which is the overwhelmingly common
|
||||
// one, simply correct: an unwritten declaration has nothing to say and says nothing.
|
||||
//
|
||||
// WHEN BOTH STAGES WROTE THE SAME NAME there is no single right answer available -
|
||||
// GL_ARB_separate_shader_objects gives the two values separate storage and the
|
||||
// composite has one slot - so the rule is LAST WRITTEN-TO GRAPHICS STAGE WINS, in
|
||||
// ShaderStage enum order (Vertex .. Fragment), decided by the stage walk in
|
||||
// RefreshCompositeUniforms. It is deterministic, and it is strictly better than what
|
||||
// it replaces: only a stage that actually holds an application-written value can now
|
||||
// take the slot. True last-WRITE-wins would need a global write ordering the dirty set
|
||||
// does not carry.
|
||||
//
|
||||
// An unwritten uniform is not left to chance either: the composite links the same
|
||||
// shader objects the stages do, so its own link seeds it with the same declared
|
||||
// initializers (ApplyUniformInitialValues), which is precisely the value GL says an
|
||||
// unwritten uniform reads.
|
||||
static void MirrorUniformValues(ProgramObject& source, ProgramObject& destination) {
|
||||
if (!source.GetLinkStatus() || !destination.GetLinkStatus()) return;
|
||||
// O(uniforms written), not O(uniforms declared). The two name lookups below are
|
||||
// string hashes into both programs' location maps, and doing them for every active
|
||||
// uniform of every stage on every gate trip was hundreds of them per draw on a
|
||||
// large program. A stage nothing has been written to costs one empty() test.
|
||||
const Vector<Uint>& writtenIndices = source.GetWrittenUniformIndices();
|
||||
if (writtenIndices.empty()) return;
|
||||
|
||||
const char* sourceUbo = static_cast<const char*>(source.GetUBOData());
|
||||
char* destinationUbo = static_cast<char*>(destination.MapUBO());
|
||||
const SizeT sourceUboSize = source.GetUBOSize();
|
||||
const SizeT destinationUboSize = destination.GetUBOSize();
|
||||
|
||||
const Uint uniformCount = source.GetUniformCount();
|
||||
for (Uint index = 0; index < uniformCount; ++index) {
|
||||
for (const Uint index : writtenIndices) {
|
||||
const String& name = source.GetActiveUniformName(index);
|
||||
if (name.empty()) continue;
|
||||
const Int sourceBase = source.GetUniformLocation(name);
|
||||
@@ -418,6 +444,10 @@ namespace MobileGL::MG_State {
|
||||
!destination.IsValidUniformLocation(destinationLocation)) {
|
||||
break;
|
||||
}
|
||||
// Per ELEMENT, not per array: `arr[3] = x` must carry element 3 and leave
|
||||
// the elements another stage owns alone. `continue`, not `break` - the
|
||||
// written elements of an array need not be a prefix of it.
|
||||
if (!source.IsUniformWrittenAtLocation(static_cast<Uint>(sourceLocation))) continue;
|
||||
// Stop at the end of EITHER side's array rather than walking onto the
|
||||
// neighbouring uniform of whichever program has the shorter one.
|
||||
if (!source.UniformLocationsAliasSameUniform(sourceBase, sourceLocation) ||
|
||||
@@ -551,13 +581,13 @@ namespace MobileGL::MG_State {
|
||||
if (!pipeline) return nullProgram;
|
||||
|
||||
// P1 join site J1. ComputeDrawProgramSignature() keys the composite cache on each
|
||||
// stage program's lifetimeId and backendStateVersion - NON-artifact fields, so
|
||||
// they do not pass through ProgramObject's join gate and a pending link would
|
||||
// stay pending right through the signature. Since the version is bumped both at
|
||||
// enqueue and at publish, the signature computed inside a pending window is one
|
||||
// that will never be produced again: every draw would miss the cache and rebuild
|
||||
// (and relink) the composite. Join first, so the signature describes settled
|
||||
// programs. In steady state this is a null check per stage.
|
||||
// stage program's lifetimeId and linkVersion - NON-artifact fields, so they do not
|
||||
// pass through ProgramObject's join gate and a pending link would stay pending
|
||||
// right through the signature. Since the version is bumped both at enqueue and at
|
||||
// publish, the signature computed inside a pending window is one that will never
|
||||
// be produced again: every draw would miss the cache and rebuild (and relink) the
|
||||
// composite. Join first, so the signature describes settled programs. In steady
|
||||
// state this is a null check per stage.
|
||||
for (SizeT stage = 0; stage < ProgramPipelineObject::kGraphicsStageCount; ++stage) {
|
||||
const auto& stageProgram = pipeline->GetStageProgram(static_cast<ShaderStage>(stage));
|
||||
if (stageProgram) stageProgram->JoinLinkAndSpirv();
|
||||
|
||||
@@ -326,6 +326,11 @@ namespace MobileGL::MG_State::GLState {
|
||||
artifacts.linkedExplicitUniformLocations.clear();
|
||||
artifacts.uniformInitialValues.clear();
|
||||
artifacts.uniformIndexInTProgram.clear();
|
||||
// GL resets every uniform to its initial value at link, so nothing is "written since
|
||||
// link" any more - and the locations these bits index no longer mean anything either.
|
||||
artifacts.writtenUniformLocationBits.clear();
|
||||
artifacts.writtenUniformIndexBits.clear();
|
||||
artifacts.writtenUniformIndices.clear();
|
||||
artifacts.uniformSamplerOrImageUnitIndex.clear();
|
||||
artifacts.explicitOpaqueUniformBindings.clear();
|
||||
artifacts.uniformBlockIndexByName.clear();
|
||||
|
||||
@@ -341,6 +341,80 @@ namespace MobileGL::MG_State::GLState {
|
||||
return UniformStorageSpanInBytes(GetUniformTType(location), GetUniformSizesInBytes(location));
|
||||
}
|
||||
|
||||
// ---- "written since link": the per-location dirty set the pipeline composite mirrors from ----
|
||||
//
|
||||
// A pipeline's stage programs each own their uniform storage, but the composite the draw
|
||||
// goes through has ONE slot per name. Mirroring every active uniform of every stage
|
||||
// therefore lets the last stage that merely DECLARES a name overwrite the value an
|
||||
// earlier stage was actually written with - the shared-header idiom (the same
|
||||
// `uniform mat4 u_mvp` in the VS and the FS) rendered nothing because of it. Recording
|
||||
// which locations an application has written is what lets the mirror carry only those.
|
||||
//
|
||||
// WHO PAYS: only a program that could ever be a pipeline stage, decided by the latch
|
||||
// below. glUseProgram's uniform path - thousands of calls per frame in Minecraft - pays
|
||||
// one predictable bool branch and nothing else.
|
||||
//
|
||||
// GRANULARITY is per LOCATION, not per name: glUniform*v writes array elements at
|
||||
// element locations, and a program that wrote `arr[3]` and nothing else must mirror
|
||||
// exactly that element. The compact index list beside it is what keeps the mirror
|
||||
// O(uniforms actually written) instead of O(active uniforms) - it is the set of GL
|
||||
// active-uniform indices owning at least one written location, so the mirror does its
|
||||
// two name lookups once per written uniform rather than once per uniform in the program.
|
||||
//
|
||||
// NOT counted as a write: the declared initializers ProgramLinkTask seeds at link
|
||||
// (ApplyUniformInitialValues). They are a property of the SHADERS, and the composite
|
||||
// links the very same shader objects, so it seeds itself with the identical values -
|
||||
// there is nothing to carry. Counting them would also re-introduce the bug this set
|
||||
// exists to fix, by letting a stage that only declares `uniform float f = 0.0;` clobber
|
||||
// the value the application wrote for `f` in another stage.
|
||||
Bool TracksUniformWrites() const { return m_tracksUniformWrites; }
|
||||
|
||||
// Records that `location` has been written since the last link. Cheap and idempotent;
|
||||
// a no-op on a program that can never be a pipeline stage.
|
||||
void MarkUniformWrittenAtLocation(Uint location) {
|
||||
if (!m_tracksUniformWrites) return;
|
||||
LinkArtifacts& artifacts = Artifacts();
|
||||
if (!IsValidUniformLocation(artifacts, static_cast<Int>(location))) return;
|
||||
|
||||
const SizeT locationWord = location / 64u;
|
||||
if (locationWord >= artifacts.writtenUniformLocationBits.size()) {
|
||||
artifacts.writtenUniformLocationBits.resize(
|
||||
static_cast<SizeT>(artifacts.maxUniformLocation) / 64u + 1u, 0u);
|
||||
if (locationWord >= artifacts.writtenUniformLocationBits.size()) return;
|
||||
}
|
||||
artifacts.writtenUniformLocationBits[locationWord] |= Uint64{1} << (location % 64u);
|
||||
|
||||
// Add the owning GL active-uniform index to the compact list, once.
|
||||
const Int tIndex = artifacts.uniformIndexInTProgram[location];
|
||||
if (tIndex < 0 || static_cast<SizeT>(tIndex) >= artifacts.tProgramUniformIndexToGl.size()) return;
|
||||
const Int glIndex = artifacts.tProgramUniformIndexToGl[tIndex];
|
||||
// -1 is a uniform the relaxed parse swept out of the GL-visible index space; the
|
||||
// mirror enumerates GL indices, so there is nothing it could look such a one up by.
|
||||
if (glIndex < 0) return;
|
||||
const SizeT indexWord = static_cast<SizeT>(glIndex) / 64u;
|
||||
if (indexWord >= artifacts.writtenUniformIndexBits.size()) {
|
||||
artifacts.writtenUniformIndexBits.resize(
|
||||
static_cast<SizeT>(artifacts.activeUniformCount) / 64u + 1u, 0u);
|
||||
if (indexWord >= artifacts.writtenUniformIndexBits.size()) return;
|
||||
}
|
||||
const Uint64 indexBit = Uint64{1} << (static_cast<SizeT>(glIndex) % 64u);
|
||||
if ((artifacts.writtenUniformIndexBits[indexWord] & indexBit) != 0) return;
|
||||
artifacts.writtenUniformIndexBits[indexWord] |= indexBit;
|
||||
artifacts.writtenUniformIndices.push_back(static_cast<Uint>(glIndex));
|
||||
}
|
||||
|
||||
Bool IsUniformWrittenAtLocation(Uint location) const {
|
||||
const auto& bits = Artifacts().writtenUniformLocationBits;
|
||||
const SizeT locationWord = location / 64u;
|
||||
return locationWord < bits.size() &&
|
||||
(bits[locationWord] & (Uint64{1} << (location % 64u))) != 0;
|
||||
}
|
||||
|
||||
// GL active-uniform indices owning at least one written location. Empty for every
|
||||
// program that has not been written to since its last link - and for every program
|
||||
// that never asked to be separable, which is what makes the mirror free for them.
|
||||
const Vector<Uint>& GetWrittenUniformIndices() const { return Artifacts().writtenUniformIndices; }
|
||||
|
||||
Int GetAttributeLocation(const String& name) {
|
||||
const auto it = std::find(Artifacts().attribs.begin(), Artifacts().attribs.end(), name);
|
||||
return (it == Artifacts().attribs.end()) ? -1 : (Int)std::distance(Artifacts().attribs.begin(), it);
|
||||
@@ -488,10 +562,15 @@ namespace MobileGL::MG_State::GLState {
|
||||
}
|
||||
|
||||
void SetUniformSamplerOrImageUnitIndex(Uint location, Int unit) {
|
||||
if (location >= Artifacts().uniformSamplerOrImageUnitIndex.size() ||
|
||||
Artifacts().uniformSamplerOrImageUnitIndex[location] == unit) {
|
||||
return;
|
||||
}
|
||||
if (location >= Artifacts().uniformSamplerOrImageUnitIndex.size()) return;
|
||||
// BEFORE the equality bail-out, not after: "written" is about the application
|
||||
// having addressed the uniform, not about the bytes changing. glUniform1i(s, 0) on
|
||||
// a sampler that already reads 0 still has to beat another stage's untouched
|
||||
// declaration of the same name in the composite - which is only possible if the
|
||||
// write is recorded. (The mirror is the only reader, and it runs this same setter
|
||||
// on the composite, where the latch is off.)
|
||||
MarkUniformWrittenAtLocation(location);
|
||||
if (Artifacts().uniformSamplerOrImageUnitIndex[location] == unit) return;
|
||||
Artifacts().uniformSamplerOrImageUnitIndex[location] = unit;
|
||||
++m_backendStateVersion;
|
||||
}
|
||||
@@ -511,7 +590,32 @@ namespace MobileGL::MG_State::GLState {
|
||||
// subset of the stages of a program pipeline. Only takes effect on the next link,
|
||||
// which is why it is plain state here rather than something Link() consults.
|
||||
Bool GetSeparable() const { return m_separable; }
|
||||
void SetSeparable(Bool separable) { m_separable = separable; }
|
||||
void SetSeparable(Bool separable) {
|
||||
m_separable = separable;
|
||||
// ---- arming the uniform-write tracking latch ----
|
||||
//
|
||||
// The predicate wanted is "this program can ever be a pipeline stage", and
|
||||
// GetSeparable() is NOT it in either direction. GL_PROGRAM_SEPARABLE takes effect
|
||||
// at the NEXT link, so it can read true on a program glUseProgramStages would
|
||||
// still reject; that direction is merely wasteful. The other direction is a
|
||||
// correctness hole: glProgramParameteri may clear the flag AFTER a separable link,
|
||||
// and glUseProgramStages tests the state the program was LINKED with, so such a
|
||||
// program is still a legal stage while GetSeparable() reads false. Tracking driven
|
||||
// by the live flag would stop recording writes on a program the composite is still
|
||||
// mirroring from, and those uniforms would silently stop reaching the draw.
|
||||
//
|
||||
// "Attached to a pipeline" is not usable either, and for a more basic reason:
|
||||
// glProgramUniform* legitimately runs before glUseProgramStages, so the marks have
|
||||
// to already exist by the time the program becomes a stage.
|
||||
//
|
||||
// So: a MONOTONE latch, armed the first time GL_PROGRAM_SEPARABLE is requested
|
||||
// true and never cleared. It over-approximates - a program that was separable once
|
||||
// keeps paying the bookkeeping - and over-approximating only ever costs a bitset,
|
||||
// never a wrong value. glCreateShaderProgramv arms it through this same setter.
|
||||
// A program that never asks (every monolithic glUseProgram program, which is the
|
||||
// hot uniform path) never arms it and pays one bool branch per glUniform*.
|
||||
if (separable) m_tracksUniformWrites = true;
|
||||
}
|
||||
// glProgramBinary always fails here (there is no format it could accept) and the
|
||||
// spec then requires the program's LINK_STATUS to read FALSE.
|
||||
void MarkLinkFailedByProgramBinary() {
|
||||
@@ -764,6 +868,16 @@ namespace MobileGL::MG_State::GLState {
|
||||
// Applied into the uniform shadow at the phase-B publish (ApplyUniformInitialValues).
|
||||
Vector<glslang::TIntermediate::TUniformInitializer> uniformInitialValues;
|
||||
UnorderedMap<String, Uint> uniformLocations;
|
||||
// ---- "written since link" (see MarkUniformWrittenAtLocation) ----
|
||||
// In LinkArtifacts deliberately: a link is exactly the event that retracts every
|
||||
// write (GL resets uniforms to their initial values), so living here means the set
|
||||
// is cleared by the same three paths that clear the rest of a link's output -
|
||||
// Link()'s whole-struct reset, ResetLinkArtifacts, and the publish's move - and no
|
||||
// fourth reset site can be forgotten. Empty (and never allocated) for a program
|
||||
// that never asked to be separable.
|
||||
Vector<Uint64> writtenUniformLocationBits;
|
||||
Vector<Uint64> writtenUniformIndexBits;
|
||||
Vector<Uint> writtenUniformIndices;
|
||||
// Ordered by location,
|
||||
// aka. uniformIndexInTProgram[loc] == "uniform index of TProgram at location `loc`"
|
||||
Vector<Int> uniformIndexInTProgram;
|
||||
@@ -1079,6 +1193,10 @@ namespace MobileGL::MG_State::GLState {
|
||||
Bool m_deleteStatus = false;
|
||||
Bool m_binaryRetrievableHint = false;
|
||||
Bool m_separable = false;
|
||||
// Monotone "this program may ever be a pipeline stage" latch; see SetSeparable for why
|
||||
// it is a latch and not just m_separable. Outside LinkArtifacts on purpose: a relink
|
||||
// clears the write SET, but a program that was separable is still separable after it.
|
||||
Bool m_tracksUniformWrites = false;
|
||||
Bool m_validateStatus = true;
|
||||
// Mutable, like m_artifacts and for the same reason: publishing a pending link is a
|
||||
// READ-side operation (the first gated getter is what pulls the result in), and the
|
||||
|
||||
@@ -67,20 +67,30 @@ namespace MobileGL {
|
||||
// GRAPHICS stages are composited into a single hidden program object, rebuilt
|
||||
// whenever the stage set - or any stage program's own link - changes. The
|
||||
// signature is what that "changes" means: a stage program's lifetime id pins the
|
||||
// object and its backend state version pins the link generation.
|
||||
// object and its LINK version pins the link generation. It covers exactly the
|
||||
// stages the composite is built from, so attaching or relinking a compute stage
|
||||
// never invalidates a perfectly good graphics composite - and the compute stage,
|
||||
// having no composite of its own, can never collide with it.
|
||||
//
|
||||
// The backend state version is BLUNTER than that description: glUniform1i on a
|
||||
// sampler and glUniformBlockBinding bump it too, so either one throws the
|
||||
// composite away and relinks it on the next draw. That is correct but slow, and
|
||||
// it is a shape the SSO conformance cases hit in a loop. Narrowing it to
|
||||
// GetLinkVersion() means the composite must instead pick those two up the way it
|
||||
// picks up uniform values (below) - the sampler half already works that way,
|
||||
// the block-binding half does not yet, which is why this still keys on the
|
||||
// blunter version.
|
||||
// It covers
|
||||
// exactly the stages the composite is built from, so attaching or relinking a
|
||||
// compute stage never invalidates a perfectly good graphics composite - and the
|
||||
// compute stage, having no composite of its own, can never collide with it.
|
||||
// GetLinkVersion() and NOT GetBackendStateVersion(), which is what this used to
|
||||
// key on. The backend state version moves on every glUniform1i to a sampler and
|
||||
// every glUniformBlockBinding, so the "set a sampler unit, draw" loop that the
|
||||
// SSO conformance cases run threw the composite away and REBUILT it on every
|
||||
// single draw: a fresh ProgramObject, a full Link(true) settled synchronously
|
||||
// (glslang + SPIR-V + spirv-opt), a full re-mirror, and a brand-new program
|
||||
// identity that invalidated both backends' per-program registries and pipeline
|
||||
// memos along the way. The composite's CONTENT depends on the link generations
|
||||
// and nothing else, and m_linkVersion is bumped by exactly those
|
||||
// (BumpLinkObservableVersions).
|
||||
//
|
||||
// The prerequisite that makes the narrowing legal: because the composite no
|
||||
// longer rebuilds when per-program uniform STATE changes, every such change must
|
||||
// reach it through the refresh below instead. Both do - sampler/image units via
|
||||
// MirrorUniformValues, interface block bindings via MirrorBlockBindings - and
|
||||
// the two setters that write them still bump the counters the REFRESH gate reads
|
||||
// (see ComputeUniformMirrorVersions), which is a separate question from what
|
||||
// this signature reads. They are the only two writers of m_backendStateVersion
|
||||
// outside the link paths, so nothing else was ever riding on the rebuild.
|
||||
using DrawProgramSignature = Array<Uint64, kGraphicsStageCount * 2>;
|
||||
|
||||
DrawProgramSignature ComputeDrawProgramSignature() const {
|
||||
@@ -89,7 +99,7 @@ namespace MobileGL {
|
||||
const auto& program = m_stagePrograms[stage];
|
||||
if (!program) continue;
|
||||
signature[stage * 2] = program->GetLifetimeId();
|
||||
signature[stage * 2 + 1] = program->GetBackendStateVersion();
|
||||
signature[stage * 2 + 1] = program->GetLinkVersion();
|
||||
}
|
||||
return signature;
|
||||
}
|
||||
@@ -102,6 +112,14 @@ namespace MobileGL {
|
||||
// the per-stage versions "needs it" is measured against. All zero after a
|
||||
// rebuild, because a fresh composite holds only what its shaders declared and
|
||||
// so needs a full refresh.
|
||||
//
|
||||
// backendStateVersion belongs HERE even though ComputeDrawProgramSignature no
|
||||
// longer reads it, and that is the whole point of the split: a sampler-unit or
|
||||
// uniform-block-binding write must still trip the MIRROR (it is now the only
|
||||
// route those values have to the composite) while deliberately NOT tripping the
|
||||
// rebuild. uboContentVersion covers ordinary uniform writes, and
|
||||
// blockBindingVersion covers the storage-block setter, which moves neither of
|
||||
// the other two.
|
||||
using UniformMirrorVersions = Array<Uint64, kGraphicsStageCount * 2>;
|
||||
|
||||
UniformMirrorVersions ComputeUniformMirrorVersions() const {
|
||||
|
||||
Reference in New Issue
Block a user