Skip to content

. - #73

Merged
EternityQwQ merged 11 commits into
mainfrom
perf/cpu-overhead-defaults
Sep 15, 2026
Merged

.#73
EternityQwQ merged 11 commits into
mainfrom
perf/cpu-overhead-defaults

Conversation

@EternityQwQ

Copy link
Copy Markdown
Owner

No description provided.

WorkBuddy Agent and others added 11 commits September 13, 2026 09:55
Rendering is CPU-bound in this layer and the cost tracks the call rate, so it
reads as "pegged while playing, fine when idle". Four sources, all of which the
port source does not have:

1. hostContextGuard was ON, not off. config_get_int() returns -1 for a missing
   key, and the assignment used `!= 0`, so an absent key read as enabled. The
   guard puts one eglGetCurrentContext() in front of each of the ~127 wrapped
   entry points; egl/loader.h already records that this was *assumed* to be a
   thread-local read and never measured. It is now opt-in (`> 0`), which also
   makes the comment that claimed it was "already off by default" true.

2. cpuSwizzle had the identical inverted default. It routes every
   TexImage/TexSubImage through per-pixel CPU reordering plus four
   glTexParameteri calls, where the port source hands the driver the bytes
   untouched. Also opt-in now.

3. glTexStorage2D called glGetError() unconditionally. That is an implicit
   glFinish on most drivers, and this is the texture-allocation path, so every
   resource-pack texture and dynamic render target paid a pipeline drain.
   glBufferStorageEXT had the same bug and was fixed with an 8-probe budget;
   this site was missed. Same budget applied.

4. g_shader_cache was an unsynchronised std::vector shared across the threads
   Minecraft compiles shaders on. Concurrent resize is UB, not a stale read.
   Now mutex-guarded, with doubling growth because shader names are sparse.

Also: the swizzle probe sat above the cpu_swizzle early return, so with the
path disabled it still counted and announced a per-pixel cost that was not
being paid; moved below it. shader.cpp's clear-source branch called
get_shader_cache() then wrote only the flag, leaving a stale type for a reused
name; it now invalidates the entry.

The two defaults are behaviour changes, so both are logged unconditionally in
latest.log at startup rather than only under verbose logging, with the opt-in
key named inline — the 26.3-pre-3 startup this can reintroduce is one line of
config away from being restored.
…rywhere

Replaces the trade the previous commit made. That one turned hostContextGuard
off to stop paying an eglGetCurrentContext() in front of ~127 entry points, and
accepted the 26.3-pre-3 startup crash as the price, with a config key to flip
back. This commit removes the price instead, so the guard can stay off.

Why the guard looked load-bearing: the lazy repair paths already existed, but
they gated on ScopedHostContext::Bound(), which is false unless the guard is
enabled. With the guard on, those retries ran; with it off, they were dead code.
So "turn the guard off" did not just remove the check, it silently disabled the
recovery for glGetString, glGetIntegerv and glGetInteger64v — which is exactly
the 26.3-pre-3 failure set. Nothing said so.

RepairHostContextOnce() (egl/loader.{h,cpp}) is the recovery path with no such
coupling. It is called only after an entry point has seen a result it cannot
trust, so a call site that never fails never pays anything. Per-thread state
remembers the answer for a generation, so the ladder runs at most once per
thread per render target; measured 0 runs over 100k steady-state calls.

Wired into the four sites 26.3-pre-3 actually failed on:
  - glCreateShader, glCreateProgram  — on a 0 result. glCreateProgram tracks the
    name in programMap/reverse/info unconditionally, so a 0 was being recorded
    as a real object; repairing first keeps that bookkeeping honest too.
  - glGetString / glGetIntegerv / glGetInteger64v — retries now call
    RepairHostContextOnce() instead of relying on Bound(), which is what made
    them dead code above.

Two bugs found in this new code and fixed before it shipped, both in the
"looks right in isolation" category:

  1. RepairHostContextOnce v1 read BindFallbackEGLContextIfNeeded()'s return
     value as success/failure. It returns false both for "could not bind" and
     for "already handled this generation", so a thread that already had a
     context never recorded success: every later failure re-ran the whole ladder
     and logged "no fallback context could be bound" about a thread that had
     one. Fixed by reading the state (t_fb.ctx via
     HostContextIsBoundOnThisThread()) rather than inferring it.

  2. A single report counter was shared by two diagnoses, so whichever fired
     first silenced the other — a thread that once failed for an unrelated
     reason could never report "this thread has no context at all". Now two
     independent one-shot flags.

