From b5f973bd28070b3ddd04e13d064a5e61b9096e72 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Tue, 6 Oct 2026 22:29:32 +0200 Subject: [PATCH 1/2] [M1-ALLOC-01 fix] Attribute the alloc window to the owner thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Master run 37519998162: macOS arm64 STILL failed TileMapZeroAlloc after the settle-window fix — 48 blocks, first site now resolved to SkyLight +CGSSnarfAndDispatchDatagrams (the WindowServer client's datagram dispatch). The macOS graphics framework chain (loaded by the GL/GLFW suites earlier in the same binary) keeps doing background heap work on its own threads — first QuartzCore's one-time init, now ongoing WindowServer datagram dispatch — and the process-wide window was counting it. 'Settle until clean' is a coin flip against an ongoing background activity; it is not the fix. Root cause: the watch's contract (G-R1, FR-2.2) is that the LOOP'S THREAD — the tick path — allocates nothing; it never said every thread in the process must. The process-wide counter measured the wrong thing: any background thread (framework, render, OS) that allocates during a window was tripping the loop's invariant. Fix: owner-thread attribution. allocWatchArm() records the calling thread as the window's owner; watchRecord() counts only the owner thread's allocations (plus the existing logging-emit exclusion). This is strictly more correct for the per-tick assertion too — it could previously false-fire on any background allocation during a tick. The disarmed hot path is unchanged; the armed non-owner path costs two loads + a branch. - New regression test ZeroAlloc.WatchExcludesNonOwnerThreads (a non-owner thread's allocation inside an armed window is not counted; fails on the old process-wide semantics). - tilemap zero-alloc test: the settle workaround is removed — a single honest window, now structurally immune to framework background work; the failure message keeps the dladdr site resolution (module + symbol). - Contract updated in alloc_watch.h, docs/api/alloc_watch.md, game_loop.h/.cpp, and the related test/doc comments; laige-api.json regenerated (symbol set unchanged — line shifts + one summary text). --- docs/README.md | 7 +- docs/api/alloc_watch.md | 63 +++++++++++------ docs/api/archetype.md | 2 +- docs/api/game_loop.md | 5 +- docs/api/query.md | 5 +- laige-api.json | 78 ++++++++++----------- src/laige-core/alloc_watch.cpp | 41 +++++++---- src/laige-core/include/laige/alloc_watch.h | 78 +++++++++++++-------- src/laige-sim/game_loop.cpp | 6 +- src/laige-sim/include/laige/sim/game_loop.h | 19 ++--- tests/laige-render/depth_sort_tests.cpp | 4 +- tests/laige-render/iso_picking_tests.cpp | 2 +- tests/laige-render/sprite_frames_tests.cpp | 2 +- tests/laige-render/tilemap_tests.cpp | 32 +++------ tests/laige-sim/archetype_tests.cpp | 4 +- tests/laige-sim/zero_alloc_tests.cpp | 52 +++++++++++++- tools/bench/laige-bench.cpp | 4 +- 17 files changed, 246 insertions(+), 158 deletions(-) diff --git a/docs/README.md b/docs/README.md index abef6fd..f3245ba 100644 --- a/docs/README.md +++ b/docs/README.md @@ -165,9 +165,10 @@ still to land. flags (M0-CORE-03/04; ADR 0002). - [Memory pools](api/pools.md) — `ArenaPool` and `Pool` with generation-checked handles and `PoolStats` accounting (M0-CORE-05). -- [Allocation watch](api/alloc_watch.md) — the process-wide heap - allocation counter behind the G-R1 zero-sim-loop-allocation - guardrail: the armed-window model, the per-tick debug assertion +- [Allocation watch](api/alloc_watch.md) — the heap-allocation + counter (owner-thread attribution) behind the G-R1 + zero-sim-loop-allocation guardrail: the armed-window model, the + per-tick debug assertion (game_loop.md), the release fallback, and the sanitizer scope (M1-ALLOC-01). - [Bounded JSON](api/json.md) — `laige::JsonValue`, `parseJson`, diff --git a/docs/api/alloc_watch.md b/docs/api/alloc_watch.md index e73e2d9..536e991 100644 --- a/docs/api/alloc_watch.md +++ b/docs/api/alloc_watch.md @@ -1,6 +1,6 @@ # Allocation watch (`laige::allocWatch*`) -The process-wide heap-allocation counter behind the G-R1 +The heap-allocation counter behind the G-R1 zero-allocation guardrail (M1-ALLOC-01; PRD §8.1, §9.3, `budgets.json` `sim_heap_allocs` target 0, AGENTS PERF-003, CORE-001, FR-12.3). Public header: `src/laige-core/include/laige/alloc_watch.h`; @@ -32,11 +32,20 @@ laige::AllocWatchReading r = laige::allocWatchRead(); // (nullptr while none) ``` -- `allocWatchArm()` resets the window's count and clears the first - site, then arms. One armed window at a time; a window stays live - until the next arm. O(1), no allocation. +- `allocWatchArm()` records the calling thread as the window's + **owner**, resets the window's count, and clears the first site, + then arms. One armed window at a time; a window stays live until the + next arm. O(1), no allocation. - `allocWatchRead()` reads the count plus the first offending call site. O(1), no allocation. +- **Owner-thread attribution:** while a window is armed, only the + **owner thread's** heap allocations are counted. Allocations from + other threads during the window are not counted: G-R1 measures the + loop's own tick path (the simulation is single-threaded, PRD + §10.2), and other threads' heap work — a background render thread, + OS/framework facilities (observed in CI: a macOS WindowServer + datagram dispatch during an armed window) — is not that path. Same + attribution principle as the logging-emit exclusion below. - `allocWatchLive()` is true in every tree where the counting backend is compiled in (every non-sanitizer tree); false in the sanitizer trees, where the watch degrades to inline no-ops (see the scope @@ -67,14 +76,17 @@ and the assert follows it immediately, so a second event never matters), then the debug assert breaks the build run with the event's fix text in the condition string. -The window covers **everything the tick runs that is the sim +The window covers **everything the tick thread runs that is the sim loop's own work**: the systems, the `onTick` hook, the replay recorder, engine storage growth (archetype column doublings, table growth), and any other heap use of the tick (a system's local -`std::vector`, a pool's backing store). A **failed** tick is not -checked (the profiler's "a failed tick is not recorded" contract, -api/profiler.md): a tick whose systems did not complete ran no user -work to blame, and its validation error is already actionable. +`std::vector`, a pool's backing store) — on the tick thread (the +window's owner); other threads' heap work during the tick is not the +tick path and is not counted (owner-thread attribution, above). A +**failed** tick is not checked (the profiler's "a failed tick is not +recorded" contract, api/profiler.md): a tick whose systems did not +complete ran no user work to blame, and its validation error is +already actionable. **Attribution — what is NOT the sim loop's heap:** G-R1's budget is `sim_heap_allocs` (budgets.json) — the sim loop's own heap: storage, @@ -118,16 +130,18 @@ variants) in `laige-core`, linked ahead of the CRT's weak defaults: - **Static build trees (the default, every P0 OS):** the overrides sit in the executable's link — an armed window sees **every** heap - allocation in the process: the engine, the pools, game systems, - test frameworks. + allocation in the process (the engine, the pools, game systems, + test frameworks) and counts the owner thread's (the attribution + above). - **Shared build trees:** the overrides live inside the laige-core image. On POSIX, dynamic linking interposes them process-wide (an executable's `operator new` call resolves to the library's definition); on Windows there is no cross-image interposition, so an armed window sees the allocations made **inside the engine images** — the engine allocators and the pools, which is the sim loop's - storage — but not allocations made in the executable itself. The - canonical (static) trees give full process coverage everywhere. + storage — and counts the owner thread's of those; allocations made + in the executable itself are not seen. The canonical (static) trees + give full process coverage everywhere. - **Sanitizer trees (`LAIGE_ASAN` / `LAIGE_TSAN`):** the sanitizer runtimes own `operator new`/`delete`, so the counting backend is **not** compiled in and the header degrades to inline no-ops @@ -151,9 +165,10 @@ watch is live. | Path | Cost | |---|---| | Per allocation, window **disarmed** | one logging-depth load + one armed-flag load + two branches (no counter traffic) | -| Per allocation, window **armed**, outside a diagnostic emit | one logging-depth load, one armed-flag load, one `fetch_add`, one CAS that fails once the first site is recorded | +| Per allocation, window **armed**, owner thread, outside a diagnostic emit | one logging-depth load, one armed-flag load, one owner-thread load, one `fetch_add`, one CAS that fails once the first site is recorded | +| Per allocation, window **armed**, another thread | one logging-depth load, one armed-flag load, one owner-thread load + one branch (not counted — the attribution above) | | Per allocation, inside a diagnostic emit (the attribution contract) | one logging-depth load + one branch (the emission's own work, not counted) | -| Per completed tick, debug builds | one arm (three atomic stores: first-site, count, armed flag) + one read (two atomic loads) | +| Per completed tick, debug builds | one arm (four atomic stores: owner thread, first-site, count, armed flag) + one read (two atomic loads) | | Release builds | the tick check is compiled out; the disarmed allocation path remains | No allocation, no logging, no lock on any healthy path (LOG-003, @@ -163,13 +178,17 @@ should not happen. ## Threading (CONC-001) -The armed window has exactly one owner: the **sim owner thread** -(the simulation is single-threaded, PRD §10.2). The loop's tick path -is the only caller that arms, so windows never nest. The counters are -relaxed atomics: an allocation from another thread during an armed -window is counted (it happened during the tick — the diagnostic says -so) but it is **observation data, not simulation state** (ARCH-009): -it never enters the tick count, the state hash, or a replay. +The armed window has exactly one owner: the thread that called +`allocWatchArm()` (for the per-tick check: the **sim owner thread** — +the simulation is single-threaded, PRD §10.2; for tests: the test +thread). The loop's tick path is the only engine caller that arms, so +windows never nest. The counters are relaxed atomics. An allocation +from **another thread** during an armed window is **not counted**: +the window measures the owner thread's tick path (G-R1), and other +threads' heap work — a background render thread, OS/framework +facilities — is not that path. The counters are **observation data, +not simulation state** (ARCH-009): they never enter the tick count, +the state hash, or a replay. ## Misuse warnings diff --git a/docs/api/archetype.md b/docs/api/archetype.md index 816ef8d..43bae78 100644 --- a/docs/api/archetype.md +++ b/docs/api/archetype.md @@ -71,7 +71,7 @@ Each column block is reserved, not sized, to the rows it holds: events per archetype (≤ 10 for a 10k world). - **Steady state:** an add/remove that does not hit a full archetype allocates nothing — the churn test proves it: zero reservation - delta over the window **and** zero process-wide allocations + delta over the window **and** zero allocations on the churn thread (test-only `operator new` counter, non-sanitizer trees; the sanitizer trees prove the same property with a leak-free run of the same loop — M1-ALLOC-01 lands the standing assertion). diff --git a/docs/api/game_loop.md b/docs/api/game_loop.md index e1a5f32..bd98e9f 100644 --- a/docs/api/game_loop.md +++ b/docs/api/game_loop.md @@ -219,8 +219,9 @@ tick path MUST allocate nothing — the `sim_heap_allocs` budget (budgets.json) targets 0 allocs/frame. `runOneTick` enforces it directly, per tick: -- **Arm** — `laige::allocWatchArm()` (the process-wide allocation - watch, [api/alloc_watch.md](alloc_watch.md)) starts a fresh window +- **Arm** — `laige::allocWatchArm()` (the allocation watch, owner: + the tick thread, [api/alloc_watch.md](alloc_watch.md)) starts a + fresh window **before** the tick body (the `beginFrame` + `runSystems` dispatch, plus the attached profiler, `onTick` hook, and replay recorder). - **Read + assert** — **after a completed tick** (`status.ok()`), diff --git a/docs/api/query.md b/docs/api/query.md index 17adfeb..ab7da4d 100644 --- a/docs/api/query.md +++ b/docs/api/query.md @@ -124,8 +124,9 @@ assert fires before the log). - **Zero-alloc evidence (CORE-001):** `QueryZeroAlloc` runs 10k entities × `{Pos, Vel}` through a Read/Write pass (a legal in-place write per visit) and a Read/Read pass in a reset allocation-counter - window (non-sanitizer trees): zero process-wide heap allocations, - zero reservation delta (pool-steady), and prints the machine- + window (non-sanitizer trees): zero heap allocations on the + iteration thread, zero reservation delta (pool-steady), and prints + the machine- greppable `query-iteration ` lines to the ctest output. The sanitizer trees cover the same loop leak-free; M1-ALLOC-01 lands the standing assertion. diff --git a/laige-api.json b/laige-api.json index e986b8e..fe0037c 100644 --- a/laige-api.json +++ b/laige-api.json @@ -43,15 +43,15 @@ "src/laige-sim/include/laige/sim/system.h" ], "symbols": [ - {"name": "laige::AllocWatchReading", "kind": "struct", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 132, "signature": "struct AllocWatchReading", "summary": "One armed-window reading (see the header preamble): the heap- allocation count since the last arm() and the call site of the first offending allocation (nullptr while none).", "budget": null, "experimental": false}, - {"name": "laige::AllocWatchReading::allocs", "kind": "variable", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 133, "signature": "std::uint64_t allocs{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::AllocWatchReading::firstSite", "kind": "variable", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 134, "signature": "const void* firstSite{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::allocWatchArm", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 141, "signature": "void allocWatchArm() noexcept", "summary": "Start a fresh watch window: reset the window's allocation count and clear the first-site capture (see the header preamble for the window model, the cost, and the threading contract). O(1), no allocation.", "budget": null, "experimental": false}, - {"name": "laige::allocWatchRead", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 145, "signature": "[[nodiscard]] AllocWatchReading allocWatchRead() noexcept", "summary": "Read the current armed window (see AllocWatchReading). O(1), no allocation.", "budget": null, "experimental": false}, - {"name": "laige::allocWatchLive", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 151, "signature": "[[nodiscard]] bool allocWatchLive() noexcept", "summary": "True when the process-wide counting backend is compiled into this build (every non-sanitizer tree); false in the sanitizer trees, where the runtimes own operator new/delete and the watch is a no-op (the header's scope section).", "budget": null, "experimental": false}, - {"name": "laige::allocWatchArm", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 179, "signature": "inline void allocWatchArm() noexcept", "summary": "The no-op fallback (the sanitizer trees): the counting backend is not compiled in, so an armed window never sees anything. The functions are inline no-ops — the engine's per-tick check and the test-side probes (tests/**/logging_alloc_counter.h) compile unchanged and read zero.", "budget": null, "experimental": false}, - {"name": "laige::allocWatchRead", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 180, "signature": "inline AllocWatchReading allocWatchRead() noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::allocWatchLive", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 183, "signature": "inline bool allocWatchLive() noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::AllocWatchReading", "kind": "struct", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 148, "signature": "struct AllocWatchReading", "summary": "One armed-window reading (see the header preamble): the heap- allocation count since the last arm() and the call site of the first offending allocation (nullptr while none).", "budget": null, "experimental": false}, + {"name": "laige::AllocWatchReading::allocs", "kind": "variable", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 149, "signature": "std::uint64_t allocs{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::AllocWatchReading::firstSite", "kind": "variable", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 150, "signature": "const void* firstSite{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::allocWatchArm", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 157, "signature": "void allocWatchArm() noexcept", "summary": "Start a fresh watch window: reset the window's allocation count and clear the first-site capture (see the header preamble for the window model, the cost, and the threading contract). O(1), no allocation.", "budget": null, "experimental": false}, + {"name": "laige::allocWatchRead", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 161, "signature": "[[nodiscard]] AllocWatchReading allocWatchRead() noexcept", "summary": "Read the current armed window (see AllocWatchReading). O(1), no allocation.", "budget": null, "experimental": false}, + {"name": "laige::allocWatchLive", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 167, "signature": "[[nodiscard]] bool allocWatchLive() noexcept", "summary": "True when the counting backend is compiled into this build (every non-sanitizer tree); false in the sanitizer trees, where the runtimes own operator new/delete and the watch is a no-op (the header's scope section).", "budget": null, "experimental": false}, + {"name": "laige::allocWatchArm", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 195, "signature": "inline void allocWatchArm() noexcept", "summary": "The no-op fallback (the sanitizer trees): the counting backend is not compiled in, so an armed window never sees anything. The functions are inline no-ops — the engine's per-tick check and the test-side probes (tests/**/logging_alloc_counter.h) compile unchanged and read zero.", "budget": null, "experimental": false}, + {"name": "laige::allocWatchRead", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 196, "signature": "inline AllocWatchReading allocWatchRead() noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::allocWatchLive", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 199, "signature": "inline bool allocWatchLive() noexcept", "summary": null, "budget": null, "experimental": false}, {"name": "laige::HistogramStats", "kind": "struct", "header": "src/laige-core/include/laige/budget_harness.h", "line": 155, "signature": "struct HistogramStats", "summary": "Summary statistics over the samples currently stored in a Histogram (rolling window). When n == 0 the six statistics are NaN (check n; budgetCheck turns an empty histogram into a loud NO_SAMPLES failure).", "budget": null, "experimental": false}, {"name": "laige::HistogramStats::n", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 156, "signature": "std::uint64_t n", "summary": null, "budget": null, "experimental": false}, {"name": "laige::HistogramStats::min", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 157, "signature": "double min", "summary": null, "budget": null, "experimental": false}, @@ -1106,36 +1106,36 @@ {"name": "laige::FrameBudgetReport::passed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 283, "signature": "bool passed{}", "summary": "The overall pass/flag (the `overall=` line): false when any frame, budget entry, or system failed (NO_SAMPLES / NO_ENTRY included).", "budget": null, "experimental": false}, {"name": "laige::FrameBudgetReport::report", "kind": "variable", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 285, "signature": "std::string report", "summary": "The machine-greppable report text (AGENTS §12 field format).", "budget": null, "experimental": false}, {"name": "laige::buildFrameBudgetReport", "kind": "function", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 300, "signature": "[[nodiscard]] FrameBudgetReport buildFrameBudgetReport( const FrameBudgetRecorder& recorder, const Profiler& profiler, const World& world, const BudgetTable& budgets, const FrameBudgetReportOptions& options = {})", "summary": "Build the frame graph / budget report (cold path). `recorder` is the run's per-frame records, `profiler` the always-on counters (the tick window feeds the sim_tick_avg / sim_tick_p99 checks), `world` the run's world (the per-system declared budgets and the M1-SYS-03 windows — read cold, never mutated), `budgets` the budgets.json table (the sim_tick_avg / sim_tick_p99 / sim_heap_allocs entries). The report evaluates every declared budget (the preamble's semantics) and formats the text. Every failure state is loud in the text (NO_SAMPLES / NO_ENTRY / FAIL lines) — never silent (CORE-008). (the report string).", "budget": "O(systemCount × n log n + window) cold path; allocates", "experimental": false}, - {"name": "laige::kMinTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 316, "signature": "inline constexpr std::uint32_t kMinTickRateHz = 20", "summary": "The supported tick-rate range (FR-1.1: default 60 Hz, configurable 20–120 Hz). Named constants (CORE-005): a rate outside this range is rejected at loop construction.", "budget": null, "experimental": false}, - {"name": "laige::kDefaultTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 319, "signature": "inline constexpr std::uint32_t kDefaultTickRateHz = 60", "summary": "The default tick rate (FR-1.1).", "budget": null, "experimental": false}, - {"name": "laige::kMaxTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 321, "signature": "inline constexpr std::uint32_t kMaxTickRateHz = 120", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::kDefaultMaxCatchUpTicks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 331, "signature": "inline constexpr std::uint32_t kDefaultMaxCatchUpTicks = 5", "summary": "The default max catch-up ticks per frame (CORE-005): at the default 60 Hz, one catch-up frame may run at most 5 ticks (~83 ms of simulation time) before the frame's demand is dropped and logged. A healthy machine runs 1 tick per frame (frames slower than the tick rate run 2–3, still under the bound); a drop fires only when a frame exceeds (maxCatchUpTicks + 1) ticks of simulation time — a real overload, not a cadence difference. Raising it is typed configuration (an ADR if the engine default changes), not a knob.", "budget": null, "experimental": false}, - {"name": "laige::GameLoopStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 343, "signature": "struct GameLoopStats", "summary": "The since-construction accounting snapshot of one GameLoop (M1-PROF-01 feed; a plain value, the EntityStats/ SystemTimingStats precedent):", "budget": null, "experimental": false}, - {"name": "laige::GameLoopStats::frames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 344, "signature": "std::uint64_t frames{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GameLoopStats::ticks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 345, "signature": "std::uint64_t ticks{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GameLoopStats::droppedTicks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 346, "signature": "std::uint64_t droppedTicks{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GameLoopStats::droppedFrames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 347, "signature": "std::uint64_t droppedFrames{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GameLoop", "kind": "class", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 353, "signature": "class GameLoop", "summary": "The fixed-timestep accumulator loop (M1-LOOP-01): see the header preamble for the accumulator, configuration, overload, beginFrame, failure, determinism, performance, and threading contracts.", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 358, "signature": "struct Options", "summary": "The typed loop configuration (API-006): the tick rate (20–120 Hz, validated at construction), the max catch-up ticks per frame (>= 1, validated), and the clock source.", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::Options::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 361, "signature": "std::uint32_t tickRateHz{kDefaultTickRateHz}", "summary": "The simulation tick rate in HERTZ (FR-1.1: 20–120 validated; default kDefaultTickRateHz).", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::Options::maxCatchUpTicks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 364, "signature": "std::uint32_t maxCatchUpTicks{kDefaultMaxCatchUpTicks}", "summary": "The max ticks one frame may run before its due-tick demand is dropped (and logged): >= 1 (default kDefaultMaxCatchUpTicks).", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::Options::ClockFn", "kind": "alias", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 370, "signature": "using ClockFn = std::int64_t (*)()", "summary": "The clock source: nanoseconds since a fixed monotonic epoch (the same time base as the default clock below). nullptr uses the headless monotonic clock (steady_clock); a test or the M2 windowed clock supplies its own (injectable for tests — the LoggerOptions::ClockFn precedent).", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::Options::nowNs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 371, "signature": "ClockFn nowNs{nullptr}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GameLoop::Options::TickFn", "kind": "alias", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 379, "signature": "using TickFn = void (*)(void* context, World& world, std::uint64_t tick) noexcept", "summary": "Optional per-completed-tick callback (M1-LOOP-02; see the preamble \"Per-tick presentation hook\"): fires after every completed tick as onTick(context, world, tick). nullptr (default): no hook (the M1-LOOP-01 behavior). Plain function pointer — no std::function (PERF-006); the callback must be bounded and allocation-free (the snapshot's onTick is the reference contract).", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::Options::onTick", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 381, "signature": "TickFn onTick{nullptr}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GameLoop::Options::onTickContext", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 384, "signature": "void* onTickContext{nullptr}", "summary": "The onTick callback's user context (opaque; must outlive the loop — the engine passes the PresentationSnapshot, M1-HEAD-01).", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::Options::profiler", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 396, "signature": "Profiler* profiler{nullptr}", "summary": "The per-completed-tick profiler (M1-PROF-01): when non-null and enabled, runOneTick times each tick (the M0-CORE-08 TimeIt — two steady_clock reads) and hands the measured ms to Profiler::recordTick; a failed tick is not recorded (the tick counts only when the system phase completes — the preamble \"Failure behavior\"). nullptr (the default): no tick timing — one branch per tick, nothing else (DBG-004; the measured enabled cost is bounded at 1% of a 10k-entity tick — docs/benchmarks/baselines/m1-profiler-cost.md). NON-OWNING: the profiler must outlive the loop (the onTickContext lifetime contract).", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 408, "signature": "[[nodiscard]] static Result create(World& world, const SystemSchedule& schedule, Options options) noexcept", "summary": "Construct the loop on `world` running `schedule` (setup phase, after World::scheduleSystems — the schedule must describe the world's CURRENT registry, and both must outlive the loop). O(1); no allocation (the loop state is fixed scalars).", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::frame", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 426, "signature": "[[nodiscard]] Status frame() noexcept", "summary": "Advance one presentation frame (the hot path; see the preamble \"Performance\"): read the clock, run the frame's due ticks (up to maxCatchUpTicks), drop the excess with a rate-limited warn.", "budget": "O(maxCatchUpTicks × per-tick system work); bounded, no allocation.", "experimental": false}, - {"name": "laige::GameLoop::currentTick", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 430, "signature": "[[nodiscard]] std::uint64_t currentTick() const noexcept", "summary": "The number of completed ticks (0 before the first; the first tick to complete is tick 1). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::startReferenceNs", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 439, "signature": "[[nodiscard]] std::int64_t startReferenceNs() const noexcept", "summary": "The clock reading that established the start reference (0 before the first frame) — the time-base origin of the due computation (the preamble \"The exact due computation\"). The M1-LOOP-02 PresentationSnapshot takes this as its start reference (presentation.h: the tick anchors A(T) = startNs + T × 10⁹ / rate must use the loop's own time base). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::tickRateHz", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 442, "signature": "[[nodiscard]] std::uint32_t tickRateHz() const noexcept", "summary": "The configured tick rate (Hz). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::maxCatchUpTicks", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 446, "signature": "[[nodiscard]] std::uint32_t maxCatchUpTicks() const noexcept", "summary": "The configured max catch-up ticks per frame. O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 451, "signature": "[[nodiscard]] GameLoopStats stats() const noexcept", "summary": "The since-construction accounting snapshot (GameLoopStats). O(1), no allocation, no side effects (a pure query, the World::stats() precedent).", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::GameLoop", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 457, "signature": "GameLoop(GameLoop&& other) noexcept", "summary": "Move transfers the tick state; the source becomes a valid but STOPPED loop (frame() returns InvalidArgument, no log — see the preamble \"Failure behavior\"; the World moved-from precedent: the source is left in a well-defined state).", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 458, "signature": "GameLoop& operator=(GameLoop&& other) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GameLoop::GameLoop", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 459, "signature": "GameLoop(const GameLoop&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GameLoop::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 460, "signature": "GameLoop& operator=(const GameLoop&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kMinTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 319, "signature": "inline constexpr std::uint32_t kMinTickRateHz = 20", "summary": "The supported tick-rate range (FR-1.1: default 60 Hz, configurable 20–120 Hz). Named constants (CORE-005): a rate outside this range is rejected at loop construction.", "budget": null, "experimental": false}, + {"name": "laige::kDefaultTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 322, "signature": "inline constexpr std::uint32_t kDefaultTickRateHz = 60", "summary": "The default tick rate (FR-1.1).", "budget": null, "experimental": false}, + {"name": "laige::kMaxTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 324, "signature": "inline constexpr std::uint32_t kMaxTickRateHz = 120", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kDefaultMaxCatchUpTicks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 334, "signature": "inline constexpr std::uint32_t kDefaultMaxCatchUpTicks = 5", "summary": "The default max catch-up ticks per frame (CORE-005): at the default 60 Hz, one catch-up frame may run at most 5 ticks (~83 ms of simulation time) before the frame's demand is dropped and logged. A healthy machine runs 1 tick per frame (frames slower than the tick rate run 2–3, still under the bound); a drop fires only when a frame exceeds (maxCatchUpTicks + 1) ticks of simulation time — a real overload, not a cadence difference. Raising it is typed configuration (an ADR if the engine default changes), not a knob.", "budget": null, "experimental": false}, + {"name": "laige::GameLoopStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 346, "signature": "struct GameLoopStats", "summary": "The since-construction accounting snapshot of one GameLoop (M1-PROF-01 feed; a plain value, the EntityStats/ SystemTimingStats precedent):", "budget": null, "experimental": false}, + {"name": "laige::GameLoopStats::frames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 347, "signature": "std::uint64_t frames{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoopStats::ticks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 348, "signature": "std::uint64_t ticks{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoopStats::droppedTicks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 349, "signature": "std::uint64_t droppedTicks{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoopStats::droppedFrames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 350, "signature": "std::uint64_t droppedFrames{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoop", "kind": "class", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 356, "signature": "class GameLoop", "summary": "The fixed-timestep accumulator loop (M1-LOOP-01): see the header preamble for the accumulator, configuration, overload, beginFrame, failure, determinism, performance, and threading contracts.", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 361, "signature": "struct Options", "summary": "The typed loop configuration (API-006): the tick rate (20–120 Hz, validated at construction), the max catch-up ticks per frame (>= 1, validated), and the clock source.", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 364, "signature": "std::uint32_t tickRateHz{kDefaultTickRateHz}", "summary": "The simulation tick rate in HERTZ (FR-1.1: 20–120 validated; default kDefaultTickRateHz).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::maxCatchUpTicks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 367, "signature": "std::uint32_t maxCatchUpTicks{kDefaultMaxCatchUpTicks}", "summary": "The max ticks one frame may run before its due-tick demand is dropped (and logged): >= 1 (default kDefaultMaxCatchUpTicks).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::ClockFn", "kind": "alias", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 373, "signature": "using ClockFn = std::int64_t (*)()", "summary": "The clock source: nanoseconds since a fixed monotonic epoch (the same time base as the default clock below). nullptr uses the headless monotonic clock (steady_clock); a test or the M2 windowed clock supplies its own (injectable for tests — the LoggerOptions::ClockFn precedent).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::nowNs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 374, "signature": "ClockFn nowNs{nullptr}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::TickFn", "kind": "alias", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 382, "signature": "using TickFn = void (*)(void* context, World& world, std::uint64_t tick) noexcept", "summary": "Optional per-completed-tick callback (M1-LOOP-02; see the preamble \"Per-tick presentation hook\"): fires after every completed tick as onTick(context, world, tick). nullptr (default): no hook (the M1-LOOP-01 behavior). Plain function pointer — no std::function (PERF-006); the callback must be bounded and allocation-free (the snapshot's onTick is the reference contract).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::onTick", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 384, "signature": "TickFn onTick{nullptr}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::onTickContext", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 387, "signature": "void* onTickContext{nullptr}", "summary": "The onTick callback's user context (opaque; must outlive the loop — the engine passes the PresentationSnapshot, M1-HEAD-01).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::profiler", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 399, "signature": "Profiler* profiler{nullptr}", "summary": "The per-completed-tick profiler (M1-PROF-01): when non-null and enabled, runOneTick times each tick (the M0-CORE-08 TimeIt — two steady_clock reads) and hands the measured ms to Profiler::recordTick; a failed tick is not recorded (the tick counts only when the system phase completes — the preamble \"Failure behavior\"). nullptr (the default): no tick timing — one branch per tick, nothing else (DBG-004; the measured enabled cost is bounded at 1% of a 10k-entity tick — docs/benchmarks/baselines/m1-profiler-cost.md). NON-OWNING: the profiler must outlive the loop (the onTickContext lifetime contract).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 411, "signature": "[[nodiscard]] static Result create(World& world, const SystemSchedule& schedule, Options options) noexcept", "summary": "Construct the loop on `world` running `schedule` (setup phase, after World::scheduleSystems — the schedule must describe the world's CURRENT registry, and both must outlive the loop). O(1); no allocation (the loop state is fixed scalars).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::frame", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 429, "signature": "[[nodiscard]] Status frame() noexcept", "summary": "Advance one presentation frame (the hot path; see the preamble \"Performance\"): read the clock, run the frame's due ticks (up to maxCatchUpTicks), drop the excess with a rate-limited warn.", "budget": "O(maxCatchUpTicks × per-tick system work); bounded, no allocation.", "experimental": false}, + {"name": "laige::GameLoop::currentTick", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 433, "signature": "[[nodiscard]] std::uint64_t currentTick() const noexcept", "summary": "The number of completed ticks (0 before the first; the first tick to complete is tick 1). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::startReferenceNs", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 442, "signature": "[[nodiscard]] std::int64_t startReferenceNs() const noexcept", "summary": "The clock reading that established the start reference (0 before the first frame) — the time-base origin of the due computation (the preamble \"The exact due computation\"). The M1-LOOP-02 PresentationSnapshot takes this as its start reference (presentation.h: the tick anchors A(T) = startNs + T × 10⁹ / rate must use the loop's own time base). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::tickRateHz", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 445, "signature": "[[nodiscard]] std::uint32_t tickRateHz() const noexcept", "summary": "The configured tick rate (Hz). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::maxCatchUpTicks", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 449, "signature": "[[nodiscard]] std::uint32_t maxCatchUpTicks() const noexcept", "summary": "The configured max catch-up ticks per frame. O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 454, "signature": "[[nodiscard]] GameLoopStats stats() const noexcept", "summary": "The since-construction accounting snapshot (GameLoopStats). O(1), no allocation, no side effects (a pure query, the World::stats() precedent).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::GameLoop", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 460, "signature": "GameLoop(GameLoop&& other) noexcept", "summary": "Move transfers the tick state; the source becomes a valid but STOPPED loop (frame() returns InvalidArgument, no log — see the preamble \"Failure behavior\"; the World moved-from precedent: the source is left in a well-defined state).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 461, "signature": "GameLoop& operator=(GameLoop&& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoop::GameLoop", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 462, "signature": "GameLoop(const GameLoop&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoop::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 463, "signature": "GameLoop& operator=(const GameLoop&) = delete", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Position2D", "kind": "struct", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 258, "signature": "template struct Position2D", "summary": "The entity's 2D simulation-space position (the ground plane — PRD §4; the axes/units contract lands with the concepts docs). The value is the selected SimMath backend's Vec2 (ADR 0002: one template instantiation per backend, factory-selected at engine init). A data carrier (S-8): trivially copyable, no behavior — the LAIGE_COMPONENT marks below register both instantiations in the same path as user components (M1-ECS-02).", "budget": null, "experimental": false}, {"name": "laige::Position2D::pos", "kind": "variable", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 260, "signature": "sim::SimMath::Vec2 pos{}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::LAIGE_COMPONENT", "kind": "function", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 263, "signature": "LAIGE_COMPONENT(Position2D)", "summary": null, "budget": null, "experimental": false}, diff --git a/src/laige-core/alloc_watch.cpp b/src/laige-core/alloc_watch.cpp index 86d723f..94d98a5 100644 --- a/src/laige-core/alloc_watch.cpp +++ b/src/laige-core/alloc_watch.cpp @@ -32,20 +32,23 @@ #include #include #include +#include namespace laige { namespace detail { // The watch's process state (see the header): exactly one armed -// window at a time, owned by the sim owner thread. Relaxed atomics: -// the only writes from the owner are the arm's two stores; a +// window at a time, owned by the thread that armed it. Relaxed +// atomics: the only writes from the owner are the arm's stores; a // concurrent allocation from another thread reads the armed flag and -// increments the counters — observation data, not simulation state -// (CONC-001, ARCH-009). +// the owner thread, finds it is not the owner, and stops (its heap +// work is not the owner's tick path — the attribution contract). +// Observation data, not simulation state (CONC-001, ARCH-009). inline std::atomic kWatchArmed{false}; inline std::atomic kWatchAllocs{0}; inline std::atomic kWatchFirstSite{nullptr}; +inline std::atomic kWatchOwnerThread{std::thread::id{}}; // The logging-facade emit depth (the attribution contract, header): // nonzero while the diagnostic subsystem is emitting an event — its @@ -70,9 +73,12 @@ LoggingAllocationGuard::~LoggingAllocationGuard() noexcept { } // namespace detail void allocWatchArm() noexcept { - // Data before the flag: the owner's two stores land first, so a - // reader that sees armed==true always sees the reset values (the - // relaxed order is enough for the single-owner-thread model). + // Data before the flag: the owner's stores land first, so a reader + // that sees armed==true always sees the owner thread and the reset + // values (the relaxed order is enough for the single-owner-thread + // model). + detail::kWatchOwnerThread.store(std::this_thread::get_id(), + std::memory_order_relaxed); detail::kWatchFirstSite.store(nullptr, std::memory_order_relaxed); detail::kWatchAllocs.store(0, std::memory_order_relaxed); detail::kWatchArmed.store(true, std::memory_order_relaxed); @@ -108,14 +114,15 @@ namespace { #endif // The counting body of the operator new overrides (see the header's -// cost contract): armed → count the allocation and capture the first -// site; unarmed → exactly one atomic load + one branch. The -// attribution contract (header): while the logging facade is -// emitting an event (kLoggingEmitDepth > 0) the diagnostic -// subsystem's heap work is not the sim loop's — it is not counted. -// `site` is the allocating call's own address (the -// LAIGE_ALLOC_CALLER_SITE builtin evaluated in the operator new -// frame — one level above here). +// cost contract): armed + owner thread → count the allocation and +// capture the first site; unarmed → exactly two atomic loads + two +// branches. The attribution contract (header): while the logging +// facade is emitting an event (kLoggingEmitDepth > 0) the diagnostic +// subsystem's heap work is not the sim loop's — it is not counted; +// and an allocation from a thread that did NOT arm the window is not +// the owner's tick path — it is not counted. `site` is the allocating +// call's own address (the LAIGE_ALLOC_CALLER_SITE builtin evaluated +// in the operator new frame — one level above here). inline void watchRecord(const void* site) noexcept { if (laige::detail::kLoggingEmitDepth.load(std::memory_order_relaxed) > 0) { @@ -124,6 +131,10 @@ inline void watchRecord(const void* site) noexcept { if (!laige::detail::kWatchArmed.load(std::memory_order_relaxed)) { return; } + if (std::this_thread::get_id() != + laige::detail::kWatchOwnerThread.load(std::memory_order_relaxed)) { + return; + } laige::detail::kWatchAllocs.fetch_add(1, std::memory_order_relaxed); // The first site wins: the relaxed CAS fails once the first // offender is recorded, so later offenders cost the failed CAS diff --git a/src/laige-core/include/laige/alloc_watch.h b/src/laige-core/include/laige/alloc_watch.h index d0fd456..757563b 100644 --- a/src/laige-core/include/laige/alloc_watch.h +++ b/src/laige-core/include/laige/alloc_watch.h @@ -10,27 +10,34 @@ // pool accounting (pools.h, M0-CORE-05) and the per-frame simAllocs // delta (M1-PROF-01/02 frame report) — nothing new ships there. // -// The watch is a process-wide heap-allocation counter with an ARMED -// WINDOW: +// The watch is a heap-allocation counter with an ARMED WINDOW that +// measures the OWNER thread (the thread that armed the window): // -// - allocWatchArm() starts a fresh window: it resets the window's -// allocation count and clears the first-site -// capture. One armed window at a time; the -// window is live until the next arm(). +// - allocWatchArm() starts a fresh window: it records the calling +// thread as the window's owner, resets the +// window's allocation count, and clears the +// first-site capture. One armed window at a +// time; the window is live until the next arm(). // - allocWatchRead() reads the window: the allocation count since // arm() plus the call site of the FIRST // offending allocation (nullptr while none). // // The counting backend is a strong definition of the global operator // new/new[] (alloc_watch.cpp, compiled in every non-sanitizer build -// tree): while a window is armed, every heap allocation made by ANY -// translation unit — engine storage, the pools, a system's local +// tree): while a window is armed, every heap allocation made by the +// OWNER thread — engine storage, the pools, a system's local // std::vector — increments the window count, and the caller's return // address of the first offending allocation is captured (the -// actionable call site, FR-12.3). The one exclusion is the diagnostic -// subsystem's own emit (the attribution contract, below): the logging -// facade marks its own work while it emits an event. While no window -// is armed, each allocation pays two atomic loads + two branches. +// actionable call site, FR-12.3). Allocations from other threads are +// NOT counted: G-R1 measures the loop's own tick path (the sim loop +// is single-threaded, PRD §10.2), and other threads' heap work is not +// that path — a background render thread, OS/framework facilities +// (e.g. a macOS WindowServer datagram dispatch) — the same +// attribution principle as the logging exclusion below. The other +// exclusion is the diagnostic subsystem's own emit (the attribution +// contract, below): the logging facade marks its own work while it +// emits an event. While no window is armed, each allocation pays two +// atomic loads + two branches. // // --------------------------------------------------------------------------- // The per-tick assertion (the laige-sim half of M1-ALLOC-01) @@ -55,7 +62,8 @@ // - Static build trees (the default): the strong operator new // overrides sit in the executable's link, so an armed window sees // EVERY heap allocation in the process (engine, pools, game -// systems, test frameworks). +// systems, test frameworks) — and counts the ones made by the +// window's owner thread (the attribution, above). // - Shared build trees: the overrides live inside the laige-core // image. On POSIX, dynamic linking interposes them process-wide // (an executable's operator new call resolves to the library's @@ -84,11 +92,15 @@ // - Per allocation, window disarmed: one logging-depth load + one // armed-flag load + two branches (no counter traffic, no // allocation, no logging). -// - Per allocation, window armed (not inside a diagnostic emit): -// one logging-depth load, one armed-flag load, one fetch_add, and -// one compare-and-swap that fails once the first site is recorded -// (windows are short and allocation-free by contract, so the CAS -// is cheap in practice). +// - Per allocation, window armed, owner thread (not inside a +// diagnostic emit): one logging-depth load, one armed-flag load, +// one owner-thread load, one fetch_add, and one compare-and-swap +// that fails once the first site is recorded (windows are short +// and allocation-free by contract, so the CAS is cheap in +// practice). +// - Per allocation, window armed, another thread: one logging-depth +// load, one armed-flag load, one owner-thread load + one branch +// (not counted — the attribution contract, above). // - Per allocation, inside a diagnostic emit: one logging-depth // load + one branch (the emission's own work, not the sim loop's // — the attribution contract). @@ -105,19 +117,23 @@ // everything a tick does that is not the diagnostic subsystem's // emit (a system's local std::vector, engine storage growth, any // other heap use) still counts and still fails the per-tick assert. -// - Per completed tick, debug builds only: one arm (three atomic -// stores — the first-site, the count, and the armed flag) + one -// read (two atomic loads) — no allocation, no logging on the -// healthy path (LOG-003). Release builds: the entire check is -// compiled out. +// - Per completed tick, debug builds only: one arm (four atomic +// stores — the owner thread, the first-site, the count, and the +// armed flag) + one read (two atomic loads) — no allocation, no +// logging on the healthy path (LOG-003). Release builds: the +// entire check is compiled out. // // Threading (CONC-001): the armed window has exactly one owner — the -// sim owner thread (the simulation is single-threaded, PRD §10.2); -// the loop's tick path is the only caller that arms, so windows never -// nest. The counters are relaxed atomics: an allocation from another -// thread during an armed window is counted (it did happen during the -// tick — the diagnostic says so) but it is observation data, not -// simulation state (ARCH-009). +// thread that called allocWatchArm() (for the per-tick check: the sim +// owner thread, the simulation being single-threaded, PRD §10.2; for +// tests: the test thread). The loop's tick path is the only engine +// caller that arms, so windows never nest. The counters are relaxed +// atomics. An allocation from another thread during an armed window +// is NOT counted: the window measures the owner thread's tick path +// (G-R1), and other threads' heap work is not that path — a +// background render thread, OS/framework facilities (observed in CI: +// a macOS WindowServer datagram dispatch during an armed window). The +// counters remain observation data, not simulation state (ARCH-009). #pragma once @@ -144,8 +160,8 @@ void allocWatchArm() noexcept; // allocation. [[nodiscard]] AllocWatchReading allocWatchRead() noexcept; -// True when the process-wide counting backend is compiled into this -// build (every non-sanitizer tree); false in the sanitizer trees, +// True when the counting backend is compiled into this build (every +// non-sanitizer tree); false in the sanitizer trees, // where the runtimes own operator new/delete and the watch is a // no-op (the header's scope section). [[nodiscard]] bool allocWatchLive() noexcept; diff --git a/src/laige-sim/game_loop.cpp b/src/laige-sim/game_loop.cpp index e19a536..2181494 100644 --- a/src/laige-sim/game_loop.cpp +++ b/src/laige-sim/game_loop.cpp @@ -260,9 +260,9 @@ Status GameLoop::runOneTick() noexcept { #if !defined(NDEBUG) // M1-ALLOC-01 (G-R1): arm the per-tick zero-allocation watch BEFORE // the tick body (debug builds — the laige/alloc_watch.h contract): - // any heap allocation inside the completed tick (a system, the - // onTick hook, the replay recorder, engine storage growth) is - // counted by the process-wide counting backend — except the + // any heap allocation ON THE TICK THREAD inside the completed tick + // (a system, the onTick hook, the replay recorder, engine storage + // growth) is counted by the counting backend — except the // diagnostic subsystem's own emit, which is attributed to the // logging facade (the attribution contract, alloc_watch.h). // Release builds: the entire check is compiled out — the pool- diff --git a/src/laige-sim/include/laige/sim/game_loop.h b/src/laige-sim/include/laige/sim/game_loop.h index 2f005f4..88ed822 100644 --- a/src/laige-sim/include/laige/sim/game_loop.h +++ b/src/laige-sim/include/laige/sim/game_loop.h @@ -190,9 +190,10 @@ // // G-R1 (PRD §9.3, budgets.json sim_heap_allocs: target 0 allocs per // frame) is enforced in DEBUG builds at the tick boundary: -// runOneTick() arms the process-wide allocation watch -// (laige/alloc_watch.h) before the tick body and reads it after a -// COMPLETED tick. A completed tick with a nonzero window count fails +// runOneTick() arms the allocation watch (laige/alloc_watch.h; the +// window's owner is the tick thread) before the tick body and reads +// it after a COMPLETED tick. A completed tick with a nonzero window +// count fails // with one structured Error event (alloc/sim_tick_allocation — the // offending call site in the site field) followed by the debug // assert (FR-12.3: actionable, never silent). The check covers @@ -213,11 +214,13 @@ // path (pools.h) and the per-frame simAllocs delta (M1-PROF-01/02) // — never silent (CORE-008), never an assert. // -// Scope of the counting backend: static build trees count every heap -// allocation in the process; shared build trees count the -// allocations made inside the engine images (the engine allocators -// and the pools — the sim loop's storage; POSIX interposes -// process-wide, Windows does not); sanitizer trees compile the watch +// Scope of the counting backend: the backend intercepts every heap +// allocation (static build trees: process-wide; shared build trees: +// the allocations made inside the engine images — the engine +// allocators and the pools, the sim loop's storage; POSIX interposes +// process-wide, Windows does not), but an armed window COUNTS only +// the window's owner thread (the tick thread) — the attribution +// contract in laige/alloc_watch.h. Sanitizer trees compile the watch // out (the runtimes own operator new/delete — the zero-allocation // property is then verified by the leak-free sanitizer run plus the // pool reservation-delta assertion, the established fallback pattern; diff --git a/tests/laige-render/depth_sort_tests.cpp b/tests/laige-render/depth_sort_tests.cpp index 0fb7723..88e6df7 100644 --- a/tests/laige-render/depth_sort_tests.cpp +++ b/tests/laige-render/depth_sort_tests.cpp @@ -298,7 +298,7 @@ TEST(DepthSortStability, ShuffledInputsAreDeterministicAndStable) { // --------------------------------------------------------------------------- // DepthSortProperty — the 10k-key oracle comparison (the AC-4.3 // workload shape) + the zero-allocation proof (1000 consecutive sorts -// = 0 heap blocks under the process-wide allocation watch, the +// = 0 heap blocks under the allocation watch, the // iso_picking / iso_depth_table precedent — non-sanitizer trees). // --------------------------------------------------------------------------- @@ -318,7 +318,7 @@ TEST(DepthSortProperty, TenKKeysAgainstOracleAndZeroAlloc) { EXPECT_LE(sk[i - 1], sk[i]) << "i=" << i; } - // Zero-allocation proof (where the process-wide watch is live — the + // Zero-allocation proof (where the allocation watch is live — the // non-sanitizer trees; the sanitizer runtimes own operator new, the // iso_depth_table_tests.cpp precedent): 1 000 consecutive 10k sorts // allocate nothing — the sort is fixed-size buffer traffic, diff --git a/tests/laige-render/iso_picking_tests.cpp b/tests/laige-render/iso_picking_tests.cpp index dab981f..a139b62 100644 --- a/tests/laige-render/iso_picking_tests.cpp +++ b/tests/laige-render/iso_picking_tests.cpp @@ -210,7 +210,7 @@ TEST(IsoPickProperty, CellCentersRoundTripAtFourZooms) { << "zoom " << z << " cell (" << gx << ", " << gy << ")"; } } - // Zero-allocation proof (where the process-wide watch is live — the + // Zero-allocation proof (where the allocation watch is live — the // non-sanitizer trees; the sanitizer runtimes own operator new, the // iso_depth_table_tests.cpp precedent): 1 000 consecutive picks // allocate nothing — the pick is a fixed sequence of float ops, diff --git a/tests/laige-render/sprite_frames_tests.cpp b/tests/laige-render/sprite_frames_tests.cpp index e995ced..1a48806 100644 --- a/tests/laige-render/sprite_frames_tests.cpp +++ b/tests/laige-render/sprite_frames_tests.cpp @@ -316,7 +316,7 @@ TEST(SpriteFrameProperty, LayoutModelAndZeroAlloc) { } } - // Zero-allocation proof (where the process-wide watch is live — the + // Zero-allocation proof (where the allocation watch is live — the // non-sanitizer trees; the sanitizer runtimes own operator new, the // depth_sort test precedent): 1 000 consecutive conversions allocate // nothing — the function is fixed-size value traffic, structurally diff --git a/tests/laige-render/tilemap_tests.cpp b/tests/laige-render/tilemap_tests.cpp index 0e855b3..3017763 100644 --- a/tests/laige-render/tilemap_tests.cpp +++ b/tests/laige-render/tilemap_tests.cpp @@ -865,34 +865,24 @@ void declareLoopAllocatesNothing() { // Zero-allocation proof (where the watch is live — the non- // sanitizer trees; the sanitizer runtimes own operator new): // 1000 frames of the declare loop allocate nothing (the batcher and - // the sorter storage are pre-allocated). + // the sorter storage are pre-allocated — FR-2.2 "no per-frame + // allocation"). // - // Two stages. The GL/GLFW suites earlier in this binary load the - // macOS graphics framework chain, which does a ONE-TIME lazy - // initialization asynchronously after load (observed in CI: a - // QuartzCore-internal hash table rehash — 48/8/6 blocks across - // runs, macOS arm64 AND Intel — landing in whichever armed window - // catches it). The settle stage re-runs the loop under armed - // windows until a CLEAN window is observed, absorbing that - // one-time init; the proof stage then pins the steady-state - // property (FR-2.2 "no per-frame allocation"). A genuine - // engine-side first-frame allocation is NOT hidden by this: the - // SpriteBatcher* suites (earlier in this binary) already exercise - // create/beginFrame/add/build under their own armed windows in the - // same process. + // The window's owner is THIS thread: the watch counts owner-thread + // allocations only (the attribution contract, alloc_watch.h). The + // GL/GLFW suites earlier in this binary load the macOS graphics + // framework chain, and its background framework threads (QuartzCore + // / SkyLight — e.g. the WindowServer datagram dispatch, observed in + // CI) do their own heap work in parallel with our loop; that is + // not the declare loop's work and is not counted here. if (laige::allocWatchLive()) { - for (std::uint32_t settle = 0; settle < 4; ++settle) { - laige::allocWatchArm(); - runFrames(); - if (laige::allocWatchRead().allocs == 0) break; - } laige::allocWatchArm(); runFrames(); const laige::AllocWatchReading reading = laige::allocWatchRead(); EXPECT_EQ(reading.allocs, 0u) << "1000 frames of beginFrame/declareTo/build allocated " - << reading.allocs << " heap blocks after a clean settle window " - "(first site: " + << reading.allocs << " heap blocks on the loop thread (first " + "site: " << describeAllocSite(reading.firstSite) << ")"; } } diff --git a/tests/laige-sim/archetype_tests.cpp b/tests/laige-sim/archetype_tests.cpp index 19d897e..8909f82 100644 --- a/tests/laige-sim/archetype_tests.cpp +++ b/tests/laige-sim/archetype_tests.cpp @@ -5,8 +5,8 @@ // component per archetype), entity -> archetype map // - add/remove component: pool-backed moves between archetypes with // no per-operation heap allocation (the churn test proves it: -// zero ArchetypeStats reservation delta + zero process-wide -// allocations over the churn window) +// zero ArchetypeStats reservation delta + zero allocations on +// the churn thread over the churn window) // - get is O(1) (archetype lookup + column index); stale handles // degrade per the M1-ECS-01 contract (nullptr + warn-once) // - 10k entities x add/remove churn: zero pool overflow and constant diff --git a/tests/laige-sim/zero_alloc_tests.cpp b/tests/laige-sim/zero_alloc_tests.cpp index d5536c6..a92ef47 100644 --- a/tests/laige-sim/zero_alloc_tests.cpp +++ b/tests/laige-sim/zero_alloc_tests.cpp @@ -3,9 +3,10 @@ // Step scope (roadmap/M1-heartbeat.md, M1-ALLOC-01): // - The G-R1 debug per-tick assertion (PRD §9.3, budgets.json // sim_heap_allocs target 0, PERF-003): GameLoop::runOneTick arms -// the process-wide allocation watch (laige/alloc_watch.h) before -// every tick body and checks it after a completed tick — any heap -// allocation inside the tick (a system, the onTick hook, the +// the allocation watch (laige/alloc_watch.h; the window's owner +// is the tick thread) before every tick body and checks it after +// a completed tick — any heap allocation on the tick thread +// inside the tick (a system, the onTick hook, the // replay recorder, engine storage growth, even a hot-path log) // fails with one structured Error event // (alloc/sim_tick_allocation — the offending call site in the @@ -40,6 +41,7 @@ #include #include #include +#include #include #include @@ -652,3 +654,47 @@ TEST(ZeroAlloc, WatchCountsAllocationsAndCapturesTheFirstSite) { EXPECT_EQ(reset.firstSite, nullptr); #endif } + +// The attribution contract (alloc_watch.h): the window counts the +// OWNER thread's allocations only. A heap allocation made by a +// thread that did NOT arm the window — even while the window is +// armed — is not the owner's tick path and is not counted. This is +// what keeps OS/framework background threads out of the sim loop's +// G-R1 window (observed in CI: a macOS WindowServer datagram +// dispatch allocating during an armed window). +TEST(ZeroAlloc, WatchExcludesNonOwnerThreads) { +#if !defined(LAIGE_ALLOC_COUNTER) + // Sanitizer trees: the counting backend is compiled out (the + // runtimes own operator new/delete) — the watch is a no-op there. + GTEST_SKIP() << "the counting backend is compiled out in sanitizer " + "trees (allocWatchLive() is false)"; +#else + EXPECT_TRUE(laige::allocWatchLive()); + // Two-phase handoff: the child signals ready BEFORE it allocates; + // the owner arms, then signals go — so the child's one allocation + // is guaranteed to land inside the armed window, and the owner + // allocates nothing in it. + std::atomic ready{false}; + std::atomic go{false}; + std::thread child([&] { + ready.store(true, std::memory_order_release); + while (!go.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + std::vector v(4, 7); // one heap allocation (child) + volatile std::int32_t sink = v[0]; + static_cast(sink); + }); + while (!ready.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + laige::allocWatchArm(); + go.store(true, std::memory_order_release); + child.join(); // sync: the allocation happened, on the child thread + const laige::AllocWatchReading r = laige::allocWatchRead(); + EXPECT_EQ(r.allocs, 0u) + << "a non-owner thread's allocation was counted (the " + "owner-thread attribution is broken)"; + EXPECT_EQ(r.firstSite, nullptr); +#endif +} diff --git a/tools/bench/laige-bench.cpp b/tools/bench/laige-bench.cpp index 65fb3cf..bb3efd9 100644 --- a/tools/bench/laige-bench.cpp +++ b/tools/bench/laige-bench.cpp @@ -53,8 +53,8 @@ // no randomness in the measured path) and, in debug non-sanitizer // builds, every measured tick additionally passes the engine's own // G-R1 per-tick zero-allocation assertion (GameLoop::runOneTick arms -// the process-wide allocation watch, M1-ALLOC-01) — an allocating -// tick aborts the run. +// the allocation watch — owner: the tick thread, M1-ALLOC-01) — an +// allocating tick aborts the run. #include #include From 272744038027c0a906828a2ae0205b55568b1810 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Tue, 6 Oct 2026 22:31:00 +0200 Subject: [PATCH 2/2] [M1-ALLOC-01 fix] empty commit to trigger a fresh PR run (ci:macos label applied)