- Plan B section 11 P0 asks for TracyPlot per-frame counters on both sides of the
boundary, and section 2.3.1 adds the deliverable v1 did not have: DYNAMIC call
counters. The static call-site counts everyone quotes (Espryt 124 / Magma 169) are
not the per-draw cost, because every one of those paths is memo-gated; without a
dynamic counter P2's verdict stays a guess. The tree had no per-frame byte or call
measurement at all - MG_Util/Metrics is format arithmetic, and Tracy has zones but
no plots.
- Cost when off: g_pipeStatsEnabled is a plain global Bool latched once in
Initialize() right after MG_ConfigLoader::Init(), and every counting site is
`if (PipeStats::Enabled()) ...` - one load of a hot global and one never-taken
branch. The counters are relaxed atomics because buffer and texture staging are
reachable from more than one thread; the off path never touches them.
- Eight byte classes (stage-buffer, stage-texture, stage-ubo-global, stage-ubo-named,
stage-vertex-client, stage-index-client, persistent-map-push and the
residual-value-block placeholder of section 6.3), six call classes and the six memo
gates of section 2.3.1, each as a hit/miss pair. Names are minted now, including the
two that stay 0 in P0, so no recorded baseline is invalidated by a later rename -
which is why the name set is pinned by a test.
- Reporting: TracyPlot per counter per frame when TRACY_ENABLE; with
MOBILEGL_PIPE_STATS=1 one MGLOG_I summary line every 120 frames and at teardown, plus
a JSON dump to MOBILEGL_PIPE_STATS_FILE when that is set. MGLOG_I against the usual
"MGLOG_D for non-critical" rule on purpose: the line has to survive an INFO build -
the only build a device runs - it is at most one line per 120 frames, and it exists
only when the operator asked for it.
- Summary lines report disjoint WINDOWS, not run totals: a run total over a workload
that changes shape (load, then steady state) averages away the very number section
2.3.1 wants an absolute value for.
- MOBILEGL_PIPE_STATS_FILE joins the six switches parsed in e48a3582; like them it
needs no allow-list entry because InitializeAcceptedEnvVariables accepts every
MOBILEGL_-prefixed variable by construction.
Six more verified defects from the residual memo/cache mechanisms:
- Program resource cache (DirectVulkan reflection): glShaderStorageBlockBinding
deliberately does not bump the backend state version, and the SSO pipeline
composite is unnamed so the by-name in-place patch can never reach its slot -
the composite kept serving pre-rebind SSBO bindings. The cache now keys on the
program's block-binding version; a binding-only change re-applies the overrides
by name instead of re-running spirv-reflect. SetShaderStorageBlockBinding also
gains the equality bail-out its uniform-block sibling has, so the composite
mirror's replay stops churning the version every draw.
- LinkProgram's allowVSOnlyPrograms function-static latch never set its own
initialized flag (dead memo, re-read every call) - and completing it would have
frozen a per-backend capability across re-initialization. Replaced with a fresh
per-link read from the null-checked active backend.
- Query object registry: drained at full library teardown (DestroyAllQueryObjects,
mirroring DestroyAllSyncObjects) - undeleted queries and their backend wrappers
leaked across Destroy/Initialize cycles, stale ids stayed IsQuery == GL_TRUE in
the re-initialized library, and a later delete could hand the old backend's
wrapper to a different backend's DeleteBackendQuery.
- Converted vertex streams and the host-side EBO max-index scan now SyncGpuWrites
before reading the coherent mapping: XFB/SSBO/image writes are merely recorded
at that point, so the conversion read pre-write bytes (the restart-index
rewrite already synced; these two host reads did not).
- Zero-stride converted bindings: both converters rejected stride 0, making the
factory's documented single-element conversion unreachable and silently
dropping every draw using such a binding; the stride is substituted with the
element size for the one-element case.
- DemoteFloat64Pass block relayout: measurement queued into the module eagerly,
so a mid-struct failure left a half-relaid-out block (compacted offsets before
the failing member, 64-bit offsets after) while claiming the block was left
alone. Decoration writes are now collected and committed only when the whole
block measures successfully.
glCompileShader with MOBILEGL_ASYNC_SHADER_COMPILE=1 snapshots its inputs on
the GL thread (source SharedPtr, CompileEnv, cache handle) and runs the whole
pure pipeline - preprocess, validators, extractors, glslang parse - as a
ShaderCompileTask on the worker pool, returning immediately. Every read of
compile-produced state joins through the single Compiled() gate; links stay
synchronous this stage and join their attached shaders at the top of the
body. Flag off, the path is the same code run inline.
Mechanics: the job node owns all its inputs (no back-pointer, no lifetime
tie to the shader object), so re-sourcing or deleting a pending shader is
cancel-and-drop, never a wait; glslang worker hygiene is a TLS-allocator
scope guard plus GL-thread builtin prewarm (gated on the flag, latch reset
on Destroy so re-initialization re-warms); worker-side diagnostics defer
through the job and replay on the GL thread at the join, enforced by
IsPoolThread asserts in RecordError and an empty-deferred-errors tripwire.
A body that throws publishes a COMPLETE failed compile (status false, real
info log) rather than an abandoned node, and never memoizes away the retry;
a failed enqueue (OOM) cancels the node instead of stranding the joiner -
including inside the dispatch loop, where the in-flight slot is repaid.
The pool StopAndDrains from an atexit sentinel too: workers still inside
glslang parse while exit() ran static destructors was a real 2-in-5 SIGSEGV,
reproduced and fixed (15/15 clean after).
Backend-internal shader objects (default FS, DirectVulkan blit/mipmap) are
cache-less and always compile inline - compile-and-read-in-one-breath needs
no round trip.
Gates: unit suite 488/488 with the flag off AND on (x5); AsyncCompileTest
(12 e2e cases: pending re-source/delete/recompile, byte-identical failure
logs across modes, 48-compile cache stress) x10 repeats clean both modes;
full NVIDIA DirectGLES retrace identical result sets flag off/on (zero new
deltas); compile-phase timing flat as designed (links still serial - the
parallel win arrives with stage 4's async link + stage 5's
KHR_parallel_shader_compile).
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.
- SpvcSession's move constructor and move assignment dropped the parsed
metadata, so a moved-to session silently reported empty reflection.
- ParseComputeLocalSize used std::stoull, whose std::out_of_range escaped
glCompileShader on an oversized local_size literal; now std::from_chars
saturating to UINT_MAX, pinned by a regression test that reproduced the
escaping exception.
- The compute local_size std::regex was rebuilt on every compile; hoisted.
- LinkProgram dumped every shader's full source through MGLOG_D per link.
- glslang::FinalizeProcess ran before the GL context tore down, leaving the
context's live TShaders pointing at freed builtin symbol tables.
MG_Config::FeaturesTable snapshots every MOBILEGL_* toggle once in
ConfigLoader::Init with a single truthy rule (non-empty, not '0', not
'false' case-insensitively), replacing 13 scattered std::getenv sites
that used four different parsing conventions. Renderer-derived bits
(IsAngleRenderer/IsAngleLlvmpipeRenderer/AvoidSamplerMipmapMinFilter)
move into GLESCapabilities, set once in FillInGLESCapabilities, so hot
paths (glMemoryBarrier ANGLE flush, sampler min-filter sync) stop doing
per-call string scans. MOBILEGL_PRESENT_DUMP_CALL/_CURRENT_CALL stay
live getenv (the retrace harness mutates them at runtime) and
MOBILEGL_LOG_FILE_PATH stays in Log.cpp (log init precedes config
init); both are documented in Config.h. Known semantic unification:
MOBILEGL_DISABLE_SUBGROUP previously required exactly 'true' and
MOBILEGL_PRESENT_STATS exactly '1'; both now follow the shared rule
(CI's 0/1 values parse identically). Also bumps CoreVersion to 26.07.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>