Also drops mg_egl_note_guarded_call() from ScopedHostContext's constructor when
the guard is not running. It was called unconditionally, so every GL call paid
a thread-local increment, a bitmask test and an integer modulo to feed a call
rate describing a guard that was not running. Verified in the generated code:
with the guard off the TLS increment is now emitted only on the branch that the
guard being on would take.

Verification: 6 translation units compile clean with -Wformat; the memoisation
and failure paths are covered by behavioural tests (steady state 0 ladder runs,
generation change exactly 1, failure logged once, recovery after a render-target
change); a 4-scenario end-to-end simulation walks the whole 26.3-pre-3 startup
with the guard off and every step now self-heals; TSan reports no race over 8
threads x 200k calls with concurrent generation churn, with a negative control
confirming the configuration actually detects the same structure made non-atomic.
…endering

The previous two commits turned hostContextGuard off and repaired the two
creation calls that crashed 26.3-pre-3. That was enough to stop the crash and
not enough to draw: the world disappeared, UI and sound were unaffected.

Cause. With the guard on, ScopedHostContext re-checked on every call, so a
worker thread that had no context got one installed by whichever entry point ran
first — and then kept it for the rest of the session. The whole shader pipeline
(create → source → compile → link) therefore ran on a thread that had a context
by the time it needed one. It was an accident, but a load-bearing one.

Turning the guard off made ScopedHostContext a no-op. Repairing glCreateShader
alone meant the name came back valid while glShaderSource and glCompileShader
were still silently discarded: the shader had no source, never compiled, and
every program failed to link. Nothing to draw — while the UI, which uses none of
those pipelines, kept working, and audio was never involved. That is exactly the
reported symptom, and it is reproduced in the tests: create=0 compiled=0
linked=0 before, create/compiled/linked all valid after.

Fix. ScopedHostContext no longer goes back to asking per call; it asks at most
once per thread per render target and is free after that. The question "does
this thread have a context?" now has a single answer, EnsureHostContextOnce(),
shared with RepairHostContextOnce() so the prevention path and the repair path
cannot disagree — a thread repaired by one is considered usable by the other.
Cost per call with the guard off drops from one real eglGetCurrentContext() to a
thread-local read that returns immediately, which preserves the point of the
earlier work.

Also removed the now-dead distinction between "the guard asked" and "the guard
did not ask" in the constructor: both paths settle the same state, they differ
only in whether the check repeats.

Verified: 25 translation units compile clean with -Wformat (the 4 that fail do
so identically before and after this change — missing third-party include paths,
not a regression); a pipeline simulation walks create/source/compile/link on a
context-less worker with the guard off and all four steps now succeed; the same
simulation against the old no-op constructor fails all four; 200k ScopedHostContext
constructions after the first run zero binding ladders; a generation change
re-settles exactly once; a thread that cannot get a context does not re-run the
ladder or spam the log.
…he picture

The real-device regression report (game launches, sound and touch work,
no picture ever) was not a shader-pipeline failure: the log shows every
atlas created, no RepairHostContextOnce line, and the window sequence
completed through 'SDL_Hook: reusing primary window'. The failure was
the SDL swap-gate repair dying with the guard.

RepairSdlCurrentWindow() re-opens SDL's swap gate after the launcher's
window-reuse hook loses SDL's TLS current_glwin; without it SDL refuses
every SDL_GL_SwapWindow and eglSwapBuffers never reaches the driver —
frames rendered, nothing shown. Its only trigger lived inside
mg_egl_note_guarded_call(), whose only caller is the guard-ON branch of
ScopedHostContext. Turning the guard off by default (4405f38) therefore
silenced the one thing that puts the picture on screen.

Whether SDL's TLS was lost has nothing to do with the guard, so the tick
now lives in SdlSwapGateTick(), called from both mg_egl_note_guarded_call()
and the new mg_egl_note_unguarded_call(); ScopedHostContext's guard-off
branch runs the latter. Steady-state cost on the default path is one
thread-local increment plus one modulo — the per-call eglGetCurrentContext()
that the guard used to pay stays gone.

Verified: 34 independently compilable TUs clean (failures are pre-existing
missing third-party includes, confirmed on the base); new sdl_gate_test
covers gate timing, the 8-attempt budget, non-binding-thread exclusion
and the guarded path; all prior regression tests still pass.
…batched indirect

The picture is back (4730713) but the CPU is still busy. Two causes were
visible statically, both hidden behind the settings this branch changed.

