[Feat] (MG_Util): the async-compile pool skeleton behind a default-off flag (P1 stage 1)

Standalone Asio (submodule, asio-1-38-2 @ 8806a680, ASIO_STANDALONE +
ASIO_NO_DEPRECATED, header-only - no linked artifact) and the job machinery
the async shader pipeline will run on: JobNode (state machine with deferred
errors, continuations firing exactly once, dependency counters, cancel
semantics split into request vs outcome) and ShaderCompilePool
(asio::thread_pool behind a pimpl so no header leaks asio; big-core count
via cpufreq at >=85% of peak clamped to [1,4]; lazily constructed, so with
the flag off no worker thread ever exists; StopAndDrain leads DestroyImpl).

MOBILEGL_ASYNC_SHADER_COMPILE / _THREADS config knobs, default OFF. Nothing
in the GL pipeline references the pool yet - grep-verified; the full
DirectGLES retrace and compile benches are byte- and time-identical. 25
threaded unit tests, clean across 20x gtest_repeat.
This commit is contained in:
BZLZHH
2026-08-08 05:28:51 -04:00
parent d6caed7822
commit 8191075133
13 changed files with 1132 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
// MobileGL - MobileGL/MG_Util/Async/JobNode.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 "JobNode.h"
#include "ShaderCompilePool.h"
namespace MobileGL::MG_Util::Async {
namespace {
Bool IsTerminalState(const JobState state) {
return state == JobState::Complete || state == JobState::Cancelled;
}
} // namespace
Bool JobNode::IsTerminal() const { return IsTerminalState(m_state.load(std::memory_order_acquire)); }
Bool JobNode::IsComplete() const { return m_state.load(std::memory_order_acquire) == JobState::Complete; }
Bool JobNode::IsCancelled() const { return m_state.load(std::memory_order_acquire) == JobState::Cancelled; }
Bool JobNode::IsCancellationRequested() const { return m_cancelled.load(std::memory_order_acquire); }
JobState JobNode::State() const { return m_state.load(std::memory_order_acquire); }
// The single place a node changes state. Keeping every transition here is what makes the
// continuation list exactly-once: the same critical section that publishes the terminal
// state also takes ownership of the callbacks, so a concurrent OnTerminal either lands in
// the list before the swap or sees the terminal state and runs inline - never neither and
// never both.
Bool JobNode::TryTransition(const JobState from, const JobState to) {
const Bool terminal = IsTerminalState(to);
Vector<std::function<void()>> continuations;
{
const std::lock_guard<std::mutex> lock(m_mutex);
if (m_state.load(std::memory_order_relaxed) != from) return false;
m_state.store(to, std::memory_order_release);
if (terminal) continuations.swap(m_continuations);
}
if (!terminal) return true;
m_cv.notify_all();
// Run continuations OUTSIDE the lock: a continuation is free to call back into this
// node (IsComplete, State) and, in the link-dependency case, to post the dependent
// job to the pool from whichever thread drove this node terminal.
for (auto& continuation : continuations) {
if (continuation) continuation();
}
return true;
}
void JobNode::Run() {
if (m_cancelled.load(std::memory_order_acquire)) {
TryTransition(JobState::Pending, JobState::Cancelled);
return;
}
// Loses to a concurrent Cancel() that already took the node terminal, and to a second
// dispatch of the same node. Either way there is nothing left to do.
if (!TryTransition(JobState::Pending, JobState::Running)) return;
try {
RunBody();
} catch (const std::exception& e) {
// Asio propagates an exception escaping a handler out of thread_pool::run(),
// which means std::terminate for the whole process. Every job boundary contains
// it and reports the job as Cancelled; the joining GL thread then sees a node
// that produced no result, which is the same shape as an abandoned node.
diagnostics.logLines.push_back(std::format("Job body threw: {}", e.what()));
TryTransition(JobState::Running, JobState::Cancelled);
return;
} catch (...) {
diagnostics.logLines.emplace_back("Job body threw a non-std exception");
TryTransition(JobState::Running, JobState::Cancelled);
return;
}
// Debug-only tripwire for the design's section 6 invariant: a compile or link body
// must not need to raise a GL error. Anything that does belongs in the GL-thread
// prologue of CompileShader_State / LinkProgram_State, next to the active-XFB relink
// rejection that already works that way.
MOBILEGL_ASSERT(diagnostics.errors.empty(),
"JobNode: a job body recorded %zu deferred GL error(s); compile and link bodies must not "
"raise GL errors (see the P1 design, section 6)",
diagnostics.errors.size());
TryTransition(JobState::Running,
m_cancelled.load(std::memory_order_acquire) ? JobState::Cancelled : JobState::Complete);
}
void JobNode::RunInline() { Run(); }
void JobNode::Wait() {
// Invariant I4, mechanically enforced: no job body ever blocks on another job, so the
// pool can never deadlock with all its workers waiting on each other.
MOBILEGL_ASSERT(!ShaderCompilePool::IsPoolThread(),
"JobNode::Wait() called from a pool thread; job dependencies must be resolved by posting "
"late (SubmitAfter), never by waiting from inside a body");
std::unique_lock<std::mutex> lock(m_mutex);
m_cv.wait(lock, [this] { return IsTerminalState(m_state.load(std::memory_order_relaxed)); });
}
void JobNode::Cancel() {
m_cancelled.store(true, std::memory_order_release);
// A node that never reached a worker settles right here. Doing this rather than
// waiting for a dispatch that may never come is what lets every cancel site
// (glShaderSource over a pending compile, glDeleteProgram, teardown) drop the node
// without a wait and without stranding a dependent link job behind it.
TryTransition(JobState::Pending, JobState::Cancelled);
}
void JobNode::OnTerminal(std::function<void()> fn) {
if (!fn) return;
{
const std::lock_guard<std::mutex> lock(m_mutex);
if (!IsTerminalState(m_state.load(std::memory_order_relaxed))) {
m_continuations.push_back(Move(fn));
return;
}
}
fn();
}
} // namespace MobileGL::MG_Util::Async
+116
View File
@@ -0,0 +1,116 @@
// MobileGL - MobileGL/MG_Util/Async/JobNode.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
#include <MG_Util/Types.h>
#include <MG_State/GLState/ErrorState/ErrorCode.h>
#include <MG_State/GLState/ErrorState/ErrorInfo.h>
#include <condition_variable>
namespace MobileGL::MG_Util::Async {
enum class JobState : Uint8 {
Pending, // constructed, not started; may still be sitting in a queue
Running, // a worker is inside RunBody()
Complete, // RunBody() returned normally and the node's outputs are readable
Cancelled, // abandoned before it started, cancelled mid-run, or threw
};
// A GL error a job body wants to raise. Nothing in the compile/link pipeline produces
// one today (see the design's section 6: GL defines compile/link *failure* as
// COMPILE_STATUS/LINK_STATUS plus an info log, not as a GL error, which is exactly why
// asynchronous compilation is legal at all), and JobNode::Finish asserts the vector is
// still empty in debug builds. The mechanism exists so that the day a body genuinely
// needs to raise one, the fix is to append here and let the join replay it on the GL
// thread - not to reach for pGLContext->RecordError() from a worker.
struct DeferredError {
Uint64 sequence = 0; // job-global monotonic counter, assigned at record time
ErrorCode code = ErrorCode::NoError;
UniquePtr<ErrorInfo> info;
};
struct JobDiagnostics {
Vector<DeferredError> errors; // replayed, in ascending `sequence`, by the join
Vector<String> logLines; // worker-side MGLOG text, flushed in order by the join
};
// The scheduling primitive every asynchronous compile and link is built on. A node owns
// its inputs and its outputs; a worker reads only the former and writes only the latter,
// which is what makes the "no worker touches GL-thread state" invariant structural
// rather than review-enforced.
//
// State machine, and the only legal transitions:
// Pending -> Running (a worker picked the node up)
// Pending -> Cancelled (cancelled before any worker started it)
// Running -> Complete (RunBody() returned normally)
// Running -> Cancelled (cancelled mid-run, or RunBody() threw)
// Complete and Cancelled are terminal and the node is immutable afterwards, so every
// reader that observed IsTerminal() may read the outputs without further synchronization.
class JobNode {
public:
JobNode() = default;
virtual ~JobNode() = default;
JobNode(const JobNode&) = delete;
JobNode& operator=(const JobNode&) = delete;
// Lock-free and non-blocking - safe from any thread, including a pool thread.
Bool IsTerminal() const;
Bool IsComplete() const; // Complete only; this is what backs GL_COMPLETION_STATUS_KHR
Bool IsCancelled() const; // settled AS cancelled - the outcome, not the request
JobState State() const;
// The cancellation *request*, which is what a body polls to bail out early: a
// running job stays Running until its body returns, so IsCancelled() is still false
// at that point. Kept separate from IsCancelled() precisely so the two questions
// ("should I stop?" and "did it end up cancelled?") cannot be confused.
Bool IsCancellationRequested() const;
// Blocks until the node is terminal. GL thread only: a job body that waited on
// another job could deadlock the whole pool, so this asserts it is not called from a
// pool thread. Dependencies are resolved by posting late (see ProgramLinkTask::
// SubmitAfter), never by waiting from inside a body.
void Wait();
// Cooperative and non-blocking. A node that has not started yet goes terminal
// immediately, so anything waiting on it or chained behind it is released rather
// than stranded; a running node is flagged and settles as Cancelled when its body
// returns. Because every node owns its inputs and writes only into itself, an
// abandoned node is always safe to simply drop - the caller never waits.
void Cancel();
// Runs `fn` once, when this node goes terminal. If the node is ALREADY terminal,
// `fn` runs on the calling thread before OnTerminal returns. Exactly-once in both
// directions: the callback is either handed to the finishing thread or run inline,
// never both.
void OnTerminal(std::function<void()> fn);
// Runs the body on the calling thread. The synchronous path (async disabled,
// context-less internal shaders, a pool that has been stopped) goes through here, so
// that "inline" and "on a worker" differ only in which thread executes RunBody().
void RunInline();
JobDiagnostics diagnostics;
protected:
virtual void RunBody() = 0;
private:
friend class ShaderCompilePool;
// Pool entry point: cancel check -> RunBody() (exceptions contained) -> Finish().
void Run();
Bool TryTransition(JobState from, JobState to);
mutable std::mutex m_mutex;
std::condition_variable m_cv;
std::atomic<JobState> m_state{JobState::Pending};
std::atomic<Bool> m_cancelled{false};
Vector<std::function<void()>> m_continuations;
};
} // namespace MobileGL::MG_Util::Async
@@ -0,0 +1,227 @@
// MobileGL - MobileGL/MG_Util/Async/ShaderCompilePool.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 "ShaderCompilePool.h"
#include <Config.h>
#include <asio/post.hpp>
#include <asio/thread_pool.hpp>
#include <cstdio>
#include <deque>
namespace MobileGL::MG_Util::Async {
namespace {
// The memory ceiling, not a throughput guess: peak RSS during a pack load scales as
// (workers x largest glslang arena), and a shaderpack stage arena is large enough
// that four concurrent ones is already as much as a phone should be asked for.
constexpr Uint kMaxAutoShaderCompileThreads = 4;
// A core counts as "big" if its cpufreq ceiling is within 15% of the fastest core's.
// On a symmetric desktop that is every core; on a big.LITTLE phone it selects the
// cluster the GL thread itself runs on.
constexpr Uint64 kBigCoreFrequencyPercent = 85;
thread_local Bool tl_isPoolThread = false;
// Mirrors DirectGLES's InProcessTeardown()/EnsureProcessTeardownSentinel(): once the
// process has entered exit(), starting a worker thread is unsafe (cross-translation
// -unit static destruction order is unspecified, and glslang's process globals may
// already be gone). The flag is latched by an atexit handler registered lazily on
// first pool use, so it is guaranteed to run before any static destructor.
Bool g_processTeardown = false;
std::once_flag g_teardownSentinelOnce;
Bool InProcessTeardown() { return g_processTeardown; }
void EnsureProcessTeardownSentinel() {
std::call_once(g_teardownSentinelOnce, [] { std::atexit(+[] { g_processTeardown = true; }); });
}
Uint64 ReadCpuMaxFrequencyKHz(const Uint cpu) {
const String path =
std::format("/sys/devices/system/cpu/cpu{}/cpufreq/cpuinfo_max_freq", cpu);
std::FILE* file = std::fopen(path.c_str(), "r");
if (file == nullptr) return 0;
unsigned long long value = 0;
const int scanned = std::fscanf(file, "%llu", &value);
std::fclose(file);
return scanned == 1 ? static_cast<Uint64>(value) : 0;
}
Uint DetectBigCoreCount() {
const Uint cpuCount = std::max(1u, std::thread::hardware_concurrency());
Vector<Uint64> frequencies;
frequencies.reserve(cpuCount);
for (Uint cpu = 0; cpu < cpuCount; ++cpu) {
const Uint64 frequency = ReadCpuMaxFrequencyKHz(cpu);
if (frequency == 0) break;
frequencies.push_back(frequency);
}
// Windows, macOS, and containers that hide the cpufreq tree land here, as does a
// partially readable tree: with no asymmetry information the honest answer is
// "every core is a big core", and the [1, 4] clamp bounds it anyway.
if (frequencies.size() != cpuCount) return cpuCount;
const Uint64 peak = *std::max_element(frequencies.begin(), frequencies.end());
const Uint64 threshold = peak * kBigCoreFrequencyPercent / 100;
Uint bigCores = 0;
for (const Uint64 frequency : frequencies) {
if (frequency >= threshold) ++bigCores;
}
return bigCores > 0 ? bigCores : cpuCount;
}
} // namespace
Bool AsyncShaderCompileEnabled() {
switch (MG_Config::Features.AsyncShaderCompile) {
case MG_Config::QuirkOverride::ForceOn: return true;
case MG_Config::QuirkOverride::ForceOff: return false;
case MG_Config::QuirkOverride::Auto: break;
}
return kAsyncShaderCompileDefault;
}
Uint DetectShaderCompileThreadCount() {
if (const Uint32 configured = MG_Config::Features.AsyncShaderCompileThreads; configured > 0) {
// An explicit request is honoured as given - it is the escape hatch for measuring
// scaling and for working around a device - so it is not squeezed into [1, 4].
return configured;
}
return std::clamp(DetectBigCoreCount(), 1u, kMaxAutoShaderCompileThreads);
}
struct ShaderCompilePool::Impl {
explicit Impl(const Uint threads) : threadCount(std::max(1u, threads)), maxConcurrency(threadCount) {}
const Uint threadCount;
std::mutex mutex;
// Created on the first dispatched Post, never in the constructor: asio::thread_pool
// spawns its threads eagerly, and a build with async off must not pay for threads it
// will never use.
UniquePtr<asio::thread_pool> pool;
std::deque<SharedPtr<JobNode>> queue;
Uint inFlight = 0;
Uint maxConcurrency;
std::atomic<Bool> stopped{false};
// Callers hold `mutex`. Hands as many queued nodes to Asio as the concurrency budget
// allows. Posting under the lock is safe and is what keeps `pool` from being moved
// out by a concurrent StopAndDrain between the decision and the dispatch: asio::post
// only enqueues, it never runs the handler on the calling thread, so it cannot
// re-enter this mutex.
void DispatchLocked() {
while (!queue.empty() && inFlight < maxConcurrency && !stopped.load(std::memory_order_acquire)) {
SharedPtr<JobNode> node = Move(queue.front());
queue.pop_front();
++inFlight;
asio::post(*pool, [this, node = Move(node)]() mutable { RunOnWorker(Move(node)); });
}
}
void RunOnWorker(SharedPtr<JobNode> node) {
tl_isPoolThread = true;
// 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
// a full compile, so the drain's join() returns promptly.
if (stopped.load(std::memory_order_acquire)) node->Cancel();
node->Run();
node.reset();
const std::lock_guard<std::mutex> lock(mutex);
--inFlight;
DispatchLocked();
}
};
ShaderCompilePool::ShaderCompilePool(const Uint threadCount) : m_impl(MakeUnique<Impl>(threadCount)) {}
ShaderCompilePool::~ShaderCompilePool() { StopAndDrain(); }
ShaderCompilePool& ShaderCompilePool::Get() {
// Leak-at-exit, like the other MobileGL singletons: a process that exits without
// eglTerminate hands the threads to the OS rather than joining them from a static
// destructor, where the rest of the library may already be gone.
static ShaderCompilePool* pool = new ShaderCompilePool(DetectShaderCompileThreadCount());
return *pool;
}
Bool ShaderCompilePool::IsPoolThread() { return tl_isPoolThread; }
Uint ShaderCompilePool::GetThreadCount() const { return m_impl->threadCount; }
Uint ShaderCompilePool::GetMaxConcurrency() const {
const std::lock_guard<std::mutex> lock(m_impl->mutex);
return m_impl->maxConcurrency;
}
void ShaderCompilePool::SetMaxConcurrency(const Uint n) {
const std::lock_guard<std::mutex> lock(m_impl->mutex);
m_impl->maxConcurrency = std::clamp(n, 1u, m_impl->threadCount);
// Raising the budget releases whatever the old one was holding back.
if (m_impl->pool) m_impl->DispatchLocked();
}
void ShaderCompilePool::Post(SharedPtr<JobNode> node) {
if (!node) return;
EnsureProcessTeardownSentinel();
{
const std::lock_guard<std::mutex> lock(m_impl->mutex);
if (!m_impl->stopped.load(std::memory_order_acquire) && !InProcessTeardown()) {
if (!m_impl->pool) m_impl->pool = MakeUnique<asio::thread_pool>(m_impl->threadCount);
m_impl->queue.push_back(Move(node));
m_impl->DispatchLocked();
return;
}
}
// A stopped pool is a synchronous pool, not a black hole: the node still runs, just
// on the caller's thread. Everything downstream already handles "terminal by the time
// Post returns", because that is exactly what the inline path looks like. Run it
// outside the lock - a body, or a continuation it releases, is free to Post again.
node->RunInline();
}
void ShaderCompilePool::StopAndDrain() {
// asio::thread_pool::join() from a pool thread would deadlock on itself, and the
// whole point of this call is that the GL thread waits for the workers.
MOBILEGL_ASSERT(!IsPoolThread(), "ShaderCompilePool::StopAndDrain() called from a pool thread");
std::deque<SharedPtr<JobNode>> abandoned;
UniquePtr<asio::thread_pool> pool;
{
const std::lock_guard<std::mutex> lock(m_impl->mutex);
m_impl->stopped.store(true, std::memory_order_release);
abandoned.swap(m_impl->queue);
pool = Move(m_impl->pool);
}
// Queued but never dispatched: settle them so anything chained behind them is
// released rather than waiting for a worker that will never pick them up.
for (const auto& node : abandoned) {
if (node) node->Cancel();
}
if (pool) {
pool->join(); // returns once every handler already handed to Asio has finished
pool.reset();
}
const std::lock_guard<std::mutex> lock(m_impl->mutex);
m_impl->inFlight = 0;
// The pool stays stopped, so ShaderCompilePool::Get() keeps returning a stopped,
// synchronous pool for the rest of the process. That is deliberate for the teardown
// path this exists to serve; if a future stage wants eglTerminate followed by a fresh
// eglInitialize to get its worker threads back, the re-arm belongs in
// MobileGL::Initialize(), next to glslang::InitializeProcess().
}
} // namespace MobileGL::MG_Util::Async
@@ -0,0 +1,82 @@
// MobileGL - MobileGL/MG_Util/Async/ShaderCompilePool.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
#include <MG_Util/Types.h>
#include <MG_Util/Async/JobNode.h>
// This header deliberately includes NO Asio header: asio::thread_pool lives behind the pimpl
// in ShaderCompilePool.cpp. Asio stays a private implementation detail of one translation
// unit, so no consumer target (MG_Test, MG_IntegrationTest, MG_Benchmark - each with its own
// target_include_directories) needs the Asio include path, and no consumer pays its compile
// time. Do not add one here.
namespace MobileGL::MG_Util::Async {
// Stage 1 ships the whole machinery switched off: the pool is constructible and tested,
// but nothing in the GL pipeline posts to it. The flip to true happens only after the
// real-client soak in the final stage, because the riskiest part of asynchronous
// compilation is not the joins - it is that Iris and Sodium change their submission
// schedule the moment GL_KHR_parallel_shader_compile is advertised, and a recorded trace
// can never cover that path.
inline constexpr Bool kAsyncShaderCompileDefault = false;
// MOBILEGL_ASYNC_SHADER_COMPILE forces the answer either way; unset keeps the built-in
// default above. Falsy is a complete kill switch: it reverts the threading *and* (from
// the extension stage on) withdraws GL_KHR_parallel_shader_compile, so the application
// behaviour change goes with it.
Bool AsyncShaderCompileEnabled();
// min(4, big cores), where a big core is one whose cpufreq ceiling is within 15% of the
// machine maximum; the whole CPU count where that sysfs tree is absent. Clamped to [1, 4]
// because peak RSS scales as workers x largest glslang arena, and four
// Complementary-sized arenas is already the memory ceiling worth accepting on a phone.
// MOBILEGL_ASYNC_SHADER_COMPILE_THREADS overrides it outright.
Uint DetectShaderCompileThreadCount();
class ShaderCompilePool {
public:
explicit ShaderCompilePool(Uint threadCount);
~ShaderCompilePool();
ShaderCompilePool(const ShaderCompilePool&) = delete;
ShaderCompilePool& operator=(const ShaderCompilePool&) = delete;
// Process-wide pool, leak-at-exit like pGLContext. Sized by
// DetectShaderCompileThreadCount() on first use; no thread is created until the first
// Post, so a build that never enables async never starts one.
static ShaderCompilePool& Get();
// True only on a thread owned by some ShaderCompilePool. Backs the two asserts that
// hold the design's invariants up: no GL/EGL reach-back from a worker, and no job
// body waiting on another job.
static Bool IsPoolThread();
// Dispatches the node, or queues it behind the concurrency budget. A stopped pool -
// and one whose process is exiting - runs the node inline on the calling thread
// instead, so a late entry point can never resurrect worker threads.
void Post(SharedPtr<JobNode> node);
// Cancels everything still queued and joins everything already running. This is the
// one cancellation path that waits, and it must run before glslang::FinalizeProcess()
// and before pGLContext is destroyed: in-flight jobs hold their own inputs safely,
// but they share glslang's process globals, which teardown is about to free.
void StopAndDrain();
Uint GetThreadCount() const;
Uint GetMaxConcurrency() const;
// Bounded concurrency doubles as the memory bound, and is how
// glMaxShaderCompilerThreadsKHR(n) is honoured: a 300-program pack load cannot put
// 300 glslang arenas in flight at once. Clamped to [1, thread count].
void SetMaxConcurrency(Uint n);
private:
struct Impl;
UniquePtr<Impl> m_impl;
};
} // namespace MobileGL::MG_Util::Async