Skip to content

[M2-TILE-01 fix] Resolve the zero-alloc failure site to module+symbol (dladdr) - #77

Merged
offdev merged 3 commits into
masterfrom
fix/m2-tile-01-macos-arm64-alloc
Oct 6, 2026
Merged

offdev merged 3 commits into
masterfrom
fix/m2-tile-01-macos-arm64-alloc

Conversation

@offdev

@offdev offdev commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Master CI (run 37506547846) was red on macOS arm64 (AppleClang): TileMapZeroAlloc.DeclareLoopAllocatesNothing counted 48 heap blocks during the 1000-frame beginFrame/declareTo/build window. macOS Intel later failed the same test too (6 blocks) — every other lane in every run was green.

Root cause

Commit 1 adds dladdr resolution of the first offending site to the failure message (module + symbol on POSIX, raw address on MSVC). The macOS lanes then reported it:

/System/Library/Frameworks/QuartzCore.framework/... +
std::__1::__hash_table<...>::__rehash<true>()      (arm64: 48, then 8 blocks)
std::__1::__hash_table<...>::__do_rehash<true>()   (Intel: 6 blocks)

i.e. a one-time lazy initialization of a hash table inside the QuartzCore system framework, landing asynchronously in whichever armed window catches it after the GL/GLFW suites earlier in the same test binary load the graphics framework chain. It is not engine code:

  • counts vary run to run (48 / 8 / 6) — a time-sliced one-time init, not a deterministic engine path;
  • the second backend instantiation in the same process reads 0;
  • every Linux tree (g++, clang++, ASan, TSan — watcher live on all of them) passes at 0 with the same code;
  • every in-window engine path is pre-allocated by construction (batcher arena + sorter + group storage at create; declareTo is pure index math).

Fix (commit 2)

Two-stage proof, matching what FR-2.2 actually pins ("no per-frame allocation" — steady state):

  • settle stage: re-run the 1000-frame loop under armed windows until a CLEAN window is observed (bounded to 4), absorbing the one-time framework init;
  • proof stage: pin the steady-state zero.

A genuine engine-side first-frame allocation cannot be hidden: the SpriteBatcher* suites (earlier in the same binary) already exercise create/beginFrame/add/build under their own armed windows, and no settle iteration can absorb a recurring allocation — it would fall through to the proof and fail with the resolved site.

Also: the loop's status checks were silent returns (a failed frame let the test pass with no evidence) — they are now ASSERTs.

Verification

  • All six local trees: build 112/112, build-clang 112/112, build-release 101/101, build-shared 112/112, build-asan 109/109, build-tsan 109/109; ctest -R tilemap green; include + determinism lints OK.
  • PR lane (this PR carries ci:macos — one P0 OS per PR by cadence): macOS arm64 + macOS Intel + all platform-independent checks green (run 37514531413). The Linux lanes run on the merge lane per the PRD §14 cadence ("all per merge" via ci.yml).
  • macOS behavior cannot be verified locally (Linux-only machine); the two macOS lanes above are the execution evidence.

… (dladdr)

The macOS arm64 lane failed TileMapZeroAlloc.DeclareLoopAllocatesNothing
with 48 heap blocks during the 1000-frame window (every other tree and
macOS Intel pass; the whole engine window path is allocation-free by
construction). The first-site address alone is not actionable — resolve
it to module + symbol with dladdr (POSIX lanes; raw address on MSVC,
which has no dladdr). Also: the loop's status checks were silent
returns — a failed frame left the test passing without evidence;
they are now ASSERTs.
@offdev offdev added the ci:macos label Oct 6, 2026
offdev added 2 commits October 6, 2026 20:31
… lazy init

Root cause (via the dladdr diagnostic from the previous commit): the
first offending site in the 48/8/6-block macOS failures (arm64 AND
Intel, run to run) is inside the QuartzCore system framework - a
one-time lazy initialization of a framework-internal hash table that
lands asynchronously in whichever armed window catches it after the
GL/GLFW suites earlier in this binary load the graphics framework
chain. The engine window is provably alloc-free (every Linux tree
passes at 0 with the same code; every in-window engine path is
pre-allocated by construction).

Fix: two-stage proof. The settle stage re-runs the 1000-frame loop
under armed windows until a CLEAN window is observed (bounded to 4),
absorbing the one-time init; the proof stage then pins the
steady-state zero-allocation property (FR-2.2). A genuine
engine-side first-frame allocation cannot be hidden: the
SpriteBatcher* suites (earlier in the same binary) already exercise
create/beginFrame/add/build under their own armed windows.
@offdev
offdev merged commit 0b241a9 into master Oct 6, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant