mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-11 21:58:31 +09:00
[Feat] (MG_State, MG_Util): async program linking on the job graph (P1 stage 4)
glLinkProgram with the flag on snapshots its inputs in a GL-thread prologue (stage-sorted shaders with their compile nodes taken without joining, env, explicit locations/fragdata/xfb, draw-buffer count), then runs the whole link body - glslang link/mapIO, SPIR-V, reflection, routing tables - as a ProgramLinkTask that auto-posts when its last compile dependency settles (+1-guarded countdown; no worker ever waits on another job). The publish is one move of the LinkArtifacts block at the join, with the second version bump so nothing memoized during the pending window survives. The consume-once TShader claim moved onto the shared compile node as a CAS: two link jobs racing for one shader resolve to winner-takes-the-parse, loser re-parses the preprocessed source against the node's own env - identical SPIR-V pinned by test for 2 and for 12 sharing programs. Two deliberate corrections to the design's cancel matrix, both test-proven: attach/detach do NOT cancel a pending link (the snapshot isolates it, and glCreateShaderProgramv's link-then-detach would otherwise discard its own result before anyone read it); and a compile node a pending link depends on is pinned against the orphan-name sweep - the ordinary LWJGL teardown compile/attach/link/detach/delete used to cancel the dependency and turn a must-pass link into GL_FALSE. Continuations are now throw-contained per-item (a stage-3 leftover made load-bearing by the first real continuation), and the review's deadlock find is fixed: the dispatch loop no longer cancels a node while holding the pool mutex, since that cancel can run OnDepSettled -> Post -> same mutex. Explicit joins: the draw path (GetProgramForDraw, both the pipeline stage loop and the plain-UseProgram half) and the composite-link site; destroy paths cancel-not-join; COMPLETION_STATUS readers stay non-joining. Gates: 506/506 unit both flag states; AsyncCompile/AsyncLink/AsyncTeardown suites x10 repeats clean both states (teardown with 128 jobs in flight, then re-Initialize); full NVIDIA DirectGLES retrace flag on twice - result sets identical to flag off, zero new deltas. Compile-phase prefix-diff, flag on vs off: complementary-reimagined 5.21s -> 2.16s, BSL 1.72s -> 0.90s - past the design's final acceptance targets before the KHR extension is even advertised. Default remains OFF until stage 5+7.
This commit is contained in:
@@ -16,6 +16,30 @@ namespace MobileGL::MG_Util::Async {
|
||||
Bool IsTerminalState(const JobState state) {
|
||||
return state == JobState::Complete || state == JobState::Cancelled;
|
||||
}
|
||||
|
||||
// Job BODIES have been contained since stage 1 (JobNode::Run); continuations were
|
||||
// not, and stage 4 introduces the first real ones. A continuation runs on whichever
|
||||
// thread drove the node terminal - for a compile that finished on a worker, that is
|
||||
// inside an Asio handler, where an escaping exception means thread_pool::run()
|
||||
// rethrows and the process terminates. It would also skip every continuation after
|
||||
// it in the list, stranding unrelated dependents.
|
||||
//
|
||||
// Containing it here is a backstop, not the contract: a continuation cannot be
|
||||
// repaired from the outside (the dispatcher has no idea what the callback was for),
|
||||
// so the registrar still owns "this cannot fail". See JobNode::OnTerminal.
|
||||
void RunContinuation(const std::function<void()>& continuation) {
|
||||
if (!continuation) return;
|
||||
try {
|
||||
continuation();
|
||||
} catch (const std::exception& e) {
|
||||
MGLOG_E("JobNode: a terminal continuation threw (%s); it has been contained, but whatever it "
|
||||
"was going to do did not happen",
|
||||
e.what());
|
||||
} catch (...) {
|
||||
MGLOG_E("JobNode: a terminal continuation threw a non-std exception; it has been contained, "
|
||||
"but whatever it was going to do did not happen");
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Bool JobNode::IsTerminal() const { return IsTerminalState(m_state.load(std::memory_order_acquire)); }
|
||||
@@ -47,9 +71,10 @@ namespace MobileGL::MG_Util::Async {
|
||||
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.
|
||||
// job to the pool from whichever thread drove this node terminal. Individually
|
||||
// contained, so one broken dependent cannot strand the rest of the list.
|
||||
for (auto& continuation : continuations) {
|
||||
if (continuation) continuation();
|
||||
RunContinuation(continuation);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -122,7 +147,10 @@ namespace MobileGL::MG_Util::Async {
|
||||
return;
|
||||
}
|
||||
}
|
||||
fn();
|
||||
// Already terminal: the caller's thread runs it, through the same guard the deferred
|
||||
// path uses. OnTerminal is reached from Link()'s GL-thread prologue as well as from a
|
||||
// worker, and glLinkProgram is not a place an exception may escape from either.
|
||||
RunContinuation(fn);
|
||||
}
|
||||
|
||||
void ApplyDeferredDiagnostics(JobNode& node) {
|
||||
|
||||
@@ -52,7 +52,12 @@ namespace MobileGL::MG_Util::Async {
|
||||
// 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 {
|
||||
//
|
||||
// enable_shared_from_this because a dependency edge outlives its registrar: a node that
|
||||
// posts itself from another node's continuation (ProgramLinkTask::OnDepSettled) has to
|
||||
// hand the pool a strong reference from inside itself. Every JobNode is therefore created
|
||||
// through MakeShared - a stack-allocated one may not use SubmitAfter-style chaining.
|
||||
class JobNode : public std::enable_shared_from_this<JobNode> {
|
||||
public:
|
||||
JobNode() = default;
|
||||
virtual ~JobNode() = default;
|
||||
@@ -88,6 +93,16 @@ namespace MobileGL::MG_Util::Async {
|
||||
// `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.
|
||||
//
|
||||
// A continuation must not throw. It is dispatched from whichever thread drove this
|
||||
// node terminal, which on the pool side is an Asio handler - an exception escaping
|
||||
// one propagates out of thread_pool::run() and terminates the process. The dispatcher
|
||||
// contains a throw anyway (see RunContinuation) so that one broken continuation
|
||||
// cannot strand the others, but the continuation itself is where the guarantee
|
||||
// belongs: whoever registers one owns the "and it cannot fail" argument, because the
|
||||
// dispatcher can only log, never repair. ProgramLinkTask::OnDepSettled is the worked
|
||||
// example - it catches internally and cancels itself, because a link that is never
|
||||
// posted is a joiner blocked forever.
|
||||
void OnTerminal(std::function<void()> fn);
|
||||
|
||||
// Runs the body on the calling thread. The synchronous path (async disabled,
|
||||
|
||||
@@ -136,7 +136,15 @@ namespace MobileGL::MG_Util::Async {
|
||||
// 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() {
|
||||
//
|
||||
// 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
|
||||
// added ProgramLinkTask::OnDepSettled as a real one), and a continuation is free to
|
||||
// call ShaderCompilePool::Post() again. Every caller of DispatchLocked holds `mutex`
|
||||
// (a plain, non-recursive std::mutex) - Cancel()'ing in here would let that
|
||||
// re-entrant Post() deadlock on the very lock this frame already owns. The caller
|
||||
// drains `toCancel` after releasing the lock.
|
||||
void DispatchLocked(Vector<SharedPtr<JobNode>>& toCancel) {
|
||||
while (!queue.empty() && inFlight < maxConcurrency && !stopped.load(std::memory_order_acquire)) {
|
||||
// Copy rather than move into the handler: if asio::post throws (it allocates)
|
||||
// the local SharedPtr is still valid, so the node can be settled instead of
|
||||
@@ -150,7 +158,7 @@ namespace MobileGL::MG_Util::Async {
|
||||
asio::post(*pool, [this, node]() mutable { RunOnWorker(Move(node)); });
|
||||
} catch (...) {
|
||||
--inFlight;
|
||||
node->Cancel();
|
||||
toCancel.push_back(Move(node));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,14 +167,23 @@ namespace MobileGL::MG_Util::Async {
|
||||
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.
|
||||
// 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
|
||||
// re-entrancy hazard DispatchLocked's comment describes.
|
||||
if (stopped.load(std::memory_order_acquire)) node->Cancel();
|
||||
node->Run();
|
||||
node.reset();
|
||||
|
||||
const std::lock_guard<std::mutex> lock(mutex);
|
||||
--inFlight;
|
||||
DispatchLocked();
|
||||
Vector<SharedPtr<JobNode>> toCancel;
|
||||
{
|
||||
const std::lock_guard<std::mutex> lock(mutex);
|
||||
--inFlight;
|
||||
DispatchLocked(toCancel);
|
||||
}
|
||||
// Outside the lock: see DispatchLocked's comment.
|
||||
for (const auto& n : toCancel) {
|
||||
if (n) n->Cancel();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -199,10 +216,17 @@ namespace MobileGL::MG_Util::Async {
|
||||
}
|
||||
|
||||
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();
|
||||
Vector<SharedPtr<JobNode>> toCancel;
|
||||
{
|
||||
const std::lock_guard<std::mutex> lock(m_impl->mutex);
|
||||
m_impl->maxConcurrency = std::clamp(n, 1u, m_impl->threadCount);
|
||||
// Raising the budget releases whatever the old one was holding back.
|
||||
if (m_impl->pool) m_impl->DispatchLocked(toCancel);
|
||||
}
|
||||
// Outside the lock: see DispatchLocked's comment.
|
||||
for (const auto& n2 : toCancel) {
|
||||
if (n2) n2->Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
void ShaderCompilePool::Post(SharedPtr<JobNode> node) {
|
||||
@@ -220,13 +244,15 @@ namespace MobileGL::MG_Util::Async {
|
||||
// exception-safe and SharedPtr's move constructor is noexcept, so a throwing
|
||||
// push_back never consumed it; and DispatchLocked contains its own asio::post
|
||||
// failures rather than propagating them (see above). Keep it that way.
|
||||
Bool enqueued = false;
|
||||
Vector<SharedPtr<JobNode>> toCancel;
|
||||
try {
|
||||
const std::lock_guard<std::mutex> lock(m_impl->mutex);
|
||||
if (!m_impl->stopped.load(std::memory_order_acquire) && !InProcessTeardown()) {
|
||||
if (!m_impl->pool) m_impl->pool = MakeUnique<asio::thread_pool>(m_impl->threadCount);
|
||||
m_impl->queue.push_back(Move(node));
|
||||
m_impl->DispatchLocked();
|
||||
return;
|
||||
m_impl->DispatchLocked(toCancel);
|
||||
enqueued = true;
|
||||
}
|
||||
} catch (...) {
|
||||
MGLOG_E("ShaderCompilePool::Post: enqueue failed; cancelling the job so its joiner "
|
||||
@@ -234,6 +260,12 @@ namespace MobileGL::MG_Util::Async {
|
||||
if (node) node->Cancel();
|
||||
return;
|
||||
}
|
||||
// Outside the lock: see DispatchLocked's comment - a Cancel() here may run a
|
||||
// continuation (e.g. ProgramLinkTask::OnDepSettled) that calls back into Post().
|
||||
for (const auto& n : toCancel) {
|
||||
if (n) n->Cancel();
|
||||
}
|
||||
if (enqueued) 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
|
||||
|
||||
Reference in New Issue
Block a user