mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-07 19:58:32 +09:00
[Perf] (MG_State): adopt in-flight compile jobs across shader objects (P1 stage 6)
~21% of a shaderpack's glCompileShader calls hand different shader objects byte-identical source; the P0b cache only helps after one finishes, so under async two workers would run the whole pipeline twice. Now the GL thread consults a per-context (stage, hash, length, envFingerprint) -> weak-node map at enqueue and ADOPTS the in-flight (or completed) node instead of posting a duplicate - a hit is honored only after a full byte comparison (the hash never decides), a cancel-requested or settled-cancelled node is never adopted, and no worker ever waits. Sharing a node makes the unconditional cancel wrong, so release is now adopter-counted: a plain GL-thread Int (every mutation site is a GL entry point; the single-threadedness argument and the terminal-early-out that keeps the count exact are in the header), and the cancel fires only at count zero AND with no pending link pinning the node (the stage-4 MarkLinkReferenced precedence). Adoption also re-points the object's source at the node's snapshot so the layer-1 memo's pointer compare stays armed - without that, an adopter's next glCompileShader would re-enqueue the very duplicate this stage removes. Both guards are negative-control-proven: each removed guard fails exactly its own tests. Count discipline was proven with a temporary hard-abort on underflow/leak across the full suite and retrace corpus - zero hits. 18 new tests (13 GL-surface incl. shared-node re-source/delete/orphan-sweep isolation, shared failure logs, 48-over-6 stress with a deterministic adoption count, flag-off and KHR-suspended zero-adoption guards; 5 direct map cases incl. fingerprint mismatch and cancelled/expired pruning). Gates: 538/538 unit both flag states, async suites x5 no flakes, NVIDIA DirectGLES retrace identical sets both states. Timing: 2-worker (Android-shaped) 1-3% faster consistently on complementary and BSL; 4-worker unchanged - the win this stage exists for lands where CPU is scarce.
This commit is contained in:
@@ -298,6 +298,7 @@ set(SOURCE_FILES
|
||||
MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp
|
||||
MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp
|
||||
MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.cpp
|
||||
MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.cpp
|
||||
MobileGL/MG_State/GLState/ProgramState/ProgramState.cpp
|
||||
MobileGL/MG_State/GLState/RenderState/RenderState.cpp
|
||||
MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.cpp
|
||||
|
||||
@@ -156,6 +156,11 @@ namespace MobileGL {
|
||||
// Settles every compile and link this context still owns; see
|
||||
// ProgramState::JoinAllPendingWork. Called by glMaxShaderCompilerThreadsKHR(0).
|
||||
void JoinAllPendingShaderWork();
|
||||
// P1 stage 6: the per-context index of adoptable compile nodes, for its
|
||||
// adoption counter. Diagnostics and tests only - no GL entry point reads it.
|
||||
ShaderCompileAdoptionMap& GetShaderCompileAdoptionMap() {
|
||||
return m_programState.GetShaderCompileAdoptionMap();
|
||||
}
|
||||
void UseProgram(Uint program);
|
||||
const SharedPtr<ProgramObject>& GetCurrentProgram();
|
||||
// What a draw or dispatch actually executes: the program in use, or - when
|
||||
|
||||
@@ -87,7 +87,8 @@ namespace MobileGL::MG_State::GLState {
|
||||
Uint shaderId = 0;
|
||||
m_programShaderNameGenerator.Generate(1, &shaderId);
|
||||
EnsureIndexAvail(shaderId, m_shaderObjects);
|
||||
auto shaderObject = MakeShared<ShaderObject>(stage, shaderId, m_shaderPreprocessCache);
|
||||
auto shaderObject =
|
||||
MakeShared<ShaderObject>(stage, shaderId, m_shaderPreprocessCache, m_shaderCompileAdoptionMap);
|
||||
if (shaderObject == nullptr) return 0;
|
||||
m_shaderObjects[shaderId] = shaderObject;
|
||||
return shaderId;
|
||||
@@ -153,11 +154,13 @@ namespace MobileGL::MG_State::GLState {
|
||||
auto& shaderObject = m_shaderObjects[shader];
|
||||
if (shaderObject == nullptr || !shaderObject->GetDeleteStatus()) return;
|
||||
if (ShaderHasGLVisibleAttachment(shaderObject)) return;
|
||||
// The name is about to go: nothing can observe this shader's compile any more, so a
|
||||
// job still in flight for it is pure waste. Cancel-not-join - the job owns its
|
||||
// inputs, so dropping the object out from under it is safe and the GL thread never
|
||||
// blocks on a delete.
|
||||
shaderObject->CancelCompile();
|
||||
// The name is about to go, so nothing can observe this shader's compile through THIS
|
||||
// object any more and a job still in flight for it is pure waste - unless another
|
||||
// shader object adopted the same node (stage 6) or a pending link pinned it, which is
|
||||
// exactly what ReleaseCompileNode weighs before it cancels anything. Cancel-not-join
|
||||
// either way: the job owns its inputs, so dropping the object out from under it is
|
||||
// safe and the GL thread never blocks on a delete.
|
||||
shaderObject->ReleaseCompileNode();
|
||||
shaderObject.reset();
|
||||
m_programShaderNameGenerator.Delete(shader);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <Includes.h>
|
||||
#include <MG_Util/Miscellany/IndexGenerator.h>
|
||||
#include "ProgramObject.h"
|
||||
#include "ShaderCompileAdoptionMap.h"
|
||||
#include "ShaderPreprocessCache.h"
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
@@ -52,6 +53,11 @@ namespace MobileGL::MG_State::GLState {
|
||||
// CreateShader().
|
||||
ShaderPreprocessCache& GetShaderPreprocessCache() { return *m_shaderPreprocessCache; }
|
||||
|
||||
// P1 stage 6, same deal: exposed for tests and diagnostics only. Its adoption counter
|
||||
// is the one number that says how many glCompileShader calls this context turned into
|
||||
// no work at all; nothing in the GL frontend branches on it.
|
||||
ShaderCompileAdoptionMap& GetShaderCompileAdoptionMap() { return *m_shaderCompileAdoptionMap; }
|
||||
|
||||
private:
|
||||
Bool ShaderHasGLVisibleAttachment(const SharedPtr<ShaderObject>& shaderObject) const;
|
||||
// Frees the name slot and releases orphaned attached shaders; the immediate half
|
||||
@@ -82,6 +88,11 @@ namespace MobileGL::MG_State::GLState {
|
||||
// in-flight compile job may outlive the context). The FIRST-member declaration is
|
||||
// kept anyway - it costs nothing and documents the intent.
|
||||
SharedPtr<ShaderPreprocessCache> m_shaderPreprocessCache = MakeShared<ShaderPreprocessCache>();
|
||||
// P1 stage 6: the GL-thread-only index of adoptable compile nodes. Shared ownership
|
||||
// for the same reason as the cache above - a ShaderObject held by a ProgramObject can
|
||||
// outlive these tables, and its destructor releases a node - though unlike the cache
|
||||
// no worker ever sees this one, which is why it carries no lock.
|
||||
SharedPtr<ShaderCompileAdoptionMap> m_shaderCompileAdoptionMap = MakeShared<ShaderCompileAdoptionMap>();
|
||||
|
||||
Vector<SharedPtr<ProgramObject>> m_programObjects;
|
||||
Vector<SharedPtr<ShaderObject>> m_shaderObjects;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
#include "ShaderCompileAdoptionMap.h"
|
||||
|
||||
#include "ShaderCompileTask.h"
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
SharedPtr<ShaderCompileTask> ShaderCompileAdoptionMap::FindAdoptable(const ShaderStage stage,
|
||||
const Uint64 sourceHash, const String& source,
|
||||
const Uint64 envFingerprint) {
|
||||
const ShaderSourceKey key{.stage = stage,
|
||||
.sourceHash = sourceHash,
|
||||
.sourceLength = source.length(),
|
||||
.envFingerprint = envFingerprint};
|
||||
|
||||
const auto it = m_entries.find(key);
|
||||
if (it == m_entries.end()) return nullptr;
|
||||
|
||||
SharedPtr<ShaderCompileTask> node = it->second.lock();
|
||||
// Expired (every shader object that held it has released it), settled as Cancelled
|
||||
// (the enqueue lost a race with teardown, or the body threw), or CANCELLATION
|
||||
// REQUESTED but not yet settled (a releaser fired Cancel() while a worker was still
|
||||
// inside RunBody(), so the node is stuck at Running until the body returns - see
|
||||
// JobNode::Run: once m_cancelled is set, the node is DOOMED to end up Cancelled no
|
||||
// matter how the body finishes, it just has not gotten there yet). All three can
|
||||
// never publish artifacts a caller may rely on, so all three are misses. Only the
|
||||
// first two are dead weight worth pruning from the index here - a cancellation-
|
||||
// requested-but-still-running node is still reachable from its own (about to
|
||||
// release) ShaderObject and will get pruned once it actually settles, so leave the
|
||||
// entry alone and just refuse to hand this node out.
|
||||
if (!node || node->IsCancelled()) {
|
||||
m_entries.erase(it);
|
||||
return nullptr;
|
||||
}
|
||||
if (node->IsCancellationRequested()) return nullptr;
|
||||
|
||||
// Never let correctness ride on a 64-bit hash. Lengths already matched (they are part
|
||||
// of the key), so this is a plain memcmp - and it is the ONLY thing that authorizes
|
||||
// two GL shader names to share one compile.
|
||||
if (*node->source != source) return nullptr;
|
||||
|
||||
++m_adoptionCount;
|
||||
return node;
|
||||
}
|
||||
|
||||
void ShaderCompileAdoptionMap::Register(const SharedPtr<ShaderCompileTask>& node) {
|
||||
if (!node) return;
|
||||
|
||||
SweepIfCrowded();
|
||||
// operator[] rather than a find/insert pair: an existing entry for this key is either
|
||||
// a re-registration of the same source (the previous node expired or was cancelled)
|
||||
// or an astronomically rare hash collision. The newcomer wins in both cases.
|
||||
m_entries[ShaderSourceKey{.stage = node->stage,
|
||||
.sourceHash = node->sourceHash,
|
||||
.sourceLength = node->source->length(),
|
||||
.envFingerprint = node->env->fingerprint}] = node;
|
||||
}
|
||||
|
||||
void ShaderCompileAdoptionMap::Clear() {
|
||||
m_entries.clear();
|
||||
m_sweepThreshold = kMinSweepThreshold;
|
||||
}
|
||||
|
||||
void ShaderCompileAdoptionMap::SweepIfCrowded() {
|
||||
if (m_entries.size() < m_sweepThreshold) return;
|
||||
|
||||
// Collect first, erase after: FastSTL::unordered_map is open-addressed, so erasing
|
||||
// through an iterator that the same loop is still advancing is not worth reasoning
|
||||
// about on a path this cold.
|
||||
Vector<ShaderSourceKey> dead;
|
||||
for (const auto& entry : m_entries) {
|
||||
const SharedPtr<ShaderCompileTask> node = entry.second.lock();
|
||||
if (!node || node->IsCancelled()) dead.push_back(entry.first);
|
||||
}
|
||||
for (const ShaderSourceKey& key : dead) {
|
||||
m_entries.erase(key);
|
||||
}
|
||||
|
||||
// Amortization: after a sweep the map holds exactly the nodes still reachable from
|
||||
// some shader object, so letting it double before the next sweep makes the whole
|
||||
// scheme O(1) per Register() while keeping the map O(live nodes).
|
||||
m_sweepThreshold = std::max(kMinSweepThreshold, m_entries.size() * 2);
|
||||
}
|
||||
} // namespace MobileGL::MG_State::GLState
|
||||
@@ -0,0 +1,97 @@
|
||||
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.h
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
#pragma once
|
||||
#include <Includes.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderSourceKey.h>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
class ShaderCompileTask;
|
||||
|
||||
// P1 stage 6: the per-context index of compile job nodes that a NEW shader object may
|
||||
// adopt instead of enqueueing a duplicate of.
|
||||
//
|
||||
// Why it is not the P0b preprocess cache. That cache only helps once a compile has
|
||||
// FINISHED - it memoizes the source-only half of the pipeline, and a worker consults it
|
||||
// from inside the job body. Under asynchronous compilation the dominant shape is
|
||||
// different: a shaderpack load hands N different shader objects byte-identical source
|
||||
// within the same GL-thread burst (measured across bsl/complementary/bliss, ~21% of all
|
||||
// Compile() calls are such cross-object duplicates), and all N are enqueued before any of
|
||||
// them completes. Every one of those workers then misses the cache, runs the whole
|
||||
// pipeline, and races the others to insert the same entry. This map closes that window on
|
||||
// the GL thread, at enqueue: the second object through takes the FIRST object's node.
|
||||
//
|
||||
// What "adopt" means: the two shader objects end up holding the same SharedPtr in their
|
||||
// m_compiled. They are two distinct GL names with two distinct info-log/COMPILE_STATUS
|
||||
// queries, but both queries read one set of artifacts - which is exactly right, because
|
||||
// the pipeline is a pure function of the key below and the full source text. Nothing is
|
||||
// copied and no worker ever waits (P1 invariant I4 is untouched: this only ever REMOVES
|
||||
// work from the pool). The single consume-once resource, the glslang parse, is already
|
||||
// guarded for sharing by ShaderCompileTask::ClaimParsedShader's CAS, which stage 4 built
|
||||
// for exactly this shape - one node, several links.
|
||||
//
|
||||
// ---- Threading: GL thread only, and therefore lock-free ----
|
||||
// Every entry point below is reached from glCompileShader (ShaderObject::Compile) and
|
||||
// from nowhere else. That is one GL entry point on the application's context thread, so
|
||||
// the map needs no mutex, unlike the preprocess cache which several workers hit at once.
|
||||
// The weak pointers are the ONLY thing this class stores, precisely so it can never keep
|
||||
// a node - or the artifacts a node owns - alive past its last real holder.
|
||||
//
|
||||
// ---- Lifetime and pruning ----
|
||||
// WeakPtr, never SharedPtr: the map is an index, not an owner. An entry whose node has
|
||||
// been released by every shader object simply expires, and a node that was CANCELLED
|
||||
// carries no result at all, so both are treated as misses and pruned where they are
|
||||
// found. Pruning is otherwise amortized: Register() sweeps the whole map whenever it has
|
||||
// grown past twice its size at the last sweep, which bounds the map at O(live nodes)
|
||||
// without a per-call cost.
|
||||
class ShaderCompileAdoptionMap {
|
||||
public:
|
||||
// Never sweep below this: a shaderpack burst is a few hundred distinct sources, and
|
||||
// an entry is a key plus a weak pointer.
|
||||
static constexpr SizeT kMinSweepThreshold = 256;
|
||||
|
||||
// The adoptable node for this exact source under this exact environment, or null.
|
||||
//
|
||||
// A hit is honored only after the FULL source text has been compared byte for byte
|
||||
// against the candidate node's own snapshot: the hash in the key is a lookup
|
||||
// accelerator, never the answer (ShaderSourceKey). A node that has settled as
|
||||
// Cancelled is never handed out - it published nothing, so adopting it would give the
|
||||
// new object a compile that can never report anything but GL_FALSE. Nor is a node
|
||||
// whose cancellation has merely been REQUESTED but not yet settled (still Running,
|
||||
// with IsCancellationRequested() true): JobNode::Run forces such a node to Cancelled
|
||||
// the moment its body returns regardless of how the body finished, so it is already
|
||||
// doomed and handing it out would just move the same GL_FALSE-with-no-log outcome to
|
||||
// a second, unrelated shader object.
|
||||
//
|
||||
// A COMPLETED node is adoptable, and deliberately so: the new object gets the right
|
||||
// answer for zero work, which is the same deal the P0b cache offers one layer down.
|
||||
SharedPtr<ShaderCompileTask> FindAdoptable(ShaderStage stage, Uint64 sourceHash, const String& source,
|
||||
Uint64 envFingerprint);
|
||||
|
||||
// Indexes `node` as the adoptable one for its key. A key already present is
|
||||
// overwritten: the newcomer is at least as fresh as whatever was there, and one entry
|
||||
// per key keeps this a plain map.
|
||||
void Register(const SharedPtr<ShaderCompileTask>& node);
|
||||
|
||||
void Clear();
|
||||
|
||||
// ---- diagnostics only; nothing in the GL frontend branches on these ----
|
||||
// Monotonic count of nodes handed out by FindAdoptable, i.e. of glCompileShader calls
|
||||
// that did NOT enqueue a job because an equivalent one already existed. Tests read it
|
||||
// as a delta across a burst.
|
||||
Uint64 GetAdoptionCount() const { return m_adoptionCount; }
|
||||
SizeT GetEntryCount() const { return m_entries.size(); }
|
||||
|
||||
private:
|
||||
void SweepIfCrowded();
|
||||
|
||||
UnorderedMap<ShaderSourceKey, WeakPtr<ShaderCompileTask>, ShaderSourceKeyHasher> m_entries;
|
||||
SizeT m_sweepThreshold = kMinSweepThreshold;
|
||||
Uint64 m_adoptionCount = 0;
|
||||
};
|
||||
} // namespace MobileGL::MG_State::GLState
|
||||
@@ -118,13 +118,61 @@ namespace MobileGL::MG_State::GLState {
|
||||
// glDeleteShader. The detach makes the shader GL-invisible, so the delete frees its
|
||||
// name, and ReleaseShaderNameIfOrphaned would cancel a compile the enqueued link is
|
||||
// waiting on - turning a link that must report GL_TRUE into GL_FALSE. Set on the GL
|
||||
// thread in Link()'s prologue, read on the GL thread by ShaderObject::CancelCompile.
|
||||
// thread in Link()'s prologue, read on the GL thread by
|
||||
// ShaderObject::ReleaseCompileNode - which from stage 6 weighs it together with the
|
||||
// adopter count below, because a node can now have both kinds of observer at once.
|
||||
//
|
||||
// Never cleared: the worst case is one stale node compiling to completion for nobody,
|
||||
// which is exactly what the pre-stage-3 implementation always did.
|
||||
void MarkLinkReferenced() { m_linkReferenced.store(true, std::memory_order_release); }
|
||||
Bool IsLinkReferenced() const { return m_linkReferenced.load(std::memory_order_acquire); }
|
||||
|
||||
// ---- P1 stage 6: the adopter count ----
|
||||
// How many live ShaderObjects currently hold this node in their m_compiled.
|
||||
//
|
||||
// It exists because stage 6 lets a node be SHARED: before it, a node had exactly one
|
||||
// shader object, so "this object stopped caring" and "nothing can observe this
|
||||
// result" were the same statement and ShaderObject::CancelCompile could cancel
|
||||
// unconditionally. Once two GL shader names hold one node, that cancel would kill the
|
||||
// other one's pending compile - a compile that must still report GL_TRUE. So a cancel
|
||||
// is now authorized by TWO conditions, both checked by the releaser:
|
||||
// * this release brings the count to zero (no shader object is left), AND
|
||||
// * IsLinkReferenced() is false (no enqueued link took the node into its snapshot).
|
||||
// The second is the stage-4 pin, unchanged; the first is what stage 6 adds.
|
||||
//
|
||||
// ---- Why a plain Int and not an atomic ----
|
||||
// Every mutation is made from ShaderObject, and every ShaderObject mutation site is a
|
||||
// GL entry point on the application's context thread: glCompileShader (adopt/create),
|
||||
// glShaderSource with different text, glDeleteShader's orphan sweep, and
|
||||
// ~ShaderObject. All of them are the SAME thread, so the count is never concurrently
|
||||
// mutated and an atomic would only buy an unneeded lock prefix on the hottest compile
|
||||
// path. Workers cannot touch it by construction: a job body's entire contract (see
|
||||
// this class's header comment) is that it reads only the node's inputs and writes only
|
||||
// `artifacts`, and a plain Int here makes that contract grep-checkable in a way an
|
||||
// atomic would quietly hide.
|
||||
//
|
||||
// The CANCEL that the count authorizes still races the worker, and deliberately so -
|
||||
// that is the settled cancel-not-join semantics from stage 3: JobNode::Cancel is
|
||||
// cooperative and non-blocking, a node already running settles as Cancelled when its
|
||||
// body returns, and a node that has already gone terminal ignores the request.
|
||||
// Nothing about that changes here.
|
||||
//
|
||||
// Exactness under that race: ShaderObject::ReleaseCompileNode returns EARLY, without
|
||||
// decrementing and without dropping its reference, when the node is already terminal
|
||||
// (there is nothing left to stop). Terminality is sticky, so if a releaser observes a
|
||||
// node as NON-terminal then no holder has ever taken that early return on it, and the
|
||||
// count it reads is exactly the number of holders. If the worker finishes in the
|
||||
// window between that observation and the Cancel(), the Cancel is a no-op on a
|
||||
// terminal node - and the count was zero, so there was no other holder to harm.
|
||||
void AddAdopter() { ++m_adopters; }
|
||||
void ReleaseAdopter() {
|
||||
MOBILEGL_ASSERT(m_adopters > 0,
|
||||
"ShaderCompileTask adopter count underflow; a ShaderObject released a node it did not "
|
||||
"hold (every release must pair with exactly one AddAdopter)");
|
||||
--m_adopters;
|
||||
}
|
||||
Int AdopterCount() const { return m_adopters; }
|
||||
|
||||
private:
|
||||
void RunBody() override;
|
||||
// The real body; RunBody wraps it so a throw becomes a GL-visible compile failure.
|
||||
@@ -132,5 +180,7 @@ namespace MobileGL::MG_State::GLState {
|
||||
|
||||
mutable std::atomic<Bool> m_parseClaimed{false};
|
||||
std::atomic<Bool> m_linkReferenced{false};
|
||||
// GL-thread-owned; see AddAdopter above for why this is not an atomic.
|
||||
Int m_adopters = 0;
|
||||
};
|
||||
} // namespace MobileGL::MG_State::GLState
|
||||
|
||||
@@ -26,16 +26,18 @@ namespace MobileGL::MG_State::GLState {
|
||||
// reason: it is computing the right answer for text this object still holds.
|
||||
if (SourceMatchesCompiledState(source)) return;
|
||||
// The text genuinely changed, so whatever a running job is computing is now about
|
||||
// an old source. Drop it where it stands - it owns its own copy of that old string,
|
||||
// so swapping the pointer below cannot race its storage.
|
||||
CancelCompile();
|
||||
// an old source. Give up our claim on it - it owns its own copy of that old string,
|
||||
// so swapping the pointer below cannot race its storage. Note "our claim", not "the
|
||||
// job": another shader object may have adopted the same node and still be waiting for
|
||||
// exactly this answer, which is what ReleaseCompileNode's count discipline protects.
|
||||
ReleaseCompileNode();
|
||||
m_source = MakeShared<const String>(source);
|
||||
InvalidateCompiledState();
|
||||
}
|
||||
|
||||
void ShaderObject::SetShaderSource(String&& source) {
|
||||
if (SourceMatchesCompiledState(source)) return;
|
||||
CancelCompile();
|
||||
ReleaseCompileNode();
|
||||
m_source = MakeShared<const String>(Move(source));
|
||||
InvalidateCompiledState();
|
||||
}
|
||||
@@ -59,33 +61,75 @@ namespace MobileGL::MG_State::GLState {
|
||||
// Errors and worker-side log lines are raised HERE, on the GL thread, at the first
|
||||
// join of the job that produced them - which for a single shader is trivially the
|
||||
// order a serial implementation would have produced them in.
|
||||
//
|
||||
// ApplyDeferredDiagnostics DRAINS, so a node shared by several shader objects
|
||||
// (stage 6) replays its worker-side log line exactly once, at whichever object joins
|
||||
// first. That is the honest report - one compile ran - and it is log text only: the
|
||||
// GL-observable half of a failure, COMPILE_STATUS and the info log, lives in
|
||||
// `artifacts` and every sharer reads the identical copy of it.
|
||||
MG_Util::Async::ApplyDeferredDiagnostics(*m_compiled);
|
||||
// A node that settled as Cancelled published nothing. Dropping it here is what keeps
|
||||
// the object's state machine to two reachable cases - "no job" and "a job that
|
||||
// completed" - so every reader below can treat a live node as authoritative.
|
||||
if (!m_compiled->IsComplete()) m_compiled.reset();
|
||||
//
|
||||
// Through DropCompileNode, not a bare reset: this object is letting the node go, so
|
||||
// its adopter slot has to go with it. A node shared with another object stays alive
|
||||
// and gets dropped once more when that object joins - once per holder, never twice
|
||||
// for the same one, because DropCompileNode is null-guarded.
|
||||
if (!m_compiled->IsComplete()) DropCompileNode();
|
||||
}
|
||||
|
||||
void ShaderObject::AdoptCompileNode(SharedPtr<ShaderCompileTask> node) const {
|
||||
// Never overwrite a hold without giving its slot back first.
|
||||
DropCompileNode();
|
||||
m_compiled = Move(node);
|
||||
m_compiled->AddAdopter();
|
||||
// Re-arm the join gate: whether this node was just created or just adopted from
|
||||
// another object, THIS object has not pulled its result yet. (An adopted node may
|
||||
// already be terminal - the join then only replays what is left of its diagnostics.)
|
||||
m_compileJoined = false;
|
||||
}
|
||||
|
||||
void ShaderObject::DropCompileNode() const {
|
||||
if (!m_compiled) return;
|
||||
m_compiled->ReleaseAdopter();
|
||||
m_compiled.reset();
|
||||
}
|
||||
|
||||
void ShaderObject::InvalidateCompiledState() {
|
||||
// The job node holds exactly what one Compile() produces, so discarding it IS the
|
||||
// invalidation - and it re-arms nothing, so the next Compile() genuinely recompiles.
|
||||
m_compiled.reset();
|
||||
DropCompileNode();
|
||||
}
|
||||
|
||||
void ShaderObject::CancelCompile() {
|
||||
if (!m_compiled || m_compiled->IsTerminal()) return;
|
||||
// Cooperative and non-blocking. A node that no worker has picked up settles
|
||||
// immediately; one that is running is flagged and settles when its body returns,
|
||||
// writing only into itself the whole time.
|
||||
void ShaderObject::ReleaseCompileNode() {
|
||||
if (!m_compiled) return;
|
||||
// Already terminal: there is nothing left to stop, so this is not a release at all -
|
||||
// the node and this object's claim on it both stay. That early return is older than
|
||||
// stage 6 and it is load-bearing: ProgramState::ReleaseShaderNameIfOrphaned calls
|
||||
// this on a shader whose name is going away but whose object a ProgramObject may
|
||||
// still hold, and dropping a COMPLETED compile there would turn that program's link
|
||||
// into GL_FALSE.
|
||||
if (m_compiled->IsTerminal()) return;
|
||||
// Two independent claimants have to be checked before a cancel, and this object is
|
||||
// authorized to cancel only if BOTH say the result has become unobservable.
|
||||
//
|
||||
// Unless a pending LINK is waiting on it. Cancelling is about discarding a result
|
||||
// nothing can observe any more, and this object is no longer the only route to this
|
||||
// one: an enqueued ProgramLinkTask holds the node as a dependency, and a cancel would
|
||||
// turn its link into GL_FALSE. Reached by the ordinary link-then-detach-then-delete
|
||||
// shader teardown - see ShaderCompileTask::MarkLinkReferenced. Dropping our own
|
||||
// reference is still right; the link keeps the node alive and finishes it.
|
||||
if (!m_compiled->IsLinkReferenced()) m_compiled->Cancel();
|
||||
m_compiled.reset();
|
||||
// 1. Other shader objects. From stage 6 a node can be SHARED by several GL shader
|
||||
// names that were handed byte-identical source; cancelling here would turn a
|
||||
// compile they must still see as GL_TRUE into GL_FALSE. Only the releaser that
|
||||
// takes the count to zero - i.e. the last holder - may cancel. See
|
||||
// ShaderCompileTask::AddAdopter for why a plain Int is sound here and for the
|
||||
// exactness argument under the worker race.
|
||||
// 2. A pending LINK. An enqueued ProgramLinkTask holds the node in its input snapshot
|
||||
// and a cancel would turn its link into GL_FALSE; reached by the ordinary
|
||||
// link-then-detach-then-delete shader teardown. See MarkLinkReferenced. Never
|
||||
// cleared, so this is a one-way pin.
|
||||
//
|
||||
// The cancel itself is cooperative and non-blocking, exactly as before: a node no
|
||||
// worker has picked up settles immediately, a running one is flagged and settles when
|
||||
// its body returns, writing only into itself the whole time.
|
||||
if (m_compiled->AdopterCount() == 1 && !m_compiled->IsLinkReferenced()) m_compiled->Cancel();
|
||||
DropCompileNode();
|
||||
}
|
||||
|
||||
void ShaderObject::Compile() {
|
||||
@@ -103,14 +147,6 @@ namespace MobileGL::MG_State::GLState {
|
||||
// source instead. Same result, one parse either way.
|
||||
if (HasMemoizedCompile()) return;
|
||||
|
||||
// The compile-environment snapshot is taken HERE, on the GL thread, and handed to
|
||||
// the job. Everything the pipeline needs to know about the device comes through it,
|
||||
// never through pActiveBackendObject - that is what makes the body movable.
|
||||
m_compiled = MakeShared<ShaderCompileTask>(m_stage, m_source, ShaderPreprocessCache::HashSource(*m_source),
|
||||
MG_Util::ShaderTranspiler::GetCurrentCompileEnv(),
|
||||
m_preprocessCache, m_externalIndex);
|
||||
m_compileJoined = false;
|
||||
|
||||
// Two reasons to stay on this thread, one rule. Without the async flag the whole
|
||||
// path must be byte-identical to the synchronous implementation, and a cache-less
|
||||
// object is an internal shader that compiles and reads its status in the same
|
||||
@@ -119,7 +155,48 @@ namespace MobileGL::MG_State::GLState {
|
||||
// has to put compilation back on this thread even though the extension is still
|
||||
// advertised, and that is exactly what makes the GL_COMPLETION_STATUS_KHR the
|
||||
// extension mandates after a zero count (immediately GL_TRUE) fall out for free.
|
||||
if (!m_preprocessCache || !MG_Util::Async::AsyncShaderCompileActive()) {
|
||||
//
|
||||
// Hoisted above the node construction because stage 6 keys off it too: this same
|
||||
// answer decides whether the adoption map is consulted at all, so a
|
||||
// glMaxShaderCompilerThreadsKHR(0) and a flag-off build both bypass sharing exactly
|
||||
// as they bypass the pool, and their behaviour stays byte-identical to pre-stage-6.
|
||||
const Bool runOnPool = m_preprocessCache && MG_Util::Async::AsyncShaderCompileActive();
|
||||
|
||||
// The compile-environment snapshot is taken HERE, on the GL thread, and handed to
|
||||
// the job. Everything the pipeline needs to know about the device comes through it,
|
||||
// never through pActiveBackendObject - that is what makes the body movable.
|
||||
const SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> env =
|
||||
MG_Util::ShaderTranspiler::GetCurrentCompileEnv();
|
||||
const Uint64 sourceHash = ShaderPreprocessCache::HashSource(*m_source);
|
||||
|
||||
// ---- P1 stage 6: adopt an equivalent compile instead of enqueueing a duplicate ----
|
||||
// ~21% of all glCompileShader calls in the shaderpack corpus are a DIFFERENT shader
|
||||
// object handed byte-identical source. P0b's memo only pays off once one of them has
|
||||
// finished; under async they are all enqueued in the same burst, so without this each
|
||||
// one runs the whole pipeline on its own worker. The map hands back the node the
|
||||
// first of them created - in flight or already complete - and this object simply
|
||||
// holds it too.
|
||||
if (runOnPool && m_adoptionMap) {
|
||||
if (SharedPtr<ShaderCompileTask> shared =
|
||||
m_adoptionMap->FindAdoptable(m_stage, sourceHash, *m_source, env->fingerprint)) {
|
||||
// Take the node's own source snapshot as ours. FindAdoptable just compared
|
||||
// the two strings in full, so this changes nothing observable - but it is not
|
||||
// optional: the layer-1 memo (HasMemoizedCompile) is a POINTER comparison
|
||||
// against the node's snapshot, so leaving our own equal-but-distinct copy in
|
||||
// place would make the very next glCompileShader on this object decide it had
|
||||
// no memo and enqueue the duplicate this whole stage exists to avoid - and
|
||||
// would make an identical glShaderSource re-source cancel a shared compile.
|
||||
// It also collapses N copies of a ~100 KB shaderpack stage into one.
|
||||
m_source = shared->source;
|
||||
AdoptCompileNode(Move(shared));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
AdoptCompileNode(MakeShared<ShaderCompileTask>(m_stage, m_source, sourceHash, env, m_preprocessCache,
|
||||
m_externalIndex));
|
||||
|
||||
if (!runOnPool) {
|
||||
m_compiled->RunInline();
|
||||
// Inline means the node is already terminal, so this join only replays
|
||||
// diagnostics; it is here so the synchronous and asynchronous paths publish
|
||||
@@ -127,6 +204,10 @@ namespace MobileGL::MG_State::GLState {
|
||||
EnsureCompileJoined();
|
||||
return;
|
||||
}
|
||||
// Registered BEFORE the post, so the very next glCompileShader in this burst can
|
||||
// adopt it however fast a worker picks it up. Registration is an index entry only -
|
||||
// the map holds a WeakPtr and never keeps a node alive.
|
||||
if (m_adoptionMap) m_adoptionMap->Register(m_compiled);
|
||||
MG_Util::Async::ShaderCompilePool::Get().Post(m_compiled);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <Includes.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderStage.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderCompileTask.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.h>
|
||||
|
||||
namespace MobileGL {
|
||||
namespace MG_State::GLState {
|
||||
@@ -31,13 +32,32 @@ namespace MobileGL {
|
||||
// add a round trip. Shared ownership rather than a raw pointer: a compile job
|
||||
// outlives neither the object nor the context deterministically, and the cache
|
||||
// has to stay alive for whoever is still reading it.
|
||||
//
|
||||
// `adoptionMap` is the same context's stage-6 index of adoptable compile nodes.
|
||||
// It is non-null exactly when `preprocessCache` is (ProgramState hands both out
|
||||
// together, and nobody else hands out either), which is what makes "no cache"
|
||||
// keep meaning "compile inline, share nothing": an internal shader object has
|
||||
// neither, so it neither adopts nor registers and its path is byte-identical to
|
||||
// the pre-stage-6 one. GL-thread-only, so unlike the cache it carries no lock -
|
||||
// shared ownership only because a ShaderObject may outlive the context's tables.
|
||||
ShaderObject(const ShaderStage stage, Uint externalIndex,
|
||||
SharedPtr<ShaderPreprocessCache> preprocessCache = nullptr)
|
||||
: m_stage(stage), m_externalIndex(externalIndex), m_preprocessCache(Move(preprocessCache)) {}
|
||||
SharedPtr<ShaderPreprocessCache> preprocessCache = nullptr,
|
||||
SharedPtr<ShaderCompileAdoptionMap> adoptionMap = nullptr)
|
||||
: m_stage(stage), m_externalIndex(externalIndex), m_preprocessCache(Move(preprocessCache)),
|
||||
m_adoptionMap(Move(adoptionMap)) {}
|
||||
// Cancel-not-join: the node owns its inputs, so an in-flight compile whose
|
||||
// object just went away is safe to abandon where it stands. Nothing can observe
|
||||
// its result any more - this object was the only route to it.
|
||||
~ShaderObject() { CancelCompile(); }
|
||||
// its result any more - unless another shader object adopted the same node, or a
|
||||
// link pinned it, which is precisely what ReleaseCompileNode() checks.
|
||||
~ShaderObject() {
|
||||
ReleaseCompileNode();
|
||||
// ReleaseCompileNode KEEPS a node that has already gone terminal - there is
|
||||
// nothing left to stop, so it is not a release at all. This object is going
|
||||
// away regardless, so hand the adopter slot back here. That is what keeps
|
||||
// ShaderCompileTask::AdopterCount() exactly "how many live ShaderObjects hold
|
||||
// this node" instead of merely an upper bound.
|
||||
DropCompileNode();
|
||||
}
|
||||
|
||||
ShaderObject(const ShaderObject&) = delete;
|
||||
ShaderObject& operator=(const ShaderObject&) = delete;
|
||||
@@ -45,10 +65,17 @@ namespace MobileGL {
|
||||
void SetShaderSource(const String& source);
|
||||
void SetShaderSource(String&& source);
|
||||
void Compile();
|
||||
// Drops a compile that is still in flight, without waiting for it. Called at the
|
||||
// points where the object's compiled state stops being observable: a real source
|
||||
// change, and the release of an orphaned shader name.
|
||||
void CancelCompile();
|
||||
// Gives up this object's claim on its compile node, cancelling the node only if
|
||||
// this object was its LAST claimant. Called at the points where the object's
|
||||
// compiled state stops being observable through THIS name: a real source change,
|
||||
// and the release of an orphaned shader name.
|
||||
//
|
||||
// Named for what it does rather than for what it used to do: before stage 6 a
|
||||
// node had exactly one shader object, so giving up the claim and cancelling the
|
||||
// compile were the same act and this was CancelCompile(). They are not the same
|
||||
// act any more - see ShaderCompileTask::AddAdopter for the count discipline and
|
||||
// its single-threadedness argument. Never waits, in either case.
|
||||
void ReleaseCompileNode();
|
||||
void MarkAsDeleted();
|
||||
|
||||
// The compile job node itself, for ProgramObject::Link()'s input snapshot.
|
||||
@@ -146,6 +173,16 @@ namespace MobileGL {
|
||||
}
|
||||
|
||||
void InvalidateCompiledState();
|
||||
|
||||
// ---- the ONLY two writers of m_compiled (P1 stage 6) ----
|
||||
// Every adopter-count mutation lives in these two, which is what makes "exactly
|
||||
// one AddAdopter per hold, exactly one ReleaseAdopter per hold" auditable rather
|
||||
// than something review has to re-derive at each call site. DropCompileNode is
|
||||
// null-guarded, so calling it on an object that already let go is a no-op and a
|
||||
// double release is unrepresentable.
|
||||
void AdoptCompileNode(SharedPtr<ShaderCompileTask> node) const;
|
||||
void DropCompileNode() const;
|
||||
|
||||
// ---- P0b layer 1: per-object no-op recompile ----
|
||||
// True iff `candidate` is byte-identical to the source that produced (or is
|
||||
// producing) the compiled state this object currently holds.
|
||||
@@ -165,17 +202,31 @@ namespace MobileGL {
|
||||
// running compile cannot race its storage - and the layer-1 memo collapses to a
|
||||
// pointer comparison against the job's snapshot, because the setter only swaps
|
||||
// the pointer when the text genuinely differs.
|
||||
//
|
||||
// Not necessarily unique to this object from stage 6 on: adopting a node also
|
||||
// takes that node's source snapshot (see Compile()), so N shader objects sharing
|
||||
// one compile share one copy of the text. The string is immutable and shared-
|
||||
// owned, so that is invisible to every reader.
|
||||
SharedPtr<const String> m_source = EmptySource();
|
||||
|
||||
// P0b layer 2: the owning context's cross-object memo, or null. Internally
|
||||
// locked, because several workers hit it at once.
|
||||
const SharedPtr<ShaderPreprocessCache> m_preprocessCache;
|
||||
// P1 stage 6: the owning context's index of adoptable compile nodes, or null.
|
||||
// Touched only from Compile(), i.e. only on the GL thread, so it carries no lock.
|
||||
const SharedPtr<ShaderCompileAdoptionMap> m_adoptionMap;
|
||||
|
||||
Bool m_deleteStatus = false;
|
||||
|
||||
// ---- Compile OUTPUT ---- pending OR completed; reachable only through Compiled().
|
||||
// Mutable because the join is a read-side operation: a const getter has to be
|
||||
// able to settle an outstanding job before answering.
|
||||
//
|
||||
// SHARED from stage 6 on: several shader objects holding byte-identical source
|
||||
// under the same CompileEnv point at one node. Every read below still goes
|
||||
// through the same join gate, and a second joiner finds the node already
|
||||
// terminal, so nothing about the read path changes - only the release path does
|
||||
// (ReleaseCompileNode).
|
||||
mutable SharedPtr<ShaderCompileTask> m_compiled;
|
||||
// Exactly-once latch for the pull above. Armed with every new job node, set by
|
||||
// the one join that consumes it.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// Deliberately NOT ShaderObject.h: ShaderCompileTask.h needs this header, and ShaderObject.h
|
||||
// needs ShaderCompileTask.h. Only ShaderStage was ever used from there.
|
||||
#include <MG_State/GLState/ProgramState/ShaderStage.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderSourceKey.h>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
// Where the shared, source-only half of ShaderObject::Compile() stopped. The two
|
||||
@@ -112,29 +113,10 @@ namespace MobileGL::MG_State::GLState {
|
||||
}
|
||||
|
||||
private:
|
||||
struct Key {
|
||||
ShaderStage stage = ShaderStage::Unknown;
|
||||
Uint64 sourceHash = 0;
|
||||
SizeT sourceLength = 0;
|
||||
Uint64 envFingerprint = 0;
|
||||
|
||||
Bool operator==(const Key& other) const {
|
||||
return stage == other.stage && sourceHash == other.sourceHash &&
|
||||
sourceLength == other.sourceLength && envFingerprint == other.envFingerprint;
|
||||
}
|
||||
};
|
||||
|
||||
struct KeyHasher {
|
||||
SizeT operator()(const Key& key) const {
|
||||
// The source hash already spreads well; fold the two discriminators in so
|
||||
// that same-hash-different-stage/length keys land in different buckets.
|
||||
Uint64 mixed = key.sourceHash;
|
||||
mixed ^= static_cast<Uint64>(key.sourceLength) + 0x9e3779b97f4a7c15ull + (mixed << 6) + (mixed >> 2);
|
||||
mixed ^= static_cast<Uint64>(static_cast<Int>(key.stage)) * 0xff51afd7ed558ccdull;
|
||||
mixed ^= key.envFingerprint + 0x9e3779b97f4a7c15ull + (mixed << 6) + (mixed >> 2);
|
||||
return static_cast<SizeT>(mixed);
|
||||
}
|
||||
};
|
||||
// Shared with ShaderCompileAdoptionMap so the two per-context memos cannot key
|
||||
// themselves on different notions of "the same compile" - see ShaderSourceKey.h.
|
||||
using Key = ShaderSourceKey;
|
||||
using KeyHasher = ShaderSourceKeyHasher;
|
||||
|
||||
struct Entry {
|
||||
Key key;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderSourceKey.h
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
#pragma once
|
||||
#include <Includes.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderStage.h>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
// The identity of "one glCompileShader's worth of input" - the tuple that decides
|
||||
// whether two compiles must produce byte-identical results. Shared by the two
|
||||
// per-context memos keyed on it, so that neither can drift from the other:
|
||||
// * P0b's ShaderPreprocessCache, which memoizes the source-only half of a compile;
|
||||
// * P1 stage 6's ShaderCompileAdoptionMap, which shares the job NODE itself.
|
||||
//
|
||||
// The 64-bit source hash is a LOOKUP ACCELERATOR ONLY. Every user of this key confirms
|
||||
// a candidate hit with a full byte comparison of the stored source before honoring it,
|
||||
// so a hash collision degrades to a miss and never to a wrong answer. That rule is not
|
||||
// negotiable - see the memo-hazard notes on ShaderPreprocessCache.
|
||||
//
|
||||
// envFingerprint is part of the identity because the pipeline's compute local-size
|
||||
// verdict is computed against CompileEnv's device limits: a memo must never be handed
|
||||
// back under an environment other than the one it was computed against.
|
||||
struct ShaderSourceKey {
|
||||
ShaderStage stage = ShaderStage::Unknown;
|
||||
Uint64 sourceHash = 0;
|
||||
SizeT sourceLength = 0;
|
||||
Uint64 envFingerprint = 0;
|
||||
|
||||
Bool operator==(const ShaderSourceKey& other) const {
|
||||
return stage == other.stage && sourceHash == other.sourceHash &&
|
||||
sourceLength == other.sourceLength && envFingerprint == other.envFingerprint;
|
||||
}
|
||||
};
|
||||
|
||||
struct ShaderSourceKeyHasher {
|
||||
SizeT operator()(const ShaderSourceKey& key) const {
|
||||
// The source hash already spreads well; fold the three discriminators in so
|
||||
// that same-hash-different-stage/length/env keys land in different buckets.
|
||||
Uint64 mixed = key.sourceHash;
|
||||
mixed ^= static_cast<Uint64>(key.sourceLength) + 0x9e3779b97f4a7c15ull + (mixed << 6) + (mixed >> 2);
|
||||
mixed ^= static_cast<Uint64>(static_cast<Int>(key.stage)) * 0xff51afd7ed558ccdull;
|
||||
mixed ^= key.envFingerprint + 0x9e3779b97f4a7c15ull + (mixed << 6) + (mixed >> 2);
|
||||
return static_cast<SizeT>(mixed);
|
||||
}
|
||||
};
|
||||
} // namespace MobileGL::MG_State::GLState
|
||||
@@ -60,6 +60,22 @@ target_link_libraries(
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
add_executable(
|
||||
ShaderCompileAdoptionTest
|
||||
ShaderCompileAdoptionTest.cpp
|
||||
)
|
||||
|
||||
target_include_directories(ShaderCompileAdoptionTest PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
)
|
||||
|
||||
target_link_libraries(
|
||||
ShaderCompileAdoptionTest PRIVATE
|
||||
GTest::gtest_main
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
add_executable(
|
||||
ParallelShaderCompileTest
|
||||
ParallelShaderCompileTest.cpp
|
||||
@@ -113,6 +129,9 @@ gtest_discover_tests(ProgramTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
# compile pool so there is something in flight to race against.
|
||||
gtest_discover_tests(AsyncCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
gtest_discover_tests(AsyncLinkTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
# Same reason: the stage-6 cases keep a backlog in flight so a release really can race a
|
||||
# worker, and the 48-object stress links every one of them.
|
||||
gtest_discover_tests(ShaderCompileAdoptionTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
# Same reason: the GL_COMPLETION_STATUS_KHR cases saturate a one-worker pool on purpose.
|
||||
gtest_discover_tests(ParallelShaderCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
gtest_discover_tests(AsyncTeardownTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "MG_Impl/GLImpl/Program/GL_Program.h"
|
||||
#include "MG_State/GLState/Core.h"
|
||||
#include "MG_State/GLState/ProgramState/ShaderPreprocessCache.h"
|
||||
#include "MG_Util/Async/ShaderCompilePool.h"
|
||||
#include "MG_Util/ShaderTranspiler/ShaderCompiler.h"
|
||||
|
||||
using namespace MobileGL;
|
||||
@@ -3006,8 +3007,19 @@ TEST_F(ProgramTest, TwoShaderObjectsWithIdenticalSourceLinkIndependently) {
|
||||
ASSERT_NE(objectA, nullptr);
|
||||
ASSERT_NE(objectB, nullptr);
|
||||
EXPECT_EQ(objectA->GetShaderSource(), objectB->GetShaderSource());
|
||||
// Independent parses despite the shared preprocess.
|
||||
EXPECT_NE(objectA->GetCompiledShader(), objectB->GetCompiledShader());
|
||||
// P0b's layer 2 shares the PREPROCESS and never the parse: glslang's TShader is
|
||||
// consume-once, so a memo hit still has to parse for itself.
|
||||
//
|
||||
// P1 stage 6 shares something stronger when it is active - the whole compile JOB, and
|
||||
// therefore the single parse that job produced - and that sharing is made safe by
|
||||
// ShaderCompileTask::ClaimParsedShader's CAS instead, exactly as it already was for one
|
||||
// shader object attached to two programs. ShaderCompileAdoptionTest is where that is
|
||||
// pinned down (it links both objects and compares the generated SPIR-V). So the
|
||||
// one-parse-per-object assertion belongs to the non-adopting path; the two independent
|
||||
// LINKS below are what both modes have to agree on, and they are the point of this case.
|
||||
if (!MG_Util::Async::AsyncShaderCompileActive()) {
|
||||
EXPECT_NE(objectA->GetCompiledShader(), objectB->GetCompiledShader());
|
||||
}
|
||||
EXPECT_NE(objectA->GetCompiledShader(), nullptr);
|
||||
EXPECT_NE(objectB->GetCompiledShader(), nullptr);
|
||||
|
||||
|
||||
@@ -0,0 +1,910 @@
|
||||
// MobileGL - MobileGL/MG_Test/Program/ShaderCompileAdoptionTest.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
// P1 stage 6: two shader objects handed byte-identical source share ONE compile job.
|
||||
//
|
||||
// The property under test is a conjunction, and every case here attacks one half of it:
|
||||
// * the sharing itself - one job, one node, both GL names reporting the same answer, and
|
||||
// two programs linking that one node to byte-identical SPIR-V;
|
||||
// * that sharing did not make a cancel dangerous. Before this stage a node had exactly one
|
||||
// shader object, so "this object stopped caring" and "nothing can observe this result"
|
||||
// were the same statement and CancelCompile() cancelled unconditionally. They are not the
|
||||
// same statement any more, and the four mutation paths that used to reach that cancel -
|
||||
// re-source, delete, the orphan-name sweep, the destructor - are each covered below with
|
||||
// a second object still holding the node.
|
||||
//
|
||||
// Like the other async suites, every case flips MG_Config::Features.AsyncShaderCompile itself
|
||||
// and drives the real GL entry points, so the file behaves identically whether or not the
|
||||
// suite was launched with MOBILEGL_ASYNC_SHADER_COMPILE=1.
|
||||
//
|
||||
// Adoption is decided ON THE GL THREAD, before anything is posted, so the counter assertions
|
||||
// here are deterministic rather than timing-dependent: whether the first object's compile has
|
||||
// already finished changes nothing about whether the second one adopts it.
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "Config.h"
|
||||
#include "Includes.h"
|
||||
#include "Init.h"
|
||||
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
|
||||
#include "MG_Impl/GLImpl/Program/GL_Program.h"
|
||||
#include "MG_State/GLState/Core.h"
|
||||
#include "MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.h"
|
||||
#include "MG_State/GLState/ProgramState/ShaderCompileTask.h"
|
||||
#include "MG_State/GLState/ProgramState/ShaderPreprocessCache.h"
|
||||
#include "MG_Util/Async/ShaderCompilePool.h"
|
||||
#include "MG_Util/ShaderTranspiler/CompileEnv.h"
|
||||
|
||||
using namespace MobileGL;
|
||||
using namespace MobileGL::MG_Impl::GLImpl;
|
||||
using MobileGL::MG_State::GLState::ShaderCompileAdoptionMap;
|
||||
using MobileGL::MG_State::GLState::ShaderCompileTask;
|
||||
using MobileGL::MG_State::GLState::ShaderObject;
|
||||
using MobileGL::MG_State::GLState::ShaderPreprocessCache;
|
||||
|
||||
namespace {
|
||||
class AsyncModeScope {
|
||||
public:
|
||||
explicit AsyncModeScope(const Bool async) : m_saved(MG_Config::Features.AsyncShaderCompile) {
|
||||
MG_Config::Features.AsyncShaderCompile =
|
||||
async ? MG_Config::QuirkOverride::ForceOn : MG_Config::QuirkOverride::ForceOff;
|
||||
}
|
||||
~AsyncModeScope() { MG_Config::Features.AsyncShaderCompile = m_saved; }
|
||||
AsyncModeScope(const AsyncModeScope&) = delete;
|
||||
AsyncModeScope& operator=(const AsyncModeScope&) = delete;
|
||||
|
||||
private:
|
||||
const MG_Config::QuirkOverride m_saved;
|
||||
};
|
||||
|
||||
// The suspension latch and the concurrency budget are PROCESS-wide, so a case that
|
||||
// touches either has to put both back or it poisons every case after it in this binary.
|
||||
class CompilerThreadScope {
|
||||
public:
|
||||
CompilerThreadScope() = default;
|
||||
~CompilerThreadScope() {
|
||||
MG_Util::Async::SetAsyncShaderCompileSuspended(false);
|
||||
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(
|
||||
MG_Util::Async::ShaderCompilePool::Get().GetThreadCount());
|
||||
}
|
||||
CompilerThreadScope(const CompilerThreadScope&) = delete;
|
||||
CompilerThreadScope& operator=(const CompilerThreadScope&) = delete;
|
||||
};
|
||||
|
||||
const char* kVs = R"(#version 460
|
||||
layout(location = 0) in vec3 aPos;
|
||||
uniform mat4 uModel;
|
||||
uniform vec4 uColor;
|
||||
out vec4 vColor;
|
||||
void main() {
|
||||
vColor = uColor;
|
||||
gl_Position = uModel * vec4(aPos, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
// Fails inside glslang rather than in the lexical pre-checks, so it exercises the same
|
||||
// ParseFailed path a real broken shaderpack source takes.
|
||||
const char* kBrokenFs = R"(#version 460
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
void main() { fragColor = thisIdentifierWasNeverDeclared; }
|
||||
)";
|
||||
|
||||
// Big enough that a compile is not instantaneous, so a duplicate really would cost
|
||||
// something. Templated on an index so every instance is a distinct source.
|
||||
String MakeBulkySource(const int index) {
|
||||
String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n";
|
||||
source += "uniform float uSeed" + std::to_string(index) + ";\n";
|
||||
source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n";
|
||||
for (int i = 0; i < 220; ++i) {
|
||||
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
|
||||
}
|
||||
source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n";
|
||||
return source;
|
||||
}
|
||||
|
||||
// Heavy enough that a spinning GL thread can reliably observe the compile Running on a
|
||||
// single-worker pool, for RunningCancelRequestedNodeIsNotAdopted below - MakeBulkySource
|
||||
// is tuned for "not instantaneous", this one is tuned for "actually spin-observable".
|
||||
String MakeVeryHeavySource(const int index) {
|
||||
String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n";
|
||||
source += "uniform float uSeed" + std::to_string(index) + ";\n";
|
||||
source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n";
|
||||
for (int i = 0; i < 4000; ++i) {
|
||||
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
|
||||
}
|
||||
source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n";
|
||||
return source;
|
||||
}
|
||||
|
||||
Uint64 AdoptionCount() {
|
||||
return MG_State::pGLContext->GetShaderCompileAdoptionMap().GetAdoptionCount();
|
||||
}
|
||||
|
||||
// A copy of the slot, never the reference: creating another shader can reallocate the
|
||||
// context's object table.
|
||||
SharedPtr<ShaderObject> Object(const GLuint shader) {
|
||||
return MG_State::pGLContext->GetShaderObject(shader);
|
||||
}
|
||||
|
||||
// The node identity, WITHOUT joining - this is what "they share one job" means, and
|
||||
// asking must not settle anything.
|
||||
const ShaderCompileTask* NodeOf(const GLuint shader) {
|
||||
const SharedPtr<ShaderObject> object = Object(shader);
|
||||
return object ? object->CompiledNodeForLink().get() : nullptr;
|
||||
}
|
||||
|
||||
GLuint MakeShader(const GLenum type, const char* source) {
|
||||
const GLuint shader = CreateShader(type);
|
||||
ShaderSource(shader, 1, &source, nullptr);
|
||||
return shader;
|
||||
}
|
||||
|
||||
GLuint MakeAndCompile(const GLenum type, const char* source) {
|
||||
const GLuint shader = MakeShader(type, source);
|
||||
CompileShader(shader);
|
||||
return shader;
|
||||
}
|
||||
|
||||
GLint QueryCompileStatus(const GLuint shader) {
|
||||
GLint status = GL_FALSE;
|
||||
GetShaderiv(shader, GL_COMPILE_STATUS, &status);
|
||||
return status;
|
||||
}
|
||||
|
||||
String QueryShaderInfoLog(const GLuint shader) {
|
||||
GLint length = 0;
|
||||
GetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
|
||||
if (length <= 0) return String();
|
||||
std::vector<GLchar> buffer(static_cast<size_t>(length));
|
||||
GLsizei written = 0;
|
||||
GetShaderInfoLog(shader, length, &written, buffer.data());
|
||||
return String(buffer.data(), static_cast<size_t>(written));
|
||||
}
|
||||
|
||||
GLint QueryLinkStatus(const GLuint program) {
|
||||
GLint status = GL_FALSE;
|
||||
GetProgramiv(program, GL_LINK_STATUS, &status);
|
||||
return status;
|
||||
}
|
||||
|
||||
// Content hash of a linked program's generated SPIR-V, through the state layer (there is
|
||||
// no GL query for it). This is what catches a mis-shared parse: if the claim CAS on a
|
||||
// SHARED node let two links both run mapIO over the same intermediate, the two programs
|
||||
// would disagree here.
|
||||
Vector<Uint64> SpirvDigest(const GLuint program) {
|
||||
const auto& object = MG_State::pGLContext->GetProgramObject(program);
|
||||
Vector<Uint64> digest;
|
||||
if (!object) return digest;
|
||||
for (const auto& module : object->GetGeneratedSpirv()) {
|
||||
Uint64 hash = 1469598103934665603ull;
|
||||
for (const unsigned word : module) {
|
||||
hash = (hash ^ static_cast<Uint64>(word)) * 1099511628211ull;
|
||||
}
|
||||
digest.push_back(hash);
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
|
||||
// Enqueues `count` distinct heavy compiles and reads nothing back, so the pool is left
|
||||
// with a real backlog for the caller's mutations to race against.
|
||||
void SaturatePool(const int count, Vector<String>& sourceStorage) {
|
||||
sourceStorage.reserve(sourceStorage.size() + static_cast<SizeT>(count));
|
||||
for (int i = 0; i < count; ++i) {
|
||||
sourceStorage.push_back(MakeBulkySource(90000 + i));
|
||||
const char* text = sourceStorage.back().c_str();
|
||||
const GLuint shader = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(shader, 1, &text, nullptr);
|
||||
CompileShader(shader);
|
||||
}
|
||||
}
|
||||
|
||||
// Links `shader` against a freshly compiled vertex stage and returns the program.
|
||||
GLuint LinkWith(const GLuint shader) {
|
||||
const GLuint vs = MakeAndCompile(GL_VERTEX_SHADER, kVs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, shader);
|
||||
LinkProgram(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
class ShaderCompileAdoptionTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override { MobileGL::Initialize(); }
|
||||
};
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// The sharing itself
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// The headline: two GL shader names, byte-identical source, exactly one job. Both names must
|
||||
// answer every query correctly, and the ONE parse they share must link into two separate
|
||||
// programs with byte-identical SPIR-V - which is the stage-4 claim CAS being exercised on a
|
||||
// shared node for the first time.
|
||||
TEST_F(ShaderCompileAdoptionTest, TwoObjectsWithIdenticalSourceShareOneCompileJob) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(32, backlog);
|
||||
|
||||
const String source = MakeBulkySource(100);
|
||||
const char* text = source.c_str();
|
||||
|
||||
const Uint64 before = AdoptionCount();
|
||||
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(a, 1, &text, nullptr);
|
||||
CompileShader(a);
|
||||
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(b, 1, &text, nullptr);
|
||||
CompileShader(b);
|
||||
|
||||
EXPECT_EQ(AdoptionCount() - before, 1u) << "the second glCompileShader must not enqueue a duplicate";
|
||||
ASSERT_NE(NodeOf(a), nullptr);
|
||||
EXPECT_EQ(NodeOf(a), NodeOf(b)) << "both objects must hold the very same job node";
|
||||
|
||||
// Both names still answer for themselves.
|
||||
EXPECT_EQ(QueryCompileStatus(a), GL_TRUE) << QueryShaderInfoLog(a);
|
||||
EXPECT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
|
||||
EXPECT_EQ(QueryShaderInfoLog(a), QueryShaderInfoLog(b));
|
||||
EXPECT_TRUE(QueryShaderInfoLog(a).empty());
|
||||
|
||||
// One node, two links: exactly one of them wins ClaimParsedShader, the other re-parses,
|
||||
// and the two must agree bit for bit.
|
||||
const GLuint programA = LinkWith(a);
|
||||
const GLuint programB = LinkWith(b);
|
||||
ASSERT_EQ(QueryLinkStatus(programA), GL_TRUE);
|
||||
ASSERT_EQ(QueryLinkStatus(programB), GL_TRUE);
|
||||
const Vector<Uint64> digestA = SpirvDigest(programA);
|
||||
const Vector<Uint64> digestB = SpirvDigest(programB);
|
||||
ASSERT_EQ(digestA.size(), 2u);
|
||||
EXPECT_EQ(digestA, digestB) << "a shared node linked twice produced different SPIR-V";
|
||||
EXPECT_GE(GetUniformLocation(programA, "uSeed100"), 0);
|
||||
EXPECT_GE(GetUniformLocation(programB, "uSeed100"), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// Adoption must also re-arm the adopter's layer-1 memo. It is a POINTER comparison against
|
||||
// the node's own source snapshot, so an adopter that kept its own equal-but-distinct copy
|
||||
// would decide on the very next glCompileShader that it had no memo and enqueue the exact
|
||||
// duplicate this stage exists to remove - and an identical glShaderSource would cancel a
|
||||
// compile another object is still waiting on.
|
||||
TEST_F(ShaderCompileAdoptionTest, AdoptingAlsoArmsTheLayerOneMemo) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(32, backlog);
|
||||
|
||||
const String source = MakeBulkySource(110);
|
||||
const char* text = source.c_str();
|
||||
|
||||
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(a, 1, &text, nullptr);
|
||||
CompileShader(a);
|
||||
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(b, 1, &text, nullptr);
|
||||
CompileShader(b);
|
||||
|
||||
const SharedPtr<ShaderObject> objectB = Object(b);
|
||||
ASSERT_NE(objectB, nullptr);
|
||||
EXPECT_TRUE(objectB->HasMemoizedCompile()) << "an adopted node must satisfy the layer-1 memo";
|
||||
|
||||
const ShaderCompileTask* shared = NodeOf(b);
|
||||
const Uint64 before = AdoptionCount();
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
CompileShader(b);
|
||||
EXPECT_EQ(NodeOf(b), shared) << "a repeat glCompileShader on an adopter must be a no-op";
|
||||
}
|
||||
// A byte-identical re-source is a no-op too, so it must not disturb the shared node.
|
||||
ShaderSource(b, 1, &text, nullptr);
|
||||
EXPECT_EQ(NodeOf(b), shared);
|
||||
EXPECT_EQ(AdoptionCount(), before) << "no-op calls must not even reach the adoption map";
|
||||
|
||||
EXPECT_EQ(QueryCompileStatus(a), GL_TRUE) << QueryShaderInfoLog(a);
|
||||
EXPECT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// Different source, and same source in a different STAGE, are different keys. This is the
|
||||
// guard against the map ever handing out a node that does not belong to the caller.
|
||||
TEST_F(ShaderCompileAdoptionTest, DifferentSourceOrStageIsNotAdopted) {
|
||||
const AsyncModeScope async(true);
|
||||
|
||||
const String first = MakeBulkySource(120);
|
||||
const String second = MakeBulkySource(121);
|
||||
const char* firstText = first.c_str();
|
||||
const char* secondText = second.c_str();
|
||||
|
||||
const Uint64 before = AdoptionCount();
|
||||
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(a, 1, &firstText, nullptr);
|
||||
CompileShader(a);
|
||||
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(b, 1, &secondText, nullptr);
|
||||
CompileShader(b);
|
||||
EXPECT_EQ(AdoptionCount(), before) << "different text must not adopt";
|
||||
EXPECT_NE(NodeOf(a), NodeOf(b));
|
||||
|
||||
// The same text in two stages: the vertex/fragment pair below shares no node either,
|
||||
// because the stage is part of the key.
|
||||
const GLuint vsA = MakeAndCompile(GL_VERTEX_SHADER, kVs);
|
||||
const GLuint vsB = MakeAndCompile(GL_VERTEX_SHADER, kVs);
|
||||
EXPECT_EQ(NodeOf(vsA), NodeOf(vsB)) << "same stage, same text: must share";
|
||||
EXPECT_NE(NodeOf(vsA), NodeOf(a));
|
||||
|
||||
EXPECT_EQ(QueryCompileStatus(a), GL_TRUE) << QueryShaderInfoLog(a);
|
||||
EXPECT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// A failed compile is shared exactly like a successful one, and both names must report the
|
||||
// identical status and the identical log - the info log lives in the node's artifacts, so
|
||||
// this is also the guard that a second joiner is not left with an empty one.
|
||||
TEST_F(ShaderCompileAdoptionTest, AdoptedFailingCompileReportsTheIdenticalLogToBothObjects) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(32, backlog);
|
||||
|
||||
const Uint64 before = AdoptionCount();
|
||||
const GLuint a = MakeAndCompile(GL_FRAGMENT_SHADER, kBrokenFs);
|
||||
const GLuint b = MakeAndCompile(GL_FRAGMENT_SHADER, kBrokenFs);
|
||||
EXPECT_EQ(AdoptionCount() - before, 1u);
|
||||
EXPECT_EQ(NodeOf(a), NodeOf(b));
|
||||
|
||||
EXPECT_EQ(QueryCompileStatus(a), GL_FALSE);
|
||||
EXPECT_EQ(QueryCompileStatus(b), GL_FALSE);
|
||||
const String logA = QueryShaderInfoLog(a);
|
||||
EXPECT_FALSE(logA.empty());
|
||||
EXPECT_EQ(QueryShaderInfoLog(b), logA);
|
||||
// GL models a failed compile as status + log, never as a GL error - which is what makes
|
||||
// moving the work off-thread (and sharing it) legal at all.
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// The four release paths, each with a second object still holding the node
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// glShaderSource with DIFFERENT text on one sharer. Its release must NOT cancel the node the
|
||||
// other one is still waiting on; the re-sourced object gets a fresh compile of its own.
|
||||
TEST_F(ShaderCompileAdoptionTest, ResourcingOneSharerLeavesTheOtherIntact) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const String shared = MakeBulkySource(200);
|
||||
const char* sharedText = shared.c_str();
|
||||
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(a, 1, &sharedText, nullptr);
|
||||
CompileShader(a);
|
||||
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(b, 1, &sharedText, nullptr);
|
||||
CompileShader(b);
|
||||
const ShaderCompileTask* sharedNode = NodeOf(b);
|
||||
ASSERT_NE(sharedNode, nullptr);
|
||||
ASSERT_EQ(NodeOf(a), sharedNode);
|
||||
|
||||
// Replace A's text while the shared compile is very probably still outstanding.
|
||||
const String replacement = MakeBulkySource(201);
|
||||
const char* replacementText = replacement.c_str();
|
||||
ShaderSource(a, 1, &replacementText, nullptr);
|
||||
EXPECT_EQ(NodeOf(a), nullptr) << "a real source change must drop the object's node";
|
||||
EXPECT_EQ(NodeOf(b), sharedNode) << "B must still hold the shared node";
|
||||
|
||||
// B is untouched: the compile it is waiting on still publishes, and its artifacts are
|
||||
// the ones that source really produces.
|
||||
ASSERT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
|
||||
const GLuint programB = LinkWith(b);
|
||||
ASSERT_EQ(QueryLinkStatus(programB), GL_TRUE);
|
||||
EXPECT_GE(GetUniformLocation(programB, "uSeed200"), 0);
|
||||
|
||||
// A gets a genuinely fresh compile of the new text.
|
||||
CompileShader(a);
|
||||
EXPECT_NE(NodeOf(a), sharedNode);
|
||||
ASSERT_EQ(QueryCompileStatus(a), GL_TRUE) << QueryShaderInfoLog(a);
|
||||
const GLuint programA = LinkWith(a);
|
||||
ASSERT_EQ(QueryLinkStatus(programA), GL_TRUE);
|
||||
EXPECT_GE(GetUniformLocation(programA, "uSeed201"), 0);
|
||||
EXPECT_EQ(GetUniformLocation(programA, "uSeed200"), -1);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// glDeleteShader on one sharer. The name goes immediately (no wait for a worker) and the
|
||||
// object is destroyed, so this covers the DESTRUCTOR release as well as the orphan sweep's.
|
||||
TEST_F(ShaderCompileAdoptionTest, DeletingOneSharerLeavesTheOtherIntact) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const String source = MakeBulkySource(210);
|
||||
const char* text = source.c_str();
|
||||
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(a, 1, &text, nullptr);
|
||||
CompileShader(a);
|
||||
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(b, 1, &text, nullptr);
|
||||
CompileShader(b);
|
||||
const ShaderCompileTask* sharedNode = NodeOf(b);
|
||||
ASSERT_NE(sharedNode, nullptr);
|
||||
ASSERT_EQ(NodeOf(a), sharedNode);
|
||||
|
||||
DeleteShader(a);
|
||||
EXPECT_EQ(IsShader(a), GL_FALSE) << "an unattached deleted shader's name goes immediately";
|
||||
EXPECT_EQ(NodeOf(b), sharedNode);
|
||||
|
||||
ASSERT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
|
||||
const GLuint program = LinkWith(b);
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE);
|
||||
EXPECT_GE(GetUniformLocation(program, "uSeed210"), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// The window DeletingOneSharerLeavesTheOtherIntact cannot reach: there, A's compile has
|
||||
// always already finished (or not yet started) by the time B adopts, because the pool is
|
||||
// merely BUSY with other backlog. Here A's OWN node is still Running - a worker is inside
|
||||
// RunBody() for it - when the last holder releases it. ReleaseCompileNode fires Cancel(),
|
||||
// but JobNode::Cancel on a Running node only sets the cancellation-REQUEST flag; the state
|
||||
// stays Running until the worker's body returns and JobNode::Run forces the final transition
|
||||
// to Cancelled (see JobNode::Run's tail: it takes Cancelled instead of Complete whenever
|
||||
// m_cancelled is set, regardless of how the body finished). FindAdoptable must refuse a node
|
||||
// in that in-between state - not just one already settled as Cancelled - or C inherits a
|
||||
// doomed node and glGetShaderiv reports GL_FALSE with an empty info log for valid source.
|
||||
TEST_F(ShaderCompileAdoptionTest, RunningCancelRequestedNodeIsNotAdopted) {
|
||||
const AsyncModeScope async(true);
|
||||
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(1);
|
||||
|
||||
const String source = MakeVeryHeavySource(310);
|
||||
const char* text = source.c_str();
|
||||
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(a, 1, &text, nullptr);
|
||||
CompileShader(a);
|
||||
|
||||
// Spin on the GL thread until the single worker is actually inside A's body. The source
|
||||
// is sized to make that window observable rather than instantaneous.
|
||||
const ShaderCompileTask* node = NodeOf(a);
|
||||
ASSERT_NE(node, nullptr);
|
||||
bool sawRunning = false;
|
||||
for (int i = 0; i < 200000 && !node->IsTerminal(); ++i) {
|
||||
if (node->State() == MG_Util::Async::JobState::Running) {
|
||||
sawRunning = true;
|
||||
break;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::microseconds(20));
|
||||
}
|
||||
ASSERT_TRUE(sawRunning) << "could not observe A's compile Running; the synthetic source "
|
||||
"needs to be heavier, or the pool did not have a free worker";
|
||||
|
||||
// A is the ONLY holder, so this release brings the adopter count to zero and (with no
|
||||
// link pin) fires Cancel() on a node that is still Running.
|
||||
DeleteShader(a);
|
||||
ASSERT_EQ(node->State(), MG_Util::Async::JobState::Running)
|
||||
<< "the node already settled; the race window closed before the assertions below "
|
||||
"could observe it - widen MakeVeryHeavySource's loop count";
|
||||
ASSERT_TRUE(node->IsCancellationRequested());
|
||||
ASSERT_FALSE(node->IsCancelled()) << "the window this test targets does not exist here";
|
||||
|
||||
// A brand-new shader name, byte-identical source, nothing wrong with it.
|
||||
const GLuint c = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(c, 1, &text, nullptr);
|
||||
CompileShader(c);
|
||||
EXPECT_NE(NodeOf(c), node) << "C adopted a cancellation-requested, still-Running node";
|
||||
|
||||
ASSERT_EQ(QueryCompileStatus(c), GL_TRUE)
|
||||
<< "valid source reported GL_FALSE; info log: [" << QueryShaderInfoLog(c) << "]";
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(
|
||||
MG_Util::Async::ShaderCompilePool::Get().GetThreadCount());
|
||||
}
|
||||
|
||||
// The deferred half of glDeleteShader: A is ATTACHED, so the delete only flags it and the
|
||||
// name is freed by ReleaseShaderNameIfOrphaned when the detach removes the last GL-visible
|
||||
// attachment. That sweep is the other caller of the release path, and it must not cancel the
|
||||
// node B is sharing.
|
||||
TEST_F(ShaderCompileAdoptionTest, OrphanSweepOnOneSharerLeavesTheOtherIntact) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const String source = MakeBulkySource(220);
|
||||
const char* text = source.c_str();
|
||||
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(a, 1, &text, nullptr);
|
||||
CompileShader(a);
|
||||
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(b, 1, &text, nullptr);
|
||||
CompileShader(b);
|
||||
const ShaderCompileTask* sharedNode = NodeOf(b);
|
||||
ASSERT_NE(sharedNode, nullptr);
|
||||
ASSERT_EQ(NodeOf(a), sharedNode);
|
||||
|
||||
// Attach A, flag it for deletion (name survives), then detach: the sweep fires here, with
|
||||
// NO link ever posted, so the stage-4 pin is NOT what is protecting the node - only the
|
||||
// adopter count is.
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, a);
|
||||
DeleteShader(a);
|
||||
EXPECT_EQ(IsShader(a), GL_TRUE) << "an attached deleted shader keeps its name";
|
||||
DetachShader(program, a);
|
||||
EXPECT_EQ(IsShader(a), GL_FALSE) << "the detach must free the flagged shader's name";
|
||||
EXPECT_EQ(NodeOf(b), sharedNode);
|
||||
|
||||
ASSERT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
|
||||
const GLuint programB = LinkWith(b);
|
||||
ASSERT_EQ(QueryLinkStatus(programB), GL_TRUE);
|
||||
EXPECT_GE(GetUniformLocation(programB, "uSeed220"), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// The same sweep, now with the stage-4 link pin also in play: A's program is LINKED (so the
|
||||
// node is MarkLinkReferenced) and then A is detached and deleted, while B still shares the
|
||||
// node. Both protections have to hold at once - the link must report GL_TRUE and B must
|
||||
// still compile.
|
||||
TEST_F(ShaderCompileAdoptionTest, OrphanSweepWithALinkPinnedSharedNodeHoldsBoth) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const String source = MakeBulkySource(230);
|
||||
const char* text = source.c_str();
|
||||
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(a, 1, &text, nullptr);
|
||||
CompileShader(a);
|
||||
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(b, 1, &text, nullptr);
|
||||
CompileShader(b);
|
||||
const ShaderCompileTask* sharedNode = NodeOf(b);
|
||||
ASSERT_NE(sharedNode, nullptr);
|
||||
ASSERT_EQ(NodeOf(a), sharedNode);
|
||||
|
||||
// The ordinary teardown order: link, then detach, then delete. No status read in between,
|
||||
// so the link's own prologue is what joins the shared compile.
|
||||
const GLuint vs = MakeAndCompile(GL_VERTEX_SHADER, kVs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, a);
|
||||
LinkProgram(program);
|
||||
DetachShader(program, a);
|
||||
DeleteShader(a);
|
||||
EXPECT_EQ(IsShader(a), GL_FALSE);
|
||||
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "the pinned shared compile must still publish";
|
||||
EXPECT_GE(GetUniformLocation(program, "uSeed230"), 0);
|
||||
EXPECT_EQ(NodeOf(b), sharedNode);
|
||||
ASSERT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// Every sharer released, in turn, with nothing pinning the node: the LAST release is the one
|
||||
// that may cancel, and afterwards the map must not hand the cancelled node to anybody. The
|
||||
// property asserted is the one that matters and it is timing-free: whatever happened to the
|
||||
// old node, a later object with the same source must end up with a CORRECT compile.
|
||||
TEST_F(ShaderCompileAdoptionTest, AfterEverySharerIsGoneTheNextCompileIsStillCorrect) {
|
||||
const AsyncModeScope async(true);
|
||||
const CompilerThreadScope compilerThreads;
|
||||
// One worker and a deep backlog: a node posted now is overwhelmingly likely to still be
|
||||
// queued when its last holder drops it, which is the state in which the cancel bites.
|
||||
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(1);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const String source = MakeBulkySource(240);
|
||||
const char* text = source.c_str();
|
||||
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(a, 1, &text, nullptr);
|
||||
CompileShader(a);
|
||||
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(b, 1, &text, nullptr);
|
||||
CompileShader(b);
|
||||
ASSERT_EQ(NodeOf(a), NodeOf(b));
|
||||
|
||||
DeleteShader(a);
|
||||
DeleteShader(b); // the last holder: this one is authorized to cancel
|
||||
|
||||
const GLuint c = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(c, 1, &text, nullptr);
|
||||
CompileShader(c);
|
||||
ASSERT_EQ(QueryCompileStatus(c), GL_TRUE)
|
||||
<< "a cancelled node must never be adopted - it can only ever report GL_FALSE. "
|
||||
<< QueryShaderInfoLog(c);
|
||||
const GLuint program = LinkWith(c);
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE);
|
||||
EXPECT_GE(GetUniformLocation(program, "uSeed240"), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// The bypasses: both must be byte-identical to the pre-stage-6 behaviour
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// The kill switch. With the flag off, compilation is synchronous and NOTHING is adopted -
|
||||
// the map is not even consulted, so the counter cannot move.
|
||||
TEST_F(ShaderCompileAdoptionTest, FlagOffAdoptsNothing) {
|
||||
const AsyncModeScope async(false);
|
||||
ASSERT_FALSE(MG_Util::Async::AsyncShaderCompileEnabled());
|
||||
|
||||
const String source = MakeBulkySource(300);
|
||||
const char* text = source.c_str();
|
||||
|
||||
const Uint64 before = AdoptionCount();
|
||||
Vector<GLuint> shaders;
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
shaders.push_back(fs);
|
||||
}
|
||||
EXPECT_EQ(AdoptionCount(), before) << "the flag-off path must not consult the adoption map";
|
||||
for (SizeT i = 1; i < shaders.size(); ++i) {
|
||||
EXPECT_NE(NodeOf(shaders[i]), NodeOf(shaders[0])) << "flag off means one node per object";
|
||||
}
|
||||
for (const GLuint fs : shaders) {
|
||||
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// glMaxShaderCompilerThreadsKHR(0) puts compilation back on the application's thread even
|
||||
// though the extension stays advertised. Adoption keys off the same predicate, so a
|
||||
// suspended context shares nothing either - which is what keeps a subsequent
|
||||
// GL_COMPLETION_STATUS_KHR immediately GL_TRUE without any reasoning about shared nodes.
|
||||
TEST_F(ShaderCompileAdoptionTest, SuspendedCompilationAdoptsNothing) {
|
||||
const AsyncModeScope async(true);
|
||||
const CompilerThreadScope compilerThreads;
|
||||
MaxShaderCompilerThreadsKHR(0);
|
||||
ASSERT_TRUE(MG_Util::Async::IsAsyncShaderCompileSuspended());
|
||||
|
||||
const String source = MakeBulkySource(310);
|
||||
const char* text = source.c_str();
|
||||
|
||||
const Uint64 before = AdoptionCount();
|
||||
Vector<GLuint> shaders;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
shaders.push_back(fs);
|
||||
GLint complete = GL_FALSE;
|
||||
GetShaderiv(fs, GL_COMPLETION_STATUS_KHR, &complete);
|
||||
EXPECT_EQ(complete, GL_TRUE) << "a zero compiler-thread count leaves nothing in flight";
|
||||
}
|
||||
EXPECT_EQ(AdoptionCount(), before);
|
||||
for (SizeT i = 1; i < shaders.size(); ++i) {
|
||||
EXPECT_NE(NodeOf(shaders[i]), NodeOf(shaders[0]));
|
||||
}
|
||||
for (const GLuint fs : shaders) {
|
||||
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Stress
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// The shaderpack shape: 48 objects over 6 distinct sources, all enqueued before anything is
|
||||
// read, on a two-worker pool. 42 of the 48 compiles must simply vanish, and all 48 objects
|
||||
// must still be individually correct - each with its own name, its own status, and its own
|
||||
// link (which means 48 claims against 6 shared parses).
|
||||
TEST_F(ShaderCompileAdoptionTest, StressFortyEightObjectsOverSixSources) {
|
||||
const AsyncModeScope async(true);
|
||||
const CompilerThreadScope compilerThreads;
|
||||
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(2);
|
||||
|
||||
constexpr int kDistinct = 6;
|
||||
constexpr int kDuplicates = 8;
|
||||
Vector<String> sources;
|
||||
sources.reserve(kDistinct);
|
||||
for (int i = 0; i < kDistinct; ++i) {
|
||||
sources.push_back(MakeBulkySource(400 + i));
|
||||
}
|
||||
|
||||
const Uint64 before = AdoptionCount();
|
||||
Vector<GLuint> shaders;
|
||||
for (int duplicate = 0; duplicate < kDuplicates; ++duplicate) {
|
||||
for (int i = 0; i < kDistinct; ++i) {
|
||||
const char* text = sources[static_cast<SizeT>(i)].c_str();
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
shaders.push_back(fs);
|
||||
}
|
||||
}
|
||||
const Uint64 adoptions = AdoptionCount() - before;
|
||||
// The floor the stage contracts for, with room for any future scheduling slack...
|
||||
ASSERT_GE(adoptions, 30u) << "48 objects over 6 sources adopted only " << adoptions << " times";
|
||||
// ...and the number this design actually produces, because the decision is made on the GL
|
||||
// thread before anything is posted and therefore does not depend on the workers at all.
|
||||
EXPECT_EQ(adoptions, static_cast<Uint64>(kDistinct * (kDuplicates - 1)));
|
||||
|
||||
for (SizeT s = 0; s < shaders.size(); ++s) {
|
||||
const GLuint fs = shaders[s];
|
||||
ASSERT_EQ(QueryCompileStatus(fs), GL_TRUE) << "shader " << s << ": " << QueryShaderInfoLog(fs);
|
||||
const GLuint program = LinkWith(fs);
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "shader index " << s;
|
||||
const String uniform = "uSeed" + std::to_string(400 + static_cast<int>(s % kDistinct));
|
||||
EXPECT_GE(GetUniformLocation(program, uniform.c_str()), 0) << uniform;
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// The adversarial interleaving, with duplicates everywhere: compile, query, re-source,
|
||||
// re-compile, delete, all with the pool busy and most objects sharing nodes. Nothing here
|
||||
// asserts timing - what it hunts for is a node cancelled out from under a sharer, which
|
||||
// surfaces as a wrong status, a wrong uniform, or a crash.
|
||||
TEST_F(ShaderCompileAdoptionTest, StressSharedNodesUnderResourceAndDelete) {
|
||||
const AsyncModeScope async(true);
|
||||
constexpr int kRounds = 6;
|
||||
constexpr int kPerRound = 12;
|
||||
|
||||
for (int round = 0; round < kRounds; ++round) {
|
||||
Vector<String> sources;
|
||||
sources.reserve(4);
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
sources.push_back(MakeBulkySource(round * 100 + i));
|
||||
}
|
||||
const String replacement = MakeBulkySource(round * 100 + 50);
|
||||
const char* replacementText = replacement.c_str();
|
||||
|
||||
Vector<GLuint> shaders;
|
||||
for (int i = 0; i < kPerRound; ++i) {
|
||||
const char* text = sources[static_cast<SizeT>(i % 4)].c_str();
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
shaders.push_back(fs);
|
||||
}
|
||||
|
||||
// Re-source a third of them onto ONE new shared source, so the survivors of each
|
||||
// original node keep waiting on it while the movers pile onto a new one.
|
||||
for (int i = 0; i < kPerRound; i += 3) {
|
||||
ShaderSource(shaders[static_cast<SizeT>(i)], 1, &replacementText, nullptr);
|
||||
CompileShader(shaders[static_cast<SizeT>(i)]);
|
||||
}
|
||||
// And delete another third outright, while their nodes are still shared.
|
||||
for (int i = 1; i < kPerRound; i += 3) {
|
||||
DeleteShader(shaders[static_cast<SizeT>(i)]);
|
||||
}
|
||||
|
||||
for (int i = 0; i < kPerRound; ++i) {
|
||||
if (i % 3 == 1) continue; // deleted
|
||||
const GLuint shader = shaders[static_cast<SizeT>(i)];
|
||||
ASSERT_EQ(QueryCompileStatus(shader), GL_TRUE)
|
||||
<< "round " << round << " shader " << i << ": " << QueryShaderInfoLog(shader);
|
||||
const GLuint program = LinkWith(shader);
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "round " << round << " shader " << i;
|
||||
const String expected =
|
||||
"uSeed" + std::to_string(i % 3 == 0 ? round * 100 + 50 : round * 100 + (i % 4));
|
||||
EXPECT_GE(GetUniformLocation(program, expected.c_str()), 0)
|
||||
<< "round " << round << " shader " << i << " expected " << expected;
|
||||
DeleteProgram(program);
|
||||
}
|
||||
for (int i = 0; i < kPerRound; ++i) {
|
||||
if (i % 3 != 1) DeleteShader(shaders[static_cast<SizeT>(i)]);
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// The map itself, driven directly
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Two of the map's rules cannot be forced deterministically through the GL surface - a
|
||||
// cancelled node depends on beating a worker to it, and a CompileEnv re-capture needs a
|
||||
// backend swap. Both are unconditional properties of the class, so they are asserted here
|
||||
// against the class.
|
||||
|
||||
namespace {
|
||||
SharedPtr<ShaderCompileTask> MakeNode(const String& text, const ShaderStage stage,
|
||||
const SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv>& env) {
|
||||
auto source = MakeShared<const String>(text);
|
||||
const Uint64 hash = ShaderPreprocessCache::HashSource(*source);
|
||||
return MakeShared<ShaderCompileTask>(stage, source, hash, env, nullptr, 0);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST(ShaderCompileAdoptionMapTest, RegisteredNodeIsAdoptedOnAnExactMatch) {
|
||||
const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv();
|
||||
ShaderCompileAdoptionMap map;
|
||||
const String text = "#version 460\nvoid main() {}\n";
|
||||
const SharedPtr<ShaderCompileTask> node = MakeNode(text, ShaderStage::Fragment, env);
|
||||
map.Register(node);
|
||||
|
||||
EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(text), text,
|
||||
env->fingerprint),
|
||||
node);
|
||||
EXPECT_EQ(map.GetAdoptionCount(), 1u);
|
||||
|
||||
// Every discriminator in the key is load-bearing.
|
||||
EXPECT_EQ(map.FindAdoptable(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(text), text,
|
||||
env->fingerprint),
|
||||
nullptr);
|
||||
const String other = text + "\n";
|
||||
EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(other), other,
|
||||
env->fingerprint),
|
||||
nullptr);
|
||||
EXPECT_EQ(map.GetAdoptionCount(), 1u) << "a miss must not count as an adoption";
|
||||
}
|
||||
|
||||
// A memo must never be handed back under an environment other than the one it was computed
|
||||
// against: the compute local-size verdict inside the pipeline reads CompileEnv's device
|
||||
// limits, so a node captured under one backend's limits is not a valid answer under
|
||||
// another's. The fingerprint is what enforces that, and it is part of the key.
|
||||
TEST(ShaderCompileAdoptionMapTest, EnvFingerprintMismatchIsNotAdopted) {
|
||||
const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv();
|
||||
ShaderCompileAdoptionMap map;
|
||||
const String text = "#version 460\nvoid main() {}\n";
|
||||
map.Register(MakeNode(text, ShaderStage::Fragment, env));
|
||||
|
||||
// A genuinely different environment: different device limits, hence a different
|
||||
// fingerprint, hence a different key.
|
||||
auto otherEnv = MakeShared<MG_Util::ShaderTranspiler::CompileEnv>(*env);
|
||||
otherEnv->maxComputeWorkGroupInvocations = env->maxComputeWorkGroupInvocations + 1;
|
||||
otherEnv->fingerprint = MG_Util::ShaderTranspiler::ComputeCompileEnvFingerprint(*otherEnv);
|
||||
ASSERT_NE(otherEnv->fingerprint, env->fingerprint);
|
||||
|
||||
EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(text), text,
|
||||
otherEnv->fingerprint),
|
||||
nullptr);
|
||||
EXPECT_EQ(map.GetAdoptionCount(), 0u);
|
||||
}
|
||||
|
||||
// A node that settled as Cancelled published nothing, so adopting it would hand the new
|
||||
// object a compile that can only ever report GL_FALSE. It must be a miss, and the dead entry
|
||||
// must be pruned where it is found rather than waiting for the amortized sweep.
|
||||
TEST(ShaderCompileAdoptionMapTest, CancelledNodeIsNotAdoptedAndIsPruned) {
|
||||
const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv();
|
||||
ShaderCompileAdoptionMap map;
|
||||
const String text = "#version 460\nvoid main() {}\n";
|
||||
const SharedPtr<ShaderCompileTask> node = MakeNode(text, ShaderStage::Fragment, env);
|
||||
map.Register(node);
|
||||
// Never posted, so this settles the node as Cancelled right here.
|
||||
node->Cancel();
|
||||
ASSERT_TRUE(node->IsCancelled());
|
||||
ASSERT_EQ(map.GetEntryCount(), 1u);
|
||||
|
||||
EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(text), text,
|
||||
env->fingerprint),
|
||||
nullptr);
|
||||
EXPECT_EQ(map.GetEntryCount(), 0u) << "the dead entry must be pruned on the lookup that found it";
|
||||
EXPECT_EQ(map.GetAdoptionCount(), 0u);
|
||||
}
|
||||
|
||||
// The map is an index, never an owner: once the last real holder is gone the entry expires
|
||||
// and is pruned, so a node's artifacts can never be kept alive by the map alone.
|
||||
TEST(ShaderCompileAdoptionMapTest, ExpiredNodeIsNotAdoptedAndIsPruned) {
|
||||
const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv();
|
||||
ShaderCompileAdoptionMap map;
|
||||
const String text = "#version 460\nvoid main() {}\n";
|
||||
{
|
||||
map.Register(MakeNode(text, ShaderStage::Fragment, env));
|
||||
}
|
||||
ASSERT_EQ(map.GetEntryCount(), 1u);
|
||||
|
||||
EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(text), text,
|
||||
env->fingerprint),
|
||||
nullptr);
|
||||
EXPECT_EQ(map.GetEntryCount(), 0u);
|
||||
}
|
||||
|
||||
// The amortized sweep keeps the index O(live nodes) instead of O(compiles ever issued).
|
||||
TEST(ShaderCompileAdoptionMapTest, SweepReclaimsDeadEntries) {
|
||||
const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv();
|
||||
ShaderCompileAdoptionMap map;
|
||||
// Every one of these dies immediately, so nothing but dead weight accumulates - and the
|
||||
// map must not grow without bound because of it.
|
||||
for (SizeT i = 0; i < ShaderCompileAdoptionMap::kMinSweepThreshold * 4; ++i) {
|
||||
map.Register(MakeNode("#version 460\nvoid main() { float x" + std::to_string(i) + " = 0.0; }\n",
|
||||
ShaderStage::Fragment, env));
|
||||
}
|
||||
EXPECT_LE(map.GetEntryCount(), ShaderCompileAdoptionMap::kMinSweepThreshold)
|
||||
<< "expired entries must be reclaimed, not accumulated";
|
||||
}
|
||||
Reference in New Issue
Block a user