Skip to content

[M1-ALLOC-01] Zero sim-loop allocation assertion (G-R1) - #50

Merged
offdev merged 5 commits into
masterfrom
feat/m1-alloc-01-zero-alloc-assert
Sep 24, 2026
Merged

offdev merged 5 commits into
masterfrom
feat/m1-alloc-01-zero-alloc-assert

Conversation

@offdev

@offdev offdev commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Implements roadmap step M1-ALLOC-01 · Zero sim-loop allocation assertion (G-R1) (roadmap/M1-heartbeat.md), scope bullets only.

What

  • The allocation watch — new src/laige-core/alloc_watch.{h,cpp}: a process-wide heap-allocation counter behind strong global operator new/new[] (+ nothrow, + sized deletes), compiled into every non-sanitizer tree (LAIGE_ALLOC_WATCH=1 PUBLIC; 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.
  • The per-tick assertion — GameLoop::runOneTick (debug builds): arm before the tick body, read after a completed tick; allocs ≠ 0 → one alloc/sim_tick_allocation Error event (fields tick/allocs/site) + the debug assert (FR-12.3: actionable, never silent). Failed ticks are not checked. Release compiles the check out (pool-overflow degradation + the simAllocs delta remain). This is the standing hot-path guardrail for every later sim/render step (roadmap README §6).
  • The attribution contract — the engine's own cold-path event emission inside a tick (a G-R5 budget_overrun/budget_critical, a replay record_failed, a guardrail warn) is the diagnostic subsystem's memory, not the sim loop's: the logging facade's emit path wraps its work in laige::detail::LoggingAllocationGuard (the LAIGE_LOG macro 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 the GameLoop (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 deliberate std::vector in 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_tests HeadlessFramePathAllocatesNothing now reads per-tick window semantics (the engine's three one-shot setup allocations land before the first arm).

Verification (local)

  • ctest -R zero_alloc green; full canonical ctest 92/92; and 92/92 on build-release, build-shared, build-asan, build-tsan, build-clang.
  • Zero warnings on every tree; tools/laige-determinism-lint + tools/laige-include-lint OK.
  • laige-api.json regenerated (820 symbols from 25 headers); api-real-tree green.

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.md cross-ref; docs/README.md index.

Roadmap: box checked, Progress Board M1 22→23 / Total 40→41, Change Log row (commit/PR recorded in the follow-up commit, house convention).

- 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.
@offdev
offdev merged commit c9b9f5b into master Sep 24, 2026
11 checks passed
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