1. The PBO CPU shadow outlived its only consumer. Every shadow site —
   glBufferData/glBufferStorage full-size copies, glBufferSubData copies,
   and the glMapBufferRange redirection that replaced the driver's mapped
   pointer with heap memory and pushed it back with glBufferSubData on
   unmap — existed solely to feed the BGRA swizzle. With cpuSwizzle off
   the swizzle short-circuits, so all of that was paid per texture update
   for data nobody read. All shadow maintenance and the map redirection
   are now gated on cpu_swizzle, and glUnmapBuffer's PBO branch is gated
   in lockstep: it used to return GL_TRUE for every PBO unmap without
   calling the host, correct only while the map side redirected, a
   driver-side mapping leak once the map became real.

2. The batched indirect multidraw backends were dead on Adreno. They were
   gated on the GL_EXT_multi_draw_indirect string AND the EXT-suffixed
   symbols, but the batched indirect entry points are GLES 3.2 CORE:
   a 3.2 driver exports glMultiDrawArraysIndirect/glMultiDrawElementsIndirect
   without listing the extension. Adreno reports 3.2 and does not list it
   (log line 'Not Detected GL_EXT_multi_draw_indirect!'), so every
   multi-draw degraded to per-sub-draw submission — thousands of host
   calls per frame. The core names are now resolved and take priority,
   with the EXT path kept for older drivers; the runtime probe and the
   fallback chain are unchanged, so a core-path failure still degrades.

Note for device testing: the resolved order in the log was
'multidrawOrderElements = unroll > indirect', which is NOT the default —
an explicit multidrawOrder/multidrawMode* key in MG/settings.json
overrides the fix. Remove those keys; multiindirect should lead the
resolved order in the new log.

Verified: 34 independently compilable TUs clean, failure list identical
to base; new cpu_opt_test covers the shadow gate in both modes (including
'real maps reach the driver unmap') and backend selection on the three
driver shapes; all prior regression tests pass.
…w never went live

Device logs show the filtered multidraw chains still without "multiindirect"
after 22823e4 (elements = "unroll > indirect", both *Indirect entries =
"indirect"). md_expand_order pads every request with the default order, so the
chain's *content* is decided purely by the capability gates, not by user
config: no multiindirect in the chain means GLES.glMultiDrawElementsIndirect
was null at gate time, and Sodium keeps submitting per sub-draw.

The failure was invisible by construction: INIT_GLES_FUNC reports through
LOG_W and multidraw diagnostics through LOG_D, both gated behind
GLOBAL_DEBUG, so a release build prints nothing. Three changes:

