Compare commits

..
Author SHA1 Message Date
swung0x48 5248b8b746 [Test] (MG_Test): replay a real Iris shader pair through the whole async frontend, both phases and both quirk states 2026-08-10 10:06:26 -04:00
swung0x48 d868e1c476 [Test] (MG_Util, MG_State): remove the TEMP stage-timing probes - the async-compile measurement campaign is done 2026-08-10 09:56:05 -04:00
swung0x48 c3412ca394 [Fix, Test] (MG_Util): give inactive vertex inputs a Location - undecorated inputs are invalid SPIR-V and Adreno rejects the whole pipeline 2026-08-10 09:27:01 -04:00
swung0x48 5ccaff37af [Fix] (MG_State): revert the Cut A-prime reorder - device dumps prove the ordering is not the pipeline failure, and record the third measurement 2026-08-10 08:58:38 -04:00
swung0x48 71e29f9d58 [Fix] (MG_State): keep GlslangToSpv ahead of buildReflection - reflection-first drops a vertex input's Location decoration and Adreno rejects the pipeline 2026-08-10 08:33:47 -04:00
swung0x48 8ad07c222c [Test] (MG_Test): cover the cancelled-SPIR-V program, the buffered-write valve and drop, the delete-does-not-block bound and the zero-thread join 2026-08-10 07:31:58 -04:00
swung0x48 5722094d6f [Docs] (MG_State): the early AST drop frees only the re-parsed shaders - the compile node co-owns the rest 2026-08-10 07:31:58 -04:00
swung0x48 4831387cf0 [Fix] (MG_State, MG_Backend/DirectVulkan): a program whose SPIR-V job was cancelled faulted on the first glUniform - bounds-check the shadow lookup 2026-08-10 07:31:57 -04:00
swung0x48 d03b72267a [Perf] (MG_State): drop the parsed ASTs as soon as the SPIR-V job has generated its modules 2026-08-10 06:37:49 -04:00
swung0x48 847ec74f48 [Test] (MG_Test): AsyncSpirvPhaseTest - phase-A completeness, buffered-uniform replay, version bumps and the cancel matrix 2026-08-10 06:12:39 -04:00
swung0x48 e02e5caa17 [Fix] (MG_State, MG_Backend/DirectGLES): join both link phases on the draw and drain paths, and refuse to bind a program whose SPIR-V never arrived 2026-08-10 06:08:30 -04:00
swung0x48 1958934594 [Feat] (MG_State, MG_Impl): buffer non-opaque glUniform writes across the SPIR-V window and replay them at its publish 2026-08-10 06:05:37 -04:00
swung0x48 dec0c5eaff [Refactor, Test] (MG_State, MG_Util): split the link into ProgramLinkTask (query surface) and a chained ProgramSpirvTask behind its own join gate 2026-08-10 06:02:52 -04:00
swung0x48 b6a44cd1e2 [Refactor] (MG_State): run reflection and link validation before SPIR-V generation - the ordering constraint retested byte-identical 2026-08-10 05:50:20 -04:00
swung0x48 85f45d0e44 [Test] (MG_Util, MG_State): TEMP spirv-null plumbing probe for the allocator-pathology discriminator 2026-08-10 05:42:23 -04:00
swung0x48 404236d337 [Test] (MG_Util, MG_State): TEMP stage-timing probes for the async-compile campaign - remove before merge 2026-08-10 05:16:24 -04:00
swung0x48 6ea948779e [Feat, Test] (MG_Impl, MG_State, MG_Util): opt-in MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS - shader compile status/log answer without joining, latched per compile 2026-08-10 02:06:17 -04:00
34 changed files with 3720 additions and 1562 deletions
-3
View File
@@ -34,6 +34,3 @@
[submodule "3rdparty/asio"] [submodule "3rdparty/asio"]
path = 3rdparty/asio path = 3rdparty/asio
url = https://github.com/chriskohlhoff/asio.git url = https://github.com/chriskohlhoff/asio.git
[submodule "3rdparty/libfork"]
path = 3rdparty/libfork
url = https://github.com/ConorWilliams/libfork.git
-1
Submodule 3rdparty/libfork deleted from 9b2b844a5f
+1 -7
View File
@@ -296,6 +296,7 @@ set(SOURCE_FILES
MobileGL/MG_State/GLState/TextureState/TextureState.cpp MobileGL/MG_State/GLState/TextureState/TextureState.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.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/ShaderCompileTask.cpp
MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp
MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.cpp MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.cpp
@@ -373,13 +374,6 @@ set(MOBILEGL_INCLUDE_DIR
# MG_Util/Async/ShaderCompilePool.cpp includes it, and it stays behind that file's # MG_Util/Async/ShaderCompilePool.cpp includes it, and it stays behind that file's
# pimpl so no consumer target needs this path. # pimpl so no consumer target needs this path.
${CMAKE_SOURCE_DIR}/3rdparty/asio/asio/include ${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 add_library(${CMAKE_PROJECT_NAME} SHARED
+13 -6
View File
@@ -66,12 +66,6 @@ namespace MobileGL::MG_Config {
// - DISPLAY: X11 session variable, not MobileGL configuration. // - DISPLAY: X11 session variable, not MobileGL configuration.
// - MOBILEGL_LOG_FILE_PATH: log-file init runs before MG_ConfigLoader::Init // - MOBILEGL_LOG_FILE_PATH: log-file init runs before MG_ConfigLoader::Init
// (see MG_Util/Debug/Log.cpp). // (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 { struct FeaturesTable {
// MOBILEGL_DISABLE_TIMERQUERY: do not advertise or use GPU timer queries. // MOBILEGL_DISABLE_TIMERQUERY: do not advertise or use GPU timer queries.
Bool DisableTimerQuery = false; Bool DisableTimerQuery = false;
@@ -142,6 +136,19 @@ namespace MobileGL::MG_Config {
// MOBILEGL_ASYNC_SHADER_COMPILE_THREADS: shader-compile worker count. 0 (unset) means // 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. // auto, which is min(4, big cores); an explicit value is honoured as given.
Uint32 AsyncShaderCompileThreads = 0; 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; extern FeaturesTable Features;
} // namespace MobileGL::MG_Config } // namespace MobileGL::MG_Config
+2
View File
@@ -183,6 +183,8 @@ namespace MobileGL::MG_ConfigLoader {
features.EsprytMultiDrawMode = QueryEnvGLESMultiDrawMode("MOBILEGL_ESPRYT_MULTIDRAW_MODE"); features.EsprytMultiDrawMode = QueryEnvGLESMultiDrawMode("MOBILEGL_ESPRYT_MULTIDRAW_MODE");
features.AsyncShaderCompile = QueryEnvQuirkOverride("MOBILEGL_ASYNC_SHADER_COMPILE"); features.AsyncShaderCompile = QueryEnvQuirkOverride("MOBILEGL_ASYNC_SHADER_COMPILE");
features.AsyncShaderCompileThreads = QueryEnvUint32("MOBILEGL_ASYNC_SHADER_COMPILE_THREADS", 0, 0, 64); features.AsyncShaderCompileThreads = QueryEnvUint32("MOBILEGL_ASYNC_SHADER_COMPILE_THREADS", 0, 0, 64);
features.AsyncOptimisticShaderStatus =
QueryEnvQuirkOverride("MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS");
} }
inline void InitBackendType() { inline void InitBackendType() {
@@ -2028,7 +2028,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_currentDrawFrontendProgram = nullptr; g_currentDrawFrontendProgram = nullptr;
g_currentDrawBackendProgram = nullptr; g_currentDrawBackendProgram = nullptr;
if (!currentProgram || !currentProgram->GetLinkStatus()) { // ... || !GetSpirvStatus(): see BackendProgramObjectImpl::SyncToBackend - a
// program whose SPIR-V never arrived is linked but not drawable.
if (!currentProgram || !currentProgram->GetLinkStatus() || !currentProgram->GetSpirvStatus()) {
g_GLESFuncs.glUseProgram(0); g_GLESFuncs.glUseProgram(0);
g_lastUsedBackendProgramId = 0; g_lastUsedBackendProgramId = 0;
return; return;
@@ -2589,7 +2591,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
static void BindCurrentProgramWithResources( static void BindCurrentProgramWithResources(
const SharedPtr<MG_State::GLState::ProgramObject>& currentProgram, const SharedPtr<MG_State::GLState::ProgramObject>& currentProgram,
const TextureImpl::DrawTextureSyncKeys& keys) { const TextureImpl::DrawTextureSyncKeys& keys) {
if (currentProgram && currentProgram->GetLinkStatus()) { if (currentProgram && currentProgram->GetLinkStatus() && currentProgram->GetSpirvStatus()) {
#ifdef TRACY_ENABLE #ifdef TRACY_ENABLE
ZoneScopedNC("BindCurrentProgram", TRACY_ZONECOLOR_BACKEND); ZoneScopedNC("BindCurrentProgram", TRACY_ZONECOLOR_BACKEND);
#endif #endif
@@ -2859,7 +2861,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// is pinned for the duration. Prefers the per-draw stash those preparations wrote. // is pinned for the duration. Prefers the per-draw stash those preparations wrote.
static PrgramImpl::BackendProgramObjectImpl* GetCurrentBackendProgram() { static PrgramImpl::BackendProgramObjectImpl* GetCurrentBackendProgram() {
const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw(); const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw();
if (!currentProgram || !currentProgram->GetLinkStatus()) { if (!currentProgram || !currentProgram->GetLinkStatus() || !currentProgram->GetSpirvStatus()) {
return nullptr; return nullptr;
} }
if (PrgramImpl::g_currentDrawFrontendProgram == currentProgram.get()) { if (PrgramImpl::g_currentDrawFrontendProgram == currentProgram.get()) {
@@ -3017,7 +3019,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
TextureImpl::SyncImageTextureBindings(); TextureImpl::SyncImageTextureBindings();
PrgramImpl::SyncCurrentProgram(currentProgram); PrgramImpl::SyncCurrentProgram(currentProgram);
if (!currentProgram || !currentProgram->GetLinkStatus()) { if (!currentProgram || !currentProgram->GetLinkStatus() || !currentProgram->GetSpirvStatus()) {
g_GLESFuncs.glUseProgram(0); g_GLESFuncs.glUseProgram(0);
PrgramImpl::g_lastUsedBackendProgramId = 0; PrgramImpl::g_lastUsedBackendProgramId = 0;
return; return;
+8 -2
View File
@@ -4156,8 +4156,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
return; return;
} }
if (!stateProgramObject->GetLinkStatus()) { // GetSpirvStatus() as well as GetLinkStatus(): a program whose phase-B job was
MGLOG_E("Program object is not linked, skipping backend sync. State program ID: %u", // 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",
stateProgramObject->GetExternalIndex()); stateProgramObject->GetExternalIndex());
return; return;
} }
@@ -4267,8 +4267,15 @@ void main() {
auto writeUniform = [&](Int location, const void* data, SizeT size) { auto writeUniform = [&](Int location, const void* data, SizeT size) {
MOBILEGL_ASSERT(location >= 0, "GenerateDepthMipmapWithShader: invalid uniform location"); MOBILEGL_ASSERT(location >= 0, "GenerateDepthMipmapWithShader: invalid uniform location");
const Uint offset = m_depthMipmapResources.program->GetUniformOffset(static_cast<Uint>(location)); const Uint offset = m_depthMipmapResources.program->GetUniformOffset(static_cast<Uint>(location));
MOBILEGL_ASSERT(offset + size <= m_depthMipmapResources.program->GetUBOSize(), // A RETURN, not only an assert: the assert compiles out in release, and a program
"GenerateDepthMipmapWithShader: uniform write out of bounds"); // 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;
}
memcpy(depthProgramData + offset, data, size); memcpy(depthProgramData + offset, data, size);
m_depthMipmapResources.program->MarkUBOContentDirty(); m_depthMipmapResources.program->MarkUBOContentDirty();
}; };
@@ -7161,8 +7168,13 @@ void main() {
auto writeUniform = [&](Int location, const void* data, SizeT size) { auto writeUniform = [&](Int location, const void* data, SizeT size) {
MOBILEGL_ASSERT(location >= 0, "TryBlitToDefaultFramebufferWithShader: invalid uniform location"); MOBILEGL_ASSERT(location >= 0, "TryBlitToDefaultFramebufferWithShader: invalid uniform location");
const Uint offset = m_blitResources.program->GetUniformOffset(static_cast<Uint>(location)); const Uint offset = m_blitResources.program->GetUniformOffset(static_cast<Uint>(location));
MOBILEGL_ASSERT(offset + size <= m_blitResources.program->GetUBOSize(), // A RETURN, not only an assert - see GenerateDepthMipmapWithShader's copy of this
"TryBlitToDefaultFramebufferWithShader: uniform write out of bounds"); // 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;
}
memcpy(blitProgramData + offset, data, size); memcpy(blitProgramData + offset, data, size);
}; };
writeUniform(m_blitResources.srcRectLocation, blitUniformData.srcRect, sizeof(blitUniformData.srcRect)); writeUniform(m_blitResources.srcRectLocation, blitUniformData.srcRect, sizeof(blitUniformData.srcRect));
+49 -3
View File
@@ -744,6 +744,21 @@ namespace MobileGL::MG_Impl::GLImpl {
CopyStr(bufSize, length, infoLog, log.c_str(), (GLsizei)log.length()); 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) { void GetShaderiv_State(GLuint shader, GLenum pname, GLint* params) {
auto& shaderObject = TryToGetShaderObject(shader); auto& shaderObject = TryToGetShaderObject(shader);
if (!shaderObject) return; if (!shaderObject) return;
@@ -756,9 +771,20 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = shaderObject->GetDeleteStatus(); *params = shaderObject->GetDeleteStatus();
break; break;
case GL_COMPILE_STATUS: case GL_COMPILE_STATUS:
if (AnswerCompileOptimistically(shaderObject)) {
*params = GL_TRUE;
break;
}
*params = shaderObject->GetCompileStatus(); *params = shaderObject->GetCompileStatus();
break; break;
case GL_INFO_LOG_LENGTH: 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; *params = shaderObject->GetInfoLog().empty() ? 0 : (GLint)shaderObject->GetInfoLog().length() + 1;
break; break;
case GL_SHADER_SOURCE_LENGTH: case GL_SHADER_SOURCE_LENGTH:
@@ -784,6 +810,15 @@ namespace MobileGL::MG_Impl::GLImpl {
auto& shaderObject = TryToGetShaderObject(shader); auto& shaderObject = TryToGetShaderObject(shader);
if (!shaderObject) return; 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(); const auto& log = shaderObject->GetInfoLog();
CopyStr(bufSize, length, infoLog, log.c_str(), (GLsizei)log.length()); CopyStr(bufSize, length, infoLog, log.c_str(), (GLsizei)log.length());
} }
@@ -1085,10 +1120,9 @@ namespace MobileGL::MG_Impl::GLImpl {
if (!programObject.IsUniformOpaqueAtLocation(location)) { if (!programObject.IsUniformOpaqueAtLocation(location)) {
MGLOG_D("%s: program = %d, location = %d, maxLocation = %d", __func__, programObject.GetExternalIndex(), MGLOG_D("%s: program = %d, location = %d, maxLocation = %d", __func__, programObject.GetExternalIndex(),
location, programObject.GetMaxUniformLocation()); 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 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); SizeT writeSize = ItemCount * sizeof(T);
if (size < writeSize) { if (size < writeSize) {
// Metadata bug: degrade to a clamped copy instead of killing the process. // Metadata bug: degrade to a clamped copy instead of killing the process.
@@ -1097,6 +1131,18 @@ namespace MobileGL::MG_Impl::GLImpl {
__func__, programObject.GetExternalIndex(), location, ItemCount * sizeof(T), size); __func__, programObject.GetExternalIndex(), location, ItemCount * sizeof(T), size);
writeSize = 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 || if (pUBO == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset ||
offset + byteOffsetInsideUniform + writeSize > uboSize) { offset + byteOffsetInsideUniform + writeSize > uboSize) {
// Should not happen: linking gives every settable uniform backing // Should not happen: linking gives every settable uniform backing
@@ -157,6 +157,22 @@ void main() {
const QuirkOverride m_saved; 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 // 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. // it has to put the pool back or it changes how every scenario after it compiles.
class CompilerThreadScope { class CompilerThreadScope {
@@ -463,5 +479,81 @@ void main() {
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); 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
} // namespace MGITest } // namespace MGITest
+12 -5
View File
@@ -380,8 +380,14 @@ namespace MobileGL::MG_State {
// inside the same draw when it finally touched an artifact, and cache under a // 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 the publish had already superseded. Settling here means every
// version a backend reads during a draw describes the program it is drawing. // version a backend reads during a draw describes the program it is drawing.
// One null check in steady state. // Two null checks in steady state.
currentProgram->JoinLink(); //
// 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();
return currentProgram; return currentProgram;
} }
if (m_boundProgramPipeline == 0) return nullProgram; if (m_boundProgramPipeline == 0) return nullProgram;
@@ -398,7 +404,7 @@ namespace MobileGL::MG_State {
// programs. In steady state this is a null check per stage. // programs. In steady state this is a null check per stage.
for (SizeT stage = 0; stage < static_cast<SizeT>(ShaderStage::ShaderStageCount); ++stage) { for (SizeT stage = 0; stage < static_cast<SizeT>(ShaderStage::ShaderStageCount); ++stage) {
const auto& stageProgram = pipeline->GetStageProgram(static_cast<ShaderStage>(stage)); const auto& stageProgram = pipeline->GetStageProgram(static_cast<ShaderStage>(stage));
if (stageProgram) stageProgram->JoinLink(); if (stageProgram) stageProgram->JoinLinkAndSpirv();
} }
const auto signature = pipeline->ComputeDrawProgramSignature(); const auto signature = pipeline->ComputeDrawProgramSignature();
@@ -430,8 +436,9 @@ namespace MobileGL::MG_State {
composite->Link(true); composite->Link(true);
// P1 join site J2. The draw that asked for this program is the very next thing to // 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 // happen, so enqueueing the composite's link buys nothing and only moves the wait
// to whichever backend accessor happens to touch its artifacts first. // to whichever backend accessor happens to touch its artifacts first. Both phases,
composite->JoinLink(); // for the same reason: the backend is about to read its SPIR-V.
composite->JoinLinkAndSpirv();
pipeline->SetCachedDrawProgram(signature, Move(composite)); pipeline->SetCachedDrawProgram(signature, Move(composite));
return pipeline->GetCachedDrawProgram(signature); return pipeline->GetCachedDrawProgram(signature);
} }
@@ -361,29 +361,53 @@ namespace MobileGL::MG_State::GLState {
} }
} }
// SPIR-V must be generated BEFORE buildReflection touches artifacts.program: // ---- everything below this line up to GenerateSpirv() is the GL query surface ----
// reflection's live-variable analysis mutates the intermediates in ways that //
// change subsequent GlslangToSpv output (observed: catastrophic uniform // ORDERING NOTE (rewritten 2026-08-10; the constraint it records was RETESTED, not
// misbinding on DirectVulkan for UBO-heavy content). The old two-link pipeline // dropped on a hunch). This block used to insist that SPIR-V be generated BEFORE
// never ran buildReflection on the SPIR-V-producing program; this order keeps // buildReflection touches artifacts.program, on the grounds that reflection's
// that property with the single link. The glUniform*-to-scratch routing // live-variable analysis mutates the shared intermediates in ways that change
// tables, in contrast, are sized and keyed by reflection results, so they are // subsequent GlslangToSpv output - "observed: catastrophic uniform misbinding on
// built strictly AFTER DoReflection. (Everything else on the reflection // DirectVulkan for UBO-heavy content", recorded with commit 0d052719.
// surface - locations, sampler units, block bindings/sizes - was measured //
// identical in either order.) // Re-measured on the glslang pin this tree vendors, with the same method 0d052719
MGLOG_D("ProgramObject %u: Starting SPIR-V generation", in.externalIndex); // used (per-module SPIR-V hashes, both orders, byte-compared): 636 modules across
GenerateSpirv(); // 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.
MGLOG_D("ProgramObject %u: Starting reflection", in.externalIndex); MGLOG_D("ProgramObject %u: Starting reflection", in.externalIndex);
if (!DoReflection(env)) { if (!DoReflection(env)) {
DeferLog(std::format("ProgramObject {}: Link failed during reflection: {}", in.externalIndex, DeferLog(std::format("ProgramObject {}: Link failed during reflection: {}", in.externalIndex,
artifacts.infoLog)); artifacts.infoLog));
return; return;
} }
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); MGLOG_D("ProgramObject %u: Reflection done (linkStatus=%d)", in.externalIndex, (int)artifacts.linkStatus);
if (!ValidateFragmentOutputLocations()) { if (!ValidateFragmentOutputLocations()) {
return; return;
} }
@@ -393,8 +417,30 @@ namespace MobileGL::MG_State::GLState {
in.externalIndex, artifacts.infoLog)); in.externalIndex, artifacts.infoLog));
return; return;
} }
MGLOG_D("ProgramObject %u: Binary generation finished (generatedSpirv size=%zu)", in.externalIndex,
artifacts.generatedSpirv.size()); // ---- 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());
} }
Bool ProgramLinkTask::ConsumeShaders(Vector<SharedPtr<glslang::TShader>>& outShaders) { Bool ProgramLinkTask::ConsumeShaders(Vector<SharedPtr<glslang::TShader>>& outShaders) {
@@ -408,6 +454,13 @@ namespace MobileGL::MG_State::GLState {
MG_Util::ConvertGLEnumToString(shaderType).c_str()); MG_Util::ConvertGLEnumToString(shaderType).c_str());
if (!compiled.compileStatus) { 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 = artifacts.infoLog =
std::format("Linking a {} with compilation error, linking will now terminate. Shader error " std::format("Linking a {} with compilation error, linking will now terminate. Shader error "
"log:\n{}\nShader src:\n{}", "log:\n{}\nShader src:\n{}",
@@ -836,183 +889,6 @@ namespace MobileGL::MG_State::GLState {
return true; 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() { Bool ProgramLinkTask::ValidateFragmentOutputLocations() {
if (!artifacts.program) return false; if (!artifacts.program) return false;
@@ -29,10 +29,16 @@ namespace MobileGL::MG_State::GLState {
SharedPtr<const ShaderCompileTask> compiled; SharedPtr<const ShaderCompileTask> compiled;
}; };
// The unit of asynchronous linking: one glLinkProgram's worth of pure CPU work - glslang // PHASE A of one glLinkProgram: the half that decides what GL can be asked about the
// link + mapIO, SPIR-V generation and optimization, the GL-facing reflection surface, the // program - glslang link + mapIO, the GL-facing reflection surface, fragment-output
// global-UBO routing tables, fragment-output validation and transform-feedback // validation and transform-feedback resolution - with every input it needs snapshotted at
// resolution - with every input it needs snapshotted at enqueue. // 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).
// //
// Same ownership rule as ShaderCompileTask: the body reads nothing but `in` (all of it // 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 // owned or immutable) and writes nothing but `artifacts`. No GL call, no
@@ -40,11 +46,13 @@ namespace MobileGL::MG_State::GLState {
// through the CompileEnv snapshot and diagnostics are deferred to the join. // 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 // ONE LINK IS ONE HANDLER. RunBody() runs start to finish inside a single pool handler
// and is the only place `artifacts` is written. Do not split it across handlers to // and is the only place `artifacts` is written. Splitting it across handlers to
// "pipeline" the reflection half: the intermediates that GlslangToSpv and buildReflection // "pipeline" the reflection half would let a cancel land between the halves and publish a
// share are mutated in a strict order (see the GenerateSpirv-before-DoReflection comment // program whose SPIR-V and reflection describe different things - so any such split has
// in Run()), and a second handler would let a cancel land between them and publish a // to be structural: the first half must publish a LINK_STATUS and a query surface that
// program whose SPIR-V and reflection describe different things. // 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.)
class ProgramLinkTask final : public MG_Util::Async::JobNode { class ProgramLinkTask final : public MG_Util::Async::JobNode {
public: public:
// ---- inputs, snapshotted on the GL thread in ProgramObject::Link()'s prologue ---- // ---- inputs, snapshotted on the GL thread in ProgramObject::Link()'s prologue ----
@@ -68,6 +76,49 @@ namespace MobileGL::MG_State::GLState {
// Moved (never copied) into the ProgramObject by EnsureLinkJoined(). // Moved (never copied) into the ProgramObject by EnsureLinkJoined().
ProgramObject::LinkArtifacts artifacts; 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 // 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 // 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 // on another job, or the pool could deadlock with all its workers waiting on each
@@ -94,8 +145,6 @@ namespace MobileGL::MG_State::GLState {
Bool ValidateFragmentOutputLocations(); Bool ValidateFragmentOutputLocations();
Bool ResolveTransformFeedbackVaryings(); Bool ResolveTransformFeedbackVaryings();
void ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate); void ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate);
void GenerateSpirv();
void BuildGlobalUboRouting();
// Worker-side MGLOG replacement: appended to diagnostics.logLines and replayed by the // 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. // join, on the GL thread, where a serial implementation would have printed it.
@@ -8,7 +8,9 @@
#include "ProgramObject.h" #include "ProgramObject.h"
#include "ProgramLinkTask.h" #include "ProgramLinkTask.h"
#include "ProgramSpirvTask.h"
#include <atomic> #include <atomic>
#include <cstring>
#include <MG_Util/Async/ShaderCompilePool.h> #include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/Converters/GLToStr/GLEnumConverter.h> #include <MG_Util/Converters/GLToStr/GLEnumConverter.h>
#include <MG_Util/ShaderTranspiler/CompileEnv.h> #include <MG_Util/ShaderTranspiler/CompileEnv.h>
@@ -68,12 +70,129 @@ namespace MobileGL::MG_State::GLState {
Bool ProgramObject::IsPendingLinkTerminal() const { return m_pendingLink->IsTerminal(); } 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() { 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; 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->Cancel();
m_pendingLink.reset(); m_pendingLink.reset();
} }
@@ -103,8 +222,12 @@ namespace MobileGL::MG_State::GLState {
// function has ever cleared, and its callers depend on that (they write infoLog // 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 // immediately AFTER calling here). Link()'s prologue does not use this - it assigns a
// whole default-constructed block, where the ordering is explicit. // 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.program.reset();
artifacts.generatedSpirv.clear();
artifacts.uniformLocations.clear(); artifacts.uniformLocations.clear();
artifacts.glUniformIndexToTProgram.clear(); artifacts.glUniformIndexToTProgram.clear();
artifacts.tProgramUniformIndexToGl.clear(); artifacts.tProgramUniformIndexToGl.clear();
@@ -117,9 +240,6 @@ namespace MobileGL::MG_State::GLState {
artifacts.uniformBlockIndexByName.clear(); artifacts.uniformBlockIndexByName.clear();
artifacts.uniformBlockBinding.clear(); artifacts.uniformBlockBinding.clear();
artifacts.shaderStorageBlockBinding.clear(); artifacts.shaderStorageBlockBinding.clear();
artifacts.uniformOffsets.clear();
artifacts.uniformSizesInBytes.clear();
artifacts.globalUboScratch.clear();
artifacts.attribs.clear(); artifacts.attribs.clear();
artifacts.attribTypes.clear(); artifacts.attribTypes.clear();
artifacts.activeUniformCount = 0; artifacts.activeUniformCount = 0;
@@ -238,6 +358,7 @@ namespace MobileGL::MG_State::GLState {
// is what every gated reader sees, so it has to be the complete "not linked" state - // 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. // including the fields ResetLinkArtifacts deliberately preserves for its own callers.
m_artifacts = {}; m_artifacts = {};
m_spirv = {};
// ---- GL-thread-owned mutations ---- // ---- GL-thread-owned mutations ----
// Remove detached shaders first // Remove detached shaders first
@@ -292,17 +413,33 @@ namespace MobileGL::MG_State::GLState {
task->in.shaders.push_back({shader->GetShaderStage(), shader->GetShaderSourcePtr(), node}); 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_pendingLink = task;
m_pendingSpirv = spirvTask;
// Flag off - or glMaxShaderCompilerThreadsKHR(0), see AsyncShaderCompileActive(): // Flag off - or glMaxShaderCompilerThreadsKHR(0), see AsyncShaderCompileActive():
// byte-identical to the synchronous implementation. RunInline() executes the same // byte-identical to the synchronous implementation. RunInline() executes the same
// body on this thread and the join below publishes through the same code, so the two // bodies on this thread, in the same order, and the join below publishes through the
// modes differ only in WHICH thread ran RunBody(). // 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.
if (!MG_Util::Async::AsyncShaderCompileActive()) { if (!MG_Util::Async::AsyncShaderCompileActive()) {
task->RunInline(); task->RunInline();
EnsureLinkJoined(); spirvTask->RunInlineAfter(task);
EnsureSpirvJoined();
return; 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); task->SubmitAfter(deps);
} }
@@ -18,6 +18,9 @@ namespace MobileGL::MG_State::GLState {
// ProgramLinkTask.h includes THIS header (it outputs a LinkArtifacts), so including it // ProgramLinkTask.h includes THIS header (it outputs a LinkArtifacts), so including it
// back would be circular. The destructor is therefore out of line. // back would be circular. The destructor is therefore out of line.
class ProgramLinkTask; 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 { class ProgramObject {
public: public:
@@ -303,7 +306,25 @@ namespace MobileGL::MG_State::GLState {
// Sentinel for a uniform location without global-UBO backing storage (should not // Sentinel for a uniform location without global-UBO backing storage (should not
// survive linking: GenerateBinary falls back to tail-allocated scratch storage). // survive linking: GenerateBinary falls back to tail-allocated scratch storage).
static constexpr Uint kInvalidUniformOffset = ~0u; static constexpr Uint kInvalidUniformOffset = ~0u;
Uint GetUniformOffset(Uint location) const { return Artifacts().uniformOffsets[location]; } // 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 GetUniformSizesInBytes(Uint location) const { return MG_Util::GetGLTypeSize(GetUniformType(location)); } Uint GetUniformSizesInBytes(Uint location) const { return MG_Util::GetGLTypeSize(GetUniformType(location)); }
Int GetAttributeLocation(const String& name) { Int GetAttributeLocation(const String& name) {
@@ -381,9 +402,14 @@ namespace MobileGL::MG_State::GLState {
const String& GetActiveAttribName(Uint index) const { const String& GetActiveAttribName(Uint index) const {
return NormalizeBuiltinPipeInputName(Artifacts().program->getPipeInput(static_cast<Int>(index)).name); return NormalizeBuiltinPipeInputName(Artifacts().program->getPipeInput(static_cast<Int>(index)).name);
} }
void* MapUBO() { return Artifacts().globalUboScratch.data(); } // PHASE B, all three (see EnsureSpirvJoined): the shadow buffer's layout is decided
const void* GetUBOData() const { return Artifacts().globalUboScratch.data(); } // by the OPTIMIZED SPIR-V, so it does not exist until the SPIR-V job has settled - and
Uint GetUBOSize() const { return static_cast<Uint>(Artifacts().globalUboScratch.size()); } // 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()); }
// Content version of the CPU-side global-UBO shadow: writers bump it so backends // 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 // 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. // backends' "never uploaded" sentinel, so skip over it on wrap.
@@ -391,6 +417,25 @@ namespace MobileGL::MG_State::GLState {
void MarkUBOContentDirty() const { void MarkUBOContentDirty() const {
if (++m_uboContentVersion == ~0u) m_uboContentVersion = 0; 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; } Uint32 GetBackendStateVersion() const { return m_backendStateVersion; }
// Bumped only by (re)linking — lets backends detect that every piece of // Bumped only by (re)linking — lets backends detect that every piece of
// link-derived reflection (locations, block order, UBO layout) is stale. // link-derived reflection (locations, block order, UBO layout) is stale.
@@ -463,6 +508,11 @@ namespace MobileGL::MG_State::GLState {
CancelLink(); CancelLink();
BumpLinkObservableVersions(); BumpLinkObservableVersions();
ResetLinkArtifacts(Artifacts()); 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."; Artifacts().infoLog = "No program binary format is supported.";
} }
Bool GetValidateStatus() const { return m_validateStatus; } Bool GetValidateStatus() const { return m_validateStatus; }
@@ -571,8 +621,15 @@ namespace MobileGL::MG_State::GLState {
return Artifacts().shaderStorageBlockBinding; return Artifacts().shaderStorageBlockBinding;
} }
Vector<Vector<unsigned>>& GetGeneratedSpirv() { return Artifacts().generatedSpirv; } // PHASE B (see EnsureSpirvJoined). Empty for a program whose SPIR-V job was
const Vector<Vector<unsigned>>& GetGeneratedSpirv() const { return Artifacts().generatedSpirv; } // 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; }
// The linked glslang reflection itself, for the ONE consumer that needs resource // The linked glslang reflection itself, for the ONE consumer that needs resource
// lists no typed getter above exposes: the GL program-interface query layer // lists no typed getter above exposes: the GL program-interface query layer
@@ -620,7 +677,6 @@ namespace MobileGL::MG_State::GLState {
// without going through the gate. // without going through the gate.
struct LinkArtifacts { struct LinkArtifacts {
SharedPtr<glslang::TProgram> program; SharedPtr<glslang::TProgram> program;
Vector<Vector<unsigned>> generatedSpirv;
// Attributes (Vertex in) // Attributes (Vertex in)
Vector<String> attribs; Vector<String> attribs;
@@ -664,11 +720,6 @@ namespace MobileGL::MG_State::GLState {
// SetShaderStorageBlockBinding for why this one is by name and not by index. // SetShaderStorageBlockBinding for why this one is by name and not by index.
UnorderedMap<String, Int> shaderStorageBlockBinding; 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 activeUniformCount = 0;
Uint maxUniformLocation = 0; Uint maxUniformLocation = 0;
Int uniformNameMaxLength = 0; Int uniformNameMaxLength = 0;
@@ -697,6 +748,35 @@ namespace MobileGL::MG_State::GLState {
Uint32 xfbPackedStride = 0; 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 ---- // ---- artifacts-only helpers, shared with ProgramLinkTask ----
// Static and taking the block explicitly, because from stage 4 the link BODY needs // 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 // them while its artifacts still live on the job node, not on any ProgramObject. The
@@ -736,9 +816,20 @@ namespace MobileGL::MG_State::GLState {
// Blocks until a pending link has published its artifacts. Public because a few call // 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 // sites have to join without reading anything - see the explicit-join list (J1-J8) in
// the P1 design. GL thread only. // 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(); } void JoinLink() const { EnsureLinkJoined(); }
// Drops a link that is still in flight, without waiting for it. Called at the points // 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
// where the pending link's result stops being the answer to "what did this program // 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, // link to": a re-link supersedes it, glProgramBinary must force LINK_STATUS false,
// and a destroyed program has no observers left. // and a destroyed program has no observers left.
@@ -757,7 +848,15 @@ namespace MobileGL::MG_State::GLState {
// MUST NOT JOIN - this is what GL_COMPLETION_STATUS_KHR reads when the extension // 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 // surface lands. "No job at all" counts as complete: there is nothing outstanding to
// wait for. // wait for.
Bool IsLinkComplete() const { return m_pendingLink == nullptr || IsPendingLinkTerminal(); } //
// 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(); }
void SetTransformFeedbackVaryings(Vector<String>&& names, GLenum bufferMode) { void SetTransformFeedbackVaryings(Vector<String>&& names, GLenum bufferMode) {
m_requestedXfbVaryings = Move(names); m_requestedXfbVaryings = Move(names);
@@ -824,6 +923,40 @@ namespace MobileGL::MG_State::GLState {
// node's state goes through this out-of-line helper. // node's state goes through this out-of-line helper.
Bool IsPendingLinkTerminal() const; 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() { LinkArtifacts& Artifacts() {
EnsureLinkJoined(); EnsureLinkJoined();
return m_artifacts; return m_artifacts;
@@ -832,6 +965,14 @@ namespace MobileGL::MG_State::GLState {
EnsureLinkJoined(); EnsureLinkJoined();
return m_artifacts; 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 // GL-thread-only companion to ResetLinkArtifacts (see its definition). Const because
// the publish half of the join calls it; see the mutable counters below. // the publish half of the join calls it; see the mutable counters below.
@@ -899,10 +1040,22 @@ namespace MobileGL::MG_State::GLState {
// Mutable because publishing is a READ-side operation: a const getter has to be able // Mutable because publishing is a READ-side operation: a const getter has to be able
// to settle an outstanding link before answering it. // to settle an outstanding link before answering it.
mutable LinkArtifacts m_artifacts; 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 // 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 // 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. // the pending window sees, and the whole reason the gate above is one branch.
mutable SharedPtr<ProgramLinkTask> m_pendingLink; 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 } // namespace MobileGL::MG_State::GLState
@@ -0,0 +1,318 @@
// 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
@@ -0,0 +1,77 @@
// 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,9 +111,13 @@ namespace MobileGL::MG_State::GLState {
// that can grow, and a reallocation underneath this loop would be a use-after-free // 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 // 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. // 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) { for (SizeT i = 0; i < m_programObjects.size(); ++i) {
const SharedPtr<ProgramObject> program = m_programObjects[i]; const SharedPtr<ProgramObject> program = m_programObjects[i];
if (program) program->JoinLink(); if (program) program->JoinLinkAndSpirv();
} }
for (SizeT i = 0; i < m_shaderObjects.size(); ++i) { for (SizeT i = 0; i < m_shaderObjects.size(); ++i) {
const SharedPtr<ShaderObject> shader = m_shaderObjects[i]; const SharedPtr<ShaderObject> shader = m_shaderObjects[i];
@@ -122,7 +126,7 @@ namespace MobileGL::MG_State::GLState {
// The currently-used program is reachable through m_programObjects unless // The currently-used program is reachable through m_programObjects unless
// glDeleteProgram already freed its slot while it stayed current. Nothing else holds // 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. // a GL-visible name for it, but a draw would still join it, so settle it here too.
if (m_currentProgram) m_currentProgram->JoinLink(); if (m_currentProgram) m_currentProgram->JoinLinkAndSpirv();
} }
void ProgramState::MarkShaderObjectForDeletion(Uint shader) { void ProgramState::MarkShaderObjectForDeletion(Uint shader) {
@@ -88,12 +88,19 @@ namespace MobileGL::MG_State::GLState {
// another object, THIS object has not pulled its result yet. (An adopted node may // 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.) // already be terminal - the join then only replays what is left of its diagnostics.)
m_compileJoined = false; 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 { void ShaderObject::DropCompileNode() const {
if (!m_compiled) return; if (!m_compiled) return;
m_compiled->ReleaseAdopter(); m_compiled->ReleaseAdopter();
m_compiled.reset(); 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() { void ShaderObject::InvalidateCompiledState() {
@@ -116,8 +116,10 @@ namespace MobileGL {
Bool GetDeleteStatus() const { return m_deleteStatus; } Bool GetDeleteStatus() const { return m_deleteStatus; }
// Blocks until a pending compile has published its artifacts. Public for the // Blocks until a pending compile has published its artifacts. Public for the
// sites that must join without reading anything - ProgramObject::Link's // sites that must join without reading anything - ProgramState::
// prologue, which needs every attached shader settled before it runs. // 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().
void JoinCompile() const { EnsureCompileJoined(); } void JoinCompile() const { EnsureCompileJoined(); }
// True while this object holds the outcome (success OR failure) of a Compile() // True while this object holds the outcome (success OR failure) of a Compile()
@@ -141,6 +143,23 @@ namespace MobileGL {
// outstanding to wait for. // outstanding to wait for.
Bool IsCompileComplete() const { return m_compiled == nullptr || m_compiled->IsTerminal(); } 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: private:
// ---- The one and only join gate for compile output (P1 invariant I5) ---- // ---- 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 - // The fast path - no job, or a job whose result this object has already pulled -
@@ -231,6 +250,10 @@ namespace MobileGL {
// Exactly-once latch for the pull above. Armed with every new job node, set by // Exactly-once latch for the pull above. Armed with every new job node, set by
// the one join that consumes it. // the one join that consumes it.
mutable Bool m_compileJoined = false; 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 MG_State::GLState
} // namespace MobileGL } // namespace MobileGL
+68 -1
View File
@@ -117,11 +117,26 @@ void main() { fragColor = thisIdentifierWasNeverDeclared; }
} }
// The non-joining view of the program, i.e. what GL_COMPLETION_STATUS_KHR will report. // 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) { Bool LinkIsSettled(const GLuint program) {
const auto& object = MG_State::pGLContext->GetProgramObject(program); const auto& object = MG_State::pGLContext->GetProgramObject(program);
return object == nullptr || object->IsLinkComplete(); 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 // Enqueues `count` distinct heavy compiles without reading anything back, so the pool is
// left with a real backlog for the caller to race against. // left with a real backlog for the caller to race against.
Vector<GLuint> SaturatePool(const int count, Vector<String>& sourceStorage) { Vector<GLuint> SaturatePool(const int count, Vector<String>& sourceStorage) {
@@ -516,11 +531,63 @@ TEST_F(AsyncLinkTest, LinkProgramReturnsBeforeTheWorkIsDone) {
for (const GLuint program : programs) { for (const GLuint program : programs) {
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
EXPECT_TRUE(LinkIsSettled(program)) << "reading LINK_STATUS must have joined"; // 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_EQ(GetError(), GL_NO_ERROR); 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 // With the flag off, a link is finished by the time glLinkProgram returns. This is the guard
// that keeps the default shippable. // that keeps the default shippable.
TEST_F(AsyncLinkTest, LinkIsFullySynchronousWithAsyncOff) { TEST_F(AsyncLinkTest, LinkIsFullySynchronousWithAsyncOff) {
File diff suppressed because it is too large Load Diff
+40
View File
@@ -49,6 +49,22 @@ add_executable(
AsyncLinkTest.cpp 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 target_include_directories(AsyncLinkTest PRIVATE
${MGL_ROOT}/include ${MGL_ROOT}/include
${MGL_ROOT}/MobileGL ${MGL_ROOT}/MobileGL
@@ -60,6 +76,24 @@ target_link_libraries(
${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( add_executable(
ShaderCompileAdoptionTest ShaderCompileAdoptionTest
ShaderCompileAdoptionTest.cpp ShaderCompileAdoptionTest.cpp
@@ -165,11 +199,17 @@ gtest_discover_tests(ProgramInterfaceTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS
# compile pool so there is something in flight to race against. # 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(AsyncCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
gtest_discover_tests(AsyncLinkTest 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 # 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. # worker, and the 48-object stress links every one of them.
gtest_discover_tests(ShaderCompileAdoptionTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) 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. # Same reason: the GL_COMPLETION_STATUS_KHR cases saturate a one-worker pool on purpose.
gtest_discover_tests(ParallelShaderCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) gtest_discover_tests(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) 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. # 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) gtest_discover_tests(XfbFrontendOrderInvarianceTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
@@ -0,0 +1,674 @@
// 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,7 +239,15 @@ TEST_F(ParallelShaderCompileTest, ProgramCompletionStatusReportsFalseWithoutJoin
for (const GLuint program : programs) { for (const GLuint program : programs) {
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE); EXPECT_EQ(QueryLinkStatus(program), GL_TRUE);
EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE) << "GL_LINK_STATUS must have joined"; // 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(GetError(), GL_NO_ERROR); EXPECT_EQ(GetError(), GL_NO_ERROR);
} }
@@ -333,6 +341,54 @@ TEST_F(ParallelShaderCompileTest, ZeroCompilerThreadsJoinsEverythingAndCompilesI
EXPECT_EQ(GetError(), GL_NO_ERROR); 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 // ...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. // join, not eglInitialize. That is the documented contract, so it gets an assertion.
TEST_F(ParallelShaderCompileTest, NonzeroCompilerThreadsRestoresAsynchronousCompilation) { TEST_F(ParallelShaderCompileTest, NonzeroCompilerThreadsRestoresAsynchronousCompilation) {
@@ -9,8 +9,11 @@
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <cstring> #include <cstring>
#include <map>
#include <set>
#include <string> #include <string>
#include <utility> #include <utility>
#include <vector>
#include "Includes.h" #include "Includes.h"
#include "Init.h" #include "Init.h"
@@ -2649,3 +2652,110 @@ TEST_F(ProgramUtilTest, ShaderPreprocessCacheHonorsByteBudget) {
EXPECT_EQ(cache.GetEntryCount(), before); EXPECT_EQ(cache.GetEntryCount(), before);
EXPECT_EQ(cache.Find(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(oversized), oversized, kEnvA), nullptr); 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
@@ -1,368 +0,0 @@
// 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;
}
+1 -25
View File
@@ -10,35 +10,11 @@ target_include_directories(JobNodeTest PRIVATE
${MGL_ROOT}/MobileGL ${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( target_link_libraries(
JobNodeTest PRIVATE JobNodeTest PRIVATE
GTest::gtest GTest::gtest_main
${LINK_LIBRARIES} ${LINK_LIBRARIES}
) )
include(GoogleTest) include(GoogleTest)
gtest_discover_tests(JobNodeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) 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}
)
+19 -344
View File
@@ -9,19 +9,8 @@
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <chrono> #include <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <stdexcept> #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 "Includes.h"
#include <Config.h> #include <Config.h>
@@ -32,29 +21,6 @@ using namespace MobileGL;
using namespace MobileGL::MG_Util::Async; using namespace MobileGL::MG_Util::Async;
namespace { 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 // Every test drives its own pool instance rather than ShaderCompilePool::Get(): the
// process-wide pool is stopped permanently by StopAndDrain (that is the teardown // 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. // contract), so a test that drained the singleton would poison every test after it.
@@ -107,21 +73,6 @@ namespace {
Bool m_open = false; 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, Bool WaitUntil(const std::function<Bool()>& predicate,
const std::chrono::milliseconds timeout = std::chrono::seconds(10)) { const std::chrono::milliseconds timeout = std::chrono::seconds(10)) {
const auto deadline = std::chrono::steady_clock::now() + timeout; const auto deadline = std::chrono::steady_clock::now() + timeout;
@@ -138,33 +89,11 @@ namespace {
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
TEST(ShaderCompilePoolLifecycle, ConstructingAPoolStartsNoThreadUntilSomethingIsPosted) { TEST(ShaderCompilePoolLifecycle, ConstructingAPoolStartsNoThreadUntilSomethingIsPosted) {
const SizeT before = LiveThreadCount();
ShaderCompilePool pool(kTestThreads); ShaderCompilePool pool(kTestThreads);
EXPECT_EQ(pool.GetThreadCount(), kTestThreads); EXPECT_EQ(pool.GetThreadCount(), kTestThreads);
EXPECT_EQ(pool.GetMaxConcurrency(), kTestThreads); EXPECT_EQ(pool.GetMaxConcurrency(), kTestThreads);
// Nothing observable to assert about thread creation from here; what this pins is that
if (before == 0) { // construction is side-effect free and the pool destructs cleanly without ever running.
// 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) { TEST(ShaderCompilePoolLifecycle, StopAndDrainIsIdempotentAndSafeOnAnUnusedPool) {
@@ -288,94 +217,6 @@ 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) { TEST(JobNodeSubmit, ConcurrencyBudgetIsNeverExceeded) {
constexpr Uint kBudget = 2; constexpr Uint kBudget = 2;
constexpr Uint kJobs = 64; constexpr Uint kJobs = 64;
@@ -631,58 +472,30 @@ TEST(JobNodeException, AThrowingJobDoesNotPoisonTheWorkerForLaterJobs) {
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
TEST(ShaderCompilePoolDrain, StopAndDrainWithAThousandQueuedJobsLeavesNoneRunningOrPending) { TEST(ShaderCompilePoolDrain, StopAndDrainWithAThousandQueuedJobsLeavesNoneRunningOrPending) {
constexpr Uint kQueued = 1000; constexpr Uint kJobs = 1000;
ShaderCompilePool pool(kTestThreads); ShaderCompilePool pool(kTestThreads);
pool.SetMaxConcurrency(1); // one slot, so everything behind the first job stays queued pool.SetMaxConcurrency(1); // keep the vast majority queued behind the budget
// Pin that slot with a job that will not return until this test says so. Everything Vector<SharedPtr<TestJob>> jobs;
// posted behind it is then PROVABLY still in the queue, which is what makes the counts jobs.reserve(kJobs);
// below exact. for (Uint i = 0; i < kJobs; ++i) {
// jobs.push_back(MakeShared<TestJob>());
// This case used to post a thousand trivial jobs and drain immediately, hoping the drain pool.Post(jobs.back());
// 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());
std::thread drain([&] { pool.StopAndDrain(); }); 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();
// The job that was already running still finished: an in-flight body is waited for, not // Every node is terminal, so nothing can be waiting on a worker that will never come.
// interrupted. Uint complete = 0;
EXPECT_TRUE(blocker->IsComplete()); Uint cancelled = 0;
EXPECT_EQ(blocker->ran.load(), 1u); for (const auto& job : jobs) {
// 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()); ASSERT_TRUE(job->IsTerminal());
EXPECT_TRUE(job->IsCancelled()); if (job->IsComplete()) ++complete;
EXPECT_EQ(job->ran.load(), 0u); if (job->IsCancelled()) ++cancelled;
EXPECT_LE(job->ran.load(), 1u);
} }
EXPECT_EQ(complete + cancelled, kJobs);
EXPECT_GT(cancelled, 0u); // the drain really did abandon queued work rather than run it
} }
TEST(ShaderCompilePoolDrain, StopAndDrainWaitsForARunningBodyToReturn) { TEST(ShaderCompilePoolDrain, StopAndDrainWaitsForARunningBodyToReturn) {
@@ -722,141 +535,3 @@ TEST(ShaderCompilePoolDrain, JobsPostedAfterADrainStillRun) {
EXPECT_TRUE(job->IsComplete()); EXPECT_TRUE(job->IsComplete());
EXPECT_EQ(job->ran.load(), 1u); 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;
}
+44 -503
View File
@@ -12,14 +12,8 @@
#include <asio/post.hpp> #include <asio/post.hpp>
#include <asio/thread_pool.hpp> #include <asio/thread_pool.hpp>
#include <libfork/core.hpp>
#include <libfork/schedule/lazy_pool.hpp>
#include <cstdio> #include <cstdio>
#include <cstdlib>
#include <deque> #include <deque>
#include <functional>
#include <span>
namespace MobileGL::MG_Util::Async { namespace MobileGL::MG_Util::Async {
namespace { namespace {
@@ -132,6 +126,15 @@ namespace MobileGL::MG_Util::Async {
return AsyncShaderCompileEnabled() && !IsAsyncShaderCompileSuspended(); 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() { Uint DetectShaderCompileThreadCount() {
if (const Uint32 configured = MG_Config::Features.AsyncShaderCompileThreads; configured > 0) { if (const Uint32 configured = MG_Config::Features.AsyncShaderCompileThreads; configured > 0) {
// An explicit request is honoured as given - it is the escape hatch for measuring // An explicit request is honoured as given - it is the escape hatch for measuring
@@ -141,506 +144,46 @@ namespace MobileGL::MG_Util::Async {
return std::clamp(DetectBigCoreCount(), 1u, kMaxAutoShaderCompileThreads); 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 { struct ShaderCompilePool::Impl {
explicit Impl(const Uint threads) explicit Impl(const Uint threads) : threadCount(std::max(1u, threads)), maxConcurrency(threadCount) {}
: threadCount(std::max(1u, threads)), engine(DetectAsyncPoolEngine()), maxConcurrency(threadCount) {}
const Uint 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; std::mutex mutex;
// Created on the first dispatched Post, never in the constructor: both engines spawn // Created on the first dispatched Post, never in the constructor: asio::thread_pool
// their threads eagerly (asio::thread_pool its workers, lf::lazy_pool its workers plus // spawns its threads eagerly, and a build with async off must not pay for threads it
// this file's dispatch thread), and a build with async off must not pay for threads it
// will never use. // will never use.
UniquePtr<JobExecutor> executor; UniquePtr<asio::thread_pool> pool;
std::deque<SharedPtr<JobNode>> queue; std::deque<SharedPtr<JobNode>> queue;
Uint inFlight = 0; Uint inFlight = 0;
Uint maxConcurrency; Uint maxConcurrency;
std::atomic<Bool> stopped{false}; std::atomic<Bool> stopped{false};
// Callers hold `mutex`. Hands as many queued nodes to the engine as the concurrency // Callers hold `mutex`. Hands as many queued nodes to Asio as the concurrency budget
// budget allows. Submitting under the lock is safe and is what keeps `executor` from // allows. Posting under the lock is safe and is what keeps `pool` from being moved
// being moved out by a concurrent StopAndDrain between the decision and the dispatch: // out by a concurrent StopAndDrain between the decision and the dispatch: asio::post
// Submit only enqueues, it never runs the callable on the calling thread, so it cannot // only enqueues, it never runs the handler on the calling thread, so it cannot
// re-enter this mutex. // re-enter this mutex.
// //
// A node the engine fails to accept is appended to `toCancel` instead of being // A node asio::post fails to hand off is appended to `toCancel` instead of being
// Cancel()'d here: Cancel() runs the node's OnTerminal continuations inline (stage 4 // 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 // added ProgramLinkTask::OnDepSettled as a real one), and a continuation is free to
// call ShaderCompilePool::Post() again. Every caller of DispatchLocked holds `mutex` // call ShaderCompilePool::Post() again. Every caller of DispatchLocked holds `mutex`
// (a plain, non-recursive std::mutex) - Cancel()'ing in here would let that // (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 // re-entrant Post() deadlock on the very lock this frame already owns. The caller
// drains `toCancel` after releasing the lock. // 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) { void DispatchLocked(Vector<SharedPtr<JobNode>>& toCancel) {
while (!queue.empty() && inFlight < maxConcurrency && !stopped.load(std::memory_order_acquire)) { while (!queue.empty() && inFlight < maxConcurrency && !stopped.load(std::memory_order_acquire)) {
// Copy rather than move into the callable: if Submit throws (both engines // Copy rather than move into the handler: if asio::post throws (it allocates)
// allocate) the local SharedPtr is still valid, so the node can be settled // the local SharedPtr is still valid, so the node can be settled instead of
// instead of being stranded Pending in a queue nothing will dispatch from // being stranded Pending in a queue nothing will dispatch from again - a
// again - a joiner would block on it forever. Reclaiming the slot matters just // joiner would block on it forever. Reclaiming the slot matters just as much:
// as much: a leaked `inFlight` shrinks the pool's concurrency budget // a leaked `inFlight` shrinks the pool's concurrency budget permanently.
// permanently.
SharedPtr<JobNode> node = queue.front(); SharedPtr<JobNode> node = queue.front();
queue.pop_front(); queue.pop_front();
++inFlight; ++inFlight;
try { try {
executor->Submit([this, node]() mutable { RunOnWorker(Move(node)); }); asio::post(*pool, [this, node]() mutable { RunOnWorker(Move(node)); });
} catch (...) { } catch (...) {
--inFlight; --inFlight;
toCancel.push_back(Move(node)); toCancel.push_back(Move(node));
@@ -650,7 +193,7 @@ namespace MobileGL::MG_Util::Async {
void RunOnWorker(SharedPtr<JobNode> node) { void RunOnWorker(SharedPtr<JobNode> node) {
tl_isPoolThread = true; tl_isPoolThread = true;
// A node that was already handed to the engine when StopAndDrain ran still arrives // A node that was already handed to Asio when StopAndDrain ran still arrives
// here; cancelling it first turns the dispatch into a state transition instead of // 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 // 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 // before `mutex` is ever taken in this frame, so it is not subject to the
@@ -700,15 +243,13 @@ namespace MobileGL::MG_Util::Async {
return m_impl->maxConcurrency; return m_impl->maxConcurrency;
} }
AsyncPoolEngine ShaderCompilePool::GetEngine() const { return m_impl->engine; }
void ShaderCompilePool::SetMaxConcurrency(const Uint n) { void ShaderCompilePool::SetMaxConcurrency(const Uint n) {
Vector<SharedPtr<JobNode>> toCancel; Vector<SharedPtr<JobNode>> toCancel;
{ {
const std::lock_guard<std::mutex> lock(m_impl->mutex); const std::lock_guard<std::mutex> lock(m_impl->mutex);
m_impl->maxConcurrency = std::clamp(n, 1u, m_impl->threadCount); m_impl->maxConcurrency = std::clamp(n, 1u, m_impl->threadCount);
// Raising the budget releases whatever the old one was holding back. // Raising the budget releases whatever the old one was holding back.
if (m_impl->executor) m_impl->DispatchLocked(toCancel); if (m_impl->pool) m_impl->DispatchLocked(toCancel);
} }
// Outside the lock: see DispatchLocked's comment. // Outside the lock: see DispatchLocked's comment.
for (const auto& n2 : toCancel) { for (const auto& n2 : toCancel) {
@@ -720,23 +261,23 @@ namespace MobileGL::MG_Util::Async {
if (!node) return; if (!node) return;
EnsureProcessTeardownSentinel(); EnsureProcessTeardownSentinel();
// Enqueueing can throw: building the engine and submitting to it both allocate (and // Enqueueing can throw: the thread_pool construction and asio::post both allocate,
// both spawn threads), and under memory pressure a throw here would escape // and under memory pressure a throw here would escape glCompileShader leaving the
// glCompileShader leaving the node Pending with nothing left to dispatch it - the // node Pending with nothing left to dispatch it - the first observable read would
// first observable read would then block the GL thread forever. Settle the node // then block the GL thread forever. Settle the node instead: a cancelled node is a
// instead: a cancelled node is a state every joiner already handles. // state every joiner already handles.
// //
// `node` is still valid in the catch for every throw this try can produce. The engine // `node` is still valid in the catch for every throw this try can produce. The
// construction runs before the move; deque::push_back is strongly exception-safe and // thread_pool construction runs before the move; deque::push_back is strongly
// SharedPtr's move constructor is noexcept, so a throwing push_back never consumed it; // exception-safe and SharedPtr's move constructor is noexcept, so a throwing
// and DispatchLocked contains its own Submit failures rather than propagating them // push_back never consumed it; and DispatchLocked contains its own asio::post
// (see above). Keep it that way. // failures rather than propagating them (see above). Keep it that way.
Bool enqueued = false; Bool enqueued = false;
Vector<SharedPtr<JobNode>> toCancel; Vector<SharedPtr<JobNode>> toCancel;
try { try {
const std::lock_guard<std::mutex> lock(m_impl->mutex); const std::lock_guard<std::mutex> lock(m_impl->mutex);
if (!m_impl->stopped.load(std::memory_order_acquire) && !InProcessTeardown()) { if (!m_impl->stopped.load(std::memory_order_acquire) && !InProcessTeardown()) {
if (!m_impl->executor) m_impl->executor = MakeJobExecutor(m_impl->engine, m_impl->threadCount); if (!m_impl->pool) m_impl->pool = MakeUnique<asio::thread_pool>(m_impl->threadCount);
m_impl->queue.push_back(Move(node)); m_impl->queue.push_back(Move(node));
m_impl->DispatchLocked(toCancel); m_impl->DispatchLocked(toCancel);
enqueued = true; enqueued = true;
@@ -772,17 +313,17 @@ namespace MobileGL::MG_Util::Async {
} }
void ShaderCompilePool::StopAndDrain() { void ShaderCompilePool::StopAndDrain() {
// Waiting for the workers from a worker would deadlock on itself (asio's join() says // asio::thread_pool::join() from a pool thread would deadlock on itself, and the
// so outright), and the whole point of this call is that the GL thread waits. // whole point of this call is that the GL thread waits for the workers.
MOBILEGL_ASSERT(!IsPoolThread(), "ShaderCompilePool::StopAndDrain() called from a pool thread"); MOBILEGL_ASSERT(!IsPoolThread(), "ShaderCompilePool::StopAndDrain() called from a pool thread");
std::deque<SharedPtr<JobNode>> abandoned; std::deque<SharedPtr<JobNode>> abandoned;
UniquePtr<JobExecutor> executor; UniquePtr<asio::thread_pool> pool;
{ {
const std::lock_guard<std::mutex> lock(m_impl->mutex); const std::lock_guard<std::mutex> lock(m_impl->mutex);
m_impl->stopped.store(true, std::memory_order_release); m_impl->stopped.store(true, std::memory_order_release);
abandoned.swap(m_impl->queue); abandoned.swap(m_impl->queue);
executor = Move(m_impl->executor); pool = Move(m_impl->pool);
} }
// Queued but never dispatched: settle them so anything chained behind them is // Queued but never dispatched: settle them so anything chained behind them is
@@ -791,9 +332,9 @@ namespace MobileGL::MG_Util::Async {
if (node) node->Cancel(); if (node) node->Cancel();
} }
if (executor) { if (pool) {
executor->JoinAll(); // returns once every job already handed to the engine is done pool->join(); // returns once every handler already handed to Asio has finished
executor.reset(); // and this stops the engine's threads pool.reset();
} }
const std::lock_guard<std::mutex> lock(m_impl->mutex); const std::lock_guard<std::mutex> lock(m_impl->mutex);
+20 -36
View File
@@ -11,12 +11,11 @@
#include <MG_Util/Types.h> #include <MG_Util/Types.h>
#include <MG_Util/Async/JobNode.h> #include <MG_Util/Async/JobNode.h>
// This header deliberately includes NO Asio and NO libfork header: both execution engines // This header deliberately includes NO Asio header: asio::thread_pool lives behind the pimpl
// live behind the pimpl in ShaderCompilePool.cpp. They stay private implementation details of // in ShaderCompilePool.cpp. Asio stays a private implementation detail of one translation
// one translation unit, so no consumer target (MG_Test, MG_IntegrationTest, MG_Benchmark - // unit, so no consumer target (MG_Test, MG_IntegrationTest, MG_Benchmark - each with its own
// each with its own target_include_directories) needs either include path, and no consumer // target_include_directories) needs the Asio include path, and no consumer pays its compile
// pays their compile time. libfork in particular is a C++20-coroutine header set whose // time. Do not add one here.
// instantiation cost nothing outside the pool has any reason to carry. Do not add one here.
namespace MobileGL::MG_Util::Async { namespace MobileGL::MG_Util::Async {
// Stage 7: on by default. The gate behind the flip (2026-08-09, headless Mesa, both // Stage 7: on by default. The gate behind the flip (2026-08-09, headless Mesa, both
@@ -60,6 +59,21 @@ namespace MobileGL::MG_Util::Async {
// GL_COMPLETION_STATUS_KHR read immediately GL_TRUE. // GL_COMPLETION_STATUS_KHR read immediately GL_TRUE.
Bool AsyncShaderCompileActive(); 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 // 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] // 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 // because peak RSS scales as workers x largest glslang arena, and four
@@ -67,31 +81,6 @@ namespace MobileGL::MG_Util::Async {
// MOBILEGL_ASYNC_SHADER_COMPILE_THREADS overrides it outright. // MOBILEGL_ASYNC_SHADER_COMPILE_THREADS overrides it outright.
Uint DetectShaderCompileThreadCount(); 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 { class ShaderCompilePool {
public: public:
explicit ShaderCompilePool(Uint threadCount); explicit ShaderCompilePool(Uint threadCount);
@@ -123,11 +112,6 @@ namespace MobileGL::MG_Util::Async {
Uint GetThreadCount() const; Uint GetThreadCount() const;
Uint GetMaxConcurrency() 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 // Bounded concurrency doubles as the memory bound, and is how
// glMaxShaderCompilerThreadsKHR(n) is honoured: a 300-program pack load cannot put // glMaxShaderCompilerThreadsKHR(n) is honoured: a 300-program pack load cannot put
// 300 glslang arenas in flight at once. Clamped to [1, thread count]. // 300 glslang arenas in flight at once. Clamped to [1, thread count].
+4 -12
View File
@@ -151,20 +151,12 @@ namespace MobileGL::MG_Util::SelfTest {
return; return;
} }
const Uint threads = MG_Util::Async::DetectShaderCompileThreadCount(); 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, builder.Pass(rowName,
format("on with {} compiler thread{} on the {} execution engine; " format("on with {} compiler thread{}; GL_KHR_parallel_shader_compile is advertised "
"GL_KHR_parallel_shader_compile is advertised "
"and GL_MAX_SHADER_COMPILER_THREADS_KHR = {} (set environment variable " "and GL_MAX_SHADER_COMPILER_THREADS_KHR = {} (set environment variable "
"MOBILEGL_ASYNC_SHADER_COMPILE=0 to disable it, " "MOBILEGL_ASYNC_SHADER_COMPILE=0 to disable it, or "
"MOBILEGL_ASYNC_SHADER_COMPILE_THREADS=n to change the count, or " "MOBILEGL_ASYNC_SHADER_COMPILE_THREADS=n to change the count)",
"MOBILEGL_ASYNC_POOL=asio|libfork to change the engine)", threads, threads == 1 ? "" : "s", threads));
threads, threads == 1 ? "" : "s", engineName, threads));
} }
// Appends the four "MobileGL reported ..." rows for one backend section. // Appends the four "MobileGL reported ..." rows for one backend section.
@@ -105,8 +105,46 @@ namespace MobileGL {
} }
int TMglGlslIoResolver::resolveInOutLocation(EShLanguage stage, glslang::TVarEntryInfo& ent) { int TMglGlslIoResolver::resolveInOutLocation(EShLanguage stage, glslang::TVarEntryInfo& ent) {
if (!ent.live && stage == EShLangVertex && ent.symbol->getType().getQualifier().isPipeInput()) { // NO dead-vertex-input early-out here, deliberately - the skip belongs in
return ent.newLocation = -1; // 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;
}
} }
return TDefaultGlslIoResolver::resolveInOutLocation(stage, ent); return TDefaultGlslIoResolver::resolveInOutLocation(stage, ent);
} }
@@ -51,5 +51,13 @@ namespace MobileGL {
std::map<glslang::TString, int> m_plainUniformLocationSizeByName; std::map<glslang::TString, int> m_plainUniformLocationSizeByName;
std::map<glslang::TString, int> m_plainUniformLocationByName; std::map<glslang::TString, int> m_plainUniformLocationByName;
bool m_plainUniformLocationsAssigned = false; 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 } // namespace MobileGL