Compare commits

..
Author SHA1 Message Date
BZLZHH 3025284a6e [Perf, Test] (MG_Util): libfork execution engine for the shader compile pool, runtime-selectable
Adds libfork v3.8.0 (3rdparty submodule, header-only, wired like the asio
precedent) as a second execution engine behind ShaderCompilePool, selected
per process by MOBILEGL_ASYNC_POOL=asio|libfork (default asio; unknown
values warn and fall back). The engine boundary is deliberately tiny: the
queue, the concurrency budget and its clamping, the suspension latch,
cancel request-vs-outcome, the stopped-is-synchronous fallback and the
drain all stay in the shared Impl - an engine only answers how a
budget-cleared job reaches a worker.

The libfork engine runs detached root tasks as CHAINS: a finished body
takes the next queued job in the same coroutine on the same worker, so
the refills a worker posts are absorbed without a scheduler round trip
(the naive dispatch-thread shape measured 4x worse than asio on short
jobs). Absorption is bounded at one job per live chain - unbounded
absorption serialized bursts posted from inside the pool, which is the
shipped shape (one compile settling fans out link jobs via SubmitAfter
and the adoption map), caught by the review and pinned by a permanent
peak-concurrency regression test (pre-fix: libfork peak 1 vs asio peak 4
on a 16-job worker-posted burst). External submissions go through a
round-robin adaptor instead of lf::lazy_pool::schedule, which both
avoids a data race on lazy_pool's unsynchronized xoshiro under
concurrent submits and beats birthday-collision placement by ~1.3x at
budget == thread count.

The measured answer to "does asio scale poorly": no - the executor was
never the bottleneck. On real pack corpora extracted from the trace
fixtures (BSL 61 shaders, Complementary 277), interleaved best-of-5 per
cell, the engines are within noise of each other at every thread count
(complementary: 4965/2506/1376/824 ms at 1/2/4/8 threads for asio;
libfork within 1%), both ~6x at 8 threads. perf counters show the
flattening past 4 threads is machine-level (instructions flat at 22.1e9
from 1 to 16 threads - no added work, no lock spinning - while cycles
and LLC misses double: memory-stall bound), and the separating control
- N fully independent single-threaded processes with no shared
scheduler at all - scales WORSE than the pool (5.27x vs 5.94x at 8).
The pool microbenchmark does favor libfork on pure dispatch (518 vs
530 ns/job at 1 worker, growing with worker count), but a real compile
body is 1-100 ms, so dispatch is under 0.1% either way. asio therefore
stays the default; this branch exists to make the comparison
reproducible (MG_Test/Util/AsyncPoolBench drives either engine over a
corpus directory) and to keep the alternative viable.

