mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-10 21:28:32 +09:00
[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:
@@ -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
|
||||
|
||||
+1
Submodule 3rdparty/asio added at 8806a6803c
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include <MG_Impl/GLImpl/Texture/ProxyTexture.h>
|
||||
#include <MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h>
|
||||
#include <MG_Impl/GLImpl/Sync/GL_Sync.h>
|
||||
#include <MG_Util/Async/ShaderCompilePool.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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 <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, 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<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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user