[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:
BZLZHH
2026-08-08 10:33:50 -04:00
parent c93e5fa409
commit e5fb57f7eb
19 changed files with 1448 additions and 379 deletions
@@ -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