Files
MobileGL/MobileGL/MG_Test/Util/JobNodeTest.cpp
T
BZLZHH 2e6fc1ffc0 [Feat] (MG_Util, MG_Test): enable asynchronous shader compilation by default (P1 stage 7)
kAsyncShaderCompileDefault flips to true, which also advertises
GL_KHR_parallel_shader_compile by default on both backends. Unset
MOBILEGL_ASYNC_SHADER_COMPILE now resolves to ON; =0 remains the complete kill
switch (reverts the threading and withdraws the extension together).

The gate behind the flip (headless Mesa - llvmpipe for Espryt, lavapipe for
Magma - at c6299f75): GL30/31/32/33/40 mustpass plus the
KHR-GL46.parallel_shader_compile group, both backends, async=1 with the
extension advertised. 58,344 case-runs, 8 failures - and every one of the 8
also fails standalone at async=0, in the full async=0 suite arms with an
identical per-case failure set, and under the pre-P1-stage-6 library. Zero
async-attributable deltas; the 8 are Mesa-upgrade drift (4 unique signatures:
Espryt GL40 transform_feedback.draw_xfb{,_feedbackk}_test, Magma
texture_size_promotion.functional + packed_pixels rgb9_e5_format_red on
GL32/33/40), recorded for separate follow-up.

Validation under the flipped default (no env var): 553/553 unit at the default
AND with the kill switch; parallel_shader_compile 3/3 on both backends proving
the default advertises; 44+44 integration scenarios; 71/72 CI trace-replay
fixtures (the one failure is the pre-existing create-indirect lavapipe crash,
identical under the pre-fix library). The lifecycle test's contract updates
with the default: AsyncIsOnByDefaultAndTheOverrideDecidesEitherWay.

Not covered by this gate and deliberately left open: SSO/DSA suites (GL41+,
separate follow-up per review), and real-driver confirmation on NVIDIA/Mali -
the Mali-G77 on-device sweep runs separately as a report-only pass.
2026-08-09 02:04:59 -04:00

538 lines
19 KiB
C++