613/613 unit tests in all four combos ({asio, libfork} x {async default
on, kill switch}), integration scenarios byte-identical between engines
on both backends.
2026-08-09 17:24:01 -04:00
34 changed files with 1562 additions and 3720 deletions
+3
View File
@@ -34,3 +34,6 @@
[submodule "3rdparty/asio"]
path = 3rdparty/asio
url = https://github.com/chriskohlhoff/asio.git
[submodule "3rdparty/libfork"]
path = 3rdparty/libfork
url = https://github.com/ConorWilliams/libfork.git
Vendored Submodule
+1
Submodule 3rdparty/libfork added at 9b2b844a5f
+7 -1
View File
@@ -296,7 +296,6 @@ set(SOURCE_FILES
MobileGL/MG_State/GLState/TextureState/TextureState.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp
MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp
MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp
MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.cpp
@@ -374,6 +373,13 @@ set(MOBILEGL_INCLUDE_DIR
# MG_Util/Async/ShaderCompilePool.cpp includes it, and it stays behind that file's
# pimpl so no consumer target needs this path.
${CMAKE_SOURCE_DIR}/3rdparty/asio/asio/include
# The second shader-compile execution engine (MOBILEGL_ASYNC_POOL=libfork), on the
# same terms as Asio above: header-only, no add_subdirectory (its CMakeLists only
# declares an INTERFACE target plus install/test scaffolding we do not want), no link
# target, and reachable from exactly one translation unit. libfork's own
# target_compile_features asks for cxx_std_23, which this project already sets
# globally, so its C++20 coroutines need no per-source standard override.
${CMAKE_SOURCE_DIR}/3rdparty/libfork/include
)
add_library(${CMAKE_PROJECT_NAME} SHARED
+6 -13
View File
@@ -66,6 +66,12 @@ namespace MobileGL::MG_Config {
// - DISPLAY: X11 session variable, not MobileGL configuration.
// - MOBILEGL_LOG_FILE_PATH: log-file init runs before MG_ConfigLoader::Init
// (see MG_Util/Debug/Log.cpp).
// - MOBILEGL_ASYNC_POOL: a ShaderCompilePool is constructed by binaries that never call
// MobileGL::Initialize() and so never run MG_ConfigLoader::Init - MG_Test's
// JobNodeTest builds pools directly, and it is the suite that runs the whole async
// matrix against both execution engines. Mirroring it here would resolve to the
// default in exactly the tests that exist to tell the engines apart (see
// MG_Util/Async/ShaderCompilePool.cpp, DetectAsyncPoolEngine).
struct FeaturesTable {
// MOBILEGL_DISABLE_TIMERQUERY: do not advertise or use GPU timer queries.
Bool DisableTimerQuery = false;
@@ -136,19 +142,6 @@ namespace MobileGL::MG_Config {
// MOBILEGL_ASYNC_SHADER_COMPILE_THREADS: shader-compile worker count. 0 (unset) means
// auto, which is min(4, big cores); an explicit value is honoured as given.
Uint32 AsyncShaderCompileThreads = 0;
// MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS: while a compile job is still in flight,
// glGetShaderiv(GL_COMPILE_STATUS) answers GL_TRUE and the shader info log reads
// empty, WITHOUT joining the job (latched per compile - see
// ShaderObject::TakeOptimisticCompileAnswer). A deliberate, bounded spec violation:
// a real failure still fails the program link with the compile log quoted. It
// exists for applications that compile hundreds of shaders serially and read the
// status right after each glCompileShader - Iris's shader-pack load - where those
// per-shader joins are what serializes the batch on its main path (Iris's gbuffer
// phase issues no program-level query between programs; program-level LINK_STATUS
// and the program info log still join truthfully, so paths that check each link
// immediately stay serial by their own construction). Off by default; never
// advertise it.
QuirkOverride AsyncOptimisticShaderStatus = QuirkOverride::Auto;
};
extern FeaturesTable Features;
} // namespace MobileGL::MG_Config
-2
View File
@@ -183,8 +183,6 @@ namespace MobileGL::MG_ConfigLoader {
features.EsprytMultiDrawMode = QueryEnvGLESMultiDrawMode("MOBILEGL_ESPRYT_MULTIDRAW_MODE");
features.AsyncShaderCompile = QueryEnvQuirkOverride("MOBILEGL_ASYNC_SHADER_COMPILE");
features.AsyncShaderCompileThreads = QueryEnvUint32("MOBILEGL_ASYNC_SHADER_COMPILE_THREADS", 0, 0, 64);
features.AsyncOptimisticShaderStatus =
QueryEnvQuirkOverride("MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS");
}
inline void InitBackendType() {
@@ -2028,9 +2028,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_currentDrawFrontendProgram = nullptr;
g_currentDrawBackendProgram = nullptr;
// ... || !GetSpirvStatus(): see BackendProgramObjectImpl::SyncToBackend - a
// program whose SPIR-V never arrived is linked but not drawable.
if (!currentProgram || !currentProgram->GetLinkStatus() || !currentProgram->GetSpirvStatus()) {
if (!currentProgram || !currentProgram->GetLinkStatus()) {
g_GLESFuncs.glUseProgram(0);
g_lastUsedBackendProgramId = 0;
return;
@@ -2591,7 +2589,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
static void BindCurrentProgramWithResources(
const SharedPtr<MG_State::GLState::ProgramObject>& currentProgram,
const TextureImpl::DrawTextureSyncKeys& keys) {
if (currentProgram && currentProgram->GetLinkStatus() && currentProgram->GetSpirvStatus()) {
if (currentProgram && currentProgram->GetLinkStatus()) {
#ifdef TRACY_ENABLE
ZoneScopedNC("BindCurrentProgram", TRACY_ZONECOLOR_BACKEND);
#endif
@@ -2861,7 +2859,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// is pinned for the duration. Prefers the per-draw stash those preparations wrote.
static PrgramImpl::BackendProgramObjectImpl* GetCurrentBackendProgram() {
const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw();
if (!currentProgram || !currentProgram->GetLinkStatus() || !currentProgram->GetSpirvStatus()) {
if (!currentProgram || !currentProgram->GetLinkStatus()) {
return nullptr;
}
if (PrgramImpl::g_currentDrawFrontendProgram == currentProgram.get()) {
@@ -3019,7 +3017,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
TextureImpl::SyncImageTextureBindings();
PrgramImpl::SyncCurrentProgram(currentProgram);
if (!currentProgram || !currentProgram->GetLinkStatus() || !currentProgram->GetSpirvStatus()) {
if (!currentProgram || !currentProgram->GetLinkStatus()) {
g_GLESFuncs.glUseProgram(0);
PrgramImpl::g_lastUsedBackendProgramId = 0;
return;
+2 -8
View File
@@ -4156,14 +4156,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
return;
}
// GetSpirvStatus() as well as GetLinkStatus(): a program whose phase-B job was
// cancelled (teardown) or whose optimizer run failed is fully linked and fully
// queryable, but has no SPIR-V to build a driver program out of. GL cannot retract
// a LINK_STATUS it already reported true, so "linked but not drawable" is the
// answer, and this is where the ES backend expresses it.
if (!stateProgramObject->GetLinkStatus() || !stateProgramObject->GetSpirvStatus()) {
MGLOG_E("Program object is not linked or has no generated SPIR-V, skipping backend sync. State "
"program ID: %u",
if (!stateProgramObject->GetLinkStatus()) {
MGLOG_E("Program object is not linked, skipping backend sync. State program ID: %u",
stateProgramObject->GetExternalIndex());
return;
}
@@ -4267,15 +4267,8 @@ void main() {
auto writeUniform = [&](Int location, const void* data, SizeT size) {
MOBILEGL_ASSERT(location >= 0, "GenerateDepthMipmapWithShader: invalid uniform location");
const Uint offset = m_depthMipmapResources.program->GetUniformOffset(static_cast<Uint>(location));
// A RETURN, not only an assert: the assert compiles out in release, and a program
// whose SPIR-V job settled cancelled reports kInvalidUniformOffset (~0u) with a
// zero-sized shadow - which would make the memcpy below a wild write at
// depthProgramData + 4 GiB rather than a dropped uniform.
if (offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset ||
offset + size > m_depthMipmapResources.program->GetUBOSize()) {
MOBILEGL_ASSERT(false, "GenerateDepthMipmapWithShader: uniform write out of bounds");
return;
}
MOBILEGL_ASSERT(offset + size <= m_depthMipmapResources.program->GetUBOSize(),
"GenerateDepthMipmapWithShader: uniform write out of bounds");
memcpy(depthProgramData + offset, data, size);
m_depthMipmapResources.program->MarkUBOContentDirty();
};
@@ -7168,13 +7161,8 @@ void main() {
auto writeUniform = [&](Int location, const void* data, SizeT size) {
MOBILEGL_ASSERT(location >= 0, "TryBlitToDefaultFramebufferWithShader: invalid uniform location");
const Uint offset = m_blitResources.program->GetUniformOffset(static_cast<Uint>(location));
// A RETURN, not only an assert - see GenerateDepthMipmapWithShader's copy of this
// guard: kInvalidUniformOffset must not reach the memcpy in a release build.
if (offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset ||
offset + size > m_blitResources.program->GetUBOSize()) {
MOBILEGL_ASSERT(false, "TryBlitToDefaultFramebufferWithShader: uniform write out of bounds");
return;
}
MOBILEGL_ASSERT(offset + size <= m_blitResources.program->GetUBOSize(),
"TryBlitToDefaultFramebufferWithShader: uniform write out of bounds");
memcpy(blitProgramData + offset, data, size);
};
writeUniform(m_blitResources.srcRectLocation, blitUniformData.srcRect, sizeof(blitUniformData.srcRect));
+3 -49
View File
@@ -744,21 +744,6 @@ namespace MobileGL::MG_Impl::GLImpl {
CopyStr(bufSize, length, infoLog, log.c_str(), (GLsizei)log.length());
}
// MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS: while the compile job is still in flight -
// and, via the latch below, for the rest of that node's life once any query was
// answered this way - GL_COMPILE_STATUS reads GL_TRUE and the info log reads empty,
// WITHOUT joining. The latch (TakeOptimisticCompileAnswer) is what makes the three
// sites tell ONE story: without it, a job settling between an application's info-log
// read and its status read would produce the torn pair "GL_FALSE with an empty log",
// and an application that aborts on that never reaches the link join that carries the
// real diagnostic. A failure hidden here still fails the program link, with the
// compile log quoted in the program info log (ProgramLinkTask::ConsumeShaders), which
// is where the serial compile-then-check applications this exists for do their error
// handling.
static Bool AnswerCompileOptimistically(const SharedPtr<MG_State::GLState::ShaderObject>& shaderObject) {
return MG_Util::Async::OptimisticShaderStatusActive() && shaderObject->TakeOptimisticCompileAnswer();
}
void GetShaderiv_State(GLuint shader, GLenum pname, GLint* params) {
auto& shaderObject = TryToGetShaderObject(shader);
if (!shaderObject) return;
@@ -771,20 +756,9 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = shaderObject->GetDeleteStatus();
break;
case GL_COMPILE_STATUS:
if (AnswerCompileOptimistically(shaderObject)) {
*params = GL_TRUE;
break;
}
*params = shaderObject->GetCompileStatus();
break;
case GL_INFO_LOG_LENGTH:
// Not cosmetic: LWJGL's one-argument glGetShaderInfoLog convenience overload
// sizes its buffer from this query, so a joining answer here would defeat the
// non-joining GetShaderInfoLog below.
if (AnswerCompileOptimistically(shaderObject)) {
*params = 0;
break;
}
*params = shaderObject->GetInfoLog().empty() ? 0 : (GLint)shaderObject->GetInfoLog().length() + 1;
break;
case GL_SHADER_SOURCE_LENGTH:
@@ -810,15 +784,6 @@ namespace MobileGL::MG_Impl::GLImpl {
auto& shaderObject = TryToGetShaderObject(shader);
if (!shaderObject) return;
// See AnswerCompileOptimistically: an in-flight compile reads as an empty log. The
// cost is a lost compile WARNING (a successful compile whose log the application
// reads exactly once, now, and never after the join) - accepted as part of the
// opt-in.
if (AnswerCompileOptimistically(shaderObject)) {
CopyStr(bufSize, length, infoLog, "", 0);
return;
}
const auto& log = shaderObject->GetInfoLog();
CopyStr(bufSize, length, infoLog, log.c_str(), (GLsizei)log.length());
}
@@ -1120,9 +1085,10 @@ namespace MobileGL::MG_Impl::GLImpl {
if (!programObject.IsUniformOpaqueAtLocation(location)) {
MGLOG_D("%s: program = %d, location = %d, maxLocation = %d", __func__, programObject.GetExternalIndex(),
location, programObject.GetMaxUniformLocation());
// Everything up to and including the clamp is phase-A data (the uniform's GL type
// decides its size), so it is answered without joining anything.
const SizeT size = programObject.GetUniformSizesInBytes(location);
const Uint offset = programObject.GetUniformOffset(location);
char* pUBO = static_cast<char*>(programObject.MapUBO());
const SizeT uboSize = programObject.GetUBOSize();
SizeT writeSize = ItemCount * sizeof(T);
if (size < writeSize) {
// Metadata bug: degrade to a clamped copy instead of killing the process.
@@ -1131,18 +1097,6 @@ namespace MobileGL::MG_Impl::GLImpl {
__func__, programObject.GetExternalIndex(), location, ItemCount * sizeof(T), size);
writeSize = size;
}
// The uniform shadow's LAYOUT is phase-B data, so a write that lands while the
// SPIR-V job is still running is recorded and replayed at its publish instead of
// joining it. This is the hot path for a shaderpack that sets its uniforms
// immediately after glLinkProgram. BufferUniformWrite declines (and we fall
// through, joining) only past its size budget.
if (programObject.IsSpirvPending() &&
programObject.BufferUniformWrite(location, byteOffsetInsideUniform, value, writeSize)) {
return;
}
const Uint offset = programObject.GetUniformOffset(location);
char* pUBO = static_cast<char*>(programObject.MapUBO());
const SizeT uboSize = programObject.GetUBOSize();
if (pUBO == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset ||
offset + byteOffsetInsideUniform + writeSize > uboSize) {
// Should not happen: linking gives every settable uniform backing
@@ -157,22 +157,6 @@ void main() {
const QuirkOverride m_saved;
};
// MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS, forced in-process for the same reason
// as AsyncModeScope: one ctest run asserts the quirk against the ambient default.
class OptimisticStatusScope {
public:
explicit OptimisticStatusScope(const QuirkOverride mode)
: m_saved(MobileGL::MG_Config::Features.AsyncOptimisticShaderStatus) {
MobileGL::MG_Config::Features.AsyncOptimisticShaderStatus = mode;
}
~OptimisticStatusScope() { MobileGL::MG_Config::Features.AsyncOptimisticShaderStatus = m_saved; }
OptimisticStatusScope(const OptimisticStatusScope&) = delete;
OptimisticStatusScope& operator=(const OptimisticStatusScope&) = delete;
private:
const QuirkOverride m_saved;
};
// glMaxShaderCompilerThreadsKHR writes process-wide state; a scenario that calls
// it has to put the pool back or it changes how every scenario after it compiles.
class CompilerThreadScope {
@@ -479,81 +463,5 @@ void main() {
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// The Iris two-phase shape end to end on a real driver, with the optimistic-status
// quirk on: phase 1 compiles each stage and reads its log then its status (both
// answered optimistically), links, detaches and deletes the shaders for every
// program with no program-level read anywhere; phase 2 then checks every link and
// draws every program. Deliberately NOT built on the harness CompileProgram(),
// whose status read would join and collapse the phase-1 overlap this exists to
// exercise. What the unit suite cannot see - worker-produced artifacts the backend
// then mis-renders - shows up here as a wrong quadrant signature.
TEST_F(AsyncCompileScenario, IrisShapedTwoPhaseBatchRendersCorrectly) {
if (!Ready()) return;
constexpr int kPrograms = 12;
// Distinct per program (so neither the source memo nor the adoption map turns
// a compile into a no-op) but a pure pass-through at runtime: the bulk sits in
// a branch a zero-initialised uniform never takes.
const auto fragmentSource = [](const int index) {
std::string source = "#version 330 core\nin vec3 vColor;\nout vec4 oColor;\n";
source += "uniform float uGate" + std::to_string(index) + ";\n";
source += "void main() {\n oColor = vec4(vColor, 1.0);\n";
source += " if (uGate" + std::to_string(index) + " > 1e30) {\n float acc = 1.0;\n";
for (int i = 0; i < 60; ++i) {
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0);\n";
}
source += " oColor = vec4(acc);\n }\n}\n";
return source;
};
std::vector<GLuint> programs;
{
const AsyncModeScope async(true);
const OptimisticStatusScope quirk(QuirkOverride::ForceOn);
const CompilerThreadScope threads;
glMaxShaderCompilerThreadsKHR(1);
for (int i = 0; i < kPrograms; ++i) {
m_sources.push_back(fragmentSource(i));
const char* fsText = m_sources.back().c_str();
const GLuint vs = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vs, 1, &kVertexSource, nullptr);
glCompileShader(vs);
(void)ShaderInfoLog(vs); // Iris's exact order: the log first...
(void)ShaderCompileStatus(vs); // ...then the status; both optimistic.
const GLuint fs = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fs, 1, &fsText, nullptr);
glCompileShader(fs);
(void)ShaderInfoLog(fs);
(void)ShaderCompileStatus(fs);
const GLuint program = glCreateProgram();
glAttachShader(program, vs);
glAttachShader(program, fs);
glBindAttribLocation(program, 0, "aPos");
glBindAttribLocation(program, 1, "aColor");
glLinkProgram(program);
glDetachShader(program, vs);
glDetachShader(program, fs);
glDeleteShader(vs);
glDeleteShader(fs);
programs.push_back(program);
}
}
for (int i = 0; i < kPrograms; ++i) {
const GLuint program = programs[static_cast<std::size_t>(i)];
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE) << "program " << i;
const Image image = DrawFrameWith(program);
EXPECT_EQ(image.QuadrantSignature(), "blue,green,red,white") << "program " << i;
}
for (const GLuint program : programs) glDeleteProgram(program);
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
} // namespace
} // namespace MGITest
+5 -12
View File
@@ -380,14 +380,8 @@ namespace MobileGL::MG_State {
// inside the same draw when it finally touched an artifact, and cache under a
// version the publish had already superseded. Settling here means every
// version a backend reads during a draw describes the program it is drawing.
// Two null checks in steady state.
//
// BOTH phases, and that is not optional: the phase-B publish bumps those same
// versions, so joining only phase A here would leave exactly the hazard this
// site exists to close - a backend samples a version, then trips the phase-B
// gate through GetGeneratedSpirv() deeper inside the same draw, and memoizes
// under a version the publish has already superseded.
currentProgram->JoinLinkAndSpirv();
// One null check in steady state.
currentProgram->JoinLink();
return currentProgram;
}
if (m_boundProgramPipeline == 0) return nullProgram;
@@ -404,7 +398,7 @@ namespace MobileGL::MG_State {
// programs. In steady state this is a null check per stage.
for (SizeT stage = 0; stage < static_cast<SizeT>(ShaderStage::ShaderStageCount); ++stage) {
const auto& stageProgram = pipeline->GetStageProgram(static_cast<ShaderStage>(stage));
if (stageProgram) stageProgram->JoinLinkAndSpirv();
if (stageProgram) stageProgram->JoinLink();
}
const auto signature = pipeline->ComputeDrawProgramSignature();
@@ -436,9 +430,8 @@ namespace MobileGL::MG_State {
composite->Link(true);
// P1 join site J2. The draw that asked for this program is the very next thing to
// happen, so enqueueing the composite's link buys nothing and only moves the wait
// to whichever backend accessor happens to touch its artifacts first. Both phases,
// for the same reason: the backend is about to read its SPIR-V.
composite->JoinLinkAndSpirv();
// to whichever backend accessor happens to touch its artifacts first.
composite->JoinLink();
pipeline->SetCachedDrawProgram(signature, Move(composite));
return pipeline->GetCachedDrawProgram(signature);
}
@@ -361,53 +361,29 @@ namespace MobileGL::MG_State::GLState {
}
}
// ---- everything below this line up to GenerateSpirv() is the GL query surface ----
//
// ORDERING NOTE (rewritten 2026-08-10; the constraint it records was RETESTED, not
// dropped on a hunch). This block used to insist that SPIR-V be generated BEFORE
// buildReflection touches artifacts.program, on the grounds that reflection's
// live-variable analysis mutates the shared intermediates in ways that change
// subsequent GlslangToSpv output - "observed: catastrophic uniform misbinding on
// DirectVulkan for UBO-heavy content", recorded with commit 0d052719.
//
// Re-measured on the glslang pin this tree vendors, with the same method 0d052719
// used (per-module SPIR-V hashes, both orders, byte-compared): 636 modules across
// 320 programs - the whole extracted trace corpus (BSL, Complementary Reimagined,
// IterationRP, Create/Flywheel) plus adversarial synthetics - came out BYTE-IDENTICAL
// in both orders, pre-optimize and post-optimize alike. glslang's code structure
// agrees: reflection.cpp performs no AST write (no getWritableType, no const_cast, no
// qualifier assignment) and GlslangToSpv takes a const TIntermediate&.
//
// Confirmed a third time ON DEVICE, 2026-08-11, and this one closes the gap the
// desktop A/B could not: the corpus replays captured SOURCES, so it never reproduced
// Iris's glBindAttribLocation-before-link flow, which is what drives the io-resolver
// that assigns vertex-input Locations. A Complementary Reimagined pack load on an
// Adreno 830 was dumped at the pipeline the driver rejects (programHash
// 0x4a7e9a37fb49caa1) under BOTH orders and under the pre-split build 6ea94877: all
// three dumps are the same bytes (md5 39ffa10d5186a4d37be82d0b42297a8d). The order
// does not perturb SPIR-V on this pin, including on the exact flow 0d052719 feared.
//
// Not a licence to stop measuring: 0d052719's observation was real once, and the
// method (per-module hashes, both orders) is cheap. Re-run it on any glslang bump.
//
// So the order is now the other way round, and deliberately: reflection, fragment
// output validation and transform-feedback resolution are what the GL query surface
// is made of, and they are also the only remaining ways a link can FAIL, so running
// them first is what lets LINK_STATUS and every query behind it become final without
// waiting for SPIR-V (and stops a program that fails validation from paying for
// ~68 s/pack-load of SPIR-V generation it is about to throw away).
//
// What has NOT changed: the routing tables are sized and keyed by reflection results
// AND read the OPTIMIZED SPIR-V, so BuildGlobalUboRouting still runs strictly after
// both DoReflection and GenerateSpirv.
// SPIR-V must be generated BEFORE buildReflection touches artifacts.program:
// reflection's live-variable analysis mutates the intermediates in ways that
// change subsequent GlslangToSpv output (observed: catastrophic uniform
// misbinding on DirectVulkan for UBO-heavy content). The old two-link pipeline
// never ran buildReflection on the SPIR-V-producing program; this order keeps
// that property with the single link. The glUniform*-to-scratch routing
// tables, in contrast, are sized and keyed by reflection results, so they are
// built strictly AFTER DoReflection. (Everything else on the reflection
// surface - locations, sampler units, block bindings/sizes - was measured
// identical in either order.)
MGLOG_D("ProgramObject %u: Starting SPIR-V generation", in.externalIndex);
GenerateSpirv();
MGLOG_D("ProgramObject %u: Starting reflection", in.externalIndex);
if (!DoReflection(env)) {
DeferLog(std::format("ProgramObject {}: Link failed during reflection: {}", in.externalIndex,
artifacts.infoLog));
return;
}
MGLOG_D("ProgramObject %u: Reflection done (linkStatus=%d)", in.externalIndex, (int)artifacts.linkStatus);
MGLOG_D("ProgramObject %u: Building global-UBO routing tables", in.externalIndex);
BuildGlobalUboRouting();
MGLOG_D("ProgramObject %u: Reflection done (linkStatus=%d)", in.externalIndex, (int)artifacts.linkStatus);
if (!ValidateFragmentOutputLocations()) {
return;
}
@@ -417,30 +393,8 @@ namespace MobileGL::MG_State::GLState {
in.externalIndex, artifacts.infoLog));
return;
}
// ---- past this point the link cannot fail any more ----
// Everything left is SPIR-V work, and it belongs to phase B. Hand it what it needs
// and stop: from the join's point of view this program is now fully linked.
//
// The TShaders move rather than copy - `attrib` borrowed them into the TProgram as
// raw pointers and this node is now their owner of record, for as long as phase B
// (which holds this node) needs the intermediates hanging off them.
spirvHandoff.shaders = Move(attrib.shaders);
spirvHandoff.shaderTypes.resize(in.shaders.size());
for (SizeT i = 0; i < in.shaders.size(); i++) {
spirvHandoff.shaderTypes[i] = MG_Util::ConvertShaderStageToGLEnum(in.shaders[i].stage);
}
// Copied, not referenced: `artifacts` is MOVED out of this node by the join, and
// phase B runs after that. Measured at ~20 us per program, which is noise against the
// ~450 ms phase B spends on the same program.
spirvHandoff.reflection.program = artifacts.program;
spirvHandoff.reflection.uniformLocations = artifacts.uniformLocations;
spirvHandoff.reflection.uniformIndexInTProgram = artifacts.uniformIndexInTProgram;
spirvHandoff.reflection.tProgramUniformIndexToGl = artifacts.tProgramUniformIndexToGl;
spirvHandoff.reflection.maxUniformLocation = artifacts.maxUniformLocation;
spirvHandoff.ready = true;
MGLOG_D("ProgramObject %u: phase A done, %zu module(s) handed to the SPIR-V job", in.externalIndex,
spirvHandoff.shaderTypes.size());
MGLOG_D("ProgramObject %u: Binary generation finished (generatedSpirv size=%zu)", in.externalIndex,
artifacts.generatedSpirv.size());
}
Bool ProgramLinkTask::ConsumeShaders(Vector<SharedPtr<glslang::TShader>>& outShaders) {
@@ -454,13 +408,6 @@ namespace MobileGL::MG_State::GLState {
MG_Util::ConvertGLEnumToString(shaderType).c_str());
if (!compiled.compileStatus) {
// The compile log LEADS the quoted source, and that order is load-bearing:
// under MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS this string is the
// application's ONLY compile diagnostic (the per-shader queries answered
// optimistically), and applications read it through a bounded buffer -
// Iris uses 32768 bytes - so the actionable text must come before the
// potentially-100KB source dump. The full source stays: the device log is
// where a failing pack gets debugged from.
artifacts.infoLog =
std::format("Linking a {} with compilation error, linking will now terminate. Shader error "
"log:\n{}\nShader src:\n{}",
@@ -889,6 +836,183 @@ namespace MobileGL::MG_State::GLState {
return true;
}
void ProgramLinkTask::GenerateSpirv() {
/* As we passed first stage compilation/linking,
* we'll assume all the operations here should
* pass. We may be able to employ some optimizations
* here without the burden of error reporting.
*/
using namespace MG_Util::ShaderTranspiler;
MGLOG_D("ProgramObject %u: GenerateSpirv - start", in.externalIndex);
// The shaders were parsed once, in the link-compatible (relaxed Vulkan-rules)
// configuration, and artifacts.program linked those parses - so artifacts.program IS
// the program the backends consume. Generate SPIR-V straight from its
// intermediates; the full re-parse + re-link that used to live here (one
// glslang pass per shader per link) is gone.
Vector<GLenum> shaderTypes(in.shaders.size());
for (SizeT i = 0; i < in.shaders.size(); i++) {
shaderTypes[i] = MG_Util::ConvertShaderStageToGLEnum(in.shaders[i].stage);
}
ProgramBinaryAttrib binaryAttrib{
.shaderTypes = shaderTypes,
.program = *artifacts.program,
};
MGLOG_D("ProgramObject %u: GenerateSpirv - requesting SPIR-V binary from program", in.externalIndex);
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
if (!binaryResult) {
DeferLog(std::format("ProgramObject {}: GenerateSpirv - GetSpirvBinaryFromProgram failed",
in.externalIndex));
}
MOBILEGL_ASSERT(binaryResult, "GetSpirvBinaryFromProgram failed");
artifacts.generatedSpirv = Move(binaryResult.value());
MGLOG_D("ProgramObject %u: GenerateSpirv - generated %zu SPIR-V modules", in.externalIndex,
artifacts.generatedSpirv.size());
// Linked SPIR-V generated, sanitize and optimize it
for (auto& spv : artifacts.generatedSpirv) {
auto success = ShaderCompiler::SanitizeAndOptimizeBinary(spv, spv);
MOBILEGL_ASSERT(success, "SanitizeBinary failed");
}
}
void ProgramLinkTask::BuildGlobalUboRouting() {
using namespace MG_Util::ShaderTranspiler;
Vector<GLenum> shaderTypes(in.shaders.size());
for (SizeT i = 0; i < in.shaders.size(); i++) {
shaderTypes[i] = MG_Util::ConvertShaderStageToGLEnum(in.shaders[i].stage);
}
artifacts.uniformSizesInBytes.clear();
artifacts.uniformOffsets.clear();
artifacts.globalUboScratch.clear();
// kInvalidUniformOffset marks locations that end up without global-UBO backing
// (e.g. the optimizer eliminated every use of the uniform); the fallback pass
// below gives those locations tail storage so glUniform* always has a target.
artifacts.uniformOffsets.resize(artifacts.maxUniformLocation + 1, ProgramObject::kInvalidUniformOffset);
artifacts.uniformSizesInBytes.resize(artifacts.maxUniformLocation + 1, 0);
for (SizeT i = 0; i < artifacts.generatedSpirv.size(); i++) {
auto& spv = artifacts.generatedSpirv[i];
auto shaderType = shaderTypes[i];
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - parsing SPIR-V meta data for module %zu "
"(shaderType=%u, wordCount=%zu)",
in.externalIndex, i, shaderType, spv.size());
SpvcSession session(spv, SessionUsageBit::Reflection);
auto result = session.ParseMetaData();
if (result < 0) {
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - SpvcSession::ParseMetaData failed for module %zu, "
"err = %d%s",
in.externalIndex, i, result,
(result == SPVC_ERROR_INVALID_SPIRV ? ". Probably no global UBO?" : ""));
continue;
} else {
auto& meta = session.GetMetadata();
auto size = meta.globalUboSize;
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - SPIR-V meta: uboSize=%zu plainUniformCount=%zu "
"plainUniformOffsets=%zu",
in.externalIndex, meta.globalUboSize, meta.plainUniformMemberSizesInBytes.size(),
meta.plainUniformOffsetsInUBO.size());
if (size == 0) {
continue;
}
if (artifacts.globalUboScratch.size() < size) {
artifacts.globalUboScratch.resize(size);
}
for (const auto& [name, offset] : meta.plainUniformOffsetsInUBO) {
// SPIRV-Reflect leaf names never carry a "[0]" suffix; frontend
// reflection keys arrays as "arr[0]" (GL naming), so retry with the
// suffix before declaring the uniform unbacked.
auto locationIt = artifacts.uniformLocations.find(name);
if (locationIt == artifacts.uniformLocations.end()) {
locationIt = artifacts.uniformLocations.find(name + "[0]");
}
if (locationIt == artifacts.uniformLocations.end()) {
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' offset=%u but not found in "
"uniformLocations",
in.externalIndex, name.c_str(), offset);
continue;
}
const Uint baseLocation = locationIt->second;
if (!ProgramObject::IsValidUniformLocation(artifacts, static_cast<Int>(baseLocation))) {
continue;
}
const Int uniformIndex = artifacts.uniformIndexInTProgram[baseLocation];
const GLint arraySize = ProgramObject::GetUniformArraySizeByTIndex(artifacts, uniformIndex);
SizeT memberSize = 0;
const auto sizeIt = meta.plainUniformMemberSizesInBytes.find(name);
if (sizeIt != meta.plainUniformMemberSizesInBytes.end()) {
memberSize = sizeIt->second;
}
Uint arrayStride = 0;
const auto strideIt = meta.plainUniformArrayStridesInUBO.find(name);
if (strideIt != meta.plainUniformArrayStridesInUBO.end()) {
arrayStride = strideIt->second;
}
// Array uniforms span one location per element (see DoReflection);
// give each element its real byte offset inside the UBO.
const GLint elementCount = (arraySize > 1 && arrayStride == 0) ? 1 : std::max(arraySize, 1);
for (GLint element = 0; element < elementCount; ++element) {
const Uint location = baseLocation + static_cast<Uint>(element);
if (location > artifacts.maxUniformLocation ||
artifacts.uniformIndexInTProgram[location] != uniformIndex) {
break;
}
artifacts.uniformOffsets[location] = offset + static_cast<Uint>(element) * arrayStride;
const SizeT consumed = static_cast<SizeT>(element) * arrayStride;
artifacts.uniformSizesInBytes[location] = memberSize > consumed ? memberSize - consumed : 0;
}
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' offset=%u stride=%u size=%zu assigned "
"to locations %u..%u",
in.externalIndex, name.c_str(), offset, arrayStride, memberSize, baseLocation,
baseLocation + static_cast<Uint>(elementCount) - 1);
}
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - finished parsing module %zu metadata",
in.externalIndex, i);
}
}
// Fallback pass: a linked program's active non-opaque uniforms must accept
// glUniform*/glGetUniform* even when the optimized SPIR-V no longer contains
// them (AggressiveDCE can remove a dead loop together with the only loads of a
// uniform -- or the entire global UBO, leaving the scratch unallocated). Hand
// such locations CPU-side storage at the (16-byte aligned) tail of the shadow
// buffer; backends bind at least the SPIR-V-declared UBO range, and the GPU
// never reads these bytes, so this only keeps the GL-visible state coherent.
for (Uint location = 0; location <= artifacts.maxUniformLocation; ++location) {
if (artifacts.uniformOffsets[location] != ProgramObject::kInvalidUniformOffset) continue;
if (!ProgramObject::IsValidUniformLocation(artifacts, static_cast<Int>(location))) continue;
const auto& uniform = artifacts.program->getUniform(artifacts.uniformIndexInTProgram[location]);
const glslang::TType* type = uniform.getType();
if (type != nullptr && type->isOpaque()) continue;
if (uniform.index >= 0 && uniform.index < artifacts.program->getNumUniformBlocks() &&
std::strstr(artifacts.program->getUniformBlock(uniform.index).name.c_str(),
MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME) == nullptr) {
// Member of a named uniform block: not settable through glUniform*, so it
// needs no global-UBO shadow storage.
continue;
}
// std140-style slot: the matrix upload paths write column vectors at
// 16-byte strides, so a matrix slot must cover cols * 16 bytes.
SizeT slotSize = MG_Util::GetGLTypeSize(uniform.glDefineType);
if (type != nullptr && type->isMatrix()) {
slotSize = static_cast<SizeT>(type->getMatrixCols()) * 16u;
}
slotSize = (slotSize + 15u) & ~static_cast<SizeT>(15u);
const SizeT slotOffset = (artifacts.globalUboScratch.size() + 15u) & ~static_cast<SizeT>(15u);
artifacts.globalUboScratch.resize(slotOffset + slotSize, 0);
artifacts.uniformOffsets[location] = static_cast<Uint>(slotOffset);
artifacts.uniformSizesInBytes[location] = slotSize;
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' location %u has no UBO backing in the "
"generated SPIR-V (optimized out?); allocated %zu fallback bytes at scratch offset %zu",
in.externalIndex, uniform.name.c_str(), location, slotSize, slotOffset);
}
}
Bool ProgramLinkTask::ValidateFragmentOutputLocations() {
if (!artifacts.program) return false;
@@ -29,16 +29,10 @@ namespace MobileGL::MG_State::GLState {
SharedPtr<const ShaderCompileTask> compiled;
};
// PHASE A of one glLinkProgram: the half that decides what GL can be asked about the
// program - glslang link + mapIO, the GL-facing reflection surface, fragment-output
// validation and transform-feedback resolution - with every input it needs snapshotted at
// enqueue.
//
// Every one of the eight ways a link can fail lives here, so once this node has published
// through EnsureLinkJoined() the program's LINK_STATUS, info log and entire query surface
// are FINAL and truthful. SPIR-V generation, spirv-opt and the global-UBO routing tables
// moved to ProgramSpirvTask, which chains behind this node and is joined by only five
// getters (see ProgramObject::EnsureSpirvJoined).
// The unit of asynchronous linking: one glLinkProgram's worth of pure CPU work - glslang
// link + mapIO, SPIR-V generation and optimization, the GL-facing reflection surface, the
// global-UBO routing tables, fragment-output validation and transform-feedback
// resolution - with every input it needs snapshotted at enqueue.
//
// Same ownership rule as ShaderCompileTask: the body reads nothing but `in` (all of it
// owned or immutable) and writes nothing but `artifacts`. No GL call, no
@@ -46,13 +40,11 @@ namespace MobileGL::MG_State::GLState {
// through the CompileEnv snapshot and diagnostics are deferred to the join.
//
// ONE LINK IS ONE HANDLER. RunBody() runs start to finish inside a single pool handler
// and is the only place `artifacts` is written. Splitting it across handlers to
// "pipeline" the reflection half would let a cancel land between the halves and publish a
// program whose SPIR-V and reflection describe different things - so any such split has
// to be structural: the first half must publish a LINK_STATUS and a query surface that
// are already final, and a lost second half must degrade to "linked but not drawable",
// never to a half-published program. (The intermediates' ordering constraint that used to
// be quoted here is retested and no longer binding; see the ordering note in RunBody.)
// and is the only place `artifacts` is written. Do not split it across handlers to
// "pipeline" the reflection half: the intermediates that GlslangToSpv and buildReflection
// share are mutated in a strict order (see the GenerateSpirv-before-DoReflection comment
// in Run()), and a second handler would let a cancel land between them and publish a
// program whose SPIR-V and reflection describe different things.
class ProgramLinkTask final : public MG_Util::Async::JobNode {
public:
// ---- inputs, snapshotted on the GL thread in ProgramObject::Link()'s prologue ----
@@ -76,49 +68,6 @@ namespace MobileGL::MG_State::GLState {
// Moved (never copied) into the ProgramObject by EnsureLinkJoined().
ProgramObject::LinkArtifacts artifacts;
// ---- output: everything ProgramSpirvTask needs to run without this node's
// artifacts, filled at the tail of a successful RunBody() ----
//
// THIS IS NOT `artifacts` AND MUST NOT BE MERGED INTO IT. The GL thread MOVES
// `artifacts` out of this node at the join, and phase B runs on a worker afterwards -
// so phase B may read `spirvHandoff` and `in` (neither is ever touched by the join)
// and this node's JobState, and nothing else on it. Reading `artifacts` or
// `diagnostics` from phase B would race the publish.
struct SpirvHandoff {
// MANDATORY, and the reason this struct exists at all: TProgram::addShader stores
// a RAW TShader*, and for the one-shader-per-stage case getIntermediate() returns
// the TShader's own intermediate rather than a copy. These used to die when
// RunBody() returned, which was safe only because nothing called getIntermediate()
// afterwards. GlslangToSpv does exactly that, so phase B has to own them.
//
// MEMORY NOTE: this is the one thing the split makes live LONGER than it used to -
// a glslang arena per stage, megabytes for a shaderpack, now alive from the end of
// phase A until phase B runs instead of dying with the link body, so a deep
// phase-B backlog holds one arena per queued program. Phase B clears this vector
// as soon as GlslangToSpv returns, but read that call site's comment before
// relying on it: for the COMMON case (a shader linked into exactly one program)
// the compile node co-owns the same TShader and phase A pins that node, so the
// clear frees nothing and only the re-parsed CAS-loser shaders are actually
// released. If peak RSS ever becomes the binding constraint on a pack load, THIS
// is the field to attack - by bounding the backlog, by releasing the compile
// node's own reference at claim time, or by moving GlslangToSpv back into phase A.
Vector<SharedPtr<glslang::TShader>> shaders;
// GL enum per entry of `in.shaders`, in the same order (GetSpirvBinaryFromProgram
// walks it to pick the intermediates).
Vector<GLenum> shaderTypes;
// The reflection slice BuildGlobalUboRouting consumes: {program, uniformLocations,
// uniformIndexInTProgram, tProgramUniformIndexToGl, maxUniformLocation}. Carried
// as a LinkArtifacts with only those five fields set, so the routing pass can keep
// calling ProgramObject::IsValidUniformLocation / GetUniformArraySizeByTIndex
// unchanged. The SharedPtr copy of `program` is also what keeps the TProgram alive
// for phase B after the join has moved `artifacts` away.
ProgramObject::LinkArtifacts reflection;
// The one flag phase B tests before doing anything: false means this link never
// reached the tail of RunBody (it failed, or was cancelled mid-body).
Bool ready = false;
} spirvHandoff;
// Posts this job once every compile in `deps` is terminal - and not one moment
// earlier, so the body never waits on anything (invariant I4: no job body may block
// on another job, or the pool could deadlock with all its workers waiting on each
@@ -145,6 +94,8 @@ namespace MobileGL::MG_State::GLState {
Bool ValidateFragmentOutputLocations();
Bool ResolveTransformFeedbackVaryings();
void ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate);
void GenerateSpirv();
void BuildGlobalUboRouting();
// Worker-side MGLOG replacement: appended to diagnostics.logLines and replayed by the
// join, on the GL thread, where a serial implementation would have printed it.
@@ -8,9 +8,7 @@
#include "ProgramObject.h"
#include "ProgramLinkTask.h"
#include "ProgramSpirvTask.h"
#include <atomic>
#include <cstring>
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/Converters/GLToStr/GLEnumConverter.h>
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
@@ -70,129 +68,12 @@ namespace MobileGL::MG_State::GLState {
Bool ProgramObject::IsPendingLinkTerminal() const { return m_pendingLink->IsTerminal(); }
Bool ProgramObject::IsPendingSpirvTerminal() const { return m_pendingSpirv->IsTerminal(); }
void ProgramObject::JoinPendingSpirv() const {
MOBILEGL_ASSERT(!MG_Util::Async::ShaderCompilePool::IsPoolThread(),
"ProgramObject::EnsureSpirvJoined() reached from a pool thread; a job body must never read "
"GL-thread-owned objects");
// Move the node out FIRST, for the same reason JoinPendingLink does: everything below
// runs GL-thread-only code that reads program state, and with m_pendingSpirv still set
// that would re-enter this function.
const SharedPtr<ProgramSpirvTask> pending = Move(m_pendingSpirv);
m_pendingSpirv.reset();
pending->Wait();
if (pending->IsComplete()) {
m_spirv = Move(pending->artifacts);
}
// A node that settled as Cancelled published nothing, so m_spirv stays empty with
// spirvStatus false: linked, queryable, not drawable. Nothing to repair.
// Before the version bump, and before any caller can read the shadow: the writes the
// application made while the layout did not exist yet.
ReplayBufferedUniformWrites();
// The THIRD version bump of this link (enqueue, phase-A publish, phase-B publish), and
// it is mandatory for exactly the reason the phase-A one is (see JoinPendingLink): a
// backend memo taken during the A->B window - when the program was already answering
// as linked but had no SPIR-V and no uniform shadow - must not survive the arrival of
// either. The memos at risk are keyed on (lifetimeId, backendStateVersion).
BumpLinkObservableVersions();
MG_Util::Async::ApplyDeferredDiagnostics(*pending);
}
Bool ProgramObject::BufferUniformWrite(const Uint location, const SizeT byteOffsetInUniform, const void* source,
const SizeT byteSize) {
if (source == nullptr || byteSize == 0) return true; // nothing to record, nothing to join for
if (m_pendingUniformBytes.size() + byteSize > kMaxBufferedUniformBytes) {
// Pressure valve: stop growing and let the caller take the join. Say so once per
// program, because the interesting fact is WHICH program did it.
MGLOG_D("ProgramObject %u: buffered uniform writes exceeded %zu bytes during the SPIR-V window; the "
"write joins instead",
m_externalIndex, kMaxBufferedUniformBytes);
return false;
}
const SizeT dataOffset = m_pendingUniformBytes.size();
m_pendingUniformBytes.resize(dataOffset + byteSize);
std::memcpy(m_pendingUniformBytes.data() + dataOffset, source, byteSize);
m_pendingUniformWrites.push_back(PendingUniformWrite{.location = location,
.byteOffsetInUniform =
static_cast<Uint>(byteOffsetInUniform),
.byteSize = static_cast<Uint>(byteSize),
.dataOffset = static_cast<Uint>(dataOffset)});
return true;
}
void ProgramObject::ReplayBufferedUniformWrites() const {
if (m_pendingUniformWrites.empty()) {
m_pendingUniformBytes.clear();
return;
}
// Drain into locals first: MarkUBOContentDirty below is a plain counter bump, but a
// future reader of this function should not be able to observe a half-drained buffer.
Vector<PendingUniformWrite> writes;
Vector<Uint8> bytes;
writes.swap(m_pendingUniformWrites);
bytes.swap(m_pendingUniformBytes);
if (m_spirv.globalUboScratch.empty() || m_spirv.uniformOffsets.empty()) {
// Phase B produced nothing (cancelled at teardown, or a relink superseded it).
// The program is not drawable, so there is nowhere for these to land and nothing
// that could observe them.
MGLOG_D("ProgramObject %u: dropping %zu buffered uniform write(s); the SPIR-V job published no shadow",
m_externalIndex, writes.size());
return;
}
Uint8* const scratch = m_spirv.globalUboScratch.data();
const SizeT uboSize = m_spirv.globalUboScratch.size();
for (const PendingUniformWrite& write : writes) {
if (write.location >= m_spirv.uniformOffsets.size()) continue;
const Uint offset = m_spirv.uniformOffsets[write.location];
if (offset == kInvalidUniformOffset ||
static_cast<SizeT>(offset) + write.byteOffsetInUniform + write.byteSize > uboSize) {
// Same verdict the live write path reaches for a uniform without backing
// storage: log and drop, rather than fault.
MGLOG_E("ProgramObject %u: buffered uniform write at location %u has no backing storage "
"(offset=%u size=%u uboSize=%zu); dropping write",
m_externalIndex, write.location, offset, write.byteSize, uboSize);
continue;
}
Uint8* const destination = scratch + offset + write.byteOffsetInUniform;
const Uint8* const sourceBytes = bytes.data() + write.dataOffset;
// The same bytes-equal dedupe the live path applies, per record and in order, so
// the "an identical write does not move the content version" property survives
// the detour byte for byte.
if (std::memcmp(destination, sourceBytes, write.byteSize) == 0) continue;
std::memcpy(destination, sourceBytes, write.byteSize);
MarkUBOContentDirty();
}
}
void ProgramObject::CancelLink() {
// Phase B first: it is chained behind phase A, so cancelling A would otherwise run A's
// continuation and post a node this call is about to abandon anyway. Cancelling it up
// front makes that continuation a no-op.
//
// Cooperative and non-blocking, both of them. 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. Either way nothing waits, and each
// node keeps its own inputs alive for as long as it needs them.
if (m_pendingSpirv) {
m_pendingSpirv->Cancel();
m_pendingSpirv.reset();
// Buffered writes belong to the link that is being abandoned. A relink resets
// every uniform to its initial value anyway (GL 4.6 core 7.6), and the other two
// callers are destruction and glProgramBinary's mandated failure, so there is
// nothing left that could want them.
m_pendingUniformWrites.clear();
m_pendingUniformBytes.clear();
}
if (!m_pendingLink) 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. Either way nothing waits, and the node
// keeps its own inputs alive for as long as it needs them.
m_pendingLink->Cancel();
m_pendingLink.reset();
}
@@ -222,12 +103,8 @@ namespace MobileGL::MG_State::GLState {
// function has ever cleared, and its callers depend on that (they write infoLog
// immediately AFTER calling here). Link()'s prologue does not use this - it assigns a
// whole default-constructed block, where the ordering is explicit.
// Phase-B output (generatedSpirv / uniformOffsets / globalUboScratch) is NOT cleared
// here and is not in LinkArtifacts at all: the link body calls this on its own block,
// where no phase-B output exists yet. The two GL-thread callers that also have to
// discard phase-B output say so themselves (MarkLinkFailedByProgramBinary clears
// m_spirv; Link()'s prologue assigns a fresh one).
artifacts.program.reset();
artifacts.generatedSpirv.clear();
artifacts.uniformLocations.clear();
artifacts.glUniformIndexToTProgram.clear();
artifacts.tProgramUniformIndexToGl.clear();
@@ -240,6 +117,9 @@ namespace MobileGL::MG_State::GLState {
artifacts.uniformBlockIndexByName.clear();
artifacts.uniformBlockBinding.clear();
artifacts.shaderStorageBlockBinding.clear();
artifacts.uniformOffsets.clear();
artifacts.uniformSizesInBytes.clear();
artifacts.globalUboScratch.clear();
artifacts.attribs.clear();
artifacts.attribTypes.clear();
artifacts.activeUniformCount = 0;
@@ -358,7 +238,6 @@ namespace MobileGL::MG_State::GLState {
// is what every gated reader sees, so it has to be the complete "not linked" state -
// including the fields ResetLinkArtifacts deliberately preserves for its own callers.
m_artifacts = {};
m_spirv = {};
// ---- GL-thread-owned mutations ----
// Remove detached shaders first
@@ -413,33 +292,17 @@ namespace MobileGL::MG_State::GLState {
task->in.shaders.push_back({shader->GetShaderStage(), shader->GetShaderSourcePtr(), node});
}
// Phase B of the same link: SPIR-V generation, spirv-opt and the global-UBO routing
// tables. Created here, alongside phase A, so that from this instant the program has
// BOTH pending nodes and every cancel site (this prologue, ~ProgramObject,
// glProgramBinary's failure) drops both through the one CancelLink().
auto spirvTask = MakeShared<ProgramSpirvTask>();
m_pendingLink = task;
m_pendingSpirv = spirvTask;
// Flag off - or glMaxShaderCompilerThreadsKHR(0), see AsyncShaderCompileActive():
// byte-identical to the synchronous implementation. RunInline() executes the same
// bodies on this thread, in the same order, and the join below publishes through the
// same code, so the two modes differ only in WHICH thread ran them.
//
// Deliberately NOT expressed as SubmitAfter here: its continuation posts to the pool,
// and in this mode the pool is merely unused rather than stopped - the work would
// silently move off-thread in the one mode whose whole contract is that it does not.
// body on this thread and the join below publishes through the same code, so the two
// modes differ only in WHICH thread ran RunBody().
if (!MG_Util::Async::AsyncShaderCompileActive()) {
task->RunInline();
spirvTask->RunInlineAfter(task);
EnsureSpirvJoined();
EnsureLinkJoined();
return;
}
// The chain edge FIRST, while phase A is still Pending, so registering it is a plain
// list append rather than an inline continuation on this thread. If SubmitAfter below
// then fails to post phase A it cancels it, and that cancel fires this edge, which
// cancels phase B - nothing is left stranded either way.
spirvTask->SubmitAfter(task);
task->SubmitAfter(deps);
}
@@ -18,9 +18,6 @@ namespace MobileGL::MG_State::GLState {
// ProgramLinkTask.h includes THIS header (it outputs a LinkArtifacts), so including it
// back would be circular. The destructor is therefore out of line.
class ProgramLinkTask;
// Phase B of the same link: SPIR-V generation, spirv-opt and the global-UBO routing
// tables. Chained behind the ProgramLinkTask, forward-declared for the same reason.
class ProgramSpirvTask;
class ProgramObject {
public:
@@ -306,25 +303,7 @@ namespace MobileGL::MG_State::GLState {
// Sentinel for a uniform location without global-UBO backing storage (should not
// survive linking: GenerateBinary falls back to tail-allocated scratch storage).
static constexpr Uint kInvalidUniformOffset = ~0u;
// PHASE B (joins the SPIR-V job; see EnsureSpirvJoined).
//
// BOUNDS-CHECKED, and that is not defensive padding - it is the load-bearing half of
// the "linked but not drawable" contract. A phase B that settles CANCELLED rather than
// Complete (its body threw, the pool failed to enqueue it, or teardown cancelled it
// while phase A had already published) publishes nothing, so the shadow is a
// default-constructed SpirvArtifacts with an EMPTY uniformOffsets - while LINK_STATUS
// stays GL_TRUE, because GL gives no way to retract one, and IsValidUniformLocation()
// keeps answering true out of phase-A reflection. Every glUniform*/glGetUniform* call
// site reaches this getter BEFORE its own kInvalidUniformOffset / null-scratch guard,
// so an unchecked operator[] here would be a null dereference on the query surface
// this design promises stays answerable. Reporting kInvalidUniformOffset instead hands
// each of those sites exactly the value their existing guard already handles - the
// same value the routing pass itself uses for a uniform the optimizer deleted.
Uint GetUniformOffset(Uint location) const {
const SpirvArtifacts& spirv = Spirv();
return location < spirv.uniformOffsets.size() ? spirv.uniformOffsets[location]
: kInvalidUniformOffset;
}
Uint GetUniformOffset(Uint location) const { return Artifacts().uniformOffsets[location]; }
Uint GetUniformSizesInBytes(Uint location) const { return MG_Util::GetGLTypeSize(GetUniformType(location)); }
Int GetAttributeLocation(const String& name) {
@@ -402,14 +381,9 @@ namespace MobileGL::MG_State::GLState {
const String& GetActiveAttribName(Uint index) const {
return NormalizeBuiltinPipeInputName(Artifacts().program->getPipeInput(static_cast<Int>(index)).name);
}
// PHASE B, all three (see EnsureSpirvJoined): the shadow buffer's layout is decided
// by the OPTIMIZED SPIR-V, so it does not exist until the SPIR-V job has settled - and
// never exists at all for a program whose SPIR-V job settled cancelled. These three
// degrade to nullptr/nullptr/0 in that case, which is exactly the "no backing storage"
// shape every caller already tests for (see GetUniformOffset's note).
void* MapUBO() { return Spirv().globalUboScratch.data(); }
const void* GetUBOData() const { return Spirv().globalUboScratch.data(); }
Uint GetUBOSize() const { return static_cast<Uint>(Spirv().globalUboScratch.size()); }
void* MapUBO() { return Artifacts().globalUboScratch.data(); }
const void* GetUBOData() const { return Artifacts().globalUboScratch.data(); }
Uint GetUBOSize() const { return static_cast<Uint>(Artifacts().globalUboScratch.size()); }
// Content version of the CPU-side global-UBO shadow: writers bump it so backends
// can skip re-uploading an unchanged UBO on every draw. ~0u is reserved as the
// backends' "never uploaded" sentinel, so skip over it on wrap.
@@ -417,25 +391,6 @@ namespace MobileGL::MG_State::GLState {
void MarkUBOContentDirty() const {
if (++m_uboContentVersion == ~0u) m_uboContentVersion = 0;
}
// ---- glUniform* inside the phase-A -> phase-B window ----
//
// True while the program is fully linked and fully queryable but its uniform shadow's
// LAYOUT (which the optimized SPIR-V decides) does not exist yet. A non-opaque
// glUniform* write in that window is RECORDED rather than joined, and replayed into
// the shadow at the phase-B publish - so a pack that sets its uniforms immediately
// after glLinkProgram never waits for SPIR-V.
//
// Nothing can observe the difference: the only route to those bytes is glGetUniform*
// (and a draw), and both of those go through the phase-B gate, which replays first.
// The OPAQUE branch of glUniform* is deliberately not buffered - a sampler unit is
// phase-A state (uniformSamplerOrImageUnitIndex), so glUniform1i(samplerLoc, unit)
// right after a link stays a zero-join operation, which is exactly what Iris does.
Bool IsSpirvPending() const { return m_pendingSpirv != nullptr; }
// Records one write. Returns false if it declined to buffer - the caller must then
// perform the write directly (which joins). Declining is the pressure valve for an
// application that writes megabytes of uniforms into a single pending window.
Bool BufferUniformWrite(Uint location, SizeT byteOffsetInUniform, const void* source, SizeT byteSize);
Uint32 GetBackendStateVersion() const { return m_backendStateVersion; }
// Bumped only by (re)linking — lets backends detect that every piece of
// link-derived reflection (locations, block order, UBO layout) is stale.
@@ -508,11 +463,6 @@ namespace MobileGL::MG_State::GLState {
CancelLink();
BumpLinkObservableVersions();
ResetLinkArtifacts(Artifacts());
// ResetLinkArtifacts is a LinkArtifacts-only operation (the link body calls it on
// its own block, where no phase-B output exists yet), so the phase-B half is
// cleared here. CancelLink() above already dropped the pending SPIR-V job, so
// this cannot be racing a publish.
m_spirv = {};
Artifacts().infoLog = "No program binary format is supported.";
}
Bool GetValidateStatus() const { return m_validateStatus; }
@@ -621,15 +571,8 @@ namespace MobileGL::MG_State::GLState {
return Artifacts().shaderStorageBlockBinding;
}
// PHASE B (see EnsureSpirvJoined). Empty for a program whose SPIR-V job was
// cancelled; GetSpirvStatus() below is how a backend tells that apart from a program
// that never linked.
Vector<Vector<unsigned>>& GetGeneratedSpirv() { return Spirv().generatedSpirv; }
const Vector<Vector<unsigned>>& GetGeneratedSpirv() const { return Spirv().generatedSpirv; }
// Whether phase B produced usable SPIR-V. Joins, like the four getters above: a
// backend asks this exactly where it used to ask GetLinkStatus(), i.e. right before
// it builds or draws with the program.
Bool GetSpirvStatus() const { return Spirv().spirvStatus; }
Vector<Vector<unsigned>>& GetGeneratedSpirv() { return Artifacts().generatedSpirv; }
const Vector<Vector<unsigned>>& GetGeneratedSpirv() const { return Artifacts().generatedSpirv; }
// The linked glslang reflection itself, for the ONE consumer that needs resource
// lists no typed getter above exposes: the GL program-interface query layer
@@ -677,6 +620,7 @@ namespace MobileGL::MG_State::GLState {
// without going through the gate.
struct LinkArtifacts {
SharedPtr<glslang::TProgram> program;
Vector<Vector<unsigned>> generatedSpirv;
// Attributes (Vertex in)
Vector<String> attribs;
@@ -720,6 +664,11 @@ namespace MobileGL::MG_State::GLState {
// SetShaderStorageBlockBinding for why this one is by name and not by index.
UnorderedMap<String, Int> shaderStorageBlockBinding;
// Need to be reflected after linking of SPIR-V binary
Vector<Uint> uniformOffsets;
Vector<Uint> uniformSizesInBytes;
Vector<Uint8> globalUboScratch;
Uint activeUniformCount = 0;
Uint maxUniformLocation = 0;
Int uniformNameMaxLength = 0;
@@ -748,35 +697,6 @@ namespace MobileGL::MG_State::GLState {
Uint32 xfbPackedStride = 0;
};
// ---- everything phase B of a link produces, in one movable block ----
//
// The membership rule is the same mechanical one LinkArtifacts uses: this is exactly
// what ProgramSpirvTask writes, which is what makes moving it THE publish. It is
// deliberately NOT part of LinkArtifacts, and that separation is what routes the five
// readers of SPIR-V-derived data through their own join gate by compiler rather than
// by review - m_spirv is private and Spirv() is the only spelling that reaches it.
//
// Why these three and nothing else: `generatedSpirv` has no GL-thread reader at all
// (every consumer is a backend draw/prepare path), and `uniformOffsets` +
// `globalUboScratch` are the ONLY things glUniform*/glGetUniform* need that are
// derived from the OPTIMIZED SPIR-V rather than from glslang reflection - spirv-opt
// runs in place and can delete a uniform, or the whole global UBO, so the offsets
// cannot be lifted out of glslang's reflection instead.
struct SpirvArtifacts {
Vector<Vector<unsigned>> generatedSpirv;
// Byte offset of each uniform location inside globalUboScratch, or
// kInvalidUniformOffset. Sized maxUniformLocation + 1 by the routing pass.
Vector<Uint> uniformOffsets;
Vector<Uint8> globalUboScratch;
// False for a program whose SPIR-V was never produced (phase B cancelled at
// teardown or by a relink) or whose optimizer run failed. GL has no way to
// retract a LINK_STATUS it already reported true, so such a program stays
// "linked" and every reflection answer it has given stays correct - it is simply
// not drawable, which the backends already express through their link-status
// gates.
Bool spirvStatus = false;
};
// ---- artifacts-only helpers, shared with ProgramLinkTask ----
// Static and taking the block explicitly, because from stage 4 the link BODY needs
// them while its artifacts still live on the job node, not on any ProgramObject. The
@@ -816,20 +736,9 @@ namespace MobileGL::MG_State::GLState {
// Blocks until a pending link has published its artifacts. Public because a few call
// sites have to join without reading anything - see the explicit-join list (J1-J8) in
// the P1 design. GL thread only.
//
// PHASE A ONLY. After this returns, LINK_STATUS and the whole GL query surface are
// final and truthful, but the SPIR-V and the uniform shadow may still be in flight.
void JoinLink() const { EnsureLinkJoined(); }
// Both phases. The draw path uses this, and must: the backends sample lifetimeId /
// backendStateVersion / the UBO content version OUTSIDE the gate, so a draw that
// joined only phase A would sample a version, join phase B later inside the same draw
// (through GetGeneratedSpirv), and memoize under a version the phase-B publish had
// already superseded - the exact lost-invalidation hazard J1 exists to prevent.
void JoinLinkAndSpirv() const { EnsureSpirvJoined(); }
// Drops BOTH phases of a link that is still in flight, without waiting for either.
// Called at the points
// Drops a link that is still in flight, without waiting for it. Called at the points
// where the pending link's result stops being the answer to "what did this program
// link to": a re-link supersedes it, glProgramBinary must force LINK_STATUS false,
// and a destroyed program has no observers left.
@@ -848,15 +757,7 @@ namespace MobileGL::MG_State::GLState {
// MUST NOT JOIN - this is what GL_COMPLETION_STATUS_KHR reads when the extension
// surface lands. "No job at all" counts as complete: there is nothing outstanding to
// wait for.
//
// BOTH phases, deliberately: an application that polls GL_COMPLETION_STATUS_KHR and
// then draws must not be told "done" while the SPIR-V is still being generated, or
// the draw it was cleared for is the thing that blocks.
Bool IsLinkComplete() const { return IsPhaseALinkComplete() && IsSpirvComplete(); }
// Phase A alone, for the callers that only care about the query surface (and for the
// tests that pin the two phases apart).
Bool IsPhaseALinkComplete() const { return m_pendingLink == nullptr || IsPendingLinkTerminal(); }
Bool IsSpirvComplete() const { return m_pendingSpirv == nullptr || IsPendingSpirvTerminal(); }
Bool IsLinkComplete() const { return m_pendingLink == nullptr || IsPendingLinkTerminal(); }
void SetTransformFeedbackVaryings(Vector<String>&& names, GLenum bufferMode) {
m_requestedXfbVaryings = Move(names);
@@ -923,40 +824,6 @@ namespace MobileGL::MG_State::GLState {
// node's state goes through this out-of-line helper.
Bool IsPendingLinkTerminal() const;
// ---- the second join gate: phase-B (SPIR-V) output only ----
// Phase A FIRST, always. Two reasons: the phase-B publish replays the uniform writes
// that were buffered during its window, and those need the phase-A reflection to
// validate against; and a caller that reaches a phase-B getter without having settled
// phase A would otherwise leave the link half-published.
//
// Same inline/out-of-line split as the phase-A gate, for the same reason: the five
// getters behind this one include the per-draw uniform upload path.
void EnsureSpirvJoined() const {
if (m_pendingLink) JoinPendingLink();
if (m_pendingSpirv) JoinPendingSpirv();
}
void JoinPendingSpirv() const;
Bool IsPendingSpirvTerminal() const;
// One buffered non-opaque glUniform* write. `dataOffset` indexes m_pendingUniformBytes,
// which is one append-only blob rather than a per-record allocation.
struct PendingUniformWrite {
Uint location = 0;
Uint byteOffsetInUniform = 0;
Uint byteSize = 0;
Uint dataOffset = 0;
};
// Replays the buffer into the freshly published shadow, in write order, and drains it.
// Each record re-does the bounds check and the bytes-equal dedupe the live write path
// performs, so "an identical write does not move the content version" survives the
// detour exactly - and a record that really does change bytes moves the version, which
// is what makes a backend re-upload the UBO it cached during the window.
void ReplayBufferedUniformWrites() const;
// Past this, BufferUniformWrite declines and the write joins instead. Sized so an
// ordinary pack load never reaches it (a pending window is one program's worth of
// uniforms) while a pathological writer cannot grow the heap without bound.
static constexpr SizeT kMaxBufferedUniformBytes = 4u << 20;
LinkArtifacts& Artifacts() {
EnsureLinkJoined();
return m_artifacts;
@@ -965,14 +832,6 @@ namespace MobileGL::MG_State::GLState {
EnsureLinkJoined();
return m_artifacts;
}
SpirvArtifacts& Spirv() {
EnsureSpirvJoined();
return m_spirv;
}
const SpirvArtifacts& Spirv() const {
EnsureSpirvJoined();
return m_spirv;
}
// GL-thread-only companion to ResetLinkArtifacts (see its definition). Const because
// the publish half of the join calls it; see the mutable counters below.
@@ -1040,22 +899,10 @@ namespace MobileGL::MG_State::GLState {
// Mutable because publishing is a READ-side operation: a const getter has to be able
// to settle an outstanding link before answering it.
mutable LinkArtifacts m_artifacts;
// Phase-B output. Same mutability argument as m_artifacts, reached only through
// Spirv().
mutable SpirvArtifacts m_spirv;
// The link job, from enqueue until the first observable read pulls its result. Null
// means m_artifacts is already the answer - which is the state every reader outside
// the pending window sees, and the whole reason the gate above is one branch.
mutable SharedPtr<ProgramLinkTask> m_pendingLink;
// The SPIR-V job, chained behind m_pendingLink. Null means m_spirv is already the
// answer. A program can be in the window where m_pendingLink is already null (phase A
// published, the query surface is live) while this is still set.
mutable SharedPtr<ProgramSpirvTask> m_pendingSpirv;
// glUniform* writes taken while m_pendingSpirv was set, in call order, plus their
// bytes. Drained by the phase-B publish and cleared by every cancel site (a relink's
// uniforms are not the previous link's uniforms).
mutable Vector<PendingUniformWrite> m_pendingUniformWrites;
mutable Vector<Uint8> m_pendingUniformBytes;
};
} // namespace MobileGL::MG_State::GLState
@@ -1,318 +0,0 @@
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.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 "ProgramSpirvTask.h"
#include <MG_State/GLState/ProgramState/ShaderCompileTask.h> // GlslangThreadAllocatorGuard
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <cstring>
namespace MobileGL::MG_State::GLState {
void ProgramSpirvTask::DeferLog(String line) { diagnostics.logLines.push_back(Move(line)); }
void ProgramSpirvTask::SubmitAfter(const SharedPtr<ProgramLinkTask>& phaseA) {
MOBILEGL_ASSERT(phaseA != nullptr, "ProgramSpirvTask::SubmitAfter: the phase-A node is missing");
m_phaseA = phaseA;
auto self = std::static_pointer_cast<ProgramSpirvTask>(shared_from_this());
// ONE dependency, so no counter and no guard slot: the whole race
// ProgramLinkTask::SubmitAfter's +1 exists to close (a dependency settling while the
// remaining edges are still being registered) cannot arise with a single edge.
//
// Runs inline, right here, if phase A is already terminal.
phaseA->OnTerminal([self, phaseA] {
// "Dependency did not complete, publish nothing" - the same collapse
// ProgramLinkTask::CompiledArtifacts() performs for an abandoned compile. Note
// this reads the HANDOFF, never phaseA->artifacts: the GL thread may already be
// moving those out (see the class comment).
if (!phaseA->IsComplete() || !phaseA->spirvHandoff.ready) {
self->Cancel();
return;
}
// A cancel that landed before phase A settled (relink, glDeleteProgram, teardown).
// Posting would only make a worker pick up a node that immediately falls out of
// Run() again.
if (self->IsCancellationRequested()) {
self->Cancel();
return;
}
// Non-throwing by construction, and it has to be: this is a JobNode continuation,
// so on the pool side it runs inside an Asio handler. Post() contains its own
// allocation failures, and the catch below CANCELS rather than swallowing - a
// phase B that is never posted is a GL thread blocked forever in
// EnsureSpirvJoined(), which is far worse than a program reported as not drawable.
try {
MG_Util::Async::ShaderCompilePool::Get().Post(self);
} catch (...) {
self->Cancel();
}
});
}
void ProgramSpirvTask::RunInlineAfter(const SharedPtr<ProgramLinkTask>& phaseA) {
MOBILEGL_ASSERT(phaseA != nullptr, "ProgramSpirvTask::RunInlineAfter: the phase-A node is missing");
MOBILEGL_ASSERT(phaseA->IsTerminal(),
"ProgramSpirvTask::RunInlineAfter: phase A has not settled; the inline path must run the "
"two bodies in order on the same thread");
m_phaseA = phaseA;
RunInline();
}
// Pure CPU work only, on a pool worker (or on the GL thread in the inline mode).
// Everything this reads is either owned by this node or published by a terminal phase A;
// everything it writes is `artifacts` (and diagnostics). Same prohibitions as
// ProgramLinkTask::RunBody - no GL/EGL call, no pActiveBackendObject read, no
// pGLContext->RecordError().
void ProgramSpirvTask::RunBody() {
// glslang leaves this worker's TLS pool allocator pointing at the last arena it
// touched; reset it on the way out so an unrelated later job cannot allocate out of a
// pool that has since been freed. Declared FIRST so it is destroyed LAST - the phase-A
// release below drops the TShaders (and their pools) and must happen inside it.
const GlslangThreadAllocatorGuard glslangGuard;
using namespace MG_Util::ShaderTranspiler;
// Drop phase A - and with it the TShaders, the TProgram reference and phase A's whole
// input snapshot - the moment this body is done, rather than at some later join. For a
// pack load that is the difference between W glslang arenas alive and all of them.
struct PhaseAReleaser {
SharedPtr<ProgramLinkTask>& node;
~PhaseAReleaser() { node.reset(); }
} const phaseAReleaser{m_phaseA};
if (!m_phaseA) return;
// Non-const: the TShaders are dropped below, the moment GlslangToSpv is finished with
// them. This is safe by ownership rather than by locking - phase A is terminal and
// therefore immutable to everyone else, the GL-thread join touches only `artifacts`
// and `diagnostics`, and this node is the sole reader of the handoff.
ProgramLinkTask::SpirvHandoff& handoff = m_phaseA->spirvHandoff;
const Uint externalIndex = m_phaseA->in.externalIndex;
if (!handoff.ready || !handoff.reflection.program) {
// Phase A did not reach its tail (it failed the link, or was cancelled mid-body).
// Publish nothing; spirvStatus stays false.
return;
}
MGLOG_D("ProgramObject %u: Starting SPIR-V generation", externalIndex);
GenerateSpirv(handoff, externalIndex);
// GlslangToSpv was the only consumer of the parsed ASTs; everything after this point
// works on the SPIR-V and on the TProgram's own self-contained reflection pool. Drop
// them here rather than at the end of the body, which is ~87% of this node's runtime
// earlier (spirv-opt plus routing).
//
// WHAT THIS ACTUALLY FREES, precisely - it is LESS than "the glslang arenas", and the
// difference matters for the peak-RSS story:
// * CAS-LOSER shaders (the re-parse in ShaderCompileTask::ClaimParsedShader, i.e.
// the 2nd..Nth link of a shared shader): freed here in full. The handoff is their
// ONLY owner.
// * CAS-WINNER shaders (the common case - one shader object linked into one
// program, which is every program of an Iris pack load): NOT freed here. The
// winner branch returns a COPY of ShaderCompileTask::artifacts.shader
// (ShaderCompileTask.cpp:320) and the node never releases its own reference, while
// phase A holds that node through in.shaders[i].compiled for its whole life - and
// phase A lives until PhaseAReleaser fires at the end of this body. So the
// refcount goes 2 -> 1 here and the arena dies where it would have died anyway.
//
// Making it free the winner's arena too means releasing whatever pins the TShader
// inside the compile node, and neither obvious route is safe as a drive-by: moving out
// of artifacts.shader at claim time races ShaderObject::GetCompiledShader() on the GL
// thread and breaks JobNode's "a terminal node is immutable" invariant, and dropping
// phase A's in.shaders[i].compiled reference only helps when nothing else holds the
// node (the adoption map is a WeakPtr index, so it would also change which nodes stay
// adoptable). Both belong in a change that can be reviewed against the consume-once
// and adoption semantics on their own terms.
handoff.shaders.clear();
MGLOG_D("ProgramObject %u: Building global-UBO routing tables", externalIndex);
BuildGlobalUboRouting(handoff, externalIndex);
MGLOG_D("ProgramObject %u: Binary generation finished (generatedSpirv size=%zu)", externalIndex,
artifacts.generatedSpirv.size());
}
void ProgramSpirvTask::GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, const Uint externalIndex) {
/* As we passed first stage compilation/linking,
* we'll assume all the operations here should
* pass. We may be able to employ some optimizations
* here without the burden of error reporting.
*/
using namespace MG_Util::ShaderTranspiler;
MGLOG_D("ProgramObject %u: GenerateSpirv - start", externalIndex);
// The shaders were parsed once, in the link-compatible (relaxed Vulkan-rules)
// configuration, and the handoff's program linked those parses - so it IS the program
// the backends consume. Generate SPIR-V straight from its intermediates, which the
// handoff's TShaders keep alive.
ProgramBinaryAttrib binaryAttrib{
.shaderTypes = handoff.shaderTypes,
.program = *handoff.reflection.program,
};
MGLOG_D("ProgramObject %u: GenerateSpirv - requesting SPIR-V binary from program", externalIndex);
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
if (!binaryResult) {
DeferLog(std::format("ProgramObject {}: GenerateSpirv - GetSpirvBinaryFromProgram failed", externalIndex));
MOBILEGL_ASSERT(binaryResult, "GetSpirvBinaryFromProgram failed");
return; // spirvStatus stays false: linked, but not drawable.
}
artifacts.generatedSpirv = Move(binaryResult.value());
MGLOG_D("ProgramObject %u: GenerateSpirv - generated %zu SPIR-V modules", externalIndex,
artifacts.generatedSpirv.size());
// Linked SPIR-V generated, sanitize and optimize it
Bool allOptimized = true;
{
for (auto& spv : artifacts.generatedSpirv) {
auto success = ShaderCompiler::SanitizeAndOptimizeBinary(spv, spv);
if (!success) {
// The one genuine phase-B failure mode: one of the seven optimizer passes
// reported failure, so `spv` is whatever the run left behind. A fordebug
// build trips the assert below; a release build used to hand that binary
// to the backend regardless. It no longer does - the program keeps its
// (truthful) LINK_STATUS and its whole query surface, and the routing
// tables below still give every settable uniform storage so glUniform*
// and glGetUniform* keep working, but spirvStatus stays false and the
// backends refuse to build or draw with it.
allOptimized = false;
DeferLog(std::format("ProgramObject {}: SanitizeAndOptimizeBinary failed; the program is linked "
"and queryable but not drawable",
externalIndex));
}
MOBILEGL_ASSERT(success, "SanitizeBinary failed");
}
}
artifacts.spirvStatus = allOptimized;
}
void ProgramSpirvTask::BuildGlobalUboRouting(const ProgramLinkTask::SpirvHandoff& handoff,
const Uint externalIndex) {
using namespace MG_Util::ShaderTranspiler;
// The phase-A reflection slice this pass keys off. Carried in the handoff rather than
// read off the phase-A node's artifacts, which the join has very likely already moved.
const ProgramObject::LinkArtifacts& reflection = handoff.reflection;
artifacts.uniformOffsets.clear();
artifacts.globalUboScratch.clear();
// kInvalidUniformOffset marks locations that end up without global-UBO backing
// (e.g. the optimizer eliminated every use of the uniform); the fallback pass
// below gives those locations tail storage so glUniform* always has a target.
artifacts.uniformOffsets.resize(reflection.maxUniformLocation + 1, ProgramObject::kInvalidUniformOffset);
for (SizeT i = 0; i < artifacts.generatedSpirv.size(); i++) {
auto& spv = artifacts.generatedSpirv[i];
auto shaderType = i < handoff.shaderTypes.size() ? handoff.shaderTypes[i] : GLenum{0};
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - parsing SPIR-V meta data for module %zu "
"(shaderType=%u, wordCount=%zu)",
externalIndex, i, shaderType, spv.size());
SpvcSession session(spv, SessionUsageBit::Reflection);
auto result = session.ParseMetaData();
if (result < 0) {
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - SpvcSession::ParseMetaData failed for module %zu, "
"err = %d%s",
externalIndex, i, result,
(result == SPVC_ERROR_INVALID_SPIRV ? ". Probably no global UBO?" : ""));
continue;
} else {
auto& meta = session.GetMetadata();
auto size = meta.globalUboSize;
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - SPIR-V meta: uboSize=%zu plainUniformCount=%zu "
"plainUniformOffsets=%zu",
externalIndex, meta.globalUboSize, meta.plainUniformMemberSizesInBytes.size(),
meta.plainUniformOffsetsInUBO.size());
if (size == 0) {
continue;
}
if (artifacts.globalUboScratch.size() < size) {
artifacts.globalUboScratch.resize(size);
}
for (const auto& [name, offset] : meta.plainUniformOffsetsInUBO) {
// SPIRV-Reflect leaf names never carry a "[0]" suffix; frontend
// reflection keys arrays as "arr[0]" (GL naming), so retry with the
// suffix before declaring the uniform unbacked.
auto locationIt = reflection.uniformLocations.find(name);
if (locationIt == reflection.uniformLocations.end()) {
locationIt = reflection.uniformLocations.find(name + "[0]");
}
if (locationIt == reflection.uniformLocations.end()) {
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' offset=%u but not found in "
"uniformLocations",
externalIndex, name.c_str(), offset);
continue;
}
const Uint baseLocation = locationIt->second;
if (!ProgramObject::IsValidUniformLocation(reflection, static_cast<Int>(baseLocation))) {
continue;
}
const Int uniformIndex = reflection.uniformIndexInTProgram[baseLocation];
const GLint arraySize = ProgramObject::GetUniformArraySizeByTIndex(reflection, uniformIndex);
Uint arrayStride = 0;
const auto strideIt = meta.plainUniformArrayStridesInUBO.find(name);
if (strideIt != meta.plainUniformArrayStridesInUBO.end()) {
arrayStride = strideIt->second;
}
// Array uniforms span one location per element (see DoReflection);
// give each element its real byte offset inside the UBO.
const GLint elementCount = (arraySize > 1 && arrayStride == 0) ? 1 : std::max(arraySize, 1);
for (GLint element = 0; element < elementCount; ++element) {
const Uint location = baseLocation + static_cast<Uint>(element);
if (location > reflection.maxUniformLocation ||
reflection.uniformIndexInTProgram[location] != uniformIndex) {
break;
}
artifacts.uniformOffsets[location] = offset + static_cast<Uint>(element) * arrayStride;
}
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' offset=%u stride=%u assigned "
"to locations %u..%u",
externalIndex, name.c_str(), offset, arrayStride, baseLocation,
baseLocation + static_cast<Uint>(elementCount) - 1);
}
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - finished parsing module %zu metadata",
externalIndex, i);
}
}
// Fallback pass: a linked program's active non-opaque uniforms must accept
// glUniform*/glGetUniform* even when the optimized SPIR-V no longer contains
// them (AggressiveDCE can remove a dead loop together with the only loads of a
// uniform -- or the entire global UBO, leaving the scratch unallocated). Hand
// such locations CPU-side storage at the (16-byte aligned) tail of the shadow
// buffer; backends bind at least the SPIR-V-declared UBO range, and the GPU
// never reads these bytes, so this only keeps the GL-visible state coherent.
for (Uint location = 0; location <= reflection.maxUniformLocation; ++location) {
if (artifacts.uniformOffsets[location] != ProgramObject::kInvalidUniformOffset) continue;
if (!ProgramObject::IsValidUniformLocation(reflection, static_cast<Int>(location))) continue;
const auto& uniform = reflection.program->getUniform(reflection.uniformIndexInTProgram[location]);
const glslang::TType* type = uniform.getType();
if (type != nullptr && type->isOpaque()) continue;
if (uniform.index >= 0 && uniform.index < reflection.program->getNumUniformBlocks() &&
std::strstr(reflection.program->getUniformBlock(uniform.index).name.c_str(),
MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME) == nullptr) {
// Member of a named uniform block: not settable through glUniform*, so it
// needs no global-UBO shadow storage.
continue;
}
// std140-style slot: the matrix upload paths write column vectors at
// 16-byte strides, so a matrix slot must cover cols * 16 bytes.
SizeT slotSize = MG_Util::GetGLTypeSize(uniform.glDefineType);
if (type != nullptr && type->isMatrix()) {
slotSize = static_cast<SizeT>(type->getMatrixCols()) * 16u;
}
slotSize = (slotSize + 15u) & ~static_cast<SizeT>(15u);
const SizeT slotOffset = (artifacts.globalUboScratch.size() + 15u) & ~static_cast<SizeT>(15u);
artifacts.globalUboScratch.resize(slotOffset + slotSize, 0);
artifacts.uniformOffsets[location] = static_cast<Uint>(slotOffset);
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' location %u has no UBO backing in the "
"generated SPIR-V (optimized out?); allocated %zu fallback bytes at scratch offset %zu",
externalIndex, uniform.name.c_str(), location, slotSize, slotOffset);
}
}
} // namespace MobileGL::MG_State::GLState
@@ -1,77 +0,0 @@
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.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/ProgramLinkTask.h>
#include <MG_Util/Async/JobNode.h>
namespace MobileGL::MG_State::GLState {
// PHASE B of one glLinkProgram: GlslangToSpv, spirv-opt, and the SPIRV-Cross pass that
// builds the glUniform*-to-scratch routing tables. Chained behind exactly one
// ProgramLinkTask and joined by exactly five ProgramObject getters (GetGeneratedSpirv,
// GetUniformOffset, MapUBO, GetUBOData, GetUBOSize), so ~120 other getters and the whole
// GL query surface stay on the phase-A gate and answer without waiting for any of this.
//
// ---- what this node may read, and what it may not ----
// It holds the phase-A node by SharedPtr and reads `phaseA->spirvHandoff` plus
// `phaseA->in`. It must NEVER read `phaseA->artifacts` or `phaseA->diagnostics`: the GL
// thread MOVES the artifacts out of the node at the phase-A join and DRAINS the
// diagnostics there, and both of those can happen while this body runs. The handoff exists
// precisely so this node has a copy of everything it needs that the join does not touch.
// (The general JobNode rule - a terminal node is immutable, so its outputs need no further
// synchronization - covers everything except the two members the join consumes.)
//
// ---- lifetime ----
// The handoff owns the Vector<SharedPtr<glslang::TShader>>, and that is mandatory rather
// than tidy: glslang::TProgram stores raw TShader* and, for the one-shader-per-stage case,
// BORROWS each stage's TIntermediate from its TShader. GlslangToSpv reads exactly those
// intermediates. Before the split the shaders died when ProgramLinkTask::RunBody returned,
// which was safe only because nothing called getIntermediate() afterwards.
//
// ---- failure ----
// A cancel (relink, teardown, program destruction) or an optimizer failure publishes
// spirvStatus = false rather than a half-built program. GL cannot retract a LINK_STATUS it
// already reported true, so such a program stays linked and fully queryable; it is just
// not drawable, which the backends express through their existing link-status gates.
class ProgramSpirvTask final : public MG_Util::Async::JobNode {
public:
// ---- output: valid iff IsComplete(), immutable afterwards ----
// Moved (never copied) into the ProgramObject by EnsureSpirvJoined().
ProgramObject::SpirvArtifacts artifacts;
// Posts this job when `phaseA` goes terminal - and not one moment earlier, so the body
// never waits on anything (invariant I4: no job body may block on another job). A
// single dependency needs no counter, just the one continuation; it runs inline right
// here if `phaseA` is already terminal, which is the same case
// ProgramLinkTask::SubmitAfter already reasons about.
//
// GL thread only, and only after the caller has stored a SharedPtr to this node: the
// continuation takes shared_from_this().
void SubmitAfter(const SharedPtr<ProgramLinkTask>& phaseA);
// The async-off / glMaxShaderCompilerThreadsKHR(0) path: run the body on the calling
// thread, right now, against an ALREADY-TERMINAL phase A. Deliberately not routed
// through SubmitAfter, whose continuation would Post() to a pool that is merely
// unused rather than stopped - that would move the work off-thread in the one mode
// whose contract is "byte-identical to the synchronous implementation".
void RunInlineAfter(const SharedPtr<ProgramLinkTask>& phaseA);
private:
void RunBody() override;
void GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex);
void BuildGlobalUboRouting(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex);
// Worker-side MGLOG replacement, replayed by the join on the GL thread. Same reason as
// ProgramLinkTask::DeferLog.
void DeferLog(String line);
SharedPtr<ProgramLinkTask> m_phaseA;
};
} // namespace MobileGL::MG_State::GLState
@@ -111,13 +111,9 @@ namespace MobileGL::MG_State::GLState {
// that can grow, and a reallocation underneath this loop would be a use-after-free
// that only shows up on the one GL call that walks the whole table. The copy costs a
// refcount bump on a path a mode switch takes at most once.
// BOTH phases per program. This is the glMaxShaderCompilerThreadsKHR(0) path, whose
// contract is that nothing is outstanding when it returns - a program left with its
// SPIR-V job in flight would make the very next GL_COMPLETION_STATUS_KHR read GL_FALSE
// in a mode the extension says cannot have anything pending.
for (SizeT i = 0; i < m_programObjects.size(); ++i) {
const SharedPtr<ProgramObject> program = m_programObjects[i];
if (program) program->JoinLinkAndSpirv();
if (program) program->JoinLink();
}
for (SizeT i = 0; i < m_shaderObjects.size(); ++i) {
const SharedPtr<ShaderObject> shader = m_shaderObjects[i];
@@ -126,7 +122,7 @@ namespace MobileGL::MG_State::GLState {
// The currently-used program is reachable through m_programObjects unless
// glDeleteProgram already freed its slot while it stayed current. Nothing else holds
// a GL-visible name for it, but a draw would still join it, so settle it here too.
if (m_currentProgram) m_currentProgram->JoinLinkAndSpirv();
if (m_currentProgram) m_currentProgram->JoinLink();
}
void ProgramState::MarkShaderObjectForDeletion(Uint shader) {
@@ -88,19 +88,12 @@ namespace MobileGL::MG_State::GLState {
// 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;
// A new compile is a new story: whatever the optimistic getters promised about the
// previous node does not carry over.
m_optimisticAnswerLatched = false;
}
void ShaderObject::DropCompileNode() const {
if (!m_compiled) return;
m_compiled->ReleaseAdopter();
m_compiled.reset();
// No node means IsCompileComplete() is trivially true and the truthful answers are
// "not compiled"; a stale latch would keep reporting a compile that no longer
// exists as GL_TRUE.
m_optimisticAnswerLatched = false;
}
void ShaderObject::InvalidateCompiledState() {
@@ -116,10 +116,8 @@ namespace MobileGL {
Bool GetDeleteStatus() const { return m_deleteStatus; }
// Blocks until a pending compile has published its artifacts. Public for the
// sites that must join without reading anything - ProgramState::
// JoinAllPendingWork, the glMaxShaderCompilerThreadsKHR(0) path that settles
// every outstanding job. glLinkProgram deliberately does NOT come through
// here: its prologue takes the nodes unjoined via CompiledNodeForLink().
// 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 Compile()
@@ -143,23 +141,6 @@ namespace MobileGL {
// outstanding to wait for.
Bool IsCompileComplete() const { return m_compiled == nullptr || m_compiled->IsTerminal(); }
// MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS's one-story-per-compile memory. The
// three optimistic getter sites in GL_Program ask THIS instead of a raw
// IsCompileComplete() peek, and the difference is the latch: without it, a job
// that settles between two adjacent queries hands the application a torn pair -
// an empty info log from the optimistic read, then the real GL_FALSE from the
// truthful one - and an application that aborts on that status never reaches
// the link join that quotes the real log. So the first optimistic answer
// latches: until the next AdoptCompileNode/DropCompileNode this object keeps
// answering optimistically even after the job settles, and a real failure
// surfaces exactly once, at the link. Returns whether the caller should answer
// optimistically; the caller has already checked the quirk is active.
Bool TakeOptimisticCompileAnswer() const {
if (!m_optimisticAnswerLatched && IsCompileComplete()) return false;
m_optimisticAnswerLatched = true;
return true;
}
private:
// ---- The one and only join gate for compile output (P1 invariant I5) ----
// The fast path - no job, or a job whose result this object has already pulled -
@@ -250,10 +231,6 @@ namespace MobileGL {
// 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;
// TakeOptimisticCompileAnswer's memory: this object has answered a compile
// query optimistically for the current node. Cleared wherever the node
// changes hands (AdoptCompileNode) or goes away (DropCompileNode).
mutable Bool m_optimisticAnswerLatched = false;
};
} // namespace MG_State::GLState
} // namespace MobileGL
+1 -68
View File
@@ -117,26 +117,11 @@ void main() { fragColor = thisIdentifierWasNeverDeclared; }
}
// The non-joining view of the program, i.e. what GL_COMPLETION_STATUS_KHR will report.
// BOTH phases: a program whose SPIR-V job is still in flight is not finished, even though
// its whole GL query surface already answers.
Bool LinkIsSettled(const GLuint program) {
const auto& object = MG_State::pGLContext->GetProgramObject(program);
return object == nullptr || object->IsLinkComplete();
}
// Phase A alone: the half that decides LINK_STATUS, the info log, and every reflection
// query. This is what a read of LINK_STATUS is required to settle.
Bool PhaseALinkIsSettled(const GLuint program) {
const auto& object = MG_State::pGLContext->GetProgramObject(program);
return object == nullptr || object->IsPhaseALinkComplete();
}
// Phase B alone: SPIR-V + the uniform shadow's layout.
Bool SpirvIsSettled(const GLuint program) {
const auto& object = MG_State::pGLContext->GetProgramObject(program);
return object == nullptr || object->IsSpirvComplete();
}
// Enqueues `count` distinct heavy compiles without reading anything back, so the pool is
// left with a real backlog for the caller to race against.
Vector<GLuint> SaturatePool(const int count, Vector<String>& sourceStorage) {
@@ -531,63 +516,11 @@ TEST_F(AsyncLinkTest, LinkProgramReturnsBeforeTheWorkIsDone) {
for (const GLuint program : programs) {
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
// PHASE A only. Reading LINK_STATUS settles the half that decides it, and no more -
// the SPIR-V job may well still be running, which is the entire point of the split.
EXPECT_TRUE(PhaseALinkIsSettled(program)) << "reading LINK_STATUS must have joined phase A";
EXPECT_TRUE(LinkIsSettled(program)) << "reading LINK_STATUS must have joined";
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The other half of the previous case, and the property the two-phase split exists for:
// LINK_STATUS is answerable without the SPIR-V, so a run of LINK_STATUS reads over a
// backlog must leave SPIR-V jobs outstanding rather than draining them one by one.
TEST_F(AsyncLinkTest, ReadingLinkStatusDoesNotSettleTheSpirvJob) {
const AsyncModeScope async(true);
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(1);
constexpr int kPrograms = 24;
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
Vector<GLuint> programs;
Vector<String> sources;
for (int i = 0; i < kPrograms; ++i) {
sources.push_back(MakeBulkySource(7900 + i));
const char* text = sources.back().c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
programs.push_back(program);
}
int spirvOutstanding = 0;
for (int i = 0; i < kPrograms; ++i) {
const GLuint program = programs[static_cast<SizeT>(i)];
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
EXPECT_TRUE(PhaseALinkIsSettled(program)) << "reading LINK_STATUS must have joined phase A";
// Reflection has to answer here too, out of phase A and with no further join.
const String uniformName = "uSeed" + std::to_string(7900 + i);
EXPECT_GE(GetUniformLocation(program, uniformName.c_str()), 0) << uniformName;
if (!SpirvIsSettled(program)) ++spirvOutstanding;
}
EXPECT_GT(spirvOutstanding, 0) << "the whole GL query surface was answered and yet every SPIR-V job had "
"already been drained - the reads are joining phase B";
// And the SPIR-V gate really is a gate: touching it settles the job.
for (const GLuint program : programs) {
const auto& object = MG_State::pGLContext->GetProgramObject(program);
ASSERT_NE(object, nullptr);
EXPECT_GT(object->GetGeneratedSpirv().size(), 0u);
EXPECT_TRUE(SpirvIsSettled(program));
EXPECT_TRUE(LinkIsSettled(program));
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(
MG_Util::Async::ShaderCompilePool::Get().GetThreadCount());
}
// With the flag off, a link is finished by the time glLinkProgram returns. This is the guard
// that keeps the default shippable.
TEST_F(AsyncLinkTest, LinkIsFullySynchronousWithAsyncOff) {
File diff suppressed because it is too large Load Diff
-40
View File
@@ -49,22 +49,6 @@ add_executable(
AsyncLinkTest.cpp
)
add_executable(
OptimisticStatusTest
OptimisticStatusTest.cpp
)
target_include_directories(OptimisticStatusTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
OptimisticStatusTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
target_include_directories(AsyncLinkTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
@@ -76,24 +60,6 @@ target_link_libraries(
${LINK_LIBRARIES}
)
# Its own binary, like the other async suites: its cases pin the compile pool down to one
# worker so a phase-B job really is still queued while the GL query surface is being read.
add_executable(
AsyncSpirvPhaseTest
AsyncSpirvPhaseTest.cpp
)
target_include_directories(AsyncSpirvPhaseTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
AsyncSpirvPhaseTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
add_executable(
ShaderCompileAdoptionTest
ShaderCompileAdoptionTest.cpp
@@ -199,17 +165,11 @@ gtest_discover_tests(ProgramInterfaceTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS
# 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: every case here links a batch against a one-worker pool so that phase B is
# genuinely outstanding while phase A is being interrogated.
gtest_discover_tests(AsyncSpirvPhaseTest 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)
# Same reason: the optimistic-window cases need a saturated one-worker pool to observe an
# in-flight compile, and the two-phase replay links 48 programs across both flag states.
gtest_discover_tests(OptimisticStatusTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
gtest_discover_tests(AsyncTeardownTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
# Same reason again: several cases leave A links outstanding while B compiles and links.
gtest_discover_tests(XfbFrontendOrderInvarianceTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
@@ -1,674 +0,0 @@
// MobileGL - MobileGL/MG_Test/Program/OptimisticStatusTest.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
// MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS: while a compile job is in flight, the two
// per-shader queries that would join it - GL_COMPILE_STATUS and the info log - answer
// optimistically instead, and the first such answer latches for that compile's lifetime
// (ShaderObject::TakeOptimisticCompileAnswer). These cases pin the corners of that
// contract: the default still joins, the optimistic window really answers without
// joining, the latch keeps the three queries telling one story even after the job
// settles, a real failure still fails the program link with the compile log quoted, and
// the Iris-shaped two-phase batch produces reflection identical to the joining path.
//
// Determinism note: the cases that need "a compile that cannot have settled yet" do not
// race the pool - they occupy its single concurrency slot with a gate-blocked job
// (PoolBlocker), so the assertions are hard EXPECTs rather than skip-if-drained guesses.
// A quirk that silently reverts to joining DEADLOCKS such a case into its 300s ctest
// timeout instead of passing - ugly, but a failure, which is the point.
//
// Like AsyncCompileTest, every case drives the real GL entry points and flips the
// MG_Config::Features fields itself rather than reading the environment, so one binary
// asserts both flag states regardless of how the suite was launched.
#include <gtest/gtest.h>
#include <algorithm>
#include <chrono>
#include <condition_variable>
#include <mutex>
#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_Util/Async/JobNode.h"
#include "MG_Util/Async/ShaderCompilePool.h"
using namespace MobileGL;
using namespace MobileGL::MG_Impl::GLImpl;
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;
};
class OptimisticStatusScope {
public:
explicit OptimisticStatusScope(const MG_Config::QuirkOverride mode)
: m_saved(MG_Config::Features.AsyncOptimisticShaderStatus) {
MG_Config::Features.AsyncOptimisticShaderStatus = mode;
}
~OptimisticStatusScope() { MG_Config::Features.AsyncOptimisticShaderStatus = m_saved; }
OptimisticStatusScope(const OptimisticStatusScope&) = delete;
OptimisticStatusScope& operator=(const OptimisticStatusScope&) = delete;
private:
const MG_Config::QuirkOverride m_saved;
};
// glMaxShaderCompilerThreadsKHR writes PROCESS-wide state (the pool's concurrency budget
// and the suspension latch), so a case that touches it has to put both back or it
// poisons every case declared 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;
};
// A job that occupies a pool slot until released, holding everything queued behind it
// in a provably-unsettled state. Same gate idea as JobNodeTest's TestJob+Gate; waiting
// on a test-owned gate inside a body does not violate the pool's no-job-waits-on-job
// rule - there is no other JOB involved.
class PoolBlocker final : public MG_Util::Async::JobNode {
public:
void Release() {
{
const std::lock_guard<std::mutex> lock(m_mutex);
m_open = true;
}
m_cv.notify_all();
}
protected:
void RunBody() override {
std::unique_lock<std::mutex> lock(m_mutex);
m_cv.wait(lock, [this] { return m_open; });
}
private:
std::mutex m_mutex;
std::condition_variable m_cv;
Bool m_open = false;
};
// Budget 1 + a blocked job in the only slot: from construction until Release(), no
// shader compile posted afterwards can run, let alone settle. The destructor releases
// and joins so no case can leak a wedged pool into the next one.
class BlockedPoolScope {
public:
BlockedPoolScope() : m_blocker(MakeShared<PoolBlocker>()) {
MaxShaderCompilerThreadsKHR(1);
MG_Util::Async::ShaderCompilePool::Get().Post(m_blocker);
}
~BlockedPoolScope() { Release(); }
void Release() {
m_blocker->Release();
m_blocker->Wait();
}
BlockedPoolScope(const BlockedPoolScope&) = delete;
BlockedPoolScope& operator=(const BlockedPoolScope&) = delete;
private:
SharedPtr<PoolBlocker> m_blocker;
};
const char* kBrokenFs = R"(#version 460
layout(location = 0) out vec4 fragColor;
void main() { fragColor = thisIdentifierWasNeverDeclared; }
)";
// Expensive enough that a compile is not instantaneous, and distinct per index so the
// source-hash memo and the stage-6 adoption map never turn a second instance into a
// no-op. Callers pass disjoint seed ranges for the same reason - two calls in one case
// must never regenerate the same text.
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 < 320; ++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;
}
// The two stages of one Iris-shaped program. Distinct per index (so nothing is memoized
// across programs) but IDENTICAL between the quirk-off and quirk-on replays of the same
// index, which is what makes the reflection comparison meaningful.
String MakeIrisVs(const int index) {
String source = "#version 460\nlayout(location = 0) in vec3 aPos;\n";
source += "uniform mat4 uModel" + std::to_string(index) + ";\n";
source += "uniform vec4 uTint;\nout vec4 vColor;\n";
source += "void main() {\n vColor = uTint;\n gl_Position = uModel" + std::to_string(index) +
" * vec4(aPos, 1.0);\n}\n";
return source;
}
String MakeIrisFs(const int index) {
String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\nin vec4 vColor;\n";
source += "uniform float uSeed" + std::to_string(index) + ";\nuniform vec2 uOffset;\n";
source += "void main() {\n float acc = uSeed" + std::to_string(index) + " + uOffset.x;\n";
for (int i = 0; i < 40; ++i) {
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0);\n";
}
source += " fragColor = vColor + vec4(acc, uOffset.y, 0.0, 1.0);\n}\n";
return source;
}
GLuint MakeShader(const GLenum type, const char* source) {
const GLuint shader = CreateShader(type);
ShaderSource(shader, 1, &source, nullptr);
CompileShader(shader);
return shader;
}
GLint QueryShaderCompletion(const GLuint shader) {
GLint status = -1;
GetShaderiv(shader, GL_COMPLETION_STATUS_KHR, &status);
return status;
}
GLint QueryCompileStatus(const GLuint shader) {
GLint status = GL_FALSE;
GetShaderiv(shader, GL_COMPILE_STATUS, &status);
return status;
}
GLint QueryInfoLogLength(const GLuint shader) {
GLint length = -1;
GetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
return length;
}
String QueryShaderInfoLog(const GLuint shader) {
std::vector<GLchar> buffer(65536);
GLsizei written = 0;
GetShaderInfoLog(shader, (GLsizei)buffer.size(), &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;
}
GLint QueryProgramCompletion(const GLuint program) {
GLint status = -1;
GetProgramiv(program, GL_COMPLETION_STATUS_KHR, &status);
return status;
}
String QueryProgramInfoLog(const GLuint program) {
// Iris reads through an explicit 32768-byte buffer; mirror that cap so the
// log-ordering contract is asserted through the same window the application has.
std::vector<GLchar> buffer(32768);
GLsizei written = 0;
GetProgramInfoLog(program, (GLsizei)buffer.size(), &written, buffer.data());
return String(buffer.data(), static_cast<size_t>(written));
}
// Enqueues `count` distinct heavy compiles without reading anything back. Seed bases
// must be disjoint across calls within one case (see MakeBulkySource).
Vector<GLuint> SaturatePool(const int count, const int seedBase, Vector<String>& sourceStorage) {
Vector<GLuint> shaders;
shaders.reserve(static_cast<SizeT>(count));
for (int i = 0; i < count; ++i) {
sourceStorage.push_back(MakeBulkySource(seedBase + i));
const char* text = sourceStorage.back().c_str();
const GLuint shader = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(shader, 1, &text, nullptr);
CompileShader(shader);
shaders.push_back(shader);
}
return shaders;
}
// One program driven through Iris's exact phase-1 shape: create, source, compile, read
// the info log then the compile status (GlShader.createShader's order), attach, bind an
// attrib, link, detach, delete. NO program-level query of any kind.
GLuint RunIrisPhaseOne(const String& vsSource, const String& fsSource) {
const char* vsText = vsSource.c_str();
const char* fsText = fsSource.c_str();
const GLuint vs = CreateShader(GL_VERTEX_SHADER);
ShaderSource(vs, 1, &vsText, nullptr);
CompileShader(vs);
(void)QueryShaderInfoLog(vs);
(void)QueryCompileStatus(vs);
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &fsText, nullptr);
CompileShader(fs);
(void)QueryShaderInfoLog(fs);
(void)QueryCompileStatus(fs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
BindAttribLocation(program, 0, "aPos");
LinkProgram(program);
DetachShader(program, vs);
DetachShader(program, fs);
DeleteShader(vs);
DeleteShader(fs);
return program;
}
// Phase 2, also in Iris's order: LINK_STATUS first, then the by-name location lookups,
// then the GL_ACTIVE_UNIFORMS enumeration ProgramUniforms$Builder.buildUniforms does.
struct ProgramReflection {
GLint linkStatus = GL_FALSE;
Vector<std::pair<String, GLint>> locations; // queried name -> location
Vector<std::tuple<String, GLenum, GLint, GLint>> activeUniforms; // name, type, size, location
};
ProgramReflection RunIrisPhaseTwo(const GLuint program, const Vector<String>& names) {
ProgramReflection out;
out.linkStatus = QueryLinkStatus(program);
for (const String& name : names) {
out.locations.emplace_back(name, GetUniformLocation(program, name.c_str()));
}
GLint activeCount = 0;
GetProgramiv(program, GL_ACTIVE_UNIFORMS, &activeCount);
for (GLint i = 0; i < activeCount; ++i) {
GLchar name[128] = {};
GLsizei written = 0;
GLint size = 0;
GLenum type = 0;
GetActiveUniform(program, (GLuint)i, (GLsizei)sizeof(name), &written, &size, &type, name);
const String nameStr(name, static_cast<size_t>(written));
out.activeUniforms.emplace_back(nameStr, type, size, GetUniformLocation(program, name));
}
// The enumeration order is an implementation detail; the SET is the contract.
std::sort(out.activeUniforms.begin(), out.activeUniforms.end());
return out;
}
class OptimisticStatusTest : public ::testing::Test {
protected:
void SetUp() override { MobileGL::Initialize(); }
};
} // namespace
// ---------------------------------------------------------------------------------------
// The default still joins
// ---------------------------------------------------------------------------------------
// With the quirk unset (Auto = the shipped default), GL_COMPILE_STATUS on a pending compile
// must join it: after the query, the node is terminal. This is the case that guards the
// default against ever silently flipping. No blocker here - a blocked pool would turn the
// (correct) joining behaviour into a deadlock; a plain backlog only makes the pre-join
// state likely, and the assertion is valid either way.
TEST_F(OptimisticStatusTest, OffByDefaultTheStatusStillJoins) {
const AsyncModeScope async(true);
const OptimisticStatusScope quirk(MG_Config::QuirkOverride::Auto);
const CompilerThreadScope threads;
MaxShaderCompilerThreadsKHR(1);
Vector<String> backlog;
const Vector<GLuint> saturation = SaturatePool(8, 70000, backlog);
const Vector<GLuint> probes = SaturatePool(1, 71000, backlog);
const GLuint probe = probes[0];
EXPECT_EQ(QueryCompileStatus(probe), GL_TRUE);
EXPECT_EQ(QueryShaderCompletion(probe), GL_TRUE)
<< "GL_COMPILE_STATUS with the quirk off must have joined the job";
for (const GLuint shader : saturation) DeleteShader(shader);
DeleteShader(probe);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// The optimistic window, deterministically
// ---------------------------------------------------------------------------------------
// A compile that provably cannot have settled (the pool's only slot is gate-blocked)
// answers GL_TRUE / length 0 / empty log, and GL_COMPLETION_STATUS_KHR still reads
// GL_FALSE after all three - i.e. none of them joined. Hard EXPECTs, no skip: if the
// quirk silently reverts to joining, the status read deadlocks against the blocked pool
// and the case fails by timeout.
TEST_F(OptimisticStatusTest, PendingCompileReportsTrueAndEmptyLogWithoutJoining) {
const AsyncModeScope async(true);
const OptimisticStatusScope quirk(MG_Config::QuirkOverride::ForceOn);
const CompilerThreadScope threads;
const BlockedPoolScope blocked;
Vector<String> storage;
const Vector<GLuint> probes = SaturatePool(1, 72000, storage);
const GLuint probe = probes[0];
EXPECT_EQ(QueryCompileStatus(probe), GL_TRUE) << "an in-flight compile must answer GL_TRUE";
EXPECT_EQ(QueryInfoLogLength(probe), 0) << "an in-flight compile must answer an empty log length";
EXPECT_TRUE(QueryShaderInfoLog(probe).empty()) << "an in-flight compile must answer an empty log";
EXPECT_EQ(QueryShaderCompletion(probe), GL_FALSE)
<< "the three reads above must not have joined the blocked job";
DeleteShader(probe);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// The latch: one story per compile
// ---------------------------------------------------------------------------------------
// The torn-pair regression case. A broken shader's log and status are read while the job
// is provably in flight (optimistic empty/GL_TRUE), the job then settles, and the app
// re-reads: the latch must keep the answers optimistic - GL_TRUE, empty log - rather than
// flip to the real GL_FALSE next to the already-consumed empty log. The real failure then
// surfaces at the link, with the compile error inside the application's 32768-byte read
// window (the compile log leads the quoted source in ConsumeShaders' format).
TEST_F(OptimisticStatusTest, LatchKeepsOneStoryPerCompileAndTheLinkCarriesTheDiagnostic) {
const AsyncModeScope async(true);
const OptimisticStatusScope quirk(MG_Config::QuirkOverride::ForceOn);
const CompilerThreadScope threads;
const GLuint vs = CreateShader(GL_VERTEX_SHADER);
const char* vsText =
"#version 460\nlayout(location = 0) in vec3 aPos;\nvoid main() { gl_Position = vec4(aPos, 1.0); }\n";
ShaderSource(vs, 1, &vsText, nullptr);
GLuint fs = 0;
{
const BlockedPoolScope blocked;
CompileShader(vs);
fs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs);
// Iris's order, while nothing can settle: log (empty), then status (GL_TRUE).
EXPECT_TRUE(QueryShaderInfoLog(fs).empty());
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE);
EXPECT_EQ(QueryShaderCompletion(fs), GL_FALSE);
} // blocker released and joined; the broken compile can now settle
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
while (QueryShaderCompletion(fs) == GL_FALSE) {
ASSERT_LT(std::chrono::steady_clock::now(), deadline) << "compile job never settled";
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
// Settled - but this shader already told the optimistic story, so it keeps telling it.
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE)
<< "the latch must keep a queried-while-pending compile optimistic after it settles";
EXPECT_EQ(QueryInfoLogLength(fs), 0);
EXPECT_TRUE(QueryShaderInfoLog(fs).empty());
// The truth arrives where the design routes it: at the link.
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
EXPECT_EQ(QueryLinkStatus(program), GL_FALSE) << "a latched-over failure must still fail the link";
EXPECT_NE(QueryProgramInfoLog(program).find("thisIdentifierWasNeverDeclared"), String::npos)
<< "the compile error must lead the program info log, inside a 32768-byte window";
DeleteProgram(program);
DeleteShader(vs);
DeleteShader(fs);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// A shader whose FIRST query arrives after the job settled was never answered
// optimistically, so it owes no continuity: the truth comes straight back. (The
// completion poll does not engage the latch - it is the extension's own non-joining
// query and always tells the truth.)
TEST_F(OptimisticStatusTest, OnceTerminalAnUnqueriedShaderTellsTheTruth) {
const AsyncModeScope async(true);
const OptimisticStatusScope quirk(MG_Config::QuirkOverride::ForceOn);
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs);
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
while (QueryShaderCompletion(fs) == GL_FALSE) {
ASSERT_LT(std::chrono::steady_clock::now(), deadline) << "compile job never settled";
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
EXPECT_EQ(QueryCompileStatus(fs), GL_FALSE) << "no optimistic answer was given, so no latch holds";
EXPECT_GT(QueryInfoLogLength(fs), 0);
EXPECT_NE(QueryShaderInfoLog(fs).find("thisIdentifierWasNeverDeclared"), String::npos);
DeleteShader(fs);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// Recompiling resets the story: a latched optimistic answer must not survive a source
// change (the latch clears when the node changes hands or goes away).
TEST_F(OptimisticStatusTest, ANewCompileResetsTheLatch) {
const AsyncModeScope async(true);
const OptimisticStatusScope quirk(MG_Config::QuirkOverride::ForceOn);
const CompilerThreadScope threads;
GLuint fs = 0;
{
const BlockedPoolScope blocked;
fs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs);
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE); // latches
}
// New source, new compile, no query before it settles.
const char* goodFs = "#version 460\nlayout(location = 0) out vec4 fragColor;\n"
"void main() { fragColor = vec4(1.0); }\n";
ShaderSource(fs, 1, &goodFs, nullptr);
CompileShader(fs);
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
while (QueryShaderCompletion(fs) == GL_FALSE) {
ASSERT_LT(std::chrono::steady_clock::now(), deadline) << "recompile never settled";
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE);
EXPECT_TRUE(QueryShaderInfoLog(fs).empty());
DeleteShader(fs);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// Failure still fails, at the link, inside the application's read window
// ---------------------------------------------------------------------------------------
// A broken fragment shader whose compile status was answered optimistically still fails
// its program link, and the compile error is readable through a 32768-byte
// glGetProgramInfoLog - the compile log LEADS the quoted source in ConsumeShaders'
// format, so even this >32KB shader source cannot push it out of the window.
TEST_F(OptimisticStatusTest, AFailingCompileStillFailsItsLink) {
const AsyncModeScope async(true);
const OptimisticStatusScope quirk(MG_Config::QuirkOverride::ForceOn);
// A >32KB broken fragment shader: the undeclared identifier sits at the top, then bulk.
String brokenSource = "#version 460\nlayout(location = 0) out vec4 fragColor;\n";
brokenSource += "void main() {\n float acc = thisIdentifierWasNeverDeclared;\n";
for (int i = 0; i < 900; ++i) {
brokenSource += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
}
brokenSource += " fragColor = vec4(acc);\n}\n";
ASSERT_GT(brokenSource.size(), 32768u);
const GLuint vs = MakeShader(GL_VERTEX_SHADER,
"#version 460\nlayout(location = 0) in vec3 aPos;\n"
"void main() { gl_Position = vec4(aPos, 1.0); }\n");
const char* brokenText = brokenSource.c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &brokenText, nullptr);
CompileShader(fs);
(void)QueryShaderInfoLog(fs);
(void)QueryCompileStatus(fs); // may latch optimistic GL_TRUE; must not matter
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
EXPECT_EQ(QueryLinkStatus(program), GL_FALSE) << "a hidden compile failure must still fail the link";
const String log = QueryProgramInfoLog(program);
EXPECT_NE(log.find("thisIdentifierWasNeverDeclared"), String::npos)
<< "the compile error must be readable through a 32768-byte program info log window";
DeleteProgram(program);
DeleteShader(vs);
DeleteShader(fs);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// The Iris two-phase replay
// ---------------------------------------------------------------------------------------
// THE LOAD-BEARING CASE. 24 programs through Iris's exact phase-1 shape (compile, read log
// then status per shader, link, detach, delete - no program query), then phase 2 (link
// status, by-name locations including an absent name, the active-uniform enumeration).
// Every location and every active-uniform record must equal what the identical sequence
// produces with the quirk off.
//
// Two determinism guards make this a real A/B rather than a tautology:
// * The quirk-on arm runs FIRST, against a cold preprocess cache, and the reference arm
// second - so it is the path under test that pays the full pipeline, not the control.
// * The quirk-on arm's phase 1 runs over a BLOCKED pool, and every program is then
// WITNESSED still-incomplete (GL_COMPLETION_STATUS_KHR == GL_FALSE) before the pool
// is released: proof that no phase-1 call joined, i.e. the quirk was really engaged.
// A quirk that silently reverts to joining deadlocks here and fails by timeout.
TEST_F(OptimisticStatusTest, IrisTwoPhaseReplayProducesIdenticalReflection) {
constexpr int kPrograms = 24;
Vector<ProgramReflection> reference;
Vector<ProgramReflection> optimistic;
for (const Bool quirkOn : {true, false}) {
const AsyncModeScope async(true);
const OptimisticStatusScope quirk(quirkOn ? MG_Config::QuirkOverride::ForceOn
: MG_Config::QuirkOverride::ForceOff);
const CompilerThreadScope threads;
Vector<String> vsSources, fsSources;
for (int i = 0; i < kPrograms; ++i) {
vsSources.push_back(MakeIrisVs(i));
fsSources.push_back(MakeIrisFs(i));
}
Vector<GLuint> programs;
if (quirkOn) {
const BlockedPoolScope blocked;
for (int i = 0; i < kPrograms; ++i) {
programs.push_back(RunIrisPhaseOne(vsSources[(SizeT)i], fsSources[(SizeT)i]));
}
// The witness: phase 1 finished with the pool blocked, so nothing can have
// settled and nothing can have been joined - every link must still be pending.
for (int i = 0; i < kPrograms; ++i) {
ASSERT_EQ(QueryProgramCompletion(programs[(SizeT)i]), GL_FALSE)
<< "program " << i << " settled under a blocked pool - a phase-1 call must have joined";
}
} else {
for (int i = 0; i < kPrograms; ++i) {
programs.push_back(RunIrisPhaseOne(vsSources[(SizeT)i], fsSources[(SizeT)i]));
}
}
Vector<ProgramReflection>& out = quirkOn ? optimistic : reference;
for (int i = 0; i < kPrograms; ++i) {
const Vector<String> names = {"uModel" + std::to_string(i), "uTint",
"uSeed" + std::to_string(i), "uOffset", "uDoesNotExist"};
out.push_back(RunIrisPhaseTwo(programs[(SizeT)i], names));
}
for (const GLuint program : programs) DeleteProgram(program);
ASSERT_EQ(GetError(), GL_NO_ERROR);
}
ASSERT_EQ(reference.size(), optimistic.size());
for (SizeT i = 0; i < reference.size(); ++i) {
EXPECT_EQ(reference[i].linkStatus, GL_TRUE) << "program " << i;
EXPECT_EQ(optimistic[i].linkStatus, GL_TRUE) << "program " << i;
EXPECT_EQ(reference[i].locations, optimistic[i].locations)
<< "program " << i << ": by-name locations diverged under the quirk";
EXPECT_EQ(reference[i].activeUniforms, optimistic[i].activeUniforms)
<< "program " << i << ": active-uniform enumeration diverged under the quirk";
// The absent name answers -1 in both worlds.
EXPECT_EQ(reference[i].locations.back().second, -1) << "program " << i;
}
}
// ---------------------------------------------------------------------------------------
// The concurrency observable
// ---------------------------------------------------------------------------------------
// The crisp A/B that phase 1 stopped joining. Quirk-on arm: the phase-1 shape over a
// blocked pool completes without joining anything - every shader is then provably still
// in flight (hard EXPECT; an inert quirk deadlocks and fails by timeout). Quirk-off arm:
// the same shape joins at every status read, so nothing is left in flight afterwards.
TEST_F(OptimisticStatusTest, PhaseOneIssuesNoCompileJoin) {
const AsyncModeScope async(true);
const CompilerThreadScope threads;
// Quirk on: nothing settles, nothing joins.
{
const OptimisticStatusScope quirk(MG_Config::QuirkOverride::ForceOn);
const BlockedPoolScope blocked;
Vector<String> storage;
Vector<GLuint> shaders;
for (int i = 0; i < 12; ++i) {
storage.push_back(MakeBulkySource(90000 + i));
const char* text = storage.back().c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
(void)QueryShaderInfoLog(fs);
(void)QueryCompileStatus(fs);
shaders.push_back(fs);
}
for (const GLuint shader : shaders) {
EXPECT_EQ(QueryShaderCompletion(shader), GL_FALSE)
<< "a phase-1 read joined a compile the blocked pool could not have run";
}
for (const GLuint shader : shaders) DeleteShader(shader);
}
// Quirk off: every status read joins its shader.
{
const OptimisticStatusScope quirk(MG_Config::QuirkOverride::ForceOff);
MaxShaderCompilerThreadsKHR(1);
Vector<String> storage;
Vector<GLuint> shaders;
for (int i = 0; i < 12; ++i) {
storage.push_back(MakeBulkySource(80000 + i));
const char* text = storage.back().c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
(void)QueryShaderInfoLog(fs);
(void)QueryCompileStatus(fs);
shaders.push_back(fs);
}
for (const GLuint shader : shaders) {
EXPECT_EQ(QueryShaderCompletion(shader), GL_TRUE)
<< "with the quirk off every per-shader status read must have joined";
}
for (const GLuint shader : shaders) DeleteShader(shader);
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
@@ -239,15 +239,7 @@ TEST_F(ParallelShaderCompileTest, ProgramCompletionStatusReportsFalseWithoutJoin
for (const GLuint program : programs) {
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE);
// GL_COMPLETION_STATUS_KHR spans BOTH phases of a link, so reading GL_LINK_STATUS -
// which is answered out of phase A - is no longer enough to turn it GL_TRUE. That is
// deliberate: an application that polls completion and then draws must not be told
// "done" while the SPIR-V is still being generated, or the draw it was cleared for is
// the thing that blocks. Settling both phases is what makes the query true.
const auto& object = MG_State::pGLContext->GetProgramObject(program);
ASSERT_NE(object, nullptr);
object->JoinLinkAndSpirv();
EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE) << "a full join must have settled both phases";
EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE) << "GL_LINK_STATUS must have joined";
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
@@ -341,54 +333,6 @@ TEST_F(ParallelShaderCompileTest, ZeroCompilerThreadsJoinsEverythingAndCompilesI
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The same obligation, but for LINKS that are already in flight when the zero count arrives -
// and specifically for BOTH phases of one. A link is two chained jobs now (ProgramLinkTask,
// then ProgramSpirvTask), and GL_COMPLETION_STATUS_KHR spans both, so
// ProgramState::JoinAllPendingWork has to settle both or this query reads GL_FALSE in the one
// mode the extension says cannot have anything pending. The case above creates its program
// AFTER the zero count, so it links inline and cannot see this; here the programs are linked
// against a saturated pool BEFORE it.
TEST_F(ParallelShaderCompileTest, ZeroCompilerThreadsJoinsPendingLinksAndTheirSpirvJobs) {
const AsyncModeScope async(true);
const CompilerThreadScope threads;
MaxShaderCompilerThreadsKHR(1);
// A backlog first, so the links below cannot all drain before the zero count lands.
Vector<String> sources;
(void)EnqueueBacklog(24, 5000, sources);
Vector<GLuint> programs;
for (int i = 0; i < 8; ++i) {
sources.push_back(MakeBulkySource(5100 + i));
const char* text = sources.back().c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
CompileShader(vs); // this file's MakeShader only sources; it does not compile
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
programs.push_back(program);
}
int outstanding = 0;
for (const GLuint program : programs) {
if (QueryProgramCompletion(program) == GL_FALSE) ++outstanding;
}
MaxShaderCompilerThreadsKHR(0);
for (const GLuint program : programs) {
EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE)
<< "glMaxShaderCompilerThreadsKHR(0) must leave neither link phase in flight";
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE);
}
EXPECT_GT(outstanding, 0) << "every link had drained before the zero count; this case proved nothing";
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ...and a later NONZERO count is what lifts it. Nothing else does: not a new context, not a
// join, not eglInitialize. That is the documented contract, so it gets an assertion.
TEST_F(ParallelShaderCompileTest, NonzeroCompilerThreadsRestoresAsynchronousCompilation) {
@@ -9,11 +9,8 @@
#include <gtest/gtest.h>
#include <cstring>
#include <map>
#include <set>
#include <string>
#include <utility>
#include <vector>
#include "Includes.h"
#include "Init.h"
@@ -2652,110 +2649,3 @@ TEST_F(ProgramUtilTest, ShaderPreprocessCacheHonorsByteBudget) {
EXPECT_EQ(cache.GetEntryCount(), before);
EXPECT_EQ(cache.Find(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(oversized), oversized, kEnvA), nullptr);
}
// Every vertex input that reaches SPIR-V must carry a Location decoration - including the
// declarations glslang's io-mapper considers INACTIVE.
//
// The shape is Iris's: seven attributes, only some of them bound through
// glBindAttribLocation (ProgramAttrib::explicitVertexInLocations), and at least one neither
// bound nor referenced. GL says only active inputs get generic attribute locations, so the
// resolver deliberately does not RESERVE a slot for a dead one - but it must still RESOLVE a
// location for it, because glslang emits an OpVariable for every declared global (the entry
// point's interface comes from the linker objects) and SPIR-V requires every non-built-in
// Input to be decorated (VUID-StandaloneSpirv-Location-04916).
//
// This test drives the FRONTEND rather than the GL entry points on purpose: it checks the RAW
// GlslangToSpv output, before SanitizeAndOptimizeBinary. A GL-level test cannot see the defect
// for an unreferenced attribute, because AggressiveDCE deletes the offending variable on its
// way to the backend - and yet the real victim (Iris' mc_midTexCoord, Adreno 830,
// programHash 0x4a7e9a37fb49caa1) survived DCE and killed the pipeline with VK_ERROR_UNKNOWN.
TEST_F(ProgramUtilTest, PartiallyBoundVertexInputsAllReceiveALocation) {
using namespace MG_Util::ShaderTranspiler;
const String vertexSource = R"(#version 460 core
in vec3 a_Position;
in vec4 a_Color;
in vec2 a_TexCoord;
in vec2 mc_midTexCoord;
in vec4 mc_Entity;
in vec3 iris_Normal;
in vec4 a_Unreferenced;
out vec4 v_Color;
void main() {
v_Color = a_Color + vec4(a_TexCoord, 0.0, 0.0) + vec4(mc_midTexCoord, 0.0, 0.0) + mc_Entity
+ vec4(iris_Normal, 0.0);
gl_Position = vec4(a_Position, 1.0);
}
)";
ShaderAttrib shaderAttrib{.shaderType = GL_VERTEX_SHADER, .sourceStr = vertexSource};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
ASSERT_TRUE(shaderResult) << shaderResult.error().log;
// PARTIALLY bound, and deliberately not a dense 0..N run - exactly what Iris does.
// mc_midTexCoord and a_Unreferenced are left unbound (FastSTL's map has no
// initializer-list constructor, hence the explicit inserts).
UnorderedMap<String, Uint> explicitVertexIns;
explicitVertexIns["a_Position"] = 0;
explicitVertexIns["a_Color"] = 1;
explicitVertexIns["a_TexCoord"] = 2;
explicitVertexIns["iris_Normal"] = 10;
explicitVertexIns["mc_Entity"] = 11;
ProgramAttrib programAttrib{.shaders = {shaderResult.value()},
.explicitVertexInLocations = explicitVertexIns};
auto programResult = ShaderCompiler::LinkProgram(programAttrib);
ASSERT_TRUE(programResult) << programResult.error().log;
ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_VERTEX_SHADER}, .program = *programResult.value()};
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
ASSERT_TRUE(binaryResult) << binaryResult.error().log;
ASSERT_EQ(binaryResult->size(), 1u);
const auto& vertexBinary = binaryResult->front();
// The authoritative check - this is the same validator whose VUID the driver enforces.
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
String validatorMessages;
tools.SetMessageConsumer([&validatorMessages](spv_message_level_t, const char*, const spv_position_t&,
const char* message) {
if (message != nullptr) validatorMessages += String(message) + "\n";
});
EXPECT_TRUE(tools.Validate(vertexBinary))
<< "the raw vertex module is not valid SPIR-V; Adreno rejects the whole pipeline for this "
<< "while lavapipe tolerates it:\n"
<< validatorMessages;
// ...and, independently of the validator, every non-built-in Input carries a UNIQUE location.
constexpr unsigned kOpDecorate = 71, kOpVariable = 59;
constexpr unsigned kDecorationBuiltIn = 11, kDecorationLocation = 30;
constexpr unsigned kStorageClassInput = 1;
std::map<unsigned, unsigned> locationById;
std::set<unsigned> builtInIds;
std::vector<unsigned> inputIds;
for (SizeT i = 5; i < vertexBinary.size();) { // 5-word header
const unsigned wordCount = vertexBinary[i] >> 16;
const unsigned opcode = vertexBinary[i] & 0xFFFFu;
ASSERT_GT(wordCount, 0u) << "malformed SPIR-V instruction stream";
if (i + wordCount > vertexBinary.size()) break;
if (opcode == kOpDecorate && wordCount >= 4 && vertexBinary[i + 2] == kDecorationLocation) {
locationById[vertexBinary[i + 1]] = vertexBinary[i + 3];
} else if (opcode == kOpDecorate && wordCount >= 3 && vertexBinary[i + 2] == kDecorationBuiltIn) {
builtInIds.insert(vertexBinary[i + 1]);
} else if (opcode == kOpVariable && wordCount >= 4 && vertexBinary[i + 3] == kStorageClassInput) {
inputIds.push_back(vertexBinary[i + 2]);
}
i += wordCount;
}
std::set<unsigned> usedLocations;
SizeT checked = 0;
for (const unsigned id : inputIds) {
if (builtInIds.count(id) != 0) continue;
const auto it = locationById.find(id);
ASSERT_NE(it, locationById.end())
<< "vertex input id " << id << " reached SPIR-V with no Location decoration";
EXPECT_TRUE(usedLocations.insert(it->second).second)
<< "two vertex inputs were assigned location " << it->second;
++checked;
}
EXPECT_GE(checked, 7u) << "expected all seven declared inputs to be present in the raw module";
}
+368
View File
@@ -0,0 +1,368 @@
// MobileGL - MobileGL/MG_Test/Util/AsyncPoolBench.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
// A head-to-head harness for the two ShaderCompilePool execution engines
// (MOBILEGL_ASYNC_POOL=asio|libfork). Not a gtest: it measures one wall-clock interval per
// process, because most of what it drives is memoized per process (the shader preprocess
// cache and the compile-adoption map both live for the life of the GL context), so a second
// timed repetition inside one process would measure the cache, not the compiler. The driver
// script re-executes the binary for every repetition instead.
//
// Two modes:
//
// corpus - the REAL frontend path. glCreateShader/glShaderSource are done untimed, then
// the clock starts and glCompileShader/glLinkProgram submit every job, and stops
// once glGetProgramiv(GL_LINK_STATUS) has joined all of them. That is exactly the
// first-submit-to-all-joined interval a shaderpack load pays.
//
// micro - N trivial JobNodes straight through ShaderCompilePool::Post, isolating the
// executor's own dispatch overhead from any workload contention.
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include <Config.h>
#include <MG_Impl/GLImpl/Program/GL_Program.h>
#include <MG_Util/Async/JobNode.h>
#include <MG_Util/Async/ShaderCompilePool.h>
using namespace MobileGL;
using namespace MobileGL::MG_Util::Async;
namespace GLImpl = MobileGL::MG_Impl::GLImpl;
namespace fs = std::filesystem;
namespace {
using Clock = std::chrono::steady_clock;
double MillisSince(const Clock::time_point start) {
return std::chrono::duration<double, std::milli>(Clock::now() - start).count();
}
GLenum StageFromExtension(const std::string& ext) {
if (ext == ".vert") return GL_VERTEX_SHADER;
if (ext == ".frag") return GL_FRAGMENT_SHADER;
if (ext == ".geom") return GL_GEOMETRY_SHADER;
if (ext == ".comp") return GL_COMPUTE_SHADER;
if (ext == ".tesc") return GL_TESS_CONTROL_SHADER;
if (ext == ".tese") return GL_TESS_EVALUATION_SHADER;
return 0;
}
std::string ReadFile(const fs::path& path) {
std::ifstream in(path, std::ios::binary);
std::ostringstream buf;
buf << in.rdbuf();
return buf.str();
}
struct CorpusShader {
std::string name;
std::string source;
GLenum stage = 0;
};
// One program's worth of the corpus: the trace's link group. Shaders are indices into
// the flat shader list, because a source shared by several programs must stay ONE entry
// - that sharing is what the compile-adoption map sees in the real path too.
struct CorpusProgram {
std::vector<SizeT> shaders;
};
struct Corpus {
std::vector<CorpusShader> shaders;
std::vector<CorpusProgram> programs;
SizeT totalBytes = 0;
};
// Reads a corpus directory written by extract_corpus.py: one file per compiled shader,
// stage in the extension, plus manifest.txt naming the trace's link groups.
Corpus LoadCorpus(const fs::path& dir) {
Corpus corpus;
std::unordered_map<std::string, SizeT> byName;
const auto intern = [&](const std::string& name) -> SizeT {
if (const auto it = byName.find(name); it != byName.end()) return it->second;
const fs::path path = dir / name;
if (!fs::exists(path)) return static_cast<SizeT>(-1);
CorpusShader shader;
shader.name = name;
shader.source = ReadFile(path);
shader.stage = StageFromExtension(path.extension().string());
if (shader.stage == 0) return static_cast<SizeT>(-1);
corpus.totalBytes += shader.source.size();
corpus.shaders.push_back(Move(shader));
const SizeT index = corpus.shaders.size() - 1;
byName.emplace(name, index);
return index;
};
const fs::path manifest = dir / "manifest.txt";
if (fs::exists(manifest)) {
std::ifstream in(manifest);
std::string line;
while (std::getline(in, line)) {
if (line.empty() || line[0] == '#') continue;
CorpusProgram program;
std::istringstream fields(line);
std::string name;
while (fields >> name) {
const SizeT index = intern(name);
if (index != static_cast<SizeT>(-1)) program.shaders.push_back(index);
}
if (!program.shaders.empty()) corpus.programs.push_back(Move(program));
}
}
// Anything in the directory the manifest never linked still gets compiled, as a
// program-less group, so the corpus on disk and the corpus measured are the same set.
std::vector<fs::path> leftovers;
for (const auto& entry : fs::directory_iterator(dir)) {
if (!entry.is_regular_file()) continue;
const std::string name = entry.path().filename().string();
if (name == "manifest.txt") continue;
if (StageFromExtension(entry.path().extension().string()) == 0) continue;
if (byName.count(name) != 0) continue;
leftovers.push_back(entry.path());
}
std::sort(leftovers.begin(), leftovers.end());
for (const auto& path : leftovers) intern(path.filename().string());
return corpus;
}
struct CorpusResult {
double submitMs = 0; // first glCompileShader -> last glLinkProgram returned
double joinMs = 0; // last submit -> every program joined
double totalMs = 0; // the number that matters: first submit -> all joined
SizeT linkFailures = 0;
SizeT compileFailures = 0;
};
CorpusResult RunCorpus(const Corpus& corpus) {
// ---- Untimed: create every GL object and stage every source ----------------------
// glShaderSource is a memcpy into the shader object and glAttachShader is a pointer
// append; neither touches the pool. Keeping them outside the clock makes the measured
// interval exactly the compile+link critical path, which is what an application's
// loading screen waits on.
std::vector<GLuint> shaderNames(corpus.shaders.size(), 0);
for (SizeT i = 0; i < corpus.shaders.size(); ++i) {
const CorpusShader& shader = corpus.shaders[i];
const GLuint name = GLImpl::CreateShader(shader.stage);
const GLchar* text = shader.source.c_str();
const GLint length = static_cast<GLint>(shader.source.size());
GLImpl::ShaderSource(name, 1, &text, &length);
shaderNames[i] = name;
}
std::vector<GLuint> programNames(corpus.programs.size(), 0);
for (SizeT p = 0; p < corpus.programs.size(); ++p) {
const GLuint program = GLImpl::CreateProgram();
for (const SizeT shaderIndex : corpus.programs[p].shaders) {
GLImpl::AttachShader(program, shaderNames[shaderIndex]);
}
programNames[p] = program;
}
// ---- Timed ------------------------------------------------------------------------
const Clock::time_point start = Clock::now();
// Submission order follows the trace: a program's shaders, then its link. That order
// is what exercises ProgramLinkTask::SubmitAfter's dependency chaining rather than a
// flat burst of independent compiles.
std::vector<Bool> submitted(corpus.shaders.size(), false);
for (SizeT p = 0; p < corpus.programs.size(); ++p) {
for (const SizeT shaderIndex : corpus.programs[p].shaders) {
if (submitted[shaderIndex]) continue;
submitted[shaderIndex] = true;
GLImpl::CompileShader(shaderNames[shaderIndex]);
}
GLImpl::LinkProgram(programNames[p]);
}
for (SizeT i = 0; i < corpus.shaders.size(); ++i) {
if (submitted[i]) continue;
submitted[i] = true;
GLImpl::CompileShader(shaderNames[i]);
}
const Clock::time_point submitted_at = Clock::now();
CorpusResult result;
// GL_LINK_STATUS is a joining query (GL_COMPLETION_STATUS_KHR is the one that must
// not join), so this loop is the all-joined barrier.
for (const GLuint program : programNames) {
GLint status = 0;
GLImpl::GetProgramiv(program, GL_LINK_STATUS, &status);
if (status == GL_FALSE) ++result.linkFailures;
}
for (const GLuint shader : shaderNames) {
GLint status = 0;
GLImpl::GetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (status == GL_FALSE) ++result.compileFailures;
}
result.totalMs = MillisSince(start);
result.submitMs = std::chrono::duration<double, std::milli>(submitted_at - start).count();
result.joinMs = result.totalMs - result.submitMs;
for (const GLuint program : programNames) GLImpl::DeleteProgram(program);
for (const GLuint shader : shaderNames) GLImpl::DeleteShader(shader);
return result;
}
// ---- Executor microbenchmark ----------------------------------------------------------
// The body is deliberately near-empty: what is being measured is Post -> engine ->
// RunOnWorker -> next dispatch, i.e. the executor's own cost per job, with no compiler
// work to hide it.
//
// The barrier is an all-jobs-ran latch, and it has to be. This bench used to stop the
// clock at StopAndDrain(), which is not a "wait for everything" - it is the teardown path,
// and its contract is to ABANDON whatever the budget has not dispatched yet (see
// ShaderCompilePool::StopAndDrain, and the JobNodeTest case that pins exactly that). With
// 100k jobs behind a budget of N, most of them were therefore cancelled rather than run,
// and the fraction that survived was decided by how fast the engine drained the queue
// relative to the posting loop - i.e. by the very quantity under test. Measured on this
// machine at 8 workers: Asio ran 75,906 of 100,000 and libfork 99,998, and both were
// scored as if they had run 100,000. The reported "libfork is 1.36x faster" was libfork
// being charged for 32% more work than Asio.
class TrivialJob final : public JobNode {
public:
TrivialJob(std::atomic<Uint64>* sink, const Uint64 total, std::mutex* mutex,
std::condition_variable* cv)
: m_sink(sink), m_total(total), m_mutex(mutex), m_cv(cv) {}
private:
void RunBody() override {
if (m_sink->fetch_add(1, std::memory_order_acq_rel) + 1 == m_total) {
// The last job wakes the timer. Under the lock, so the waiter cannot miss it
// between its predicate check and its wait.
const std::lock_guard<std::mutex> lock(*m_mutex);
m_cv->notify_all();
}
}
std::atomic<Uint64>* m_sink;
Uint64 m_total;
std::mutex* m_mutex;
std::condition_variable* m_cv;
};
struct MicroResult {
double ms = 0;
Uint64 ran = 0;
};
MicroResult RunMicrobench(const Uint threads, const SizeT jobs) {
ShaderCompilePool pool(threads);
std::atomic<Uint64> counter{0};
std::mutex mutex;
std::condition_variable cv;
const auto total = static_cast<Uint64>(jobs);
// Nodes are allocated up front: MakeShared is not what is under test, and leaving it
// inside the loop would put an allocator on the critical path in front of the
// dispatch path this is meant to isolate.
std::vector<SharedPtr<JobNode>> nodes;
nodes.reserve(jobs);
for (SizeT i = 0; i < jobs; ++i) {
nodes.push_back(MakeShared<TrivialJob>(&counter, total, &mutex, &cv));
}
const Clock::time_point start = Clock::now();
for (auto& node : nodes) pool.Post(Move(node));
{
std::unique_lock<std::mutex> lock(mutex);
cv.wait(lock, [&] { return counter.load(std::memory_order_acquire) >= total; });
}
const double ms = MillisSince(start);
MicroResult result;
result.ms = ms;
result.ran = counter.load(std::memory_order_acquire);
return result;
}
[[noreturn]] void Usage() {
std::fprintf(stderr,
"usage: AsyncPoolBench --corpus DIR\n"
" AsyncPoolBench --micro JOBS --threads N\n"
"env: MOBILEGL_ASYNC_POOL=asio|libfork, "
"MOBILEGL_ASYNC_SHADER_COMPILE_THREADS=N\n");
std::exit(2);
}
} // namespace
int main(int argc, char** argv) {
std::string corpusDir;
SizeT microJobs = 0;
Uint microThreads = 0;
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
const auto next = [&]() -> std::string {
if (i + 1 >= argc) Usage();
return argv[++i];
};
if (arg == "--corpus") corpusDir = next();
else if (arg == "--micro") microJobs = static_cast<SizeT>(std::stoull(next()));
else if (arg == "--threads") microThreads = static_cast<Uint>(std::stoul(next()));
else Usage();
}
if (corpusDir.empty() && microJobs == 0) Usage();
Initialize();
const AsyncPoolEngine engine = DetectAsyncPoolEngine();
const char* engineName = AsyncPoolEngineName(engine);
if (microJobs != 0) {
const Uint threads = microThreads != 0 ? microThreads : DetectShaderCompileThreadCount();
const MicroResult result = RunMicrobench(threads, microJobs);
// `ran` is printed, not just checked, so that a run in which the arms did different
// amounts of work is visible in the results file rather than on a stderr the driver
// script redirects to /dev/null. ns_per_job divides by what actually ran.
std::printf("RESULT mode=micro engine=%s threads=%u jobs=%zu ran=%llu total_ms=%.3f "
"ns_per_job=%.1f\n",
engineName, threads, microJobs, static_cast<unsigned long long>(result.ran),
result.ms, result.ms * 1e6 / static_cast<double>(result.ran));
return result.ran == microJobs ? 0 : 1;
}
const Corpus corpus = LoadCorpus(corpusDir);
if (corpus.shaders.empty()) {
std::fprintf(stderr, "AsyncPoolBench: no shaders found in %s\n", corpusDir.c_str());
return 1;
}
if (!AsyncShaderCompileActive()) {
std::fprintf(stderr, "AsyncPoolBench: asynchronous compilation is OFF; measuring the "
"inline path\n");
}
const CorpusResult result = RunCorpus(corpus);
const Uint threads = ShaderCompilePool::Get().GetThreadCount();
std::printf("RESULT mode=corpus engine=%s threads=%u corpus=%s shaders=%zu programs=%zu "
"bytes=%zu total_ms=%.3f submit_ms=%.3f join_ms=%.3f link_fail=%zu "
"compile_fail=%zu\n",
engineName, threads, corpusDir.c_str(), corpus.shaders.size(),
corpus.programs.size(), corpus.totalBytes, result.totalMs, result.submitMs,
result.joinMs, result.linkFailures, result.compileFailures);
return 0;
}
+25 -1
View File
@@ -10,11 +10,35 @@ target_include_directories(JobNodeTest PRIVATE
${MGL_ROOT}/MobileGL
)
# GTest::gtest, not GTest::gtest_main: JobNodeTest supplies its own main so that
# MOBILEGL_LOG_FILE_PATH is set before the first log write in the process. The engine
# -selection cases read the log back to assert that an unrecognized MOBILEGL_ASYNC_POOL value
# warns, and the desktop log sink is the file (MOBILEGL_LOG_ENABLE_CONSOLE is 0).
target_link_libraries(
JobNodeTest PRIVATE
GTest::gtest_main
GTest::gtest
${LINK_LIBRARIES}
)
include(GoogleTest)
gtest_discover_tests(JobNodeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
# The engine comparison harness. Deliberately NOT registered with add_test: it measures wall
# time, so it has no pass/fail verdict to give CI, and it is driven by a script that varies
# MOBILEGL_ASYNC_POOL and MOBILEGL_ASYNC_SHADER_COMPILE_THREADS across a matrix. It lives
# beside JobNodeTest because it drives the same pool through the same two engines; it links
# MobileGL_s for the real glCompileShader/glLinkProgram frontend path.
add_executable(
AsyncPoolBench
AsyncPoolBench.cpp
)
target_include_directories(AsyncPoolBench PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
AsyncPoolBench PRIVATE
${LINK_LIBRARIES}
)
+344 -19
View File
@@ -9,8 +9,19 @@
#include <gtest/gtest.h>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <stdexcept>
#ifdef _WIN32
#include <process.h>
#define MGL_TEST_GETPID _getpid
#else
#include <unistd.h>
#define MGL_TEST_GETPID getpid
#endif
#include "Includes.h"
#include <Config.h>
@@ -21,6 +32,29 @@ using namespace MobileGL;
using namespace MobileGL::MG_Util::Async;
namespace {
// Where this binary's MobileGL log lands, set by main() below. The engine-selection cases
// read it back: MobileGL's desktop log sink is the FILE, not the console
// (MOBILEGL_LOG_ENABLE_CONSOLE is 0 in Defines.h), so gtest's stdout capture would see
// nothing, and "unrecognized value warns" is a contract worth pinning rather than
// assuming - a silent fallback makes a misspelt engine name look exactly like an unset
// variable.
String g_logFilePath;
// Log.cpp flushes the file after every line, so everything written before this call is
// already visible.
String ReadLogFrom(const std::streamoff offset) {
std::ifstream file(g_logFilePath, std::ios::binary);
if (!file) return {};
file.seekg(offset);
return String((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
}
std::streamoff LogSize() {
std::error_code error;
const auto size = std::filesystem::file_size(g_logFilePath, error);
return error ? 0 : static_cast<std::streamoff>(size);
}
// Every test drives its own pool instance rather than ShaderCompilePool::Get(): the
// process-wide pool is stopped permanently by StopAndDrain (that is the teardown
// contract), so a test that drained the singleton would poison every test after it.
@@ -73,6 +107,21 @@ namespace {
Bool m_open = false;
};
// Live thread count of this process. Linux only - /proc/self/task has one entry per
// thread - and 0 where that is not available, which is how the one case that uses it
// decides to skip rather than to assert something it cannot see.
SizeT LiveThreadCount() {
#ifdef __linux__
std::error_code error;
const auto count = static_cast<SizeT>(
std::distance(std::filesystem::directory_iterator("/proc/self/task", error),
std::filesystem::directory_iterator()));
return error ? 0 : count;
#else
return 0;
#endif
}
Bool WaitUntil(const std::function<Bool()>& predicate,
const std::chrono::milliseconds timeout = std::chrono::seconds(10)) {
const auto deadline = std::chrono::steady_clock::now() + timeout;
@@ -89,11 +138,33 @@ namespace {
// ---------------------------------------------------------------------------------------
TEST(ShaderCompilePoolLifecycle, ConstructingAPoolStartsNoThreadUntilSomethingIsPosted) {
const SizeT before = LiveThreadCount();
ShaderCompilePool pool(kTestThreads);
EXPECT_EQ(pool.GetThreadCount(), kTestThreads);
EXPECT_EQ(pool.GetMaxConcurrency(), kTestThreads);
// Nothing observable to assert about thread creation from here; what this pins is that
// construction is side-effect free and the pool destructs cleanly without ever running.
if (before == 0) {
// No thread census on this platform. The rest still holds: construction is
// side-effect free and the pool destructs cleanly without ever having run.
SUCCEED();
return;
}
// "A build that never posts pays nothing" is a real requirement, not a stylistic one -
// asynchronous compilation can be switched off entirely, and a switched-off pool that
// still spawned its workers would cost every such process its threads and their stacks.
// Worth asserting rather than asserting-by-comment now that an engine's thread shape is
// selectable: the libfork engine starts its workers AND a dispatch thread of its own, so
// a regression here would cost more than it used to.
EXPECT_EQ(LiveThreadCount(), before) << "constructing a pool started " << (LiveThreadCount() - before)
<< " thread(s) before anything was posted";
auto job = MakeShared<TestJob>();
pool.Post(job);
job->Wait();
EXPECT_GT(LiveThreadCount(), before) << "the first Post started no thread at all, so the engine did not "
"really run the job off the calling thread";
}
TEST(ShaderCompilePoolLifecycle, StopAndDrainIsIdempotentAndSafeOnAnUnusedPool) {
@@ -217,6 +288,94 @@ TEST(JobNodeSubmit, ManyJobsAllComplete) {
}
}
TEST(JobNodeSubmit, AJobBodyMayPostAnotherJobToTheSamePool) {
// The ProgramLinkTask::SubmitAfter shape, reduced to its scheduling core: the dependent is
// posted by whichever thread drove the dependency terminal, which for a job that finished
// on a worker is that WORKER. Every engine therefore has to accept a submission from
// inside its own pool.
//
// Not a hypothetical: libfork refuses this outright at its normal entry point
// (lf::schedule throws lf::schedule_in_worker, because a libfork worker may never block),
// which is why the libfork engine owns a dispatch thread of its own. Without this case a
// naive port passes every other test in the file and turns every dependency-released link
// job into a cancelled one on the real GL path.
ShaderCompilePool pool(kTestThreads);
std::atomic<Bool> innerSawPoolThread{false};
auto inner = MakeShared<TestJob>(
[&](TestJob&) { innerSawPoolThread.store(ShaderCompilePool::IsPoolThread(), std::memory_order_release); });
std::atomic<Bool> postedFromPoolThread{false};
auto outer = MakeShared<TestJob>([&](TestJob&) {
postedFromPoolThread.store(ShaderCompilePool::IsPoolThread(), std::memory_order_release);
pool.Post(inner);
});
pool.Post(outer);
outer->Wait();
inner->Wait();
EXPECT_TRUE(postedFromPoolThread.load()) << "the outer body did not run on a pool thread, so this case "
"did not exercise posting from inside the pool";
EXPECT_TRUE(outer->IsComplete());
// The load-bearing one: the inner job RAN. A dispatch the engine refused would have
// settled it Cancelled instead, and its body would never have executed.
EXPECT_TRUE(inner->IsComplete()) << "a job posted from a pool thread was not dispatched";
EXPECT_FALSE(inner->IsCancelled());
EXPECT_EQ(inner->ran.load(), 1u);
EXPECT_TRUE(innerSawPoolThread.load());
}
TEST(JobNodeSubmit, ABurstPostedFromInsideThePoolStillRunsInParallel) {
// The tail of a pack load: one compile job goes terminal and its continuations release
// several programs at once (ShaderCompileAdoptionMap lets one compile settle many), so a
// WORKER posts a burst into a pool that is otherwise idle. Every one of those posts clears
// the budget immediately, so the engine is handed `kBurst` runnable jobs from inside
// itself - and it has to spread them, not run them one behind another on the thread that
// submitted them.
//
// Asserting on peak concurrency rather than on wall time: the budget is the contract, and
// an engine that dispatches within the budget but executes serially has silently turned
// the budget into an upper bound nothing reaches.
constexpr Uint kBurst = 4; // == kTestThreads, so the budget can hold all of them at once
ShaderCompilePool pool(kTestThreads);
std::atomic<Uint> live{0};
std::atomic<Uint> peak{0};
std::atomic<Uint> finished{0};
Vector<SharedPtr<TestJob>> burst;
burst.reserve(kBurst);
for (Uint i = 0; i < kBurst; ++i) {
burst.push_back(MakeShared<TestJob>([&](TestJob&) {
const Uint now = live.fetch_add(1, std::memory_order_acq_rel) + 1;
Uint seen = peak.load(std::memory_order_acquire);
while (now > seen && !peak.compare_exchange_weak(seen, now, std::memory_order_acq_rel)) {
}
// Long enough that a serial engine cannot fake overlap, short enough to keep the
// case cheap: with any real spread every body is inside this window together.
std::this_thread::sleep_for(std::chrono::milliseconds(120));
live.fetch_sub(1, std::memory_order_acq_rel);
finished.fetch_add(1, std::memory_order_acq_rel);
}));
}
std::atomic<Bool> postedFromPoolThread{false};
auto seeder = MakeShared<TestJob>([&](TestJob&) {
postedFromPoolThread.store(ShaderCompilePool::IsPoolThread(), std::memory_order_release);
for (const auto& job : burst) pool.Post(job);
});
pool.Post(seeder);
seeder->Wait();
for (const auto& job : burst) job->Wait();
ASSERT_TRUE(postedFromPoolThread.load()) << "the burst was not posted from a pool thread";
EXPECT_EQ(finished.load(), kBurst);
EXPECT_GT(peak.load(), 1u) << "a burst posted from inside the pool ran strictly one at a time; the "
"engine serialized work the budget had already cleared";
}
TEST(JobNodeSubmit, ConcurrencyBudgetIsNeverExceeded) {
constexpr Uint kBudget = 2;
constexpr Uint kJobs = 64;
@@ -472,30 +631,58 @@ TEST(JobNodeException, AThrowingJobDoesNotPoisonTheWorkerForLaterJobs) {
// ---------------------------------------------------------------------------------------
TEST(ShaderCompilePoolDrain, StopAndDrainWithAThousandQueuedJobsLeavesNoneRunningOrPending) {
constexpr Uint kJobs = 1000;
constexpr Uint kQueued = 1000;
ShaderCompilePool pool(kTestThreads);
pool.SetMaxConcurrency(1); // keep the vast majority queued behind the budget
pool.SetMaxConcurrency(1); // one slot, so everything behind the first job stays queued
Vector<SharedPtr<TestJob>> jobs;
jobs.reserve(kJobs);
for (Uint i = 0; i < kJobs; ++i) {
jobs.push_back(MakeShared<TestJob>());
pool.Post(jobs.back());
// Pin that slot with a job that will not return until this test says so. Everything
// posted behind it is then PROVABLY still in the queue, which is what makes the counts
// below exact.
//
// This case used to post a thousand trivial jobs and drain immediately, hoping the drain
// would beat the workers to some of them - and then assert only that "some" were
// cancelled. That hope does not survive an engine whose workers take their next job
// without a scheduler round trip: the libfork engine drained all thousand before the
// posting loop had finished, so the assertion failed about one run in fifty. The property
// being tested (a drain ABANDONS queued work rather than running it) is real and
// engine-independent; only the way it was provoked was a race.
Gate gate;
std::atomic<Bool> entered{false};
auto blocker = MakeShared<TestJob>([&](TestJob&) {
entered.store(true, std::memory_order_release);
gate.Wait();
});
pool.Post(blocker);
ASSERT_TRUE(WaitUntil([&] { return entered.load(); }));
Vector<SharedPtr<TestJob>> queued;
queued.reserve(kQueued);
for (Uint i = 0; i < kQueued; ++i) {
queued.push_back(MakeShared<TestJob>());
pool.Post(queued.back());
}
for (const auto& job : queued) ASSERT_FALSE(job->IsTerminal());
pool.StopAndDrain();
std::thread drain([&] { pool.StopAndDrain(); });
// StopAndDrain settles the entire queue before it waits for the running body, so the
// first cancelled node proves it is past that point - and the gate can then be released
// without racing it.
ASSERT_TRUE(WaitUntil([&] { return queued.front()->IsTerminal(); }));
gate.Open();
drain.join();
// Every node is terminal, so nothing can be waiting on a worker that will never come.
Uint complete = 0;
Uint cancelled = 0;
for (const auto& job : jobs) {
// The job that was already running still finished: an in-flight body is waited for, not
// interrupted.
EXPECT_TRUE(blocker->IsComplete());
EXPECT_EQ(blocker->ran.load(), 1u);
// And every queued node is terminal, so nothing is left waiting on a worker that will
// never come - settled as cancelled, with its body never entered.
for (const auto& job : queued) {
ASSERT_TRUE(job->IsTerminal());
if (job->IsComplete()) ++complete;
if (job->IsCancelled()) ++cancelled;
EXPECT_LE(job->ran.load(), 1u);
EXPECT_TRUE(job->IsCancelled());
EXPECT_EQ(job->ran.load(), 0u);
}
EXPECT_EQ(complete + cancelled, kJobs);
EXPECT_GT(cancelled, 0u); // the drain really did abandon queued work rather than run it
}
TEST(ShaderCompilePoolDrain, StopAndDrainWaitsForARunningBodyToReturn) {
@@ -535,3 +722,141 @@ TEST(ShaderCompilePoolDrain, JobsPostedAfterADrainStillRun) {
EXPECT_TRUE(job->IsComplete());
EXPECT_EQ(job->ran.load(), 1u);
}
// ---------------------------------------------------------------------------------------
// Execution engine selection (MOBILEGL_ASYNC_POOL)
// ---------------------------------------------------------------------------------------
//
// The engine decides only HOW a job that the concurrency budget has already cleared reaches a
// worker thread. Everything else in this file - the budget, cancel request-vs-outcome, the
// continuation machinery, the inline fallback after a stop, the drain - is engine-independent
// by construction, which is why the whole suite is expected to pass unchanged with
// MOBILEGL_ASYNC_POOL unset and with it set to libfork. These cases pin the selection itself,
// so that a run of the matrix cannot silently test asio twice.
TEST(AsyncPoolEngineSelection, EveryAcceptedSpellingParsesToItsEngine) {
EXPECT_EQ(ParseAsyncPoolEngine("asio"), AsyncPoolEngine::Asio);
EXPECT_EQ(ParseAsyncPoolEngine("libfork"), AsyncPoolEngine::Libfork);
// Case-insensitive, like the other named-value variables (MOBILEGL_*_MULTIDRAW_MODE).
EXPECT_EQ(ParseAsyncPoolEngine("Libfork"), AsyncPoolEngine::Libfork);
EXPECT_EQ(ParseAsyncPoolEngine("LIBFORK"), AsyncPoolEngine::Libfork);
EXPECT_EQ(ParseAsyncPoolEngine("ASIO"), AsyncPoolEngine::Asio);
EXPECT_STREQ(AsyncPoolEngineName(AsyncPoolEngine::Asio), "asio");
EXPECT_STREQ(AsyncPoolEngineName(AsyncPoolEngine::Libfork), "libfork");
// Round trip: whatever the name prints is a spelling the variable accepts back.
EXPECT_EQ(ParseAsyncPoolEngine(AsyncPoolEngineName(AsyncPoolEngine::Asio)), AsyncPoolEngine::Asio);
EXPECT_EQ(ParseAsyncPoolEngine(AsyncPoolEngineName(AsyncPoolEngine::Libfork)), AsyncPoolEngine::Libfork);
}
TEST(AsyncPoolEngineSelection, EmptyAndAutoAreTheDefaultEngineAndSaySoSilently) {
// Unset resolves through the empty string, and "auto" is the spelling the other named
// -value variables accept for "no preference". Neither is a mistake, so neither warns.
const std::streamoff before = LogSize();
EXPECT_EQ(ParseAsyncPoolEngine(""), AsyncPoolEngine::Asio);
EXPECT_EQ(ParseAsyncPoolEngine("auto"), AsyncPoolEngine::Asio);
EXPECT_EQ(ReadLogFrom(before).find("MOBILEGL_ASYNC_POOL"), String::npos)
<< "a legitimate value warned; only an unrecognized one may";
}
TEST(AsyncPoolEngineSelection, AnUnrecognizedEngineNameFallsBackToAsioAndWarns) {
const std::streamoff before = LogSize();
EXPECT_EQ(ParseAsyncPoolEngine("libfrok"), AsyncPoolEngine::Asio);
// The warning is the other half of the contract: a misspelt engine name that fell back
// silently would be indistinguishable from an unset variable, and a scaling measurement
// taken against the wrong engine is worse than no measurement.
//
// Guarded because MGLOG_W is a compile-time no-op unless the build's log level admits it -
// and the shipped level does not (Log.h orders the levels DEBUG=0, WARN=1, ERROR=2, INFO=3,
// FATAL=4 and gates on `ACTIVE <= LEVEL`, so the default INFO build enables only INFO and
// FATAL). Nothing is skipped: the fallback above is pinned in every build, and this half is
// checked by a build configured with
// -DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_WARN. The same guard is what makes the
// preceding "says so silently" case honest rather than vacuously true.
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_WARN
const String logged = ReadLogFrom(before);
EXPECT_NE(logged.find("MOBILEGL_ASYNC_POOL"), String::npos) << "no warning names the variable; log tail: " << logged;
EXPECT_NE(logged.find("libfrok"), String::npos)
<< "the warning does not quote the rejected value; log tail: " << logged;
EXPECT_NE(logged.find("asio"), String::npos)
<< "the warning does not say what it fell back to; log tail: " << logged;
#else
(void)before;
#endif
}
TEST(AsyncPoolEngineSelection, TheDetectedEngineIsTheOneTheEnvironmentAskedFor) {
// Read the variable directly rather than through the pool, so this really compares the
// process's answer against the environment the runner exported. This is the case that
// makes "the suite passed with MOBILEGL_ASYNC_POOL=libfork" mean something.
const char* const raw = std::getenv("MOBILEGL_ASYNC_POOL");
const AsyncPoolEngine expected = ParseAsyncPoolEngine(raw != nullptr ? String(raw) : String());
EXPECT_EQ(DetectAsyncPoolEngine(), expected);
// Stable: resolved once per process, so it cannot drift between calls.
EXPECT_EQ(DetectAsyncPoolEngine(), DetectAsyncPoolEngine());
if (DetectAsyncPoolEngine() != AsyncPoolEngine::Asio) {
// Selecting a non-default engine announces itself at INFO, which the shipped log level
// does admit - so on the libfork half of the matrix this doubles as the positive
// control for the log plumbing the preceding two cases read: it proves
// MOBILEGL_LOG_FILE_PATH took effect and that ReadLogFrom really sees MobileGL's
// output, rather than passing because the file is always empty.
const String logged = ReadLogFrom(0);
EXPECT_NE(logged.find("MOBILEGL_ASYNC_POOL"), String::npos)
<< "the selected engine was never announced, so this binary's log capture proves nothing";
EXPECT_NE(logged.find(AsyncPoolEngineName(DetectAsyncPoolEngine())), String::npos);
}
}
TEST(AsyncPoolEngineSelection, EveryPoolReportsTheProcessEngineAndRunsWorkOnIt) {
ShaderCompilePool first(kTestThreads);
ShaderCompilePool second(kTestThreads);
EXPECT_EQ(first.GetEngine(), DetectAsyncPoolEngine());
EXPECT_EQ(second.GetEngine(), first.GetEngine())
<< "two pools in one process disagree about the engine; a process must never run both";
// And the engine it reports is the one that actually executed the work: the body ran off
// the calling thread, on a thread the pool owns.
const auto callingThread = std::this_thread::get_id();
std::atomic<Bool> sawPoolThread{false};
std::thread::id bodyThread{};
auto job = MakeShared<TestJob>([&](TestJob&) {
sawPoolThread.store(ShaderCompilePool::IsPoolThread(), std::memory_order_release);
bodyThread = std::this_thread::get_id();
});
first.Post(job);
job->Wait();
ASSERT_TRUE(job->IsComplete());
EXPECT_TRUE(sawPoolThread.load());
EXPECT_NE(bodyThread, callingThread);
}
// gtest_main is replaced here for one reason: the engine-selection cases above assert that an
// unrecognized MOBILEGL_ASYNC_POOL value WARNS, and MobileGL's desktop log sink is the log
// file - MOBILEGL_LOG_ENABLE_CONSOLE is 0 in Defines.h, so there is nothing on stdout to
// capture. MOBILEGL_LOG_FILE_PATH is read by Log.cpp's InitFile() at the first log write in
// the process, so it has to be set before any test body runs.
int main(int argc, char** argv) {
const std::filesystem::path logPath =
std::filesystem::temp_directory_path() /
("mobilegl-jobnodetest-" + std::to_string(static_cast<long long>(MGL_TEST_GETPID())) + ".log");
g_logFilePath = logPath.string();
std::filesystem::remove(logPath);
#ifdef _WIN32
::_putenv_s("MOBILEGL_LOG_FILE_PATH", g_logFilePath.c_str());
#else
::setenv("MOBILEGL_LOG_FILE_PATH", g_logFilePath.c_str(), 1);
#endif
::testing::InitGoogleTest(&argc, argv);
const int result = RUN_ALL_TESTS();
// Best-effort: leaving a log file per test process in the temp directory would be litter,
// and a failed run has already printed the tail it needed into the gtest output.
std::error_code ignored;
std::filesystem::remove(logPath, ignored);
return result;
}
+503 -44
View File
@@ -12,8 +12,14 @@
#include <asio/post.hpp>
#include <asio/thread_pool.hpp>
#include <libfork/core.hpp>
#include <libfork/schedule/lazy_pool.hpp>
#include <cstdio>
#include <cstdlib>
#include <deque>
#include <functional>
#include <span>
namespace MobileGL::MG_Util::Async {
namespace {
@@ -126,15 +132,6 @@ namespace MobileGL::MG_Util::Async {
return AsyncShaderCompileEnabled() && !IsAsyncShaderCompileSuspended();
}
Bool OptimisticShaderStatusActive() {
switch (MG_Config::Features.AsyncOptimisticShaderStatus) {
case MG_Config::QuirkOverride::ForceOn: return AsyncShaderCompileActive();
case MG_Config::QuirkOverride::ForceOff: return false;
case MG_Config::QuirkOverride::Auto: break;
}
return kOptimisticShaderStatusDefault && AsyncShaderCompileActive();
}
Uint DetectShaderCompileThreadCount() {
if (const Uint32 configured = MG_Config::Features.AsyncShaderCompileThreads; configured > 0) {
// An explicit request is honoured as given - it is the escape hatch for measuring
@@ -144,46 +141,506 @@ namespace MobileGL::MG_Util::Async {
return std::clamp(DetectBigCoreCount(), 1u, kMaxAutoShaderCompileThreads);
}
// ---- Engine selection -----------------------------------------------------------------
const char* AsyncPoolEngineName(const AsyncPoolEngine engine) {
switch (engine) {
case AsyncPoolEngine::Libfork: return "libfork";
case AsyncPoolEngine::Asio: break;
}
return "asio";
}
AsyncPoolEngine ParseAsyncPoolEngine(const String& value) {
String lowered = value;
std::transform(lowered.begin(), lowered.end(), lowered.begin(),
[](const unsigned char c) { return static_cast<char>(std::tolower(c)); });
if (lowered == "libfork") return AsyncPoolEngine::Libfork;
if (lowered == "asio" || lowered == "auto" || lowered.empty()) return AsyncPoolEngine::Asio;
// Not silent: a misspelt engine name resolving to the default would be
// indistinguishable from not having set the variable at all, and the only reason to
// set it is to know which engine ran.
MGLOG_W("Config: Ignoring invalid env variable MOBILEGL_ASYNC_POOL='%s'; expected asio|libfork, "
"using asio",
value.c_str());
return AsyncPoolEngine::Asio;
}
AsyncPoolEngine DetectAsyncPoolEngine() {
// A live std::getenv rather than an MG_Config::Features mirror, and deliberately so:
// a ShaderCompilePool is constructed by binaries that never call MobileGL::Initialize()
// and therefore never run MG_ConfigLoader::Init() - MG_Test/Util/JobNodeTest builds
// pools directly, and it is the suite that exercises the engines against each other.
// Reading Features there would silently resolve to the default and the libfork half of
// the test matrix would prove nothing. See the exemption list in Config.h.
//
// Resolved once per process (a function-local static): every pool in a process gets
// the same engine, so a process can never end up running two.
static const AsyncPoolEngine engine = [] {
const char* value = std::getenv("MOBILEGL_ASYNC_POOL");
const AsyncPoolEngine resolved = ParseAsyncPoolEngine(value != nullptr ? String(value) : String());
if (resolved != AsyncPoolEngine::Asio) {
MGLOG_I("ShaderCompilePool: MOBILEGL_ASYNC_POOL selected the %s execution engine",
AsyncPoolEngineName(resolved));
}
return resolved;
}();
return engine;
}
namespace {
// ---- The engine boundary ----------------------------------------------------------
// Submit() has exactly asio::post's contract, and ShaderCompilePool::Impl leans on all
// four halves of it:
// * it NEVER runs `fn` on the calling thread. DispatchLocked calls it while holding
// the pool's plain, non-recursive mutex, and a job body (or a terminal
// continuation it releases) is free to call Post() again - an inline run would
// deadlock on the lock this frame already owns.
// * it is callable from ANY thread, a worker of this very pool included:
// ProgramLinkTask::OnDepSettled posts the link job from whichever thread drove the
// last compile terminal, which is a worker.
// * it may throw, and when it does it must not have consumed the caller's job node,
// so Post/DispatchLocked can settle the node instead of stranding it Pending with
// a joiner blocked forever.
// * once it has accepted `fn`, `fn` WILL run. A dropped callable is a node nothing
// ever settles, so the engines run it themselves rather than discard it.
class JobExecutor {
public:
virtual ~JobExecutor() = default;
JobExecutor() = default;
JobExecutor(const JobExecutor&) = delete;
JobExecutor& operator=(const JobExecutor&) = delete;
virtual void Submit(std::function<void()> fn) = 0;
// Returns once every callable ever handed to Submit has finished running. The
// guarantee StopAndDrain sells to library teardown: after it returns, no worker is
// still inside a job body that could touch glslang's process globals.
virtual void JoinAll() = 0;
};
// ---- Engine 1: Asio (the shipped default) -----------------------------------------
class AsioJobExecutor final : public JobExecutor {
public:
explicit AsioJobExecutor(const Uint threads) : m_pool(threads) {}
// asio::post only enqueues; it never runs the handler on the calling thread, which
// is what makes calling it under the pool mutex safe.
void Submit(std::function<void()> fn) override { asio::post(m_pool, Move(fn)); }
void JoinAll() override { m_pool.join(); }
private:
asio::thread_pool m_pool;
};
// ---- Engine 2: libfork ------------------------------------------------------------
//
// libfork is a continuation-stealing fork-join runtime, and the shape that fits here is
// NOT fork-join: a job body is one coarse, blocking, non-forking unit (a glslang
// compile), and the concurrency budget that bounds peak RSS is Impl's, not the
// scheduler's. So libfork is used as a job executor - each dispatched job is a detached
// root task - and what it is being asked to beat is Asio's single scheduler queue with
// its per-worker work-stealing deques and sleeping workers.
//
// The one thing libfork forbids is the thing this pool does constantly: lf::schedule
// (which lf::detach is built on) THROWS lf::schedule_in_worker when the calling thread
// is a libfork worker, because workers may never block. Yet a worker submits on every
// job completion - RunOnWorker's tail refills the budget - and again whenever a
// terminal continuation posts (ProgramLinkTask::OnDepSettled). Routing those through a
// separate dispatch thread works but costs two thread wakeups per job, which measured
// 4x worse than Asio on short jobs. So instead a dispatched root is a CHAIN: when its
// body returns it takes the next queued job itself and runs it in the same coroutine
// on the same worker. The refill a worker submits is therefore absorbed by the very
// chain that submitted it - no scheduler round trip, no wakeup - and libfork is only
// entered for work that arrives from outside the pool.
//
// Absorption is bounded at one job per running chain, though, because a chain is one
// worker: past that bound the queue would be jobs the budget has already cleared,
// waiting behind each other on a single thread. See Submit.
//
// Why none of this can strand a job: the queue below is only ever added to from inside
// a running chain (tl_chainOwner == this), and a chain exits only when it finds the
// queue empty - unconditionally, whatever the bound says. Every other submitter goes
// to the dispatch thread or straight to lf::detach.
class LibforkJobExecutor;
// Which executor's chain, if any, is running on this thread. Deliberately narrower
// than ShaderCompilePool::IsPoolThread(): that flag is process-wide and latched
// forever, so a worker of a DIFFERENT pool would read as "mine" and queue a job into a
// chain that will never drain it. This says exactly "a chain of *this* executor is
// executing on this thread, and it will look at the queue again before it exits".
thread_local LibforkJobExecutor* tl_chainOwner = nullptr;
// One dispatched job, heap-owned. It reaches its coroutine as a POINTER passed BY
// VALUE: libfork forwards a root task's arguments into the coroutine frame, so a
// by-value pointer is copied into the frame, whereas anything passed by reference
// would dangle the moment lf::detach returns - and detach, unlike sync_wait, does not
// outlive the task.
struct LibforkJob {
std::function<void()> body;
LibforkJobExecutor* owner;
};
// A scheduler adaptor for lf::detach: it places external submissions round-robin over
// lf::lazy_pool's worker contexts instead of letting the pool pick one at random.
// Both reasons are load-bearing, and the second was worth 1.3x at a budget equal to
// the worker count - the configuration MobileGL actually ships, since maxConcurrency
// is clamped to the thread count:
// * lf::lazy_pool::schedule chooses its victim with a
// std::uniform_int_distribution over a lazy_pool-member xoshiro generator -
// unsynchronized mutable state, so two concurrent submissions are a data race
// inside libfork itself. An atomic cursor is not.
// * A worker's SUBMISSION list is drained only by that worker
// (worker_context::try_pop_all is documented "for use only by the owning worker
// thread"); a thief takes from the task deque, which is a different queue. So a
// job placed on a worker that is inside a long blocking body waits for that body
// rather than being stolen - and random placement of `budget` submissions over
// `budget` workers collides by the birthday rule. Round-robin lands the GL
// thread's burst one per worker, which is exactly the intended shape.
struct RoundRobinSubmitter {
std::span<lf::worker_context*> contexts;
std::atomic<Uint64>* cursor;
void schedule(const lf::submit_handle job) const {
const Uint64 index = cursor->fetch_add(1, std::memory_order_relaxed);
contexts[static_cast<SizeT>(index % contexts.size())]->schedule(job);
}
};
void RunLibforkChain(LibforkJob* raw) noexcept;
// The root task every dispatched chain runs as. libfork async function objects are
// copyable, captureless callables returning lf::task<>, whose first parameter is the
// combinator's synthesized first argument (unused here: this task neither forks nor
// joins). The coroutine exists purely as libfork's entry protocol; the loop is in
// RunLibforkChain.
inline constexpr auto kLibforkChainTask = [](auto /*self*/, LibforkJob* job) -> lf::task<void> {
RunLibforkChain(job);
co_return;
};
class LibforkJobExecutor final : public JobExecutor {
public:
explicit LibforkJobExecutor(const Uint threads)
: m_pool(static_cast<std::size_t>(std::max(1u, threads))), m_contexts(m_pool.contexts()),
m_fallback([this] { FallbackLoop(); }) {}
~LibforkJobExecutor() override {
JoinAll();
{
const std::lock_guard<std::mutex> lock(m_mutex);
m_fallbackStop = true;
}
m_fallbackCv.notify_all();
if (m_fallback.joinable()) m_fallback.join();
// m_pool is destroyed last, and only here: lf::lazy_pool may not be destructed
// while any submitted task can still run or submit more. JoinAll() has
// established the first and the joined fallback thread the second. Its
// destructor then joins the worker threads, so a worker still unwinding a
// finished coroutine frame is waited for rather than pulled out from under.
}
void Submit(std::function<void()> fn) override {
if (tl_chainOwner == this) {
const std::lock_guard<std::mutex> lock(m_mutex);
// The hot path: ONE job per running chain. A chain picks up exactly one
// queued job each time its body returns, so a queue no longer than the
// number of live chains is a queue every entry of which has a distinct
// worker waiting to take it - which is precisely the steady state this
// absorption exists for (every worker finishes a job and refills its own
// slot, all at once, with no scheduler round trip between them).
//
// Past that it is oversubscription, and absorbing it would be a
// correctness-preserving way to destroy the pool's parallelism: the
// budget would still say `maxConcurrency` jobs are in flight while one
// worker ran them one behind another. That is not hypothetical - it is
// the tail of a pack load, where one compile going terminal releases
// several programs at once (ShaderCompileAdoptionMap lets a single
// compile settle many) and the worker that drove it posts the whole
// burst into an otherwise idle pool. Measured before this branch existed:
// four such jobs took 4x one job's wall time on libfork and 1x on Asio.
//
// The overflow cannot go to lf::detach from here - a libfork worker may
// not schedule - so it goes to the dispatch thread, which detaches it to
// a worker of its own. That costs one thread wakeup; serializing costs a
// whole compile.
//
// The count is taken AFTER the push, not before: deque::push_back is
// strongly exception-safe, so an allocation failure here leaves `fn`
// intact for DispatchLocked to settle - but a count incremented in front
// of it would be a count nothing ever gives back, and JoinAll would wait
// on it forever.
const Bool takeable = m_chainQueue.size() < m_liveChains;
if (takeable) {
m_chainQueue.push_back(Move(fn));
++m_outstanding;
} else {
m_fallbackQueue.push_back(Move(fn));
++m_outstanding;
m_fallbackCv.notify_one();
}
return;
}
{
// Counted before anything can run it, so JoinAll cannot observe a zero
// that this job would have broken.
const std::lock_guard<std::mutex> lock(m_mutex);
++m_outstanding;
}
try {
DetachChain(Move(fn));
} catch (const lf::schedule_in_worker&) {
// Submitted from a libfork worker that is not running one of my chains -
// a worker of another ShaderCompilePool. libfork will not take a
// submission from there at all, and the queue above is not safe for it
// (no chain of mine is running on that thread to drain it), so it goes to
// the fallback thread, which is neither. DetachChain restored `fn` before
// it threw.
const std::lock_guard<std::mutex> lock(m_mutex);
m_fallbackQueue.push_back(Move(fn));
m_fallbackCv.notify_one();
} catch (...) {
// Out of memory. Give the count back and let the caller settle its node:
// that is Submit's contract and what DispatchLocked is written against.
Retire();
throw;
}
}
void JoinAll() override {
std::unique_lock<std::mutex> lock(m_mutex);
m_idleCv.wait(lock, [this] { return m_outstanding == 0; });
}
// A chain announces itself before it runs its first body, so that Submit's
// absorption rule can count the workers that are going to come back and ask for
// more. Under-counting is the only direction this can be wrong in (a detached
// chain is not counted until it starts), and under-counting only sends work to
// the dispatch thread that a chain could have taken - never the reverse.
void EnterChain() noexcept {
const std::lock_guard<std::mutex> lock(m_mutex);
++m_liveChains;
}
// The end of one job in a chain. Returns true having loaded `body` with the next
// job to run on this same worker, false when there is nothing left - after which
// the caller must touch neither `this` nor anything owned by it, because the
// count this drops to zero may be the one JoinAll is waiting for.
//
// `body` must arrive empty: the finished job's captures (a strong reference to its
// JobNode) are released by the chain, outside this lock, so that no JobNode
// destructor ever runs inside the executor's critical section.
Bool RetireAndTakeNext(std::function<void()>& body) noexcept {
const std::lock_guard<std::mutex> lock(m_mutex);
--m_outstanding;
if (!m_chainQueue.empty()) {
// Unconditional, and it has to stay that way: a chain that exited while
// the queue was non-empty could be the last one, and the entry would then
// be waiting on a worker that never comes. That is what makes the
// absorption bound in Submit a scheduling policy rather than a liveness
// requirement.
//
// swap, not move-assign: std::function's move assignment is not noexcept,
// and this function is.
body.swap(m_chainQueue.front());
m_chainQueue.pop_front();
return true; // the taken job's own count stays held
}
--m_liveChains;
// Notified while STILL HOLDING the lock, which is the whole reason this is not
// the usual notify-after-unlock. The wakeup this sends can be the one that
// lets JoinAll return and ~LibforkJobExecutor destroy m_idleCv - and a
// std::condition_variable may not be destroyed while another thread is inside
// notify_all() on it. Holding the lock across the notify means the waiter
// cannot re-acquire the mutex, and therefore cannot leave wait(), until this
// thread is out of both the notify and the unlock. ThreadSanitizer catches the
// other order immediately (pthread_cond_destroy vs pthread_cond_broadcast).
if (m_outstanding == 0) m_idleCv.notify_all();
return false;
}
private:
// Builds the root task and hands it to libfork. On any failure `fn` is restored,
// so the caller can still decide what to do with the job.
void DetachChain(std::function<void()>&& fn) {
// `new T{...}` allocates before it constructs, so a throwing operator new
// leaves `fn` untouched; the member move is std::function's noexcept one.
LibforkJob* job = new LibforkJob{Move(fn), this};
try {
lf::detach(RoundRobinSubmitter{m_contexts, &m_cursor}, kLibforkChainTask, job);
} catch (...) {
// lf::schedule upholds the strong exception guarantee, so nothing was
// scheduled and the payload is still ours.
const UniquePtr<LibforkJob> owned(job);
fn = Move(owned->body);
throw;
}
}
void Retire() noexcept {
// Under the lock, for the reason RetireAndTakeNext spells out.
const std::lock_guard<std::mutex> lock(m_mutex);
if (--m_outstanding == 0) m_idleCv.notify_all();
}
// The dispatch thread. It exists because lf::detach is illegal on a libfork worker
// and legal here, and it serves the two cases Submit cannot take itself: a
// submission from another pool's worker, and a chain's overflow past the
// one-job-per-chain bound. It sleeps otherwise, and it dispatches rather than
// executes - a body only ever runs here if libfork refuses the job outright.
void FallbackLoop() {
for (;;) {
std::function<void()> fn;
{
std::unique_lock<std::mutex> lock(m_mutex);
m_fallbackCv.wait(lock, [this] { return !m_fallbackQueue.empty() || m_fallbackStop; });
// Emptiness is checked before the stop flag so that a stop can never
// strand accepted work: an accepted job always runs, because the node
// behind it has a joiner that would otherwise block forever.
if (m_fallbackQueue.empty()) return;
fn.swap(m_fallbackQueue.front());
m_fallbackQueue.pop_front();
}
try {
DetachChain(Move(fn));
} catch (...) {
MGLOG_E("ShaderCompilePool: libfork refused a fallback dispatch; running the job on "
"the dispatch thread instead of dropping it");
RunHere(Move(fn));
}
}
}
// Last resort. Running the body here costs this engine its parallelism for one
// job; dropping it would cost a joiner its wakeup forever.
void RunHere(std::function<void()>&& fn) noexcept {
try {
if (fn) fn();
} catch (...) {
MGLOG_E("ShaderCompilePool: a job body escaped its own containment on the dispatch "
"thread; it has been swallowed to keep the thread alive");
}
fn = nullptr;
Retire();
}
lf::lazy_pool m_pool;
// Fixed for the pool's lifetime, so it is read once rather than per submission.
std::span<lf::worker_context*> m_contexts;
std::atomic<Uint64> m_cursor{0};
std::mutex m_mutex;
std::condition_variable m_fallbackCv;
std::condition_variable m_idleCv;
// Refills and continuations submitted from inside a chain: drained by the chains.
std::deque<std::function<void()>> m_chainQueue;
// Chains currently executing, i.e. workers that will look at m_chainQueue again
// before they exit. The bound on how much Submit may absorb into a chain.
Uint m_liveChains = 0;
// Submissions from another pool's libfork worker, and the overflow of the rule
// above: drained by m_fallback, which detaches each one to a worker.
std::deque<std::function<void()>> m_fallbackQueue;
// Everything submitted and not yet finished, whichever queue it is in and whether
// or not it has reached a worker, so JoinAll needs a single predicate.
Uint m_outstanding = 0;
Bool m_fallbackStop = false;
std::thread m_fallback;
};
void RunLibforkChain(LibforkJob* const raw) noexcept {
UniquePtr<LibforkJob> job(raw);
LibforkJobExecutor* const owner = job->owner;
std::function<void()> body;
body.swap(job->body);
job.reset();
LibforkJobExecutor* const savedOwner = tl_chainOwner;
tl_chainOwner = owner;
owner->EnterChain();
for (;;) {
try {
if (body) body();
} catch (...) {
// JobNode::Run contains every body exception already; this is the backstop
// for the wrapper itself. An exception escaping here would be stashed in
// the root task's shared state, which lf::detach discards - i.e. silently
// lost - and would abandon the rest of the chain.
MGLOG_E("ShaderCompilePool: a job body escaped its own containment on a libfork worker; "
"it has been swallowed to keep the chain alive");
}
// Release the finished job's captures (its strong JobNode reference) HERE,
// outside the executor's lock: a JobNode destructor is arbitrary code.
body = nullptr;
if (!owner->RetireAndTakeNext(body)) break;
}
// `owner` may already be destroyed - RetireAndTakeNext returning false can be the
// call that releases a JoinAll. Nothing below touches it.
tl_chainOwner = savedOwner;
}
UniquePtr<JobExecutor> MakeJobExecutor(const AsyncPoolEngine engine, const Uint threads) {
switch (engine) {
case AsyncPoolEngine::Libfork: return MakeUnique<LibforkJobExecutor>(threads);
case AsyncPoolEngine::Asio: break;
}
return MakeUnique<AsioJobExecutor>(threads);
}
} // namespace
struct ShaderCompilePool::Impl {
explicit Impl(const Uint threads) : threadCount(std::max(1u, threads)), maxConcurrency(threadCount) {}
explicit Impl(const Uint threads)
: threadCount(std::max(1u, threads)), engine(DetectAsyncPoolEngine()), maxConcurrency(threadCount) {}
const Uint threadCount;
// Latched at construction, not re-read: a pool may not change engines under its own
// workers, and GetEngine() is what the tests compare against the environment.
const AsyncPoolEngine engine;
std::mutex mutex;
// Created on the first dispatched Post, never in the constructor: asio::thread_pool
// spawns its threads eagerly, and a build with async off must not pay for threads it
// Created on the first dispatched Post, never in the constructor: both engines spawn
// their threads eagerly (asio::thread_pool its workers, lf::lazy_pool its workers plus
// this file's dispatch thread), and a build with async off must not pay for threads it
// will never use.
UniquePtr<asio::thread_pool> pool;
UniquePtr<JobExecutor> executor;
std::deque<SharedPtr<JobNode>> queue;
Uint inFlight = 0;
Uint maxConcurrency;
std::atomic<Bool> stopped{false};
// Callers hold `mutex`. Hands as many queued nodes to Asio as the concurrency budget
// allows. Posting under the lock is safe and is what keeps `pool` from being moved
// out by a concurrent StopAndDrain between the decision and the dispatch: asio::post
// only enqueues, it never runs the handler on the calling thread, so it cannot
// Callers hold `mutex`. Hands as many queued nodes to the engine as the concurrency
// budget allows. Submitting under the lock is safe and is what keeps `executor` from
// being moved out by a concurrent StopAndDrain between the decision and the dispatch:
// Submit only enqueues, it never runs the callable on the calling thread, so it cannot
// re-enter this mutex.
//
// A node asio::post fails to hand off is appended to `toCancel` instead of being
// A node the engine fails to accept is appended to `toCancel` instead of being
// Cancel()'d here: Cancel() runs the node's OnTerminal continuations inline (stage 4
// added ProgramLinkTask::OnDepSettled as a real one), and a continuation is free to
// call ShaderCompilePool::Post() again. Every caller of DispatchLocked holds `mutex`
// (a plain, non-recursive std::mutex) - Cancel()'ing in here would let that
// re-entrant Post() deadlock on the very lock this frame already owns. The caller
// drains `toCancel` after releasing the lock.
//
// The `stopped` check is also what keeps this loop from dereferencing a null
// `executor`: StopAndDrain sets the flag and moves the executor out in the same
// critical section, so a stopped pool never reaches the Submit below.
void DispatchLocked(Vector<SharedPtr<JobNode>>& toCancel) {
while (!queue.empty() && inFlight < maxConcurrency && !stopped.load(std::memory_order_acquire)) {
// Copy rather than move into the handler: if asio::post throws (it allocates)
// the local SharedPtr is still valid, so the node can be settled instead of
// being stranded Pending in a queue nothing will dispatch from again - a
// joiner would block on it forever. Reclaiming the slot matters just as much:
// a leaked `inFlight` shrinks the pool's concurrency budget permanently.
// Copy rather than move into the callable: if Submit throws (both engines
// allocate) the local SharedPtr is still valid, so the node can be settled
// instead of being stranded Pending in a queue nothing will dispatch from
// again - a joiner would block on it forever. Reclaiming the slot matters just
// as much: a leaked `inFlight` shrinks the pool's concurrency budget
// permanently.
SharedPtr<JobNode> node = queue.front();
queue.pop_front();
++inFlight;
try {
asio::post(*pool, [this, node]() mutable { RunOnWorker(Move(node)); });
executor->Submit([this, node]() mutable { RunOnWorker(Move(node)); });
} catch (...) {
--inFlight;
toCancel.push_back(Move(node));
@@ -193,7 +650,7 @@ namespace MobileGL::MG_Util::Async {
void RunOnWorker(SharedPtr<JobNode> node) {
tl_isPoolThread = true;
// A node that was already handed to Asio when StopAndDrain ran still arrives
// A node that was already handed to the engine when StopAndDrain ran still arrives
// here; cancelling it first turns the dispatch into a state transition instead of
// a full compile, so the drain's join() returns promptly. This Cancel() runs
// before `mutex` is ever taken in this frame, so it is not subject to the
@@ -243,13 +700,15 @@ namespace MobileGL::MG_Util::Async {
return m_impl->maxConcurrency;
}
AsyncPoolEngine ShaderCompilePool::GetEngine() const { return m_impl->engine; }
void ShaderCompilePool::SetMaxConcurrency(const Uint n) {
Vector<SharedPtr<JobNode>> toCancel;
{
const std::lock_guard<std::mutex> lock(m_impl->mutex);
m_impl->maxConcurrency = std::clamp(n, 1u, m_impl->threadCount);
// Raising the budget releases whatever the old one was holding back.
if (m_impl->pool) m_impl->DispatchLocked(toCancel);
if (m_impl->executor) m_impl->DispatchLocked(toCancel);
}
// Outside the lock: see DispatchLocked's comment.
for (const auto& n2 : toCancel) {
@@ -261,23 +720,23 @@ namespace MobileGL::MG_Util::Async {
if (!node) return;
EnsureProcessTeardownSentinel();
// Enqueueing can throw: the thread_pool construction and asio::post both allocate,
// and under memory pressure a throw here would escape glCompileShader leaving the
// node Pending with nothing left to dispatch it - the first observable read would
// then block the GL thread forever. Settle the node instead: a cancelled node is a
// state every joiner already handles.
// Enqueueing can throw: building the engine and submitting to it both allocate (and
// both spawn threads), and under memory pressure a throw here would escape
// glCompileShader leaving the node Pending with nothing left to dispatch it - the
// first observable read would then block the GL thread forever. Settle the node
// instead: a cancelled node is a state every joiner already handles.
//
// `node` is still valid in the catch for every throw this try can produce. The
// thread_pool construction runs before the move; deque::push_back is strongly
// exception-safe and SharedPtr's move constructor is noexcept, so a throwing
// push_back never consumed it; and DispatchLocked contains its own asio::post
// failures rather than propagating them (see above). Keep it that way.
// `node` is still valid in the catch for every throw this try can produce. The engine
// construction runs before the move; deque::push_back is strongly exception-safe and
// SharedPtr's move constructor is noexcept, so a throwing push_back never consumed it;
// and DispatchLocked contains its own Submit failures rather than propagating them
// (see above). Keep it that way.
Bool enqueued = false;
Vector<SharedPtr<JobNode>> toCancel;
try {
const std::lock_guard<std::mutex> lock(m_impl->mutex);
if (!m_impl->stopped.load(std::memory_order_acquire) && !InProcessTeardown()) {
if (!m_impl->pool) m_impl->pool = MakeUnique<asio::thread_pool>(m_impl->threadCount);
if (!m_impl->executor) m_impl->executor = MakeJobExecutor(m_impl->engine, m_impl->threadCount);
m_impl->queue.push_back(Move(node));
m_impl->DispatchLocked(toCancel);
enqueued = true;
@@ -313,17 +772,17 @@ namespace MobileGL::MG_Util::Async {
}
void ShaderCompilePool::StopAndDrain() {
// asio::thread_pool::join() from a pool thread would deadlock on itself, and the
// whole point of this call is that the GL thread waits for the workers.
// Waiting for the workers from a worker would deadlock on itself (asio's join() says
// so outright), and the whole point of this call is that the GL thread waits.
MOBILEGL_ASSERT(!IsPoolThread(), "ShaderCompilePool::StopAndDrain() called from a pool thread");
std::deque<SharedPtr<JobNode>> abandoned;
UniquePtr<asio::thread_pool> pool;
UniquePtr<JobExecutor> executor;
{
const std::lock_guard<std::mutex> lock(m_impl->mutex);
m_impl->stopped.store(true, std::memory_order_release);
abandoned.swap(m_impl->queue);
pool = Move(m_impl->pool);
executor = Move(m_impl->executor);
}
// Queued but never dispatched: settle them so anything chained behind them is
@@ -332,9 +791,9 @@ namespace MobileGL::MG_Util::Async {
if (node) node->Cancel();
}
if (pool) {
pool->join(); // returns once every handler already handed to Asio has finished
pool.reset();
if (executor) {
executor->JoinAll(); // returns once every job already handed to the engine is done
executor.reset(); // and this stops the engine's threads
}
const std::lock_guard<std::mutex> lock(m_impl->mutex);
+36 -20
View File
@@ -11,11 +11,12 @@
#include <MG_Util/Types.h>
#include <MG_Util/Async/JobNode.h>
// This header deliberately includes NO Asio header: asio::thread_pool lives behind the pimpl
// in ShaderCompilePool.cpp. Asio stays a private implementation detail of one translation
// unit, so no consumer target (MG_Test, MG_IntegrationTest, MG_Benchmark - each with its own
// target_include_directories) needs the Asio include path, and no consumer pays its compile
// time. Do not add one here.
// This header deliberately includes NO Asio and NO libfork header: both execution engines
// live behind the pimpl in ShaderCompilePool.cpp. They stay private implementation details of
// one translation unit, so no consumer target (MG_Test, MG_IntegrationTest, MG_Benchmark -
// each with its own target_include_directories) needs either include path, and no consumer
// pays their compile time. libfork in particular is a C++20-coroutine header set whose
// instantiation cost nothing outside the pool has any reason to carry. Do not add one here.
namespace MobileGL::MG_Util::Async {
// Stage 7: on by default. The gate behind the flip (2026-08-09, headless Mesa, both
@@ -59,21 +60,6 @@ namespace MobileGL::MG_Util::Async {
// GL_COMPLETION_STATUS_KHR read immediately GL_TRUE.
Bool AsyncShaderCompileActive();
// MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS (see Config.h): opt-in, off by default, and a
// spec violation by design - GL_COMPILE_STATUS and the shader info log answer
// optimistically while the compile job is in flight instead of joining it. Do not flip
// this default without an enumerated CTS delta: the compile-error-reporting cases WILL
// regress under it, deliberately.
inline constexpr Bool kOptimisticShaderStatusDefault = false;
// The one question the three optimistic getter sites ask. ANDed with
// AsyncShaderCompileActive() so that async-off (env kill switch) and
// glMaxShaderCompilerThreadsKHR(0) both switch the quirk off structurally: in those
// modes every compile settles before its enqueue returns, so a non-terminal node - the
// only state the quirk changes - cannot exist, and keeping the AND means there is no
// new mode interaction to reason about.
Bool OptimisticShaderStatusActive();
// min(4, big cores), where a big core is one whose cpufreq ceiling is within 15% of the
// machine maximum; the whole CPU count where that sysfs tree is absent. Clamped to [1, 4]
// because peak RSS scales as workers x largest glslang arena, and four
@@ -81,6 +67,31 @@ namespace MobileGL::MG_Util::Async {
// MOBILEGL_ASYNC_SHADER_COMPILE_THREADS overrides it outright.
Uint DetectShaderCompileThreadCount();
// ---- MOBILEGL_ASYNC_POOL: which engine drives the worker threads ----------------------
// The engine is ONLY the execution engine. The job queue, the concurrency budget and its
// clamping, the suspension latch, cancel request-vs-outcome, the stopped-is-synchronous
// fallback and the drain are all engine-independent - they live in ShaderCompilePool::Impl
// and are shared verbatim by both engines, which is what lets the whole async suite run
// unchanged against either one. An engine answers exactly one question: how does a job
// that the budget has already cleared reach a worker thread?
enum class AsyncPoolEngine : Uint8 {
Asio, // asio::thread_pool: one shared queue behind Asio's scheduler lock
Libfork, // lf::lazy_pool: per-worker work-stealing deques, workers sleep when idle
};
// "asio" / "libfork" - the spelling the environment variable accepts and the log prints.
const char* AsyncPoolEngineName(AsyncPoolEngine engine);
// Parses one MOBILEGL_ASYNC_POOL value. Case-insensitive; empty, "auto" and anything
// unrecognized resolve to Asio, and an unrecognized value warns (a misspelt engine name
// would otherwise be indistinguishable from the default, and the whole point of the
// variable is to know which engine ran).
AsyncPoolEngine ParseAsyncPoolEngine(const String& value);
// The process's engine, resolved from MOBILEGL_ASYNC_POOL on first call and cached. Every
// pool constructed afterwards reports the same answer, so a process never mixes engines.
AsyncPoolEngine DetectAsyncPoolEngine();
class ShaderCompilePool {
public:
explicit ShaderCompilePool(Uint threadCount);
@@ -112,6 +123,11 @@ namespace MobileGL::MG_Util::Async {
Uint GetThreadCount() const;
Uint GetMaxConcurrency() const;
// The engine this pool was built with, latched at construction from
// DetectAsyncPoolEngine(). Reported rather than re-resolved so that a pool cannot
// change engines under its own workers.
AsyncPoolEngine GetEngine() const;
// Bounded concurrency doubles as the memory bound, and is how
// glMaxShaderCompilerThreadsKHR(n) is honoured: a 300-program pack load cannot put
// 300 glslang arenas in flight at once. Clamped to [1, thread count].
+12 -4
View File
@@ -151,12 +151,20 @@ namespace MobileGL::MG_Util::SelfTest {
return;
}
const Uint threads = MG_Util::Async::DetectShaderCompileThreadCount();
// The execution engine is named here too. It changes no observable GL behaviour -
// both engines run the same job queue under the same budget - but when a scaling
// or stall report comes back from a device, "which engine was this?" is the first
// question, and a POST page is the one artefact that always accompanies it.
const char* const engineName =
MG_Util::Async::AsyncPoolEngineName(MG_Util::Async::DetectAsyncPoolEngine());
builder.Pass(rowName,
format("on with {} compiler thread{}; GL_KHR_parallel_shader_compile is advertised "
format("on with {} compiler thread{} on the {} execution engine; "
"GL_KHR_parallel_shader_compile is advertised "
"and GL_MAX_SHADER_COMPILER_THREADS_KHR = {} (set environment variable "
"MOBILEGL_ASYNC_SHADER_COMPILE=0 to disable it, or "
"MOBILEGL_ASYNC_SHADER_COMPILE_THREADS=n to change the count)",
threads, threads == 1 ? "" : "s", threads));
"MOBILEGL_ASYNC_SHADER_COMPILE=0 to disable it, "
"MOBILEGL_ASYNC_SHADER_COMPILE_THREADS=n to change the count, or "
"MOBILEGL_ASYNC_POOL=asio|libfork to change the engine)",
threads, threads == 1 ? "" : "s", engineName, threads));
}
// Appends the four "MobileGL reported ..." rows for one backend section.
@@ -105,46 +105,8 @@ namespace MobileGL {
}
int TMglGlslIoResolver::resolveInOutLocation(EShLanguage stage, glslang::TVarEntryInfo& ent) {
// NO dead-vertex-input early-out here, deliberately - the skip belongs in
// reserverStorageSlot() and ONLY there.
//
// Skipping RESERVATION is the GL semantic: only active inputs get generic attribute
// locations, so a dead declaration must not consume a slot an active input should
// have. Skipping RESOLUTION as well used to look like the same statement, but it is a
// different one: it leaves the variable with no layoutLocation, and glslang still
// EMITS it - a declared input is in the shader's linker objects and therefore in the
// entry point's interface. The result is an OpVariable of storage class Input with no
// Location decoration, which SPIR-V forbids
// (VUID-StandaloneSpirv-Location-04916). lavapipe tolerates it; Adreno rejects the
// whole pipeline with VK_ERROR_UNKNOWN, which is how this shipped undetected - every
// desktop gate, retrace corpus included, is blind to it.
//
// Found 2026-08-11 on an Adreno 830: the Iris weather program (mc_midTexCoord among
// seven attributes, only some of them glBindAttribLocation-bound) died at the first
// rainy-world draw, 100% reproducible, programHash 0x4a7e9a37fb49caa1.
//
// They cannot simply be handed to the base resolver either. Auto-assignment for inputs
// WITHOUT an explicit binding happens entirely in the resolve pass, in sort order, so a
// dead declaration reaching the free-slot search first would take location 0 and push
// the active input up - which is precisely the GL violation the reservation skip
// exists to prevent (ProgramTest.InactiveExplicitVertexBindingsDoNotReserveLocations
// pins it: Iris injects Position/UV0 into packs that actually read vaPosition).
//
// So dead inputs get their locations from the TOP of the attribute range downward,
// while the base resolver hands active ones out from 0 upward. Both properties hold at
// once: every emitted input carries a Location, and no active input is displaced. The
// two allocators can only meet if live + dead exceed the attribute limit, which is an
// over-subscribed program GL would reject anyway; if that happens we leave the
// variable to the base resolver rather than hand out a colliding location.
const glslang::TType& type = ent.symbol->getType();
if (!ent.live && stage == EShLangVertex && type.getQualifier().isPipeInput() &&
!type.getQualifier().hasLocation() && !type.isBuiltIn()) {
const int size = std::max(1, glslang::TIntermediate::computeTypeLocationSize(type, stage));
if (m_nextInactiveVertexInLocation - (size - 1) >= 0) {
m_nextInactiveVertexInLocation -= (size - 1);
ent.symbol->getWritableType().getQualifier().layoutLocation = m_nextInactiveVertexInLocation;
--m_nextInactiveVertexInLocation;
}
if (!ent.live && stage == EShLangVertex && ent.symbol->getType().getQualifier().isPipeInput()) {
return ent.newLocation = -1;
}
return TDefaultGlslIoResolver::resolveInOutLocation(stage, ent);
}
@@ -51,13 +51,5 @@ namespace MobileGL {
std::map<glslang::TString, int> m_plainUniformLocationSizeByName;
std::map<glslang::TString, int> m_plainUniformLocationByName;
bool m_plainUniformLocationsAssigned = false;
// Descending allocator for INACTIVE vertex inputs (see resolveInOutLocation): they
// still have to carry a Location because glslang emits them, but they must not take a
// slot an active input would get. 15, not 31: the location survives into the ESSL
// SPIRV-Cross emits for DirectGLES, and GL/ES only guarantee GL_MAX_VERTEX_ATTRIBS
// >= 16 - a location of 31 makes the generated shader fail to compile on a real ES
// driver (caught by the super-duper-vanilla and chocapic retrace fixtures).
static constexpr int kInactiveVertexInLocationTop = 15;
int m_nextInactiveVertexInLocation = kInactiveVertexInLocationTop;
};
} // namespace MobileGL