You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit e4a7b7c
Browse filesBrowse the repository at this point in the historyBrowse files
[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)
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.
78
90
79
91
**Attribution — what is NOT the sim loop's heap:** G-R1's budget is
80
92
`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:
118
130
119
131
-**Static build trees (the default, every P0 OS):** the overrides sit
120
132
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).
123
136
-**Shared build trees:** the overrides live inside the laige-core
124
137
image. On POSIX, dynamic linking interposes them process-wide (an
125
138
executable's `operator new` call resolves to the library's
126
139
definition); on Windows there is no cross-image interposition, so an
127
140
armed window sees the allocations made **inside the engine images**
128
141
— 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.
131
145
-**Sanitizer trees (`LAIGE_ASAN` / `LAIGE_TSAN`):** the sanitizer
132
146
runtimes own `operator new`/`delete`, so the counting backend is
133
147
**not** compiled in and the header degrades to inline no-ops
@@ -151,9 +165,10 @@ watch is live.
151
165
| Path | Cost |
152
166
|---|---|
153
167
| 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) |
155
170
| 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) |
157
172
| Release builds | the tick check is compiled out; the disarmed allocation path remains |
158
173
159
174
No allocation, no logging, no lock on any healthy path (LOG-003,
@@ -163,13 +178,17 @@ should not happen.
163
178
164
179
## Threading (CONC-001)
165
180
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,
0 commit comments