// MobileGL - MobileGL/MG_Test/Util/JobNodeTest.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 <gtest/gtest.h>
#include <chrono>
#include <stdexcept>
#include "Includes.h"
#include <Config.h>
#include <MG_Util/Async/JobNode.h>
#include <MG_Util/Async/ShaderCompilePool.h>
using namespace MobileGL;
using namespace MobileGL::MG_Util::Async;
namespace {
// Every test drives its own pool instance rather than ShaderCompilePool::Get(): the
// process-wide pool is stopped permanently by StopAndDrain (that is the teardown
// contract), so a test that drained the singleton would poison every test after it.
constexpr Uint kTestThreads = 4;
// A job whose body does exactly what the test tells it to. `ran` counts executions so
// "enqueued once, ran once" is checkable, and the optional gate lets a test hold a job
// inside its body while it inspects the node from the outside.
class TestJob final : public JobNode {
public:
explicit TestJob(std::function<void(TestJob&)> body = {}) : m_body(Move(body)) {}
std::atomic<Uint> ran{0};
std::atomic<Bool> observedCancelledInBody{false};
std::atomic<Bool> observedCancelledStateInBody{false};
protected:
void RunBody() override {
ran.fetch_add(1, std::memory_order_acq_rel);
if (m_body) m_body(*this);
observedCancelledInBody.store(IsCancellationRequested(), std::memory_order_release);
// A running body sees the request, not the outcome: the node is still Running
// until it returns, which is exactly the cooperative contract.
observedCancelledStateInBody.store(IsCancelled(), std::memory_order_release);
}
private:
std::function<void(TestJob&)> m_body;
};
// A manual gate, so a test can pin a job in Running and observe the node meanwhile.
class Gate {
public:
void Open() {
{
const std::lock_guard<std::mutex> lock(m_mutex);
m_open = true;
}
m_cv.notify_all();
}
void Wait() {
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;
};
Bool WaitUntil(const std::function<Bool()>& predicate,
const std::chrono::milliseconds timeout = std::chrono::seconds(10)) {
const auto deadline = std::chrono::steady_clock::now() + timeout;
while (std::chrono::steady_clock::now() < deadline) {
if (predicate()) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
return predicate();
}
} // namespace
// ---------------------------------------------------------------------------------------
// Pool lifecycle
// ---------------------------------------------------------------------------------------
TEST(ShaderCompilePoolLifecycle, ConstructingAPoolStartsNoThreadUntilSomethingIsPosted) {
ShaderCompilePool pool(kTestThreads);
EXPECT_EQ(pool.GetThreadCount(), kTestThreads);
EXPECT_EQ(pool.GetMaxConcurrency(), kTestThreads);
// Nothing observable to assert about thread creation from here; what this pins is that
// construction is side-effect free and the pool destructs cleanly without ever running.
}
TEST(ShaderCompilePoolLifecycle, StopAndDrainIsIdempotentAndSafeOnAnUnusedPool) {
ShaderCompilePool pool(kTestThreads);
pool.StopAndDrain();
pool.StopAndDrain();
SUCCEED();
}
TEST(ShaderCompilePoolLifecycle, AStoppedPoolRunsPostedJobsInlineOnTheCallingThread) {
ShaderCompilePool pool(kTestThreads);
pool.StopAndDrain();
const auto callingThread = std::this_thread::get_id();
std::thread::id bodyThread{};
auto job = MakeShared<TestJob>([&](TestJob&) { bodyThread = std::this_thread::get_id(); });
pool.Post(job);
// Terminal by the time Post returned - the whole point of the stopped-is-synchronous
// rule: a late entry point after teardown still gets a correct result, it just gets it
// without resurrecting a worker thread.
EXPECT_TRUE(job->IsComplete());
EXPECT_EQ(job->ran.load(), 1u);
EXPECT_EQ(bodyThread, callingThread);
}
TEST(ShaderCompilePoolLifecycle, SetMaxConcurrencyIsClampedToTheThreadCount) {
ShaderCompilePool pool(kTestThreads);
pool.SetMaxConcurrency(0);
EXPECT_EQ(pool.GetMaxConcurrency(), 1u);
pool.SetMaxConcurrency(1000);
EXPECT_EQ(pool.GetMaxConcurrency(), kTestThreads);
pool.SetMaxConcurrency(2);
EXPECT_EQ(pool.GetMaxConcurrency(), 2u);
}
TEST(ShaderCompilePoolLifecycle, DetectedThreadCountIsPositive) {
EXPECT_GE(DetectShaderCompileThreadCount(), 1u);
}
TEST(ShaderCompilePoolLifecycle, AsyncIsOnByDefaultAndTheOverrideDecidesEitherWay) {
// The shipped default flipped to ON at stage 7 (the GL30-40 + parallel_shader_compile
// gate found zero async-attributable failures), and an unset
// MOBILEGL_ASYNC_SHADER_COMPILE resolves to it. If the first expectation ever fails
// without the constant having been deliberately flipped back, something disabled async
// by accident - the kill switch below is the supported way off.
//
// Driven through Features rather than read from it: the suite is also run with
// MOBILEGL_ASYNC_SHADER_COMPILE exported both ways, so a test that simply asserted
// the resolved answer would fail in one of those runs or - worse - silently pass in a
// binary that never loaded the config and prove nothing at all.
EXPECT_TRUE(kAsyncShaderCompileDefault);
const MG_Config::QuirkOverride saved = MG_Config::Features.AsyncShaderCompile;
MG_Config::Features.AsyncShaderCompile = MG_Config::QuirkOverride::Auto;
EXPECT_EQ(AsyncShaderCompileEnabled(), kAsyncShaderCompileDefault);
MG_Config::Features.AsyncShaderCompile = MG_Config::QuirkOverride::ForceOn;
EXPECT_TRUE(AsyncShaderCompileEnabled());
MG_Config::Features.AsyncShaderCompile = MG_Config::QuirkOverride::ForceOff;
EXPECT_FALSE(AsyncShaderCompileEnabled());
MG_Config::Features.AsyncShaderCompile = saved;
}
// ---------------------------------------------------------------------------------------
// Submit and join
// ---------------------------------------------------------------------------------------
TEST(JobNodeSubmit, PostedJobRunsOnAPoolThreadAndWaitJoinsIt) {
ShaderCompilePool pool(kTestThreads);
std::atomic<Bool> sawPoolThread{false};
auto job = MakeShared<TestJob>(
[&](TestJob&) { sawPoolThread.store(ShaderCompilePool::IsPoolThread(), std::memory_order_release); });
pool.Post(job);
job->Wait();
EXPECT_TRUE(job->IsTerminal());
EXPECT_TRUE(job->IsComplete());
EXPECT_FALSE(job->IsCancelled());
EXPECT_EQ(job->ran.load(), 1u);
EXPECT_TRUE(sawPoolThread.load());
// The joining thread is not a pool thread - the assert inside Wait() depends on it.
EXPECT_FALSE(ShaderCompilePool::IsPoolThread());
}
TEST(JobNodeSubmit, WaitOnAnAlreadyTerminalJobReturnsImmediately) {
ShaderCompilePool pool(kTestThreads);
auto job = MakeShared<TestJob>();
pool.Post(job);
job->Wait();
job->Wait();
EXPECT_EQ(job->ran.load(), 1u);
}
TEST(JobNodeSubmit, RunInlineExecutesOnTheCallingThreadWithoutAPool) {
auto job = MakeShared<TestJob>();
job->RunInline();
EXPECT_TRUE(job->IsComplete());
EXPECT_EQ(job->ran.load(), 1u);
}
TEST(JobNodeSubmit, ManyJobsAllComplete) {
constexpr Uint kJobs = 256;
ShaderCompilePool pool(kTestThreads);
Vector<SharedPtr<TestJob>> jobs;
jobs.reserve(kJobs);
std::atomic<Uint> completed{0};
for (Uint i = 0; i < kJobs; ++i) {
jobs.push_back(MakeShared<TestJob>([&](TestJob&) { completed.fetch_add(1, std::memory_order_acq_rel); }));
pool.Post(jobs.back());
}
for (const auto& job : jobs) job->Wait();
EXPECT_EQ(completed.load(), kJobs);
for (const auto& job : jobs) {
EXPECT_TRUE(job->IsComplete());
EXPECT_EQ(job->ran.load(), 1u);
}
}
TEST(JobNodeSubmit, ConcurrencyBudgetIsNeverExceeded) {
constexpr Uint kBudget = 2;
constexpr Uint kJobs = 64;
ShaderCompilePool pool(kTestThreads);
pool.SetMaxConcurrency(kBudget);
std::atomic<Uint> inFlight{0};
std::atomic<Uint> peak{0};
Vector<SharedPtr<TestJob>> jobs;
jobs.reserve(kJobs);
for (Uint i = 0; i < kJobs; ++i) {
jobs.push_back(MakeShared<TestJob>([&](TestJob&) {
const Uint current = inFlight.fetch_add(1, std::memory_order_acq_rel) + 1;
Uint observed = peak.load(std::memory_order_acquire);
while (current > observed && !peak.compare_exchange_weak(observed, current)) {
}
std::this_thread::sleep_for(std::chrono::milliseconds(1));
inFlight.fetch_sub(1, std::memory_order_acq_rel);
}));
pool.Post(jobs.back());
}
for (const auto& job : jobs) job->Wait();
// This is also the memory bound: it is what stops a 300-program pack load from putting
// 300 glslang arenas in flight at once.
EXPECT_LE(peak.load(), kBudget);
EXPECT_GE(peak.load(), 1u);
}
// ---------------------------------------------------------------------------------------
// OnTerminal and dependency ordering
// ---------------------------------------------------------------------------------------
TEST(JobNodeContinuation, OnTerminalOnAnAlreadyTerminalNodeRunsInlineBeforeItReturns) {
auto job = MakeShared<TestJob>();
job->RunInline();
ASSERT_TRUE(job->IsTerminal());
Bool ranInline = false;
const auto callingThread = std::this_thread::get_id();
std::thread::id continuationThread{};
job->OnTerminal([&] {
ranInline = true;
continuationThread = std::this_thread::get_id();
});
EXPECT_TRUE(ranInline);
EXPECT_EQ(continuationThread, callingThread);
}
TEST(JobNodeContinuation, EveryContinuationFiresExactlyOnce) {
constexpr Uint kContinuations = 8;
ShaderCompilePool pool(kTestThreads);
Gate gate;
auto job = MakeShared<TestJob>([&](TestJob&) { gate.Wait(); });
pool.Post(job);
std::atomic<Uint> fired{0};
for (Uint i = 0; i < kContinuations; ++i) {
job->OnTerminal([&] { fired.fetch_add(1, std::memory_order_acq_rel); });
}
gate.Open();
job->Wait();
// Registered while the job was pending or running, so all of them are handed to the
// finishing thread; a late one would have run inline instead. Either way: once each.
EXPECT_TRUE(WaitUntil([&] { return fired.load() == kContinuations; }));
EXPECT_EQ(fired.load(), kContinuations);
// A continuation registered after the fact still fires, exactly once, inline.
job->OnTerminal([&] { fired.fetch_add(1, std::memory_order_acq_rel); });
EXPECT_EQ(fired.load(), kContinuations + 1);
}
TEST(JobNodeContinuation, DependencyCounterReachesZeroExactlyOnceAndOnlyAfterEveryDependency) {
// The shape ProgramLinkTask::SubmitAfter uses: the dependent is posted by whichever
// thread drives the counter to zero, so it is enqueued only once every dependency is
// terminal - which is why no job body ever has to wait on another job.
constexpr Uint kDeps = 16;
ShaderCompilePool pool(kTestThreads);
Vector<SharedPtr<TestJob>> deps;
deps.reserve(kDeps);
for (Uint i = 0; i < kDeps; ++i) deps.push_back(MakeShared<TestJob>());
std::atomic<Int> remaining{static_cast<Int>(kDeps) + 1}; // +1 guard: nothing fires mid-registration
std::atomic<Uint> released{0};
std::atomic<Bool> allDepsTerminalAtRelease{false};
const auto settle = [&] {
if (remaining.fetch_sub(1, std::memory_order_acq_rel) == 1) {
Bool allTerminal = true;
for (const auto& dep : deps) allTerminal = allTerminal && dep->IsTerminal();
allDepsTerminalAtRelease.store(allTerminal, std::memory_order_release);
released.fetch_add(1, std::memory_order_acq_rel);
}
};
for (const auto& dep : deps) {
pool.Post(dep);
dep->OnTerminal(settle);
}
settle(); // release the guard
EXPECT_TRUE(WaitUntil([&] { return released.load() == 1u; }));
EXPECT_EQ(released.load(), 1u);
EXPECT_TRUE(allDepsTerminalAtRelease.load());
for (const auto& dep : deps) EXPECT_TRUE(dep->IsComplete());
}
TEST(JobNodeContinuation, AlreadyTerminalDependenciesStillSettleTheCounterExactlyOnce) {
// Same counter, but every dependency is terminal before registration, so every
// continuation runs inline on the registering thread.
constexpr Uint kDeps = 4;
Vector<SharedPtr<TestJob>> deps;
for (Uint i = 0; i < kDeps; ++i) {
deps.push_back(MakeShared<TestJob>());
deps.back()->RunInline();
}
std::atomic<Int> remaining{static_cast<Int>(kDeps) + 1};
Uint released = 0;
const auto settle = [&] {
if (remaining.fetch_sub(1, std::memory_order_acq_rel) == 1) ++released;
};
for (const auto& dep : deps) dep->OnTerminal(settle);
settle();
EXPECT_EQ(released, 1u);
}
// ---------------------------------------------------------------------------------------
// Cancellation
// ---------------------------------------------------------------------------------------
TEST(JobNodeCancel, CancelBeforeAnyDispatchSettlesTheNodeAndSkipsTheBody) {
ShaderCompilePool pool(kTestThreads);
auto job = MakeShared<TestJob>();
job->Cancel();
EXPECT_TRUE(job->IsCancelled());
EXPECT_TRUE(job->IsTerminal());
EXPECT_FALSE(job->IsComplete());
// Posting an already-cancelled node is a no-op, not a second run.
pool.Post(job);
job->Wait();
EXPECT_EQ(job->ran.load(), 0u);
EXPECT_TRUE(job->IsCancelled());
}
TEST(JobNodeCancel, CancelWhileTheBodyIsRunningLetsItFinishAndReportsCancelled) {
ShaderCompilePool pool(kTestThreads);
Gate gate;
std::atomic<Bool> entered{false};
auto job = MakeShared<TestJob>([&](TestJob&) {
entered.store(true, std::memory_order_release);
gate.Wait();
});
pool.Post(job);
ASSERT_TRUE(WaitUntil([&] { return entered.load(); }));
job->Cancel();
// A running body is not interrupted - cancellation is cooperative - so the node is
// still Running until the body returns.
EXPECT_FALSE(job->IsTerminal());
gate.Open();
job->Wait();
EXPECT_EQ(job->ran.load(), 1u);
EXPECT_TRUE(job->IsCancelled());
EXPECT_FALSE(job->IsComplete());
EXPECT_TRUE(job->observedCancelledInBody.load());
EXPECT_FALSE(job->observedCancelledStateInBody.load());
}
TEST(JobNodeCancel, CancelAfterCompletionDoesNotUndoTheResult) {
ShaderCompilePool pool(kTestThreads);
auto job = MakeShared<TestJob>();
pool.Post(job);
job->Wait();
ASSERT_TRUE(job->IsComplete());
job->Cancel();
// The request is recorded, but a settled result is never retroactively undone.
EXPECT_TRUE(job->IsCancellationRequested());
EXPECT_TRUE(job->IsComplete());
EXPECT_FALSE(job->IsCancelled());
EXPECT_EQ(job->ran.load(), 1u);
}
TEST(JobNodeCancel, CancelReleasesContinuationsSoDependentsAreNotStranded) {
auto job = MakeShared<TestJob>();
std::atomic<Uint> fired{0};
job->OnTerminal([&] { fired.fetch_add(1, std::memory_order_acq_rel); });
job->Cancel();
EXPECT_EQ(fired.load(), 1u);
job->Wait(); // must not hang: a cancelled pending node is terminal
EXPECT_TRUE(job->IsCancelled());
}
// ---------------------------------------------------------------------------------------
// Exceptions
// ---------------------------------------------------------------------------------------
TEST(JobNodeException, AnExceptionEscapingABodyCancelsTheJobInsteadOfTerminating) {
// Asio propagates an exception out of thread_pool::run(), which is std::terminate for
// the process. Containing it at the job boundary is what makes that impossible.
ShaderCompilePool pool(kTestThreads);
auto job = MakeShared<TestJob>([](TestJob&) { throw std::runtime_error("boom"); });
pool.Post(job);
job->Wait();
EXPECT_TRUE(job->IsTerminal());
EXPECT_TRUE(job->IsCancelled());
EXPECT_FALSE(job->IsComplete());
ASSERT_EQ(job->diagnostics.logLines.size(), 1u);
EXPECT_NE(job->diagnostics.logLines[0].find("boom"), String::npos);
}
TEST(JobNodeException, ANonStandardExceptionIsContainedToo) {
ShaderCompilePool pool(kTestThreads);
auto job = MakeShared<TestJob>([](TestJob&) { throw 42; });
pool.Post(job);
job->Wait();
EXPECT_TRUE(job->IsCancelled());
ASSERT_EQ(job->diagnostics.logLines.size(), 1u);
}
TEST(JobNodeException, AThrowingJobDoesNotPoisonTheWorkerForLaterJobs) {
ShaderCompilePool pool(kTestThreads);
auto thrower = MakeShared<TestJob>([](TestJob&) { throw std::runtime_error("boom"); });
pool.Post(thrower);
thrower->Wait();
auto healthy = MakeShared<TestJob>();
pool.Post(healthy);
healthy->Wait();
EXPECT_TRUE(healthy->IsComplete());
}
// ---------------------------------------------------------------------------------------
// Drain
// ---------------------------------------------------------------------------------------
TEST(ShaderCompilePoolDrain, StopAndDrainWithAThousandQueuedJobsLeavesNoneRunningOrPending) {
constexpr Uint kJobs = 1000;
ShaderCompilePool pool(kTestThreads);
pool.SetMaxConcurrency(1); // keep the vast majority queued behind the budget
Vector<SharedPtr<TestJob>> jobs;
jobs.reserve(kJobs);
for (Uint i = 0; i < kJobs; ++i) {
jobs.push_back(MakeShared<TestJob>());
pool.Post(jobs.back());
}
pool.StopAndDrain();
// Every node is terminal, so nothing can be waiting on a worker that will never come.
Uint complete = 0;
Uint cancelled = 0;
for (const auto& job : jobs) {
ASSERT_TRUE(job->IsTerminal());
if (job->IsComplete()) ++complete;
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) {
ShaderCompilePool pool(kTestThreads);
Gate gate;
std::atomic<Bool> entered{false};
std::atomic<Bool> left{false};
auto job = MakeShared<TestJob>([&](TestJob&) {
entered.store(true, std::memory_order_release);
gate.Wait();
left.store(true, std::memory_order_release);
});
pool.Post(job);
ASSERT_TRUE(WaitUntil([&] { return entered.load(); }));
std::thread opener([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(20));
gate.Open();
});
pool.StopAndDrain();
opener.join();
// This is the guarantee library teardown relies on: once StopAndDrain returns, no worker
// is still inside a body that could touch glslang's process globals.
EXPECT_TRUE(left.load());
EXPECT_TRUE(job->IsTerminal());
}
TEST(ShaderCompilePoolDrain, JobsPostedAfterADrainStillRun) {
ShaderCompilePool pool(kTestThreads);
pool.StopAndDrain();
auto job = MakeShared<TestJob>();
pool.Post(job);
EXPECT_TRUE(job->IsComplete());
EXPECT_EQ(job->ran.load(), 1u);
}