[M1-ALLOC-01] Zero sim-loop allocation assertion (G-R1) - #50
Merged
Merged
Conversation
- The allocation watch (new public header src/laige-core/include/laige/alloc_watch.h
+ src/laige-core/alloc_watch.cpp): a process-wide heap-allocation counter behind
strong global operator new/new[] (+ nothrow, + sized deletes) in laige-core,
compiled into every non-sanitizer tree (LAIGE_ALLOC_WATCH=1 PUBLIC on laige-core;
the sanitizer trees degrade to inline no-ops with allocWatchLive() false — the
established fallback: the leak-free sanitizer run + the pool reservation delta).
Armed-window model: allocWatchArm() = three relaxed stores (first-site, count,
armed flag); allocWatchRead() = two relaxed loads returning
AllocWatchReading{allocs, firstSite}. The first offending call site is the
allocating call's own return address (__builtin_return_address(0) on GCC/Clang,
__return_address on MSVC), evaluated in the operator-new frame; the first
offender wins via a relaxed CAS that fails once recorded.
- The attribution contract (laige::detail::LoggingAllocationGuard): the logging
facade's emit path (the LAIGE_LOG macro block + Logger::record) marks its own
heap work — the field value strings, the rate-state, the sink's message
formatting — so it is not attributed to the sim loop's G-R1 window. The
engine's documented in-tick degradations (a G-R5 budget_overrun/budget_critical,
a replay record_failed, a guardrail warn) still log (NFR-13.3, rate-limited,
actionable) and never trip G-R1; any other in-tick allocation (a system's local
std::vector, engine storage growth) still fails at its call site.
- The per-tick check (GameLoop::runOneTick, #if !NDEBUG): arm BEFORE the tick
body (beginFrame + runSystems + attached profiler + onTick hook + replay
recorder), read AFTER a completed tick (status.ok() — a failed tick is not
checked, the profiler's "a failed tick is not recorded" contract). A nonzero
count logs one alloc/sim_tick_allocation Error event (fields tick/allocs/site)
and then fails the debug assert (FR-12.3: actionable, never silent). Release
builds compile the whole check out (CPP-012): an allocating tick degrades
through the already-logged pool accounting + the per-frame simAllocs delta.
The check is the standing hot-path guardrail for every later sim/render step
(roadmap README §6).
- Tests: the new ZeroAlloc suite + zero_alloc CTest entry (TSan property list):
the 10k-entity M1-ECS-07 workload through the GameLoop (700 direct warm-up
ticks bring every archetype to its high water before the window; then 10k
ticks at 60 Hz on the synthetic clock, kTickNs = 16666667 = ceil(1e9/60)) —
per-tick window reads 0 allocs, reservation delta 0, rows/entity invariants,
FNV-1a visit checksum, machine-greppable zero-alloc window line; a scratch
system with a deliberate std::vector fails the tick assert (forked SIGABRT
child, POSIX; GTEST_SKIP on Windows; release/sanitizer branch runs 5 clean
ticks); the watch's first-site capture checked directly. The test-side
counter shim moves to laige-core (tests/**/logging_alloc_counter.h wraps the
watch; the LAIGE_ALLOC_COUNTER test define is gated on the same trees as
LAIGE_ALLOC_WATCH). HeadlessFramePathAllocatesNothing (engine_tests) now
reads per-tick window semantics (the engine's three one-shot setup
allocations land before the first arm).
- Docs in the same change: docs/api/alloc_watch.md (new — window model, the
per-tick assertion, the attribution contract, release builds, scope, cost,
threading, misuse, example) + game_loop.md (the zero-allocation section +
the Performance cost line) + profiler.md cross-ref + the docs/README index;
laige-api.json regenerated (820 symbols from 25 headers, api-real-tree green).
- Local Verify: ctest -R zero_alloc green; the full canonical ctest 92/92 and
92/92 on build-release/build-shared/build-asan/build-tsan/build-clang;
zero warnings on every tree; the determinism + include lints OK.
The game_loop.cpp arm-site comment and the alloc_watch.h preamble still described the pre-attribution behavior (hot-path log counted, one disarmed-load cost). Both now state the attribution contract and the two-load disarmed cost.
…un 35988075605 green)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements roadmap step M1-ALLOC-01 · Zero sim-loop allocation assertion (G-R1) (roadmap/M1-heartbeat.md), scope bullets only.
What
src/laige-core/alloc_watch.{h,cpp}: a process-wide heap-allocation counter behind strong globaloperator new/new[](+ nothrow, + sized deletes), compiled into every non-sanitizer tree (LAIGE_ALLOC_WATCH=1PUBLIC; sanitizer trees degrade to inline no-ops — the fallback is the leak-free sanitizer run + the pool reservation delta, the M0-CORE-02/05 pattern). Armed-window model:allocWatchArm()(3 relaxed stores) /allocWatchRead()(2 relaxed loads →AllocWatchReading{allocs, firstSite}); the first offending call site is the allocating call's own return address (the GCC/Clang builtin, MSVC__return_address), first offender wins via a relaxed CAS.GameLoop::runOneTick(debug builds): arm before the tick body, read after a completed tick;allocs ≠ 0→ onealloc/sim_tick_allocationError event (fieldstick/allocs/site) + the debug assert (FR-12.3: actionable, never silent). Failed ticks are not checked. Release compiles the check out (pool-overflow degradation + thesimAllocsdelta remain). This is the standing hot-path guardrail for every later sim/render step (roadmap README §6).budget_overrun/budget_critical, a replayrecord_failed, a guardrail warn) is the diagnostic subsystem's memory, not the sim loop's: the logging facade's emit path wraps its work inlaige::detail::LoggingAllocationGuard(theLAIGE_LOGmacro block +Logger::record), so those documented degradations still log and never trip G-R1 — while any other in-tick allocation still fails at its call site.Tests (
ctest -R zero_alloc)ZeroAlloc.TenKWorkloadThroughTheLoopAllocatesNothing— the 10k-entity M1-ECS-07 workload through theGameLoop(700 direct warm-up ticks + 10k ticks @ 60 Hz synthetic clock,kTickNs = 16666667 = ceil(1e9/60)): per-tick window 0 allocs, reservation delta 0, rows/entity invariants, FNV-1a checksum.ZeroAlloc.ScratchSystemAllocationFailsTheTickAssertion— a deliberatestd::vectorin a scratch system fails the assertion (forked SIGABRT child; the Verify clause's deliberate-then-revert scratch, kept as the standing negative test — the violation lives in the test TU only).ZeroAlloc.WatchCountsAllocationsAndCapturesTheFirstSite— the watch's own counting + first-site capture.engine_testsHeadlessFramePathAllocatesNothingnow reads per-tick window semantics (the engine's three one-shot setup allocations land before the first arm).Verification (local)
ctest -R zero_allocgreen; full canonicalctest92/92; and 92/92 onbuild-release,build-shared,build-asan,build-tsan,build-clang.tools/laige-determinism-lint+tools/laige-include-lintOK.laige-api.jsonregenerated (820 symbols from 25 headers);api-real-treegreen.Docs (same change, CORE-006/DOC-007)
docs/api/alloc_watch.md(new): window model, the per-tick assertion, the attribution contract, release builds, scope, cost, threading, misuse, example.docs/api/game_loop.md: the "The zero-allocation check (M1-ALLOC-01, G-R1)" section + the Performance cost line;docs/api/profiler.mdcross-ref;docs/README.mdindex.Roadmap: box checked, Progress Board M1 22→23 / Total 40→41, Change Log row (commit/PR recorded in the follow-up commit, house convention).