Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ still to land.
flags (M0-CORE-03/04; ADR 0002).
- [Memory pools](api/pools.md) — `ArenaPool<T>` and `Pool<T>` 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
(game_loop.md), the release fallback, and the sanitizer scope
(M1-ALLOC-01).
- [Bounded JSON](api/json.md) — `laige::JsonValue`, `parseJson`,
`serializeJson`, `JsonOptions` bounds (M0-CORE-07; ADR 0003).
- [Budget harness](api/budget_harness.md) — `Histogram`, `TimeIt`,
Expand Down
204 changes: 204 additions & 0 deletions docs/api/alloc_watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# Allocation watch (`laige::allocWatch*`)

The process-wide 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`;
counting backend: `src/laige-core/alloc_watch.cpp` (compiled in every
non-sanitizer build tree — the `LAIGE_ALLOC_WATCH=1` public definition
marks exactly those trees). Unit suite: `ctest -R zero_alloc`
(`tests/laige-sim/zero_alloc_tests.cpp`); the engine-side consumer is
the per-tick check in
[api/game_loop.md](game_loop.md#the-zero-allocation-check-m1-alloc-01-g-r1).

G-R1: the simulation's steady-state tick path MUST allocate nothing.
The budgets table (PRD §9.3) names the enforcement: **"Debug:
allocation counter + assert; Release: pool overflow → logged
degradation"**. This header is the debug side; the release side
already exists (the pool overflow path, [api/pools.md](pools.md);
the per-frame `simAllocs` delta, [api/profiler.md](profiler.md)) —
nothing new ships there.

## The armed-window model

The watch has one process-wide state: an **armed window**.

```cpp
laige::allocWatchArm(); // start a fresh window
// ... the region under test (one tick, one loop run, one API call) ...
laige::AllocWatchReading r = laige::allocWatchRead();
// r.allocs heap allocations since the arm
// r.firstSite the call site of the FIRST offending allocation
// (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.
- `allocWatchRead()` reads the count plus the first offending call
site. O(1), no allocation.
- `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
section).

**First-site semantics:** the first allocation after the arm is
recorded (the caller's return address of the allocating call —
`__builtin_return_address` on GCC/Clang, `__return_address` on MSVC);
later offenders are counted but keep the first site (a relaxed CAS
that fails once the first site is recorded). The first site is the
actionable one: it is where the invariant broke first (FR-12.3).

## The per-tick assertion (the engine consumer)

In **debug builds**, `GameLoop::runOneTick()`
([api/game_loop.md](game_loop.md)) arms a fresh window **before** the
tick body and reads it **after a completed tick**. A completed tick
with a nonzero window count fails with:

| Condition | Event | Severity |
|---|---|---|
| a completed tick allocated (debug only) | `alloc/sim_tick_allocation` | Error |

Event fields: `tick`, `allocs` (the window's count), `site` (the first
offending call site, hex). The event fires once per offending tick
(the rate limiter never suppresses the first event of an episode —
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
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.

**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,
systems, pools. The diagnostic subsystem's memory is a separate
subsystem with its own contract (LOG-003 gates hot-path logging by
construction; LOG-004 rate-limits repeated failures), so the
logging facade's emit path wraps its work in
`laige::detail::LoggingAllocationGuard` (the `LAIGE_LOG` macro;
`Logger::record`): the field value strings, the rate-state, and the
sink's message formatting are attributed to the diagnostic
subsystem, not the tick's window. Consequence: the engine's
documented in-tick degradations — a G-R5 budget-overrun
warn/critical, a replay write failure, a guardrail warn — still log
(actionable, rate-limited) and never trip G-R1; a tick that
allocates for any other reason still fails at its call site.

This is the **standing hot-path guardrail for every later sim/render
step** (roadmap README §6, "Global invariants"): any M2/M3 step that
adds an allocation inside a tick — a new system, a new recorder, a
new pool's backing store — fails the debug build immediately, at the
allocating call site, instead of silently eroding the 0-allocs
budget.

## Release builds

Release compiles the entire check out (the arm/read and the assert —
`#if !NDEBUG`): no assertion, no crash (CPP-012). A game system that
allocates in release degrades through the already-logged pool
accounting (pool overflow, pools.md) and shows up in the per-frame
`simAllocs` delta (M1-PROF-01/02 frame report) — never a silent
success, never an assert. The counting backend itself is still
compiled in (it is not `NDEBUG`-gated), so release tools and tests
can read the watch; only the tick-boundary enforcement is
debug-only.

## Scope of the counting backend

The backend is a **strong definition of the global
`operator new`/`new[]`** (plus the nothrow and sized-deallocation
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.
- **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.
- **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
(`allocWatchLive()` is false; arm/read return zero). There, the
zero-allocation property is verified by the leak-free sanitizer run
of the same loop plus the pool reservation-delta assertion — the
established fallback pattern (tests/laige-core, M0-CORE-02/05;
tests/laige-sim, M1-ECS-03/07; the `LAIGE_ALLOC_COUNTER` test
definition gates the test-side probes on the same trees).

The `LAIGE_ALLOC_WATCH=1` public compile definition is set on
laige-core in exactly the trees where the backend is compiled in, so
every consumer (the sim module, tools, test executables) sees the same
live state. The test-side `LAIGE_ALLOC_COUNTER` definition
(`tests/**/logging_alloc_counter.h`) uses the same gate, so the test
probes and the engine's assertion always agree about whether the
watch is live.

## Cost (PERF-003, DBG-004)

| 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, 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) |
| 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,
DBG-004). Windows are short and allocation-free by contract, so the
armed path's extra atomics are paid only on the rare allocation that
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.