- gles/loader.cpp: when the core indirect names fail to resolve from the
  libGLESv3.so handle, retry from a libGLESv2.so handle (same vendor driver,
  full cumulative 3.x symbol table on Android 10+; eglGetProcAddress is not
  an option - it would find this library's own symbol and recurse). Result
  printed once through LOG_I, which a release build does print.
- config/settings.cpp: init_settings_post() logs the gate verdicts once
  through LOG_W_FORCE next to the filtered-chain dump, so the log answers
  "why no multiindirect" and not just "no multiindirect".
- gl/multidraw.cpp: the arrays multiindirect probe logs its success through
  LOG_V; the failure path already had MD_WARN_ONCE.

Documentation §10.3 revised (user config was wrongly blamed) and §11 added
with the full judgment chain, the silence analysis and the three-line
verification checklist for the next device log.
The core-name resolution this commit was built on rested on a false premise:
multi-draw indirect was promoted to core in desktop GL 4.3 and never entered
any OpenGL ES version -- on GLES it exists only as GL_EXT_multi_draw_indirect.
The device log confirmed it: both libGLESv3 and libGLESv2 dlsym the unsuffixed
names to 0x0 (correct behaviour, not a loader bug), and this Adreno 619 driver
advertises no batched-draw extension at all.

The multidraw emulation backends (unroll/indirect/basevertex/compute) are
verified working on device -- that is the designed path for drivers without
the batched extensions, so this commit serves no purpose. Tree is identical
to 22823e4.
The Elements entry excluded Compute on the rationale that with no base
vertex to apply it would be the same loop as unroll. That is true only of
the CPU-side prefix walk: the pipeline's actual job is fusing the whole
batch into ONE glDrawElements through GPU-side index concatenation, and
it already treats a null basevertex as 0. On the many mobile drivers
with no batched multi-draw extension at all this is the difference
between thousands of per-frame driver draw calls and hundreds.

- gl/multidraw.cpp: the BaseVertex compute body becomes a shared core
  (md_compute_fused) taking the owning entry point; a new
  md_fall_from_compute routes in-pipeline failures through the chain of
  the owner, so an Elements call never escapes into the separately
  configured BaseVertex order. The degenerating stub
  mg_glMultiDrawElements_compute now rides the real pipeline, sharing
  the grow-only scratch and warn-once dedup with the BV entry. Both
  dispatchers (md_call_elements and the func_ptr cache) learn the
  Compute case -- without the latter a compute resolution was silently
  swallowed into unroll.
- config/settings.cpp: Elements' allowed mask admits Compute; a new
  per-entry default order (native > multiindirect > multibasevertex >
  compute > unroll > indirect) lets compute lead on no-batched-extension
  drivers while keeping batched one-call forms ahead where they exist;
  other entries keep the global order. Also corrects the false
  'promoted into 3.2 core' comment next to the multidraw gate: batched
  multi-draw indirect is desktop GL 4.3+ core and never entered GLES.
- tests/md_order_test.cpp: chain-selection model asserting the
  no-config Elements chain (compute > unroll > indirect), batched-form
  precedence, gate-off behaviour, explicit-config precedence, the
  untouched BaseVertex default and owner-aware fallback routing.

NOTE: a device config pinning multidrawOrderElements to unroll>indirect
pads compute to an unreachable last place; such a line must be updated
or removed to pick the fusion up.
…atch routing

A full pass over the per-frame hot paths found no structural wrapper
overhead left (LOG() is a dead branch in release, ScopedHostContext is
one thread-local read per generation, the state entry points already
short-circuit redundant changes, glUniform* is a plain pass-through).
What remains is the number of driver calls, and two costs in the
compute fusion scale badly exactly where Minecraft's foliage renders:

- the fusion scope re-read the generic and four indexed SSBO bindings
  from the driver on EVERY batch -- the most expensive kind of call,
  and one that can flush. The bindings live in the per-context scratch
  now; every out-of-multidraw writer (glBindBuffer, glBindBufferBase/
  Range, buffer deletion, and the atomic-counter emulation, which binds
  its buffers as SSBOs through GLES directly) flips a dirty flag via
  the new mg_multidraw_ssbo_touched(), so the cache can only drift when
  an application actually uses SSBOs -- which Minecraft and Sodium do
  not. This also replaces a stale comment claiming buffer.cpp does not
  record SSBO bindings: track_ssbo_indexed has existed for a while.

- batches with fewer sub-draws than the pipeline's fixed cost (roughly
  17 driver calls: three scratch re-specs, the binding dance, dispatch,
  barrier, fused draw, restore) are a net loss. Dense foliage produces
  exactly those: a small section's cutout layer yields a handful of
  sub-draws. Batches under kComputeMinBatch (12) now route straight to
  the unroll backend -- a routing decision, not a failure, so the
  fallback chain and its counters stay untouched.

- a five-second LOG_V line reports fused batches, sub-draws and
  small-batch routing, so a device log shows the fusion working without
  a debug build.

tests/md_order_test.cpp: cutoff routing, threshold-vs-fixed-cost sanity
and an assertion that routing never moves the fallback tick.
All of these were last touched by the July upstream merge, are absent
from CMakeLists.txt (an explicit file list, no GLOB), and are not
included by any compiled translation unit -- so the green builds have
never contained them:

- gl/impl/ (9 cpp + 9 h): an older export/impl layout superseded by
  gl/gl_stub.cpp + gl/gl_native.cpp + the ExtWrappers
- gl/state/ (Core.cpp/h): an older GLStateManager split, superseded by
  gl/state.cpp
- gl/backend/ (BackendObject/DirectGLES headers + Init.cpp): the
  backend-objekt architecture exists only in comments now; the live
  paths are egl/context_bridge.cpp and the DirectGLES backend in-tree
- gl/transfer.cpp/h: texture-transfer helpers nothing includes
- config/stats.cpp/h: settings stats nothing includes
- egl/context.cpp: egl/context.h stays (context_bridge.cpp includes
  it); this TU's symbols were never linked
- gl/glsl/benchmark_parser.cpp: parser for a benchmark data format no
  target consumes
- bench/multidraw_bench.cpp: an in-process benchmark wired to nothing
  (mg_multidraw_bench_run is referenced from comments only)

Verified: no GLOB in CMakeLists, every compiled TU passes syntax
check, md_order_test still passes. The CI build is the final link
check.
@EternityQwQ
EternityQwQ merged commit 082ce11 into main Sep 15, 2026
1 check passed
@EternityQwQ
EternityQwQ deleted the perf/cpu-overhead-defaults branch September 15, 2026 03:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant