Skip to content

Commit e4a7b7c

Browse files
authored
[M1-ALLOC-01 fix] Attribute the alloc window to the owner thread (#78)
* [M1-ALLOC-01 fix] Attribute the alloc window to the owner thread 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). * [M1-ALLOC-01 fix] empty commit to trigger a fresh PR run (ci:macos label applied)
1 parent 0b241a9 commit e4a7b7c

17 files changed

Lines changed: 246 additions & 158 deletions

File tree

‎docs/README.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,10 @@ still to land.
165165
flags (M0-CORE-03/04; ADR 0002).
166166
- [Memory pools](api/pools.md) — `ArenaPool<T>` and `Pool<T>` with
167167
generation-checked handles and `PoolStats` accounting (M0-CORE-05).
168-
- [Allocation watch](api/alloc_watch.md) — the process-wide heap
169-
allocation counter behind the G-R1 zero-sim-loop-allocation
170-
guardrail: the armed-window model, the per-tick debug assertion
168+
- [Allocation watch](api/alloc_watch.md) — the heap-allocation
169+
counter (owner-thread attribution) behind the G-R1
170+
zero-sim-loop-allocation guardrail: the armed-window model, the
171+
per-tick debug assertion
171172
(game_loop.md), the release fallback, and the sanitizer scope
172173
(M1-ALLOC-01).
173174
- [Bounded JSON](api/json.md) — `laige::JsonValue`, `parseJson`,

‎docs/api/alloc_watch.md‎

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Allocation watch (`laige::allocWatch*`)
22

3-
The process-wide heap-allocation counter behind the G-R1
3+
The heap-allocation counter behind the G-R1
44
zero-allocation guardrail (M1-ALLOC-01; PRD §8.1, §9.3, `budgets.json`
55
`sim_heap_allocs` target 0, AGENTS PERF-003, CORE-001, FR-12.3).
66
Public header: `src/laige-core/include/laige/alloc_watch.h`;
@@ -32,11 +32,20 @@ laige::AllocWatchReading r = laige::allocWatchRead();
3232
// (nullptr while none)
3333
```
3434

35-
- `allocWatchArm()` resets the window's count and clears the first
36-
site, then arms. One armed window at a time; a window stays live
37-
until the next arm. O(1), no allocation.
35+
- `allocWatchArm()` records the calling thread as the window's
36+
**owner**, resets the window's count, and clears the first site,
37+
then arms. One armed window at a time; a window stays live until the
38+
next arm. O(1), no allocation.
3839
- `allocWatchRead()` reads the count plus the first offending call
3940
site. O(1), no allocation.
41+
- **Owner-thread attribution:** while a window is armed, only the
42+
**owner thread's** heap allocations are counted. Allocations from
43+
other threads during the window are not counted: G-R1 measures the
44+
loop's own tick path (the simulation is single-threaded, PRD
45+
§10.2), and other threads' heap work — a background render thread,
46+
OS/framework facilities (observed in CI: a macOS WindowServer
47+
datagram dispatch during an armed window) — is not that path. Same
48+
attribution principle as the logging-emit exclusion below.
4049
- `allocWatchLive()` is true in every tree where the counting backend
4150
is compiled in (every non-sanitizer tree); false in the sanitizer
4251
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
6776
matters), then the debug assert breaks the build run with the event's
6877
fix text in the condition string.
6978

70-
The window covers **everything the tick runs that is the sim
79+
The window covers **everything the tick thread runs that is the sim
7180
loop's own work**: the systems, the `onTick` hook, the replay
7281
recorder, engine storage growth (archetype column doublings, table
7382
growth), and any other heap use of the tick (a system's local
74-
`std::vector`, a pool's backing store). A **failed** tick is not
75-
checked (the profiler's "a failed tick is not recorded" contract,
76-
api/profiler.md): a tick whose systems did not complete ran no user
77-
work to blame, and its validation error is already actionable.
83+
`std::vector`, a pool's backing store) — on the tick thread (the
84+
window's owner); other threads' heap work during the tick is not the
85+
tick path and is not counted (owner-thread attribution, above). A
86+
**failed** tick is not checked (the profiler's "a failed tick is not
87+
recorded" contract, api/profiler.md): a tick whose systems did not
88+
complete ran no user work to blame, and its validation error is
89+
already actionable.
7890

7991
**Attribution — what is NOT the sim loop's heap:** G-R1's budget is
8092
`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:
118130

