mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-12 06:08:30 +09:00
[Feat] (MG_State, MG_Util): async shader compilation behind the default-off flag (P1 stage 3)
glCompileShader with MOBILEGL_ASYNC_SHADER_COMPILE=1 snapshots its inputs on the GL thread (source SharedPtr, CompileEnv, cache handle) and runs the whole pure pipeline - preprocess, validators, extractors, glslang parse - as a ShaderCompileTask on the worker pool, returning immediately. Every read of compile-produced state joins through the single Compiled() gate; links stay synchronous this stage and join their attached shaders at the top of the body. Flag off, the path is the same code run inline. Mechanics: the job node owns all its inputs (no back-pointer, no lifetime tie to the shader object), so re-sourcing or deleting a pending shader is cancel-and-drop, never a wait; glslang worker hygiene is a TLS-allocator scope guard plus GL-thread builtin prewarm (gated on the flag, latch reset on Destroy so re-initialization re-warms); worker-side diagnostics defer through the job and replay on the GL thread at the join, enforced by IsPoolThread asserts in RecordError and an empty-deferred-errors tripwire. A body that throws publishes a COMPLETE failed compile (status false, real info log) rather than an abandoned node, and never memoizes away the retry; a failed enqueue (OOM) cancels the node instead of stranding the joiner - including inside the dispatch loop, where the in-flight slot is repaid. The pool StopAndDrains from an atexit sentinel too: workers still inside glslang parse while exit() ran static destructors was a real 2-in-5 SIGSEGV, reproduced and fixed (15/15 clean after). Backend-internal shader objects (default FS, DirectVulkan blit/mipmap) are cache-less and always compile inline - compile-and-read-in-one-breath needs no round trip. Gates: unit suite 488/488 with the flag off AND on (x5); AsyncCompileTest (12 e2e cases: pending re-source/delete/recompile, byte-identical failure logs across modes, 48-compile cache stress) x10 repeats clean both modes; full NVIDIA DirectGLES retrace identical result sets flag off/on (zero new deltas); compile-phase timing flat as designed (links still serial - the parallel win arrives with stage 4's async link + stage 5's KHR_parallel_shader_compile).
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
#include "MG_State/GLState/RenderbufferState/RenderbufferObject.h"
|
||||
#include "MG_State/EGLState/Core.h"
|
||||
#include <MG_Backend/BackendObjects.h>
|
||||
#include <MG_Util/Async/ShaderCompilePool.h>
|
||||
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
|
||||
#include <Config.h>
|
||||
|
||||
@@ -40,6 +41,13 @@ namespace MobileGL::MG_State {
|
||||
|
||||
// Error
|
||||
void GLContext::RecordError(ErrorCode code, UniquePtr<ErrorInfo> info) {
|
||||
// Invariant I1, mechanically enforced: the GL error state is GL-thread-owned.
|
||||
// A compile or link body that needs to raise an error must append to its node's
|
||||
// JobDiagnostics and let the join replay it here (see the P1 design section 6);
|
||||
// reaching this from a worker would corrupt the sticky-flag set that
|
||||
// glGetError's ordering depends on.
|
||||
MOBILEGL_ASSERT(!MG_Util::Async::ShaderCompilePool::IsPoolThread(),
|
||||
"GLContext::RecordError() called from a shader-compile pool thread");
|
||||
m_errorState.RecordError(code, Move(info));
|
||||
}
|
||||
|
||||
|
||||
@@ -572,6 +572,20 @@ namespace MobileGL::MG_State::GLState {
|
||||
// below is a pure function of the snapshot taken here, which is what lets stage 4
|
||||
// lift it into a ProgramLinkTask. `env` is the first piece of that snapshot: the
|
||||
// link's only window onto the backend.
|
||||
|
||||
// P1 stage 3: linking is still synchronous, so every attached shader's compile has
|
||||
// to be settled before the body below touches a single one of its artifacts. One
|
||||
// loop up front rather than leaning on the per-accessor gate, deliberately: it lets
|
||||
// all the outstanding compiles finish concurrently and blocks once at the end,
|
||||
// instead of serializing them one join at a time down the loop below.
|
||||
//
|
||||
// Placed AFTER the prologue, not before it, so it joins exactly the shader set this
|
||||
// link will read. Shaders removed by the detach pass above are not joined - the link
|
||||
// never reads them, their objects are still alive, and whoever queries one next
|
||||
// joins it then.
|
||||
for (const auto& shader : m_shaders) {
|
||||
shader->JoinCompile();
|
||||
}
|
||||
const SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> envPtr =
|
||||
MG_Util::ShaderTranspiler::GetCurrentCompileEnv();
|
||||
const MG_Util::ShaderTranspiler::CompileEnv& env = *envPtr;
|
||||
|
||||
@@ -120,6 +120,11 @@ 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();
|
||||
shaderObject.reset();
|
||||
m_programShaderNameGenerator.Delete(shader);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.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 "ShaderCompileTask.h"
|
||||
|
||||
#include <MG_Util/Converters/MGToGL/ProgramEnumConverter.h>
|
||||
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
|
||||
#include <MG_Util/ShaderTranspiler/ShaderSourceProcessor.h>
|
||||
#include <MG_Util/ShaderTranspiler/Types.h>
|
||||
|
||||
#include <glslang/Include/PoolAlloc.h>
|
||||
|
||||
#include <charconv>
|
||||
|
||||
namespace {
|
||||
struct ComputeLocalSize {
|
||||
MobileGL::Uint x = 1;
|
||||
MobileGL::Uint y = 1;
|
||||
MobileGL::Uint z = 1;
|
||||
bool declared = false;
|
||||
};
|
||||
|
||||
static MobileGL::String StripGlslComments(const MobileGL::String& source) {
|
||||
MobileGL::String result;
|
||||
result.reserve(source.length());
|
||||
|
||||
bool inLineComment = false;
|
||||
bool inBlockComment = false;
|
||||
for (MobileGL::SizeT i = 0; i < source.length(); ++i) {
|
||||
if (inLineComment) {
|
||||
if (source[i] == '\n') {
|
||||
inLineComment = false;
|
||||
result.push_back(source[i]);
|
||||
} else {
|
||||
result.push_back(' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBlockComment) {
|
||||
if (source[i] == '*' && i + 1 < source.length() && source[i + 1] == '/') {
|
||||
inBlockComment = false;
|
||||
result.append(" ");
|
||||
++i;
|
||||
} else {
|
||||
result.push_back(source[i] == '\n' ? '\n' : ' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source[i] == '/' && i + 1 < source.length()) {
|
||||
if (source[i + 1] == '/') {
|
||||
inLineComment = true;
|
||||
result.append(" ");
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (source[i + 1] == '*') {
|
||||
inBlockComment = true;
|
||||
result.append(" ");
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
result.push_back(source[i]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Hoisted out of ParseComputeLocalSize: constructing a std::regex costs far more than
|
||||
// running it over a small source, and it was being rebuilt on every compute compile. A
|
||||
// const regex carries no mutable state, so sharing one instance across workers is safe.
|
||||
static const std::regex kComputeLocalSizePattern(R"(local_size_([xyz])\s*=\s*([0-9]+))");
|
||||
|
||||
static ComputeLocalSize ParseComputeLocalSize(const MobileGL::String& source) {
|
||||
ComputeLocalSize localSize;
|
||||
const MobileGL::String uncommentedSource = StripGlslComments(source);
|
||||
|
||||
for (std::sregex_iterator it(uncommentedSource.begin(), uncommentedSource.end(), kComputeLocalSizePattern),
|
||||
end;
|
||||
it != end; ++it) {
|
||||
const char axis = (*it)[1].str()[0];
|
||||
// The [0-9]+ capture is unbounded, so `local_size_x = 99999999999999999999999`
|
||||
// is a legal match. std::stoull would throw std::out_of_range on it and let the
|
||||
// exception escape glCompileShader; std::from_chars reports the overflow instead.
|
||||
// An overflowing literal saturates to UINT_MAX, which the device-limit check
|
||||
// below rejects anyway - the same verdict a non-overflowing huge value gets.
|
||||
const MobileGL::String digits = (*it)[2].str();
|
||||
unsigned long long value = 0;
|
||||
const std::from_chars_result parsed =
|
||||
std::from_chars(digits.data(), digits.data() + digits.size(), value);
|
||||
const MobileGL::Uint clampedValue = (parsed.ec != std::errc() || value > UINT_MAX)
|
||||
? UINT_MAX
|
||||
: static_cast<MobileGL::Uint>(value);
|
||||
|
||||
// TODO: Replace this literal layout scanner with parser/AST-backed validation so expressions and
|
||||
// specialization-id layouts are handled consistently with glslang.
|
||||
localSize.declared = true;
|
||||
if (axis == 'x') {
|
||||
localSize.x = clampedValue;
|
||||
} else if (axis == 'y') {
|
||||
localSize.y = clampedValue;
|
||||
} else {
|
||||
localSize.z = clampedValue;
|
||||
}
|
||||
}
|
||||
|
||||
return localSize;
|
||||
}
|
||||
|
||||
// The device limits come from the CompileEnv snapshot, never from a live driver query.
|
||||
// GL_MAX_COMPUTE_WORK_GROUP_SIZE is a real GLES call on the DirectGLES backend: issued
|
||||
// off the context thread it would silently no-op and turn a legal local_size_z into
|
||||
// COMPILE_STATUS=FALSE. CaptureCompileEnv() issues it once, on the GL thread.
|
||||
static std::optional<MobileGL::String> ValidateComputeLocalSizeLimits(
|
||||
const MobileGL::String& source, const MobileGL::MG_Util::ShaderTranspiler::CompileEnv& env) {
|
||||
const ComputeLocalSize localSize = ParseComputeLocalSize(source);
|
||||
if (!localSize.declared) return std::nullopt;
|
||||
|
||||
if (localSize.x > env.maxComputeWorkGroupSize[0] || localSize.y > env.maxComputeWorkGroupSize[1] ||
|
||||
localSize.z > env.maxComputeWorkGroupSize[2]) {
|
||||
return "Compute shader local_size exceeds GL_MAX_COMPUTE_WORK_GROUP_SIZE.";
|
||||
}
|
||||
|
||||
const unsigned long long invocations = static_cast<unsigned long long>(localSize.x) * localSize.y * localSize.z;
|
||||
if (invocations > env.maxComputeWorkGroupInvocations) {
|
||||
return "Compute shader local_size product exceeds GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS.";
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// The half of a compile that depends on nothing but the source text, the stage and the
|
||||
// environment snapshot: preprocessing, the two lexical rejections, and the two lexical
|
||||
// side-channel extractions. Split out so P0b layer 2 can memoize exactly this and
|
||||
// nothing else - the glslang parse stays per-object because its TShader is consume-once.
|
||||
// Deliberately free of any per-object state so the memo is sound.
|
||||
//
|
||||
// The compute local-size verdict reads `env` rather than the live backend, and
|
||||
// env.fingerprint is part of the P0b cache key, so a memo can never be returned against
|
||||
// limits other than the ones it was computed against.
|
||||
static MobileGL::MG_State::GLState::ShaderPreprocessResult RunSourceOnlyPipeline(
|
||||
const MobileGL::ShaderStage stage, const MobileGL::String& source,
|
||||
const MobileGL::MG_Util::ShaderTranspiler::CompileEnv& env) {
|
||||
using namespace MobileGL;
|
||||
using namespace MobileGL::MG_Util::ShaderTranspiler;
|
||||
using MobileGL::MG_State::GLState::ShaderPreprocessOutcome;
|
||||
|
||||
MobileGL::MG_State::GLState::ShaderPreprocessResult result;
|
||||
result.preprocessedSource = source;
|
||||
PreprocessShaderSource(stage, result.preprocessedSource, env);
|
||||
|
||||
if (stage == ShaderStage::Compute) {
|
||||
if (const std::optional<String> localSizeError =
|
||||
ValidateComputeLocalSizeLimits(result.preprocessedSource, env)) {
|
||||
result.outcome = ShaderPreprocessOutcome::ComputeLocalSizeRejected;
|
||||
result.infoLog = *localSizeError;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (const std::optional<String> reservedError = FindReservedIdentifierViolation(result.preprocessedSource)) {
|
||||
result.outcome = ShaderPreprocessOutcome::ReservedIdentifierRejected;
|
||||
result.infoLog = *reservedError;
|
||||
return result;
|
||||
}
|
||||
|
||||
// The parse this feeds runs in the link-compatible configuration (Vulkan-client
|
||||
// env with relaxed rules): the TShader it produces is what glLinkProgram links and
|
||||
// what the backends' SPIR-V is generated from - there is no second, GL-client
|
||||
// parse. The GL frontend semantics the relaxed parse cannot provide are restored
|
||||
// on top: explicit default-block uniform locations through the lexical
|
||||
// side-channels below, dead-uniform/global-UBO filtering in
|
||||
// ProgramObject::DoReflection.
|
||||
result.explicitUniformLocations = ExtractExplicitUniformLocations(result.preprocessedSource);
|
||||
result.explicitOpaqueBindings = ExtractExplicitOpaqueBindings(result.preprocessedSource);
|
||||
result.outcome = ShaderPreprocessOutcome::Preprocessed;
|
||||
return result;
|
||||
}
|
||||
|
||||
// glslang has no "detach this thread" API in the vendored revision (there is no
|
||||
// InitThread/DetachThread pair any more; thread attachment is implicit through
|
||||
// thread_local state, and glslang::InitializeProcess() is process-wide, refcounted and
|
||||
// mutex-guarded, so it needs no per-worker counterpart).
|
||||
//
|
||||
// What DOES need undoing is the thread pool allocator: TShader::parse sets the calling
|
||||
// thread's TLS allocator to the shader's own pool and never restores it. Left pointing
|
||||
// there, the next allocation this worker makes - in an unrelated job, or in glslang code
|
||||
// reached from a different object - would come out of a pool the GL thread may already
|
||||
// have deleted with the TShader. SetThreadPoolAllocator(nullptr) reverts the thread to
|
||||
// its own thread_local default and is the documented idiom. A scope guard, so it also
|
||||
// runs when a body throws.
|
||||
struct GlslangThreadAllocatorGuard {
|
||||
~GlslangThreadAllocatorGuard() { glslang::SetThreadPoolAllocator(nullptr); }
|
||||
};
|
||||
} // namespace
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
// Pure CPU work only. Everything this reads is either an input the node owns or a
|
||||
// process-wide constant; everything it writes is `artifacts`. Do not add a GL/EGL call,
|
||||
// a pActiveBackendObject read, or a pGLContext->RecordError() here - the first two are
|
||||
// what CompileEnv exists to replace, and the third is why the design's section 6
|
||||
// deferral mechanism (and JobNode's debug assert on it) exists.
|
||||
void ShaderCompileTask::RunBody() {
|
||||
// Own the failure rather than letting JobNode's backstop settle the node as
|
||||
// Cancelled: an abandoned node publishes nothing, so the shader would report
|
||||
// COMPILE_STATUS false with an EMPTY info log. GL models a failed compile as
|
||||
// status + log, so turn a throw into exactly that - a completed job whose result
|
||||
// is "this shader did not compile", with a log the application can read.
|
||||
// (JobNode still catches: it is the last resort for anything below.)
|
||||
try {
|
||||
RunCompilePipeline();
|
||||
} catch (const std::exception& e) {
|
||||
artifacts = {};
|
||||
artifacts.env = env;
|
||||
artifacts.compileStatus = false;
|
||||
artifacts.infoLog = std::format("Error: shader compilation failed: {}", e.what());
|
||||
} catch (...) {
|
||||
artifacts = {};
|
||||
artifacts.env = env;
|
||||
artifacts.compileStatus = false;
|
||||
artifacts.infoLog = "Error: shader compilation failed: unknown exception";
|
||||
}
|
||||
}
|
||||
|
||||
void ShaderCompileTask::RunCompilePipeline() {
|
||||
using namespace MG_Util::ShaderTranspiler;
|
||||
const GlslangThreadAllocatorGuard glslangGuard;
|
||||
|
||||
const CompileEnv& compileEnv = *env;
|
||||
artifacts.env = env;
|
||||
|
||||
// P0b layer 2: another shader object in this context may already have run the
|
||||
// source-only half over byte-identical text under the same environment.
|
||||
ShaderPreprocessResultPtr cached =
|
||||
cache ? cache->Find(stage, sourceHash, *source, compileEnv.fingerprint) : nullptr;
|
||||
SharedPtr<ShaderPreprocessResult> fresh;
|
||||
if (!cached) fresh = MakeShared<ShaderPreprocessResult>(RunSourceOnlyPipeline(stage, *source, compileEnv));
|
||||
const ShaderPreprocessResult& shared = cached ? *cached : *fresh;
|
||||
const Bool shouldPopulateCache = !cached && cache != nullptr;
|
||||
|
||||
if (!shared.Preprocessed()) {
|
||||
// Rejected lexically, or a glslang failure this context has already seen for
|
||||
// this exact source (ParseFailed) - either way the parse can be skipped.
|
||||
artifacts.infoLog = shared.infoLog;
|
||||
if (shouldPopulateCache) {
|
||||
cache->Insert(stage, sourceHash, *source, compileEnv.fingerprint, Move(fresh));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ShaderAttrib attrib{.shaderType = MG_Util::ConvertShaderStageToGLEnum(stage),
|
||||
.sourceStr = shared.preprocessedSource,
|
||||
.flags = 0,
|
||||
.env = &compileEnv};
|
||||
|
||||
auto result = ShaderCompiler::CompileShader(attrib);
|
||||
if (result) {
|
||||
artifacts.compileStatus = true;
|
||||
artifacts.shader = result.value();
|
||||
// Copy, not move: `shared` may alias a cache entry that has to outlive us, and
|
||||
// `fresh` is about to be handed to the cache.
|
||||
artifacts.preprocessedSource = shared.preprocessedSource;
|
||||
artifacts.explicitUniformLocations = shared.explicitUniformLocations;
|
||||
artifacts.explicitOpaqueBindings = shared.explicitOpaqueBindings;
|
||||
artifacts.infoLog.clear();
|
||||
if (shouldPopulateCache) {
|
||||
cache->Insert(stage, sourceHash, *source, compileEnv.fingerprint, Move(fresh));
|
||||
}
|
||||
} else {
|
||||
artifacts.infoLog = result.error().log;
|
||||
// Deferred, not logged here, for two reasons. MGLOG from a pool thread interleaves
|
||||
// mid-line with the GL thread's own output and lands out of order relative to the
|
||||
// glCompileShader that caused it; diagnostics.logLines is replayed by the join, on
|
||||
// the GL thread, exactly where a serial implementation would have printed it.
|
||||
// And a one-line summary rather than the old full source dump: a shaderpack stage
|
||||
// is ~100KB, so the dump was the single largest thing this driver ever wrote to
|
||||
// the log, for every failing shader. The info log is what names the offending
|
||||
// line; the source is recoverable from the application.
|
||||
const SizeT firstLineEnd = artifacts.infoLog.find('\n');
|
||||
diagnostics.logLines.push_back(std::format(
|
||||
"ShaderCompileTask: shader {} (stage {}) failed to compile; compileStatus = false. "
|
||||
"Preprocessed source: {} bytes. First log line: {}",
|
||||
externalIndex, static_cast<Int>(stage), shared.preprocessedSource.length(),
|
||||
artifacts.infoLog.substr(0, firstLineEnd == String::npos ? artifacts.infoLog.length()
|
||||
: firstLineEnd)));
|
||||
if (shouldPopulateCache) {
|
||||
fresh->outcome = ShaderPreprocessOutcome::ParseFailed;
|
||||
fresh->infoLog = artifacts.infoLog;
|
||||
fresh->explicitUniformLocations.clear();
|
||||
fresh->explicitOpaqueBindings.clear();
|
||||
cache->Insert(stage, sourceHash, *source, compileEnv.fingerprint, Move(fresh));
|
||||
}
|
||||
}
|
||||
}
|
||||
} // namespace MobileGL::MG_State::GLState
|
||||
@@ -0,0 +1,83 @@
|
||||
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.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_Util/Async/JobNode.h>
|
||||
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderPreprocessCache.h>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
// Everything one glCompileShader PRODUCES, in one block.
|
||||
//
|
||||
// This is exactly the set a single run of the compile pipeline writes, which is what
|
||||
// makes "discard the artifacts" a complete invalidation and "move the artifacts" a
|
||||
// complete publish. It lives on the job node rather than on ShaderObject: a worker fills
|
||||
// it in, and the GL thread reads it through ShaderObject's join gate.
|
||||
struct ShaderCompileArtifacts {
|
||||
// The CompileEnv snapshot this compile ran against. Held so the consume-once
|
||||
// re-parse in TakeShaderForLink() reproduces the original parse exactly, instead of
|
||||
// re-reading whatever the backend says now.
|
||||
SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> env;
|
||||
SharedPtr<glslang::TShader> shader;
|
||||
// The source the parse actually consumed (after PreprocessShaderSource), kept for
|
||||
// TakeShaderForLink's re-parse so a later link never depends on the preprocessor
|
||||
// being deterministic across backend-state changes.
|
||||
String preprocessedSource;
|
||||
UnorderedMap<String, Int> explicitUniformLocations;
|
||||
UnorderedMap<String, Uint> explicitOpaqueBindings;
|
||||
// GL-thread-owned, and the one field here a worker never touches: TakeShaderForLink
|
||||
// flips it after the join. Stage 4 replaces it with an atomic claim on this node,
|
||||
// because two ProgramLinkTasks for two programs sharing this shader can then race
|
||||
// for the parse on two workers.
|
||||
Bool shaderConsumedByLink = false;
|
||||
String infoLog;
|
||||
Bool compileStatus = false;
|
||||
};
|
||||
|
||||
// The unit of asynchronous shader compilation: one glCompileShader's worth of pure CPU
|
||||
// work - preprocess, the two lexical rejections, the two lexical extractions, and the
|
||||
// glslang parse - with every input it needs owned by the node itself.
|
||||
//
|
||||
// That ownership is the whole point. The node reads no GL-thread state (the source is a
|
||||
// SharedPtr<const String> snapshot, the device limits come from the CompileEnv snapshot,
|
||||
// the P0b cross-object memo is shared-owned and internally locked) and writes nothing
|
||||
// but its own `artifacts`. So a node whose ShaderObject was re-sourced, deleted, or
|
||||
// destroyed while it was still running is safe to simply abandon - no wait, no
|
||||
// synchronization with the GL thread beyond the node's own terminal state.
|
||||
class ShaderCompileTask final : public MG_Util::Async::JobNode {
|
||||
public:
|
||||
ShaderCompileTask(const ShaderStage stage, SharedPtr<const String> source, const Uint64 sourceHash,
|
||||
SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> env,
|
||||
SharedPtr<ShaderPreprocessCache> cache, const Uint externalIndex)
|
||||
: stage(stage), source(Move(source)), sourceHash(sourceHash), env(Move(env)), cache(Move(cache)),
|
||||
externalIndex(externalIndex) {}
|
||||
|
||||
// ---- inputs: immutable after construction, all owned by the node ----
|
||||
const ShaderStage stage;
|
||||
// The exact text at enqueue. ShaderObject compares this pointer against its own
|
||||
// m_source to decide whether its layer-1 memo is armed, which is why glShaderSource
|
||||
// only ever swaps the pointer when the text genuinely differs.
|
||||
const SharedPtr<const String> source;
|
||||
const Uint64 sourceHash;
|
||||
const SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> env;
|
||||
// P0b layer 2, or null. Null is the "no context" case (the default fragment shader,
|
||||
// the backends' internal blit/mipmap shaders) and doubles as the marker for
|
||||
// "compile inline regardless of the async flag" - see ShaderObject::Compile().
|
||||
const SharedPtr<ShaderPreprocessCache> cache;
|
||||
const Uint externalIndex; // logs only
|
||||
|
||||
// ---- output: valid iff IsComplete(), immutable afterwards ----
|
||||
ShaderCompileArtifacts artifacts;
|
||||
|
||||
private:
|
||||
void RunBody() override;
|
||||
// The real body; RunBody wraps it so a throw becomes a GL-visible compile failure.
|
||||
void RunCompilePipeline();
|
||||
};
|
||||
} // namespace MobileGL::MG_State::GLState
|
||||
@@ -8,313 +8,127 @@
|
||||
|
||||
#include "ShaderObject.h"
|
||||
#include "ShaderPreprocessCache.h"
|
||||
#include <MG_Util/ShaderTranspiler/Types.h>
|
||||
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
|
||||
#include <MG_Util/Async/ShaderCompilePool.h>
|
||||
#include <MG_Util/Converters/MGToGL/ProgramEnumConverter.h>
|
||||
#include <MG_Util/ShaderTranspiler/ShaderSourceProcessor.h>
|
||||
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
|
||||
#include <MG_Util/ShaderTranspiler/glslang/UniformTraverser.h>
|
||||
|
||||
#include <charconv>
|
||||
|
||||
namespace {
|
||||
struct ComputeLocalSize {
|
||||
MobileGL::Uint x = 1;
|
||||
MobileGL::Uint y = 1;
|
||||
MobileGL::Uint z = 1;
|
||||
bool declared = false;
|
||||
};
|
||||
|
||||
static MobileGL::String StripGlslComments(const MobileGL::String& source) {
|
||||
MobileGL::String result;
|
||||
result.reserve(source.length());
|
||||
|
||||
bool inLineComment = false;
|
||||
bool inBlockComment = false;
|
||||
for (MobileGL::SizeT i = 0; i < source.length(); ++i) {
|
||||
if (inLineComment) {
|
||||
if (source[i] == '\n') {
|
||||
inLineComment = false;
|
||||
result.push_back(source[i]);
|
||||
} else {
|
||||
result.push_back(' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBlockComment) {
|
||||
if (source[i] == '*' && i + 1 < source.length() && source[i + 1] == '/') {
|
||||
inBlockComment = false;
|
||||
result.append(" ");
|
||||
++i;
|
||||
} else {
|
||||
result.push_back(source[i] == '\n' ? '\n' : ' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source[i] == '/' && i + 1 < source.length()) {
|
||||
if (source[i + 1] == '/') {
|
||||
inLineComment = true;
|
||||
result.append(" ");
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (source[i + 1] == '*') {
|
||||
inBlockComment = true;
|
||||
result.append(" ");
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
result.push_back(source[i]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Hoisted out of ParseComputeLocalSize: constructing a std::regex costs far more than
|
||||
// running it over a small source, and it was being rebuilt on every compute compile. A
|
||||
// const regex carries no mutable state, so sharing one instance is safe.
|
||||
static const std::regex kComputeLocalSizePattern(R"(local_size_([xyz])\s*=\s*([0-9]+))");
|
||||
|
||||
static ComputeLocalSize ParseComputeLocalSize(const MobileGL::String& source) {
|
||||
ComputeLocalSize localSize;
|
||||
const MobileGL::String uncommentedSource = StripGlslComments(source);
|
||||
|
||||
for (std::sregex_iterator it(uncommentedSource.begin(), uncommentedSource.end(), kComputeLocalSizePattern),
|
||||
end;
|
||||
it != end; ++it) {
|
||||
const char axis = (*it)[1].str()[0];
|
||||
// The [0-9]+ capture is unbounded, so `local_size_x = 99999999999999999999999`
|
||||
// is a legal match. std::stoull would throw std::out_of_range on it and let the
|
||||
// exception escape glCompileShader; std::from_chars reports the overflow instead.
|
||||
// An overflowing literal saturates to UINT_MAX, which the device-limit check
|
||||
// below rejects anyway - the same verdict a non-overflowing huge value gets.
|
||||
const MobileGL::String digits = (*it)[2].str();
|
||||
unsigned long long value = 0;
|
||||
const std::from_chars_result parsed =
|
||||
std::from_chars(digits.data(), digits.data() + digits.size(), value);
|
||||
const MobileGL::Uint clampedValue = (parsed.ec != std::errc() || value > UINT_MAX)
|
||||
? UINT_MAX
|
||||
: static_cast<MobileGL::Uint>(value);
|
||||
|
||||
// TODO: Replace this literal layout scanner with parser/AST-backed validation so expressions and
|
||||
// specialization-id layouts are handled consistently with glslang.
|
||||
localSize.declared = true;
|
||||
if (axis == 'x') {
|
||||
localSize.x = clampedValue;
|
||||
} else if (axis == 'y') {
|
||||
localSize.y = clampedValue;
|
||||
} else {
|
||||
localSize.z = clampedValue;
|
||||
}
|
||||
}
|
||||
|
||||
return localSize;
|
||||
}
|
||||
|
||||
// The device limits come from the CompileEnv snapshot, never from a live driver query.
|
||||
// GL_MAX_COMPUTE_WORK_GROUP_SIZE is a real GLES call on the DirectGLES backend: issued
|
||||
// off the context thread it would silently no-op and turn a legal local_size_z into
|
||||
// COMPILE_STATUS=FALSE. CaptureCompileEnv() issues it once, on the GL thread.
|
||||
static std::optional<MobileGL::String> ValidateComputeLocalSizeLimits(
|
||||
const MobileGL::String& source, const MobileGL::MG_Util::ShaderTranspiler::CompileEnv& env) {
|
||||
const ComputeLocalSize localSize = ParseComputeLocalSize(source);
|
||||
if (!localSize.declared) return std::nullopt;
|
||||
|
||||
if (localSize.x > env.maxComputeWorkGroupSize[0] || localSize.y > env.maxComputeWorkGroupSize[1] ||
|
||||
localSize.z > env.maxComputeWorkGroupSize[2]) {
|
||||
return "Compute shader local_size exceeds GL_MAX_COMPUTE_WORK_GROUP_SIZE.";
|
||||
}
|
||||
|
||||
const unsigned long long invocations = static_cast<unsigned long long>(localSize.x) * localSize.y * localSize.z;
|
||||
if (invocations > env.maxComputeWorkGroupInvocations) {
|
||||
return "Compute shader local_size product exceeds GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS.";
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// The half of ShaderObject::Compile() that depends on nothing but the source text and
|
||||
// the stage: preprocessing, the two lexical rejections, and the two lexical
|
||||
// side-channel extractions. Split out so P0b layer 2 can memoize exactly this and
|
||||
// nothing else - the glslang parse stays per-object because its TShader is
|
||||
// consume-once. Deliberately free of any per-object state so the memo is sound.
|
||||
//
|
||||
// The former caveat is gone: the compute local-size verdict reads `env` rather than the
|
||||
// live backend, and env.fingerprint is part of the P0b cache key, so a memo can never be
|
||||
// returned against limits other than the ones it was computed against.
|
||||
static MobileGL::MG_State::GLState::ShaderPreprocessResult RunSourceOnlyPipeline(
|
||||
const MobileGL::ShaderStage stage, const MobileGL::String& source,
|
||||
const MobileGL::MG_Util::ShaderTranspiler::CompileEnv& env) {
|
||||
using namespace MobileGL;
|
||||
using namespace MobileGL::MG_Util::ShaderTranspiler;
|
||||
using MobileGL::MG_State::GLState::ShaderPreprocessOutcome;
|
||||
|
||||
MobileGL::MG_State::GLState::ShaderPreprocessResult result;
|
||||
result.preprocessedSource = source;
|
||||
PreprocessShaderSource(stage, result.preprocessedSource, env);
|
||||
|
||||
if (stage == ShaderStage::Compute) {
|
||||
if (const std::optional<String> localSizeError =
|
||||
ValidateComputeLocalSizeLimits(result.preprocessedSource, env)) {
|
||||
result.outcome = ShaderPreprocessOutcome::ComputeLocalSizeRejected;
|
||||
result.infoLog = *localSizeError;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (const std::optional<String> reservedError = FindReservedIdentifierViolation(result.preprocessedSource)) {
|
||||
result.outcome = ShaderPreprocessOutcome::ReservedIdentifierRejected;
|
||||
result.infoLog = *reservedError;
|
||||
return result;
|
||||
}
|
||||
|
||||
// The parse this feeds runs in the link-compatible configuration (Vulkan-client
|
||||
// env with relaxed rules): the TShader it produces is what glLinkProgram links and
|
||||
// what the backends' SPIR-V is generated from - there is no second, GL-client
|
||||
// parse. The GL frontend semantics the relaxed parse cannot provide are restored
|
||||
// on top: explicit default-block uniform locations through the lexical
|
||||
// side-channels below, dead-uniform/global-UBO filtering in
|
||||
// ProgramObject::DoReflection.
|
||||
result.explicitUniformLocations = ExtractExplicitUniformLocations(result.preprocessedSource);
|
||||
result.explicitOpaqueBindings = ExtractExplicitOpaqueBindings(result.preprocessedSource);
|
||||
result.outcome = ShaderPreprocessOutcome::Preprocessed;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
|
||||
#include <MG_Util/ShaderTranspiler/Types.h>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
void ShaderObject::SetShaderSource(const String& source) {
|
||||
// P0b layer 1. glShaderSource always REPLACES the source, but replacing it with a
|
||||
// byte-identical one cannot change what a compile would produce: the whole
|
||||
// pipeline below (preprocess -> lexical checks -> glslang parse) is a pure
|
||||
// function of (stage, source) plus context-lifetime backend limits. So keeping the
|
||||
// compiled state is not an optimization that changes observable behaviour - the
|
||||
// COMPILE_STATUS, the info log and the reflection a caller can query are exactly
|
||||
// what a real recompile would have rebuilt, byte for byte.
|
||||
// pipeline (preprocess -> lexical checks -> glslang parse) is a pure function of
|
||||
// (stage, source, CompileEnv). So keeping the compiled state is not an optimization
|
||||
// that changes observable behaviour - the COMPILE_STATUS, the info log and the
|
||||
// reflection a caller can query are exactly what a real recompile would have
|
||||
// rebuilt, byte for byte. A compile still IN FLIGHT is left running for the same
|
||||
// reason: it is computing the right answer for text this object still holds.
|
||||
if (SourceMatchesCompiledState(source)) return;
|
||||
m_source = source;
|
||||
// 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();
|
||||
m_source = MakeShared<const String>(source);
|
||||
InvalidateCompiledState();
|
||||
}
|
||||
|
||||
void ShaderObject::SetShaderSource(String&& source) {
|
||||
if (SourceMatchesCompiledState(source)) return;
|
||||
m_source = Move(source);
|
||||
CancelCompile();
|
||||
m_source = MakeShared<const String>(Move(source));
|
||||
InvalidateCompiledState();
|
||||
}
|
||||
|
||||
Bool ShaderObject::SourceMatchesCompiledState(const String& candidate) const {
|
||||
if (!m_hasCompiledState) return false;
|
||||
if (candidate.length() != m_compiledSourceLength) return false;
|
||||
if (ShaderPreprocessCache::HashSource(candidate) != m_compiledSourceHash) return false;
|
||||
// The hash is a fast reject only; confirm against the actual stored text. While
|
||||
// m_hasCompiledState holds, m_source IS the source that produced the state.
|
||||
return candidate == m_source;
|
||||
// The memo is armed exactly while a job exists that was built from the string this
|
||||
// object still points at - pending or finished, success or failure.
|
||||
if (!HasMemoizedCompile()) return false;
|
||||
if (candidate.length() != m_source->length()) return false;
|
||||
// Never let correctness ride on a hash: the answer is the full text comparison.
|
||||
// (The stored hash on the node is a cache-lookup accelerator, not a substitute.)
|
||||
return candidate == *m_source;
|
||||
}
|
||||
|
||||
void ShaderObject::RememberCompiledSource(const Uint64 sourceHash) {
|
||||
m_hasCompiledState = true;
|
||||
m_compiledSourceHash = sourceHash;
|
||||
m_compiledSourceLength = m_source.length();
|
||||
void ShaderObject::JoinPendingCompile() const {
|
||||
MOBILEGL_ASSERT(!MG_Util::Async::ShaderCompilePool::IsPoolThread(),
|
||||
"ShaderObject::EnsureCompileJoined() reached from a pool thread; a job body must never read "
|
||||
"GL-thread-owned objects");
|
||||
m_compiled->Wait();
|
||||
m_compileJoined = true;
|
||||
// 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.
|
||||
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();
|
||||
}
|
||||
|
||||
// EnsureCompileJoined() is defined inline in ShaderObject.h (see the comment there for
|
||||
// why: no LTO, and it is called from every Compiled() read).
|
||||
|
||||
void ShaderObject::InvalidateCompiledState() {
|
||||
// The compile artifacts are exactly what one Compile() writes, so discarding them
|
||||
// wholesale IS the invalidation. (Kept as an explicit reset rather than a
|
||||
// default-construct so the intent survives a future field addition.)
|
||||
Compiled() = CompileArtifacts{};
|
||||
m_hasCompiledState = false;
|
||||
m_compiledSourceHash = 0;
|
||||
m_compiledSourceLength = 0;
|
||||
// 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();
|
||||
}
|
||||
|
||||
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.
|
||||
m_compiled->Cancel();
|
||||
m_compiled.reset();
|
||||
}
|
||||
|
||||
void ShaderObject::Compile() {
|
||||
using namespace MG_Util::ShaderTranspiler;
|
||||
|
||||
// P0b layer 1: the state this object holds was produced by a previous Compile() of
|
||||
// the exact source it still holds, so a recompile is a no-op. This covers the
|
||||
// failure case too - the info log stays queryable because nothing is cleared.
|
||||
// P0b layer 1, as a tri-state: the memo is "the node in m_compiled was built from
|
||||
// the string m_source still points at". SetShaderSource only swaps that pointer when
|
||||
// the text actually differs, so this is a pointer compare, and it covers Pending as
|
||||
// well as Complete - a second glCompileShader on an in-flight object is a no-op, not
|
||||
// a duplicate job racing to write the same fields.
|
||||
//
|
||||
// shaderConsumedByLink interaction: if the stored TShader already fed a link,
|
||||
// the no-op leaves preprocessedSource and both side-channel maps intact, which
|
||||
// is precisely what TakeShaderForLink's on-demand re-parse needs. A real recompile
|
||||
// would have handed the next link a fresh parse; the no-op hands it a fresh
|
||||
// re-parse of the identical source instead. Same result, one parse either way.
|
||||
if (m_hasCompiledState) return;
|
||||
// The failure case is covered too: the info log stays queryable because nothing is
|
||||
// cleared. And if the stored TShader already fed a link, the no-op leaves
|
||||
// preprocessedSource and both side-channel maps intact, which is precisely what
|
||||
// TakeShaderForLink's on-demand re-parse needs - a real recompile would have handed
|
||||
// the next link a fresh parse, the no-op hands it a fresh re-parse of the identical
|
||||
// source instead. Same result, one parse either way.
|
||||
if (HasMemoizedCompile()) return;
|
||||
|
||||
InvalidateCompiledState();
|
||||
// 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;
|
||||
|
||||
const Uint64 sourceHash = ShaderPreprocessCache::HashSource(m_source);
|
||||
|
||||
// The compile-environment snapshot, taken here on the GL thread. Everything below
|
||||
// reads the device through it and never through pActiveBackendObject, which is what
|
||||
// makes the whole body movable onto a worker in stage 3.
|
||||
CompileArtifacts& compiled = Compiled();
|
||||
compiled.env = MG_Util::ShaderTranspiler::GetCurrentCompileEnv();
|
||||
const MG_Util::ShaderTranspiler::CompileEnv& env = *compiled.env;
|
||||
|
||||
// P0b layer 2: another shader object in this context may already have run the
|
||||
// source-only half over byte-identical text under the same environment.
|
||||
ShaderPreprocessResultPtr cached =
|
||||
m_preprocessCache ? m_preprocessCache->Find(m_stage, sourceHash, m_source, env.fingerprint) : nullptr;
|
||||
SharedPtr<ShaderPreprocessResult> fresh;
|
||||
if (!cached) fresh = MakeShared<ShaderPreprocessResult>(RunSourceOnlyPipeline(m_stage, m_source, env));
|
||||
const ShaderPreprocessResult& shared = cached ? *cached : *fresh;
|
||||
const Bool shouldPopulateCache = !cached && m_preprocessCache != nullptr;
|
||||
|
||||
if (!shared.Preprocessed()) {
|
||||
// Rejected lexically, or a glslang failure this context has already seen for
|
||||
// this exact source (ParseFailed) - either way the parse can be skipped.
|
||||
compiled.infoLog = shared.infoLog;
|
||||
if (shouldPopulateCache) {
|
||||
m_preprocessCache->Insert(m_stage, sourceHash, m_source, env.fingerprint, Move(fresh));
|
||||
}
|
||||
RememberCompiledSource(sourceHash);
|
||||
// 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
|
||||
// breath (see the constructor comment) - a job would only add a round trip.
|
||||
if (!m_preprocessCache || !MG_Util::Async::AsyncShaderCompileEnabled()) {
|
||||
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
|
||||
// through the identical code.
|
||||
EnsureCompileJoined();
|
||||
return;
|
||||
}
|
||||
|
||||
ShaderAttrib attrib{.shaderType = MG_Util::ConvertShaderStageToGLEnum(m_stage),
|
||||
.sourceStr = shared.preprocessedSource,
|
||||
.flags = 0,
|
||||
.env = &env};
|
||||
|
||||
auto result = ShaderCompiler::CompileShader(attrib);
|
||||
if (result) {
|
||||
compiled.compileStatus = true;
|
||||
compiled.shader = result.value();
|
||||
// Copy, not move: `shared` may alias a cache entry that has to outlive us, and
|
||||
// `fresh` is about to be handed to the cache.
|
||||
compiled.preprocessedSource = shared.preprocessedSource;
|
||||
compiled.explicitUniformLocations = shared.explicitUniformLocations;
|
||||
compiled.explicitOpaqueBindings = shared.explicitOpaqueBindings;
|
||||
compiled.infoLog.clear();
|
||||
if (shouldPopulateCache) {
|
||||
m_preprocessCache->Insert(m_stage, sourceHash, m_source, env.fingerprint, Move(fresh));
|
||||
}
|
||||
} else {
|
||||
compiled.infoLog = result.error().log;
|
||||
MGLOG_D("ShaderObject::Compile: Shader %d compilation failed.\nSource:\n%s\nInfoLog:\n%s\nSetting "
|
||||
"compileStatus = false as a result.",
|
||||
m_externalIndex, shared.preprocessedSource.c_str(), compiled.infoLog.c_str());
|
||||
if (shouldPopulateCache) {
|
||||
fresh->outcome = ShaderPreprocessOutcome::ParseFailed;
|
||||
fresh->infoLog = compiled.infoLog;
|
||||
fresh->explicitUniformLocations.clear();
|
||||
fresh->explicitOpaqueBindings.clear();
|
||||
m_preprocessCache->Insert(m_stage, sourceHash, m_source, env.fingerprint, Move(fresh));
|
||||
}
|
||||
}
|
||||
RememberCompiledSource(sourceHash);
|
||||
MG_Util::Async::ShaderCompilePool::Get().Post(m_compiled);
|
||||
}
|
||||
|
||||
SharedPtr<glslang::TShader> ShaderObject::TakeShaderForLink(String& outReparseLog) {
|
||||
CompileArtifacts& compiled = Compiled();
|
||||
EnsureCompileJoined();
|
||||
// Unreachable through the GL frontend: callers gate on GetCompileStatus().
|
||||
if (!m_compiled) return nullptr;
|
||||
|
||||
// Mutating the node's artifacts from here is legal precisely because the node is
|
||||
// terminal by now: no worker will ever touch it again, and this thread is the GL
|
||||
// thread. Stage 4, where two link JOBS can reach the same node concurrently,
|
||||
// replaces this flag with an atomic claim on the node.
|
||||
ShaderCompileArtifacts& compiled = m_compiled->artifacts;
|
||||
if (compiled.shader && !compiled.shaderConsumedByLink) {
|
||||
compiled.shaderConsumedByLink = true;
|
||||
return compiled.shader;
|
||||
|
||||
@@ -8,39 +8,47 @@
|
||||
|
||||
#pragma once
|
||||
#include <Includes.h>
|
||||
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderStage.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderCompileTask.h>
|
||||
|
||||
namespace MobileGL {
|
||||
enum class ShaderStage {
|
||||
Vertex,
|
||||
TessControl,
|
||||
TessEval,
|
||||
Geometry,
|
||||
Fragment,
|
||||
Compute,
|
||||
ShaderStageCount,
|
||||
Unknown = -1
|
||||
};
|
||||
|
||||
namespace MG_State::GLState {
|
||||
// P0b layer 2. Declared, not included: the cache keys on ShaderStage, so including
|
||||
// its header here would be circular.
|
||||
class ShaderPreprocessCache;
|
||||
|
||||
// The GL-visible shader name. It owns the source text and one compile job node; the
|
||||
// job node owns everything a compile produces.
|
||||
//
|
||||
// Every member below is GL-thread-owned, and every read of worker-produced state
|
||||
// goes through Compiled(), which joins first. That is invariant I5 of the P1 design:
|
||||
// because Compiled() is the SOLE accessor of the node's artifacts, the compiler
|
||||
// enumerates every reader for us and none can be forgotten.
|
||||
class ShaderObject {
|
||||
public:
|
||||
// `preprocessCache` is the owning context's cross-object memo (P0b layer 2);
|
||||
// null is fully supported and simply means "no sharing" - that is what the
|
||||
// context-less internal shader objects (the default FS, the blit pipeline) use.
|
||||
// Shared ownership rather than a raw pointer: once compiles run on a worker the
|
||||
// job outlives neither the object nor the context deterministically, and the
|
||||
// cache has to stay alive for whoever is still reading it.
|
||||
// `preprocessCache` is the owning context's cross-object memo (P0b layer 2).
|
||||
// Null is fully supported and means two things at once: "no sharing", and
|
||||
// "compile inline, never on a worker". Those coincide exactly - the only
|
||||
// cache-less shader objects are the internal ones (ProgramObject's default
|
||||
// fragment shader, the DirectVulkan blit and depth-mipmap shaders) and every one
|
||||
// of them compiles and reads its status in the same breath, so a job would only
|
||||
// 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.
|
||||
ShaderObject(const ShaderStage stage, Uint externalIndex,
|
||||
SharedPtr<ShaderPreprocessCache> preprocessCache = nullptr)
|
||||
: m_stage(stage), m_externalIndex(externalIndex), m_preprocessCache(Move(preprocessCache)) {}
|
||||
// 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(); }
|
||||
|
||||
ShaderObject(const ShaderObject&) = delete;
|
||||
ShaderObject& operator=(const ShaderObject&) = delete;
|
||||
|
||||
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();
|
||||
void MarkAsDeleted();
|
||||
|
||||
// Hands out a link-consumable TShader. glslang's mapIO mutates the TShader's
|
||||
@@ -54,10 +62,14 @@ namespace MobileGL {
|
||||
|
||||
Uint GetExternalIndex() const { return m_externalIndex; }
|
||||
ShaderStage GetShaderStage() const { return m_stage; }
|
||||
const String& GetShaderSource() const { return m_source; }
|
||||
// No join: the source is GL-thread-owned, and a worker only ever reads the
|
||||
// immutable snapshot it was handed at enqueue.
|
||||
const String& GetShaderSource() const { return *m_source; }
|
||||
// The snapshot itself, for whoever needs to hand it to a job.
|
||||
const SharedPtr<const String>& GetShaderSourcePtr() const { return m_source; }
|
||||
|
||||
const SharedPtr<glslang::TShader>& GetCompiledShader() const { return Compiled().shader; }
|
||||
const String& GetInfoLog() const { return Compiled().infoLog; }
|
||||
const UnorderedMap<String, Uint>& GetUniformLocations() const { return Compiled().uniforms; }
|
||||
// Explicit layout(location = N) qualifiers on this shader's default-block
|
||||
// uniforms, captured lexically at Compile() because the relaxed parse drops
|
||||
// them from reflection (see ExtractExplicitUniformLocations).
|
||||
@@ -73,96 +85,98 @@ namespace MobileGL {
|
||||
Bool GetCompileStatus() const { return Compiled().compileStatus; }
|
||||
Bool GetDeleteStatus() const { return m_deleteStatus; }
|
||||
|
||||
// Blocks until a pending compile (P1 stage 3 onwards) has published its
|
||||
// artifacts. Public for the few sites that must join without reading anything.
|
||||
// A no-op today - nothing is ever pending.
|
||||
// Blocks until a pending compile has published its artifacts. Public for the
|
||||
// sites that must join without reading anything - ProgramObject::Link's
|
||||
// prologue, which needs every attached shader settled before it runs.
|
||||
void JoinCompile() const { EnsureCompileJoined(); }
|
||||
|
||||
// True while this object holds the outcome (success OR failure) of a previous
|
||||
// Compile() of exactly the source it currently holds - i.e. while the P0b
|
||||
// layer-1 memo is armed and a glCompileShader would be a no-op. Diagnostics
|
||||
// and tests only; nothing in the GL frontend branches on it.
|
||||
// True while this object holds the outcome (success OR failure) of a Compile()
|
||||
// of exactly the source it currently holds - i.e. while the P0b layer-1 memo is
|
||||
// armed and a glCompileShader would be a no-op. Diagnostics and tests only;
|
||||
// nothing in the GL frontend branches on it.
|
||||
//
|
||||
// Deliberately does NOT join: the memo bookkeeping below is GL-thread-owned and
|
||||
// says nothing about whether a worker has finished, which is exactly the
|
||||
// property GL_COMPLETION_STATUS_KHR needs when stage 3 lands.
|
||||
Bool HasMemoizedCompile() const { return m_hasCompiledState; }
|
||||
// Tri-state, and deliberately NOT joining: an in-flight compile of the current
|
||||
// source counts as memoized (a second glCompileShader must not enqueue a
|
||||
// duplicate job), but asking that question must never block.
|
||||
// A node that settled as Cancelled (the job body threw, or the enqueue failed)
|
||||
// carries no result, so it must NOT satisfy the memo: otherwise a second
|
||||
// glCompileShader on the same source enqueues nothing and the eventual join
|
||||
// reports GL_FALSE forever. The synchronous path retries in exactly this case.
|
||||
Bool HasMemoizedCompile() const {
|
||||
return m_compiled != nullptr && m_compiled->source == m_source && !m_compiled->IsCancelled();
|
||||
}
|
||||
|
||||
// MUST NOT JOIN - this is what GL_COMPLETION_STATUS_KHR will read when the
|
||||
// extension surface lands. "No job at all" counts as complete: there is nothing
|
||||
// outstanding to wait for.
|
||||
Bool IsCompileComplete() const { return m_compiled == nullptr || m_compiled->IsTerminal(); }
|
||||
|
||||
private:
|
||||
// ---- P1: everything a compile PRODUCES, in one block ----
|
||||
//
|
||||
// Same rule as ProgramObject::LinkArtifacts: this is exactly what
|
||||
// InvalidateCompiledState() clears, i.e. exactly what one run of Compile()
|
||||
// writes. Stage 3 lifts this struct wholesale into ShaderCompileTask, where a
|
||||
// worker fills it in and the GL thread reads it through the same gate.
|
||||
struct CompileArtifacts {
|
||||
// The CompileEnv snapshot this compile ran against. Held so the
|
||||
// consume-once re-parse in TakeShaderForLink() reproduces the original
|
||||
// parse exactly, instead of re-reading whatever the backend says now.
|
||||
SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> env;
|
||||
SharedPtr<glslang::TShader> shader;
|
||||
// The source Compile() actually parsed (after PreprocessShaderSource), kept
|
||||
// for TakeShaderForLink's re-parse so a later link never depends on the
|
||||
// preprocessor being deterministic across backend-state changes.
|
||||
String preprocessedSource;
|
||||
UnorderedMap<String, Uint> uniforms;
|
||||
UnorderedMap<String, Int> explicitUniformLocations;
|
||||
UnorderedMap<String, Uint> explicitOpaqueBindings;
|
||||
Bool shaderConsumedByLink = false;
|
||||
String infoLog;
|
||||
Bool compileStatus = false;
|
||||
};
|
||||
|
||||
// ---- The one and only join gate for compile output (P1 invariant I5) ----
|
||||
// Blocks until a pending compile has published into m_compiled. Today nothing
|
||||
// is ever pending - glCompileShader still runs the whole body inline - so this
|
||||
// is an unconditional no-op. It exists NOW so that every reader of compile
|
||||
// output is already routed through it when stage 3 makes it block.
|
||||
// The fast path - no job, or a job whose result this object has already pulled -
|
||||
// is two predictable branches and stays inline: it runs on every Compiled() read
|
||||
// and the project never builds with LTO, so an out-of-line body would be a real
|
||||
// cross-TU call at each of those sites. The blocking half is out of line.
|
||||
//
|
||||
// Defined inline (not in ShaderObject.cpp): called from every Compiled() read,
|
||||
// and the project never builds with LTO, so an out-of-line empty body would be
|
||||
// a real cross-TU call at each of those call sites instead of folding away.
|
||||
void EnsureCompileJoined() const {}
|
||||
CompileArtifacts& Compiled() {
|
||||
EnsureCompileJoined();
|
||||
return m_compiled;
|
||||
// The gate keys on "has this object pulled the job's result yet", NOT on "is the
|
||||
// job terminal". Those differ in the case that matters: a worker can finish a
|
||||
// compile before the GL thread ever looks at it, and the pull is where deferred
|
||||
// diagnostics get replayed and an abandoned node gets dropped. Keying on
|
||||
// terminality would silently skip both.
|
||||
void EnsureCompileJoined() const {
|
||||
if (m_compiled && !m_compileJoined) JoinPendingCompile();
|
||||
}
|
||||
const CompileArtifacts& Compiled() const {
|
||||
void JoinPendingCompile() const;
|
||||
|
||||
// The artifacts of a compile that ran to completion. A node that was abandoned
|
||||
// (cancelled at teardown, or whose body threw) never publishes: JoinPendingCompile
|
||||
// drops it, so anything reachable here is either Complete or absent, and "absent"
|
||||
// reads as the never-compiled defaults - COMPILE_STATUS false, empty info log,
|
||||
// which is exactly what GL requires before the first glCompileShader.
|
||||
static const ShaderCompileArtifacts& EmptyArtifacts() {
|
||||
static const ShaderCompileArtifacts empty;
|
||||
return empty;
|
||||
}
|
||||
const ShaderCompileArtifacts& Compiled() const {
|
||||
EnsureCompileJoined();
|
||||
return m_compiled;
|
||||
return m_compiled ? m_compiled->artifacts : EmptyArtifacts();
|
||||
}
|
||||
|
||||
void InvalidateCompiledState();
|
||||
// ---- P0b layer 1: per-object no-op recompile ----
|
||||
// True iff `candidate` is byte-identical to the source that produced the
|
||||
// compiled state this object is currently holding. The stored hash and length
|
||||
// are only a fast reject; the answer is always confirmed against the full
|
||||
// stored text, so no behaviour rides on a 64-bit hash.
|
||||
// True iff `candidate` is byte-identical to the source that produced (or is
|
||||
// producing) the compiled state this object currently holds.
|
||||
Bool SourceMatchesCompiledState(const String& candidate) const;
|
||||
// Arms the layer-1 memo for the source that Compile() just processed.
|
||||
void RememberCompiledSource(Uint64 sourceHash);
|
||||
|
||||
// ---- GL-thread-owned state: never produced by a compile, so it never joins ----
|
||||
const Uint m_externalIndex = 0;
|
||||
const ShaderStage m_stage;
|
||||
// glShaderSource text. A worker only ever reads the snapshot handed to it, so
|
||||
// GL_SHADER_SOURCE_LENGTH and glGetShaderSource never join.
|
||||
String m_source;
|
||||
const Uint m_externalIndex = 0;
|
||||
// The pre-glShaderSource state, shared by every untouched object rather than
|
||||
// allocated per glCreateShader.
|
||||
static const SharedPtr<const String>& EmptySource() {
|
||||
static const SharedPtr<const String> empty = MakeShared<const String>();
|
||||
return empty;
|
||||
}
|
||||
// glShaderSource text, as an immutable snapshot. Never null. A job holds its own
|
||||
// SharedPtr to the exact string it was given, so replacing the source under a
|
||||
// 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.
|
||||
SharedPtr<const String> m_source = EmptySource();
|
||||
|
||||
// P0b layer 2: the owning context's cross-object memo, or null.
|
||||
// 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;
|
||||
// P0b layer 1. m_hasCompiledState is the invariant "m_source is byte-identical
|
||||
// to the source that produced the compile artifacts"; it is armed at the end of
|
||||
// every Compile() and disarmed by InvalidateCompiledState(). Stage 3 replaces
|
||||
// all three with a pointer compare against the in-flight job's source snapshot.
|
||||
Bool m_hasCompiledState = false;
|
||||
Uint64 m_compiledSourceHash = 0;
|
||||
SizeT m_compiledSourceLength = 0;
|
||||
|
||||
Bool m_deleteStatus = false;
|
||||
|
||||
// ---- Compile OUTPUT ---- reachable only through Compiled().
|
||||
CompileArtifacts m_compiled;
|
||||
// ---- 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.
|
||||
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.
|
||||
mutable Bool m_compileJoined = false;
|
||||
};
|
||||
} // namespace MG_State::GLState
|
||||
} // namespace MobileGL
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
#include <Includes.h>
|
||||
#include <list>
|
||||
#include <mutex>
|
||||
#include <MG_State/GLState/ProgramState/ShaderObject.h>
|
||||
// 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>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
// Where the shared, source-only half of ShaderObject::Compile() stopped. The two
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderStage.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
|
||||
|
||||
namespace MobileGL {
|
||||
// Split out of ShaderObject.h so the compile pipeline's headers form a DAG:
|
||||
// ShaderStage.h <- ShaderPreprocessCache.h <- ShaderCompileTask.h <- ShaderObject.h.
|
||||
// Every existing includer of ShaderObject.h still sees this type unchanged.
|
||||
enum class ShaderStage {
|
||||
Vertex,
|
||||
TessControl,
|
||||
TessEval,
|
||||
Geometry,
|
||||
Fragment,
|
||||
Compute,
|
||||
ShaderStageCount,
|
||||
Unknown = -1
|
||||
};
|
||||
} // namespace MobileGL
|
||||
Reference in New Issue
Block a user