From 819107513303a63941cf0e58a4d5bd73c7beb924 Mon Sep 17 00:00:00 2001 From: BZLZHH Date: Sat, 8 Aug 2026 05:28:51 -0400 Subject: [PATCH] [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. --- .gitmodules | 3 + 3rdparty/asio | 1 + CMakeLists.txt | 17 + MobileGL/Config.h | 9 + MobileGL/ConfigLoader.cpp | 2 + MobileGL/Init.cpp | 7 + MobileGL/MG_Test/CMakeLists.txt | 1 + MobileGL/MG_Test/Util/CMakeLists.txt | 20 + MobileGL/MG_Test/Util/JobNodeTest.cpp | 522 +++++++++++++++++++ MobileGL/MG_Util/Async/JobNode.cpp | 125 +++++ MobileGL/MG_Util/Async/JobNode.h | 116 +++++ MobileGL/MG_Util/Async/ShaderCompilePool.cpp | 227 ++++++++ MobileGL/MG_Util/Async/ShaderCompilePool.h | 82 +++ 13 files changed, 1132 insertions(+) create mode 160000 3rdparty/asio create mode 100644 MobileGL/MG_Test/Util/CMakeLists.txt create mode 100644 MobileGL/MG_Test/Util/JobNodeTest.cpp create mode 100644 MobileGL/MG_Util/Async/JobNode.cpp create mode 100644 MobileGL/MG_Util/Async/JobNode.h create mode 100644 MobileGL/MG_Util/Async/ShaderCompilePool.cpp create mode 100644 MobileGL/MG_Util/Async/ShaderCompilePool.h diff --git a/.gitmodules b/.gitmodules index 5f573729..5bebb9c2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -31,3 +31,6 @@ [submodule "3rdparty/apitrace"] path = 3rdparty/apitrace url = https://github.com/MobileGL-Dev/apitrace.git +[submodule "3rdparty/asio"] + path = 3rdparty/asio + url = https://github.com/chriskohlhoff/asio.git diff --git a/3rdparty/asio b/3rdparty/asio new file mode 160000 index 00000000..8806a680 --- /dev/null +++ b/3rdparty/asio @@ -0,0 +1 @@ +Subproject commit 8806a6803cde7054c3049d3666d3ec36786568c5 diff --git a/CMakeLists.txt b/CMakeLists.txt index bda2f898..376dca6e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -154,6 +154,9 @@ set(SOURCE_FILES MobileGL/MG_Util/Debug/Log.cpp + MobileGL/MG_Util/Async/JobNode.cpp + MobileGL/MG_Util/Async/ShaderCompilePool.cpp + MobileGL/MG_Util/Math/VectorTypes.cpp MobileGL/MG_Util/Metrics/TextureMetrics.cpp @@ -325,6 +328,11 @@ if (WIN32) ) endif() +# The shader-compile pool runs standalone Asio on real threads. This host's glibc (>= 2.34) +# merged pthread into libc, so it links without asking, but the NDK and musl are not +# guaranteed to be as forgiving - ask for it explicitly rather than rely on the accident. +find_package(Threads REQUIRED) + set(MOBILEGL_LINK_LIBRARIES glslang::glslang spirv-cross-c @@ -334,12 +342,17 @@ set(MOBILEGL_LINK_LIBRARIES GPUOpen::VulkanMemoryAllocator Vulkan::UtilityHeaders spirv-reflect-static + Threads::Threads ) set(MOBILEGL_COMPILE_DEF -DVMA_STATIC_VULKAN_FUNCTIONS=0 -DVMA_DYNAMIC_VULKAN_FUNCTIONS=1 -DVMA_VULKAN_VERSION=1001000 + # Header-only Asio, no Boost, no deprecated interfaces. Set on the definition list + # rather than per-target so the shared library and the _s static target agree. + -DASIO_STANDALONE + -DASIO_NO_DEPRECATED ) message(STATUS "MOBILEGL_COMPILE_DEF=${MOBILEGL_COMPILE_DEF}") @@ -351,6 +364,10 @@ set(MOBILEGL_INCLUDE_DIR ${spirv-tools_SOURCE_DIR}/include ${spirv-tools_BINARY_DIR} ${SPIRV-Headers_SOURCE_DIR}/include + # Header-only submodule: no add_subdirectory, no link target. Only + # MG_Util/Async/ShaderCompilePool.cpp includes it, and it stays behind that file's + # pimpl so no consumer target needs this path. + ${CMAKE_SOURCE_DIR}/3rdparty/asio/asio/include ) add_library(${CMAKE_PROJECT_NAME} SHARED diff --git a/MobileGL/Config.h b/MobileGL/Config.h index 4595528b..087ae783 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -127,6 +127,15 @@ namespace MobileGL::MG_Config { // "compute", see GLESMultiDrawMode). Clamped to driver support; unset picks the best // supported tier, which never includes "compute" - see the note on its resolution. GLESMultiDrawMode EsprytMultiDrawMode = GLESMultiDrawMode::Auto; + // MOBILEGL_ASYNC_SHADER_COMPILE: overrides asynchronous shader compilation. Unset + // keeps the built-in default (MG_Util::Async::kAsyncShaderCompileDefault); falsy + // forces every glCompileShader/glLinkProgram to run synchronously on the calling + // thread AND withdraws GL_KHR_parallel_shader_compile, so the single switch reverts + // both the threading and the application-visible behaviour change. + QuirkOverride AsyncShaderCompile = QuirkOverride::Auto; + // MOBILEGL_ASYNC_SHADER_COMPILE_THREADS: shader-compile worker count. 0 (unset) means + // auto, which is min(4, big cores); an explicit value is honoured as given. + Uint32 AsyncShaderCompileThreads = 0; }; extern FeaturesTable Features; } // namespace MobileGL::MG_Config diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index 8ed0b58c..058c2618 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -181,6 +181,8 @@ namespace MobileGL::MG_ConfigLoader { features.DisableRobustBufferAccess = QueryEnvFlag("MOBILEGL_DISABLE_ROBUST_BUFFER_ACCESS"); features.MagmaMultiDrawMode = QueryEnvMultiDrawMode("MOBILEGL_MAGMA_MULTIDRAW_MODE"); features.EsprytMultiDrawMode = QueryEnvGLESMultiDrawMode("MOBILEGL_ESPRYT_MULTIDRAW_MODE"); + features.AsyncShaderCompile = QueryEnvQuirkOverride("MOBILEGL_ASYNC_SHADER_COMPILE"); + features.AsyncShaderCompileThreads = QueryEnvUint32("MOBILEGL_ASYNC_SHADER_COMPILE_THREADS", 0, 0, 64); } inline void InitBackendType() { diff --git a/MobileGL/Init.cpp b/MobileGL/Init.cpp index 51b1b519..c7a0b983 100644 --- a/MobileGL/Init.cpp +++ b/MobileGL/Init.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include @@ -37,6 +38,12 @@ namespace MobileGL { if (logLifecycle) { MGLOG_I("MobileGL closing..."); } + // First, before anything else is torn down. In-flight compile/link jobs own + // their own inputs and are safe against everything below EXCEPT glslang's + // process globals and the TShader/TProgram objects hanging off pGLContext, + // both of which this function is about to destroy. This is the one + // cancellation path in the whole design that waits. + MG_Util::Async::ShaderCompilePool::Get().StopAndDrain(); // GL syncs die with their contexts, and every context is gone by the // time full teardown runs: drain the live-sync registry while the // backend function table can still release the backend handles (and diff --git a/MobileGL/MG_Test/CMakeLists.txt b/MobileGL/MG_Test/CMakeLists.txt index 1e73ede8..fb8a080d 100644 --- a/MobileGL/MG_Test/CMakeLists.txt +++ b/MobileGL/MG_Test/CMakeLists.txt @@ -74,6 +74,7 @@ add_subdirectory(Program) add_subdirectory(Query) add_subdirectory(Pipeline) add_subdirectory(ShaderTranspiler) +add_subdirectory(Util) if (ENABLE_INTEGRATION_TESTS) add_subdirectory(Backend/DirectVulkan) endif() diff --git a/MobileGL/MG_Test/Util/CMakeLists.txt b/MobileGL/MG_Test/Util/CMakeLists.txt new file mode 100644 index 00000000..2bc31b7d --- /dev/null +++ b/MobileGL/MG_Test/Util/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.14) + +add_executable( + JobNodeTest + JobNodeTest.cpp +) + +target_include_directories(JobNodeTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL +) + +target_link_libraries( + JobNodeTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + +include(GoogleTest) +gtest_discover_tests(JobNodeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/Util/JobNodeTest.cpp b/MobileGL/MG_Test/Util/JobNodeTest.cpp new file mode 100644 index 00000000..9760d9b4 --- /dev/null +++ b/MobileGL/MG_Test/Util/JobNodeTest.cpp @@ -0,0 +1,522 @@ +// 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 + +#include +#include + +#include "Includes.h" +#include + +#include +#include + +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 body = {}) : m_body(Move(body)) {} + + std::atomic ran{0}; + std::atomic observedCancelledInBody{false}; + std::atomic 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 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 lock(m_mutex); + m_open = true; + } + m_cv.notify_all(); + } + + void Wait() { + std::unique_lock 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& 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&) { 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, AsyncIsOffByDefaultInThisStage) { + // Stage 1 ships the machinery wired to nothing. If this ever fails without the default + // constant having been deliberately flipped, something enabled async by accident. + EXPECT_EQ(MG_Config::Features.AsyncShaderCompile, MG_Config::QuirkOverride::Auto); + EXPECT_FALSE(kAsyncShaderCompileDefault); + EXPECT_FALSE(AsyncShaderCompileEnabled()); +} + +// --------------------------------------------------------------------------------------- +// Submit and join +// --------------------------------------------------------------------------------------- + +TEST(JobNodeSubmit, PostedJobRunsOnAPoolThreadAndWaitJoinsIt) { + ShaderCompilePool pool(kTestThreads); + + std::atomic sawPoolThread{false}; + auto job = MakeShared( + [&](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(); + pool.Post(job); + job->Wait(); + job->Wait(); + EXPECT_EQ(job->ran.load(), 1u); +} + +TEST(JobNodeSubmit, RunInlineExecutesOnTheCallingThreadWithoutAPool) { + auto job = MakeShared(); + job->RunInline(); + EXPECT_TRUE(job->IsComplete()); + EXPECT_EQ(job->ran.load(), 1u); +} + +TEST(JobNodeSubmit, ManyJobsAllComplete) { + constexpr Uint kJobs = 256; + ShaderCompilePool pool(kTestThreads); + + Vector> jobs; + jobs.reserve(kJobs); + std::atomic completed{0}; + for (Uint i = 0; i < kJobs; ++i) { + jobs.push_back(MakeShared([&](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 inFlight{0}; + std::atomic peak{0}; + + Vector> jobs; + jobs.reserve(kJobs); + for (Uint i = 0; i < kJobs; ++i) { + jobs.push_back(MakeShared([&](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(); + 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&) { gate.Wait(); }); + pool.Post(job); + + std::atomic 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> deps; + deps.reserve(kDeps); + for (Uint i = 0; i < kDeps; ++i) deps.push_back(MakeShared()); + + std::atomic remaining{static_cast(kDeps) + 1}; // +1 guard: nothing fires mid-registration + std::atomic released{0}; + std::atomic 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> deps; + for (Uint i = 0; i < kDeps; ++i) { + deps.push_back(MakeShared()); + deps.back()->RunInline(); + } + + std::atomic remaining{static_cast(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(); + + 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 entered{false}; + auto job = MakeShared([&](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(); + 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(); + std::atomic 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&) { 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&) { 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&) { throw std::runtime_error("boom"); }); + pool.Post(thrower); + thrower->Wait(); + + auto healthy = MakeShared(); + 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> jobs; + jobs.reserve(kJobs); + for (Uint i = 0; i < kJobs; ++i) { + jobs.push_back(MakeShared()); + 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 entered{false}; + std::atomic left{false}; + auto job = MakeShared([&](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(); + pool.Post(job); + EXPECT_TRUE(job->IsComplete()); + EXPECT_EQ(job->ran.load(), 1u); +} diff --git a/MobileGL/MG_Util/Async/JobNode.cpp b/MobileGL/MG_Util/Async/JobNode.cpp new file mode 100644 index 00000000..35b8a7e2 --- /dev/null +++ b/MobileGL/MG_Util/Async/JobNode.cpp @@ -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> continuations; + { + const std::lock_guard 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 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 fn) { + if (!fn) return; + { + const std::lock_guard 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 diff --git a/MobileGL/MG_Util/Async/JobNode.h b/MobileGL/MG_Util/Async/JobNode.h new file mode 100644 index 00000000..6f61e3d6 --- /dev/null +++ b/MobileGL/MG_Util/Async/JobNode.h @@ -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 +#include +#include +#include + +#include + +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 info; + }; + + struct JobDiagnostics { + Vector errors; // replayed, in ascending `sequence`, by the join + Vector 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 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 m_state{JobState::Pending}; + std::atomic m_cancelled{false}; + Vector> m_continuations; + }; +} // namespace MobileGL::MG_Util::Async diff --git a/MobileGL/MG_Util/Async/ShaderCompilePool.cpp b/MobileGL/MG_Util/Async/ShaderCompilePool.cpp new file mode 100644 index 00000000..bf58e609 --- /dev/null +++ b/MobileGL/MG_Util/Async/ShaderCompilePool.cpp @@ -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 + +#include +#include + +#include +#include + +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(value) : 0; + } + + Uint DetectBigCoreCount() { + const Uint cpuCount = std::max(1u, std::thread::hardware_concurrency()); + + Vector 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 pool; + std::deque> queue; + Uint inFlight = 0; + Uint maxConcurrency; + std::atomic 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 node = Move(queue.front()); + queue.pop_front(); + ++inFlight; + asio::post(*pool, [this, node = Move(node)]() mutable { RunOnWorker(Move(node)); }); + } + } + + void RunOnWorker(SharedPtr 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 lock(mutex); + --inFlight; + DispatchLocked(); + } + }; + + ShaderCompilePool::ShaderCompilePool(const Uint threadCount) : m_impl(MakeUnique(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 lock(m_impl->mutex); + return m_impl->maxConcurrency; + } + + void ShaderCompilePool::SetMaxConcurrency(const Uint n) { + const std::lock_guard 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 node) { + if (!node) return; + EnsureProcessTeardownSentinel(); + + { + const std::lock_guard lock(m_impl->mutex); + if (!m_impl->stopped.load(std::memory_order_acquire) && !InProcessTeardown()) { + if (!m_impl->pool) m_impl->pool = MakeUnique(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> abandoned; + UniquePtr pool; + { + const std::lock_guard 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 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 diff --git a/MobileGL/MG_Util/Async/ShaderCompilePool.h b/MobileGL/MG_Util/Async/ShaderCompilePool.h new file mode 100644 index 00000000..56ff2c73 --- /dev/null +++ b/MobileGL/MG_Util/Async/ShaderCompilePool.h @@ -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 +#include +#include + +// 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 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 m_impl; + }; +} // namespace MobileGL::MG_Util::Async