## Misuse warnings

- **Arming from more than one owner** breaks the single-window
contract (the two owners' windows interleave; the reads are
meaningless). The only intended armer is the per-tick check;
tests arm deliberately and single-threaded.
- **Reading a disarmed window** returns whatever the last window
held (the count is not reset on disarm — disarming is not an
operation; the window ends by the next arm). Arm before the region
you measure.
- **Trusting the count in a shared Windows build:** see the scope
section — outside-image allocations are invisible there.
- **Expecting the watch in sanitizer trees:** it is a no-op by
design (the runtimes own the allocators); use the leak-free
sanitizer run + the reservation delta there.

## Example

```cpp
laige::allocWatchArm();
runOneSimTick(); // must allocate nothing (G-R1)
laige::AllocWatchReading r = laige::allocWatchRead();
// r.allocs == 0, r.firstSite == nullptr — otherwise the tick broke
// G-R1 and the engine's per-tick check has already logged
// alloc/sim_tick_allocation with the first offending site.
```

The engine's per-tick check is exactly this shape, repeated around
every completed tick in debug builds ([api/game_loop.md](game_loop.md));
the test-side probes wrap the same API
(`tests/**/logging_alloc_counter.h`).
46 changes: 45 additions & 1 deletion docs/api/game_loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,45 @@ tick is not counted and not recorded (the tick-count contract,
(ARCH-009) — it never enters the tick count, the state hash, or a
replay.

## The zero-allocation check (M1-ALLOC-01, G-R1)

**Debug builds only.** G-R1 (PRD §9.3): the simulation's steady-state
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
**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()`),
`laige::allocWatchRead()`: a nonzero count logs one
`alloc/sim_tick_allocation` Error event (fields: `tick`, `allocs`,
`site` — the first offending call site) and then fails the debug
assert (FR-12.3: actionable, never silent). A failed tick is not
checked (the profiler's "a failed tick is not recorded" contract).
- **Attribution** — the window counts the sim loop's own heap: the
systems, the `onTick` hook, the replay recorder, engine storage
growth, any other tick heap use. The diagnostic subsystem's own
emit (the logging facade's field strings, rate-state, sink
formatting — see [api/alloc_watch.md](alloc_watch.md)) is
attributed to the diagnostic subsystem: the engine's documented
in-tick degradations (a G-R5 budget overrun, a replay write
failure, a guardrail warn) still log and never trip G-R1.
- **Release builds:** the whole check is compiled out (`#if !NDEBUG`)
— no assert, no crash (CPP-012); an allocating tick degrades
through the logged pool accounting and the per-frame `simAllocs`
delta ([api/profiler.md](profiler.md)) instead.
- **Standing guardrail** — this check is the hot-path guardrail for
every later sim/render step (roadmap README §6, "Global
invariants"): any M2/M3 step that adds an allocation inside a tick
fails the debug build at the allocating call site.

Suite: `ctest -R zero_alloc` (the 10k-entity M1-ECS-07 workload
through the loop does 0 allocs per tick; a deliberate `std::vector` in
a scratch system fails the assert; the watch's first-site capture is
checked directly).

## Performance (DOC-004)

- **Per frame (hot path):** one clock read, a few integer ops (the
Expand All @@ -227,7 +266,12 @@ tick is not counted and not recorded (the tick-count contract,
ring write per completed tick (the `runOneTick` `TimeIt`) — no
allocation; the measured enabled cost is bounded at 1% of a
10k-entity tick (the m1-profiler-cost baseline, CORE-001/DBG-004).
Profiler null or disabled: one branch per tick, nothing else.
Profiler null or disabled: one branch per tick, nothing else. The
G-R1 watch (M1-ALLOC-01, debug builds only) adds three atomic
stores per completed tick (the arm: first-site, count, armed flag)
+ two atomic loads (the read) — no allocation, no logging on the
healthy path; release builds compile the check out entirely (the
section above).
- **Cold path (overload):** one rate-limited `tick_dropped` warn with
field construction — only while a frame exceeds the catch-up bound.
- **Complexity:** `frame()` is O(maxCatchUpTicks × per-tick system
Expand Down
8 changes: 6 additions & 2 deletions docs/api/profiler.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,12 @@ compressed into one greppable line.
wall-clock diagnostics (ARCH-009): never in the tick count, the
state hash, or a replay (the M1-SYS-03 precedent).
- **`sim_allocs` is a total, not a per-frame delta** — the steady-
state **per-frame delta** is the FR-11.1 target of 0 (M1-ALLOC-01
asserts it per tick via the allocation hook).
state **per-frame delta** is the FR-11.1 target of 0. M1-ALLOC-01
enforces it directly in debug builds: the per-tick allocation watch
([api/alloc_watch.md](alloc_watch.md)) arms around each completed
tick and asserts a zero count (the diagnostic subsystem's own emit
is attributed to it, not the tick — see that doc's attribution
note).

## Testing and CI

Expand Down
Loading
Loading