119131
- **Static build trees (the default, every P0 OS):** the overrides sit
120132
in the executable's link — an armed window sees **every** heap
121-
allocation in the process: the engine, the pools, game systems,
122-
test frameworks.
133+
allocation in the process (the engine, the pools, game systems,
134+
test frameworks) and counts the owner thread's (the attribution
135+
above).
123136
- **Shared build trees:** the overrides live inside the laige-core
124137
image. On POSIX, dynamic linking interposes them process-wide (an
125138
executable's `operator new` call resolves to the library's
126139
definition); on Windows there is no cross-image interposition, so an
127140
armed window sees the allocations made **inside the engine images**
128141
— the engine allocators and the pools, which is the sim loop's
129-
storage — but not allocations made in the executable itself. The
130-
canonical (static) trees give full process coverage everywhere.
142+
storage — and counts the owner thread's of those; allocations made
143+
in the executable itself are not seen. The canonical (static) trees
144+
give full process coverage everywhere.
131145
- **Sanitizer trees (`LAIGE_ASAN` / `LAIGE_TSAN`):** the sanitizer
132146
runtimes own `operator new`/`delete`, so the counting backend is
133147
**not** compiled in and the header degrades to inline no-ops
@@ -151,9 +165,10 @@ watch is live.
151165
| Path | Cost |
152166
|---|---|
153167
| Per allocation, window **disarmed** | one logging-depth load + one armed-flag load + two branches (no counter traffic) |
154-
| 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 |
168+
| 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 |
169+
| 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) |
155170
| Per allocation, inside a diagnostic emit (the attribution contract) | one logging-depth load + one branch (the emission's own work, not counted) |
156-
| Per completed tick, debug builds | one arm (three atomic stores: first-site, count, armed flag) + one read (two atomic loads) |
171+
| Per completed tick, debug builds | one arm (four atomic stores: owner thread, first-site, count, armed flag) + one read (two atomic loads) |
157172
| Release builds | the tick check is compiled out; the disarmed allocation path remains |
158173

159174
No allocation, no logging, no lock on any healthy path (LOG-003,
@@ -163,13 +178,17 @@ should not happen.
163178

164179
## Threading (CONC-001)
165180

166-
The armed window has exactly one owner: the **sim owner thread**
167-
(the simulation is single-threaded, PRD §10.2). The loop's tick path
168-
is the only caller that arms, so windows never nest. The counters are
169-
relaxed atomics: an allocation from another thread during an armed
170-
window is counted (it happened during the tick — the diagnostic says
171-
so) but it is **observation data, not simulation state** (ARCH-009):
172-
it never enters the tick count, the state hash, or a replay.
181+
The armed window has exactly one owner: the thread that called
182+
`allocWatchArm()` (for the per-tick check: the **sim owner thread** —
183+
the simulation is single-threaded, PRD §10.2; for tests: the test
184+
thread). The loop's tick path is the only engine caller that arms, so
185+
windows never nest. The counters are relaxed atomics. An allocation
186+
from **another thread** during an armed window is **not counted**:
187+
the window measures the owner thread's tick path (G-R1), and other
188+
threads' heap work — a background render thread, OS/framework
189+
facilities — is not that path. The counters are **observation data,
190+
not simulation state** (ARCH-009): they never enter the tick count,
191+
the state hash, or a replay.
173192

174193
## Misuse warnings
175194

‎docs/api/archetype.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ Each column block is reserved, not sized, to the rows it holds:
7171
events per archetype (≤ 10 for a 10k world).
7272
- **Steady state:** an add/remove that does not hit a full archetype
7373
allocates nothing — the churn test proves it: zero reservation
74-
delta over the window **and** zero process-wide allocations
74+
delta over the window **and** zero allocations on the churn thread
7575
(test-only `operator new` counter, non-sanitizer trees; the
7676
sanitizer trees prove the same property with a leak-free run of the
7777
same loop — M1-ALLOC-01 lands the standing assertion).

‎docs/api/game_loop.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,9 @@ tick path MUST allocate nothing — the `sim_heap_allocs` budget
219219
(budgets.json) targets 0 allocs/frame. `runOneTick` enforces it
220220
directly, per tick:
221221

222-
- **Arm** — `laige::allocWatchArm()` (the process-wide allocation
223-
watch, [api/alloc_watch.md](alloc_watch.md)) starts a fresh window
222+
- **Arm** — `laige::allocWatchArm()` (the allocation watch, owner:
223+
the tick thread, [api/alloc_watch.md](alloc_watch.md)) starts a
224+
fresh window
224225
**before** the tick body (the `beginFrame` + `runSystems` dispatch,
225226
plus the attached profiler, `onTick` hook, and replay recorder).
226227
- **Read + assert** — **after a completed tick** (`status.ok()`),

‎docs/api/query.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,9 @@ assert fires before the log).
124124
- **Zero-alloc evidence (CORE-001):** `QueryZeroAlloc` runs 10k
125125
entities × `{Pos, Vel}` through a Read/Write pass (a legal in-place
126126
write per visit) and a Read/Read pass in a reset allocation-counter
127-
window (non-sanitizer trees): zero process-wide heap allocations,
128-
zero reservation delta (pool-steady), and prints the machine-
127+
window (non-sanitizer trees): zero heap allocations on the
128+
iteration thread, zero reservation delta (pool-steady), and prints
129+
the machine-
129130
greppable `query-iteration <stats>` lines to the ctest output.
130131
The sanitizer trees cover the same loop leak-free; M1-ALLOC-01
131132
lands the standing assertion.

0 commit comments

Comments
 (0)