Skip to content

Commit 1ffeb7f

Browse files
committed
Destroy rootless typed bodies before arena grace
1 parent 0575ac4 commit 1ffeb7f

6 files changed

Lines changed: 906 additions & 17 deletions

notes/arena-destructor-identity-rootless-fnew-2026-07-13.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,13 @@ within 60 seconds per run.
217217

218218
## Benchmark result
219219

220+
Correction (2026-07-13): the historical `GC stopped` row below is not a valid
221+
stopped-GC comparison and must not be used for release decisions. The benchmark
222+
ignores `BENCH_GC_MODE=stop`, and its explicit `collectgarbage("collect")`
223+
restarts automatic collection even after a simple command-line `stop`. The
224+
active row remains valid. A probed wrapper and corrected active/stopped matrix
225+
are recorded in `pregrace-typed-semantic-destruction-2026-07-13.md`.
226+
220227
Five-run `BENCH_SCALE=.1 closures_upval` measurements on this container were:
221228

222229
| Mode | Current ns/op (five runs; median) | Stock `/usr/bin/luajit` ns/op (five runs; median) | Ratio |
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
# Pre-grace typed semantic destruction
2+
3+
## Status and scope
4+
5+
This tranche lets the main-TG SWEEP owner perform semantic destruction of an
6+
unmarked, rootless, typed small-arena allocation before the arena-wide physical
7+
reuse grace. It is deliberately restricted to the three fixed-layout kinds
8+
whose immutable arena identity is already published:
9+
10+
- `LFUNC0`,
11+
- `LFUNC1`, and
12+
- `CLOSED_UV`.
13+
14+
The semantic destructor releases the closure or closed-upvalue resources and
15+
subtracts its GC charge immediately. The allocation boundary, READY bit,
16+
destructor kind, and body bytes remain physically present until the normal
17+
arena quarantine grace completes. No cell is reused early.
18+
19+
This removes the need to recognize a special closure/upvalue pair. Every start
20+
is admitted, validated, destroyed, or retained independently from its immutable
21+
kind and exact extent. In particular, an adjacent closed-upvalue disagreement
22+
does not veto destruction of an independently valid `LFUNC1`, and no C-call or
23+
FFI-shape matcher is involved.
24+
25+
## Why the ordinary retirement path was not enough
26+
27+
The earlier sidecar path classified each dead typed start as `WHITE ->
28+
RETIRED`, incremented `reclaim_deferred`, requested a full arena grace, and ran
29+
the semantic destructor only after grace. That is the general safe fallback,
30+
but closure-heavy active-GC workloads repeatedly pay its per-object retirement,
31+
deferred-accounting, and post-grace finish costs.
32+
33+
A typed object may be destroyed earlier only when the collector can prove both
34+
that no semantic mark producer was omitted and that its body cannot be mutated
35+
or reclaimed while it is inspected. Merely observing a global exclusivity bit,
36+
an empty queue, or a sealed arena is not such a capability. The new path
37+
therefore combines a locally acquired global gate, an SMR read lease, an exact
38+
arena seal, current-cycle root/fixpoint evidence, and a per-object lifetime
39+
claim. Any failed predicate falls back to the existing retirement path.
40+
41+
## Exclusive admission transaction
42+
43+
`lj_gc_sweep_gc2_arena_unmarked()` keeps its original two-argument interface
44+
and non-exclusive behavior. Main-TG sweep-owner progress calls the new internal
45+
`lj_gc_sweep_gc2_arena_unmarked_exclusive()` entry point. Secondary TGs continue
46+
to use the original path.
47+
48+
The exclusive wrapper attempts the following capability:
49+
50+
1. locally tries to acquire `mt_gc_exclusive`;
51+
2. after that succeeds, tries to enter one outer SMR reader;
52+
3. flushes pending roots and repairs the ownership spine;
53+
4. verifies the SWEEP phase, bridge/root snapshot, activation state, main-TG
54+
identity, internal arena allocator, zero mutator entry/live/active counts,
55+
configured/active worker state, closed JIT gate, idle recorder, empty
56+
recovery/assist/weak/SSB/grey/thread/table/finalizer work, and the exact arena
57+
owner/flags;
58+
5. locally seals the target arena;
59+
6. clears only the completed snapshot generation's count-zero `PENDING` bit;
60+
7. rechecks the complete certificate and exact remote state `CLOSED|SEALED`;
61+
8. holds that seal across the bounded arena scan; and
62+
9. unseals with `keep_pending=1`, leaves SMR, and releases the global gate on
63+
every exit.
64+
65+
Losing either global-gate or SMR admission disables only early body access. It
66+
still runs the original sidecar-only classifier before owner progress moves the
67+
arena from NEEDSWEEP to quarantine. In particular, an SMR conflict cannot skip
68+
classification and accidentally quarantine a still-WHITE dead start.
69+
70+
The completed SWEEP root snapshot naturally leaves a conservative
71+
`CLOSED|PENDING` arena state. Clearing it is sound only inside the complete
72+
certificate above. A new publisher first installs its count/`PENDING` intent;
73+
it therefore defeats either the clear/recheck or a later exact per-object
74+
commit check. The path never waits for a producer and never treats a pre-held
75+
`mt_gc_exclusive` value as locally owned authority.
76+
77+
## Claim, validate, commit
78+
79+
No target header or body byte is read while its lifetime is merely `LIVE`.
80+
Admission for one supported start is:
81+
82+
1. derive the exact extent from the immutable sidecar kind;
83+
2. check only global state and arena side planes, expecting `LIVE`, `WHITE`, an
84+
unmarked exact start, READY, root `NONE`, recovery `IDLE`, no late bit, and
85+
exact `CLOSED|SEALED` remote state;
86+
3. atomically claim `LIVE -> DESTRUCT`;
87+
4. execute the sequentially consistent fence paired with rescue admission;
88+
5. repeat the complete global, arena, and exact-start predicate, now expecting
89+
`DESTRUCT`;
90+
6. validate the function/upvalue header and exact layout under that body lease;
91+
7. execute the final fence and repeat the complete predicate;
92+
8. atomically commit `DESTRUCT -> FREE`, then `WHITE -> FREEING`; and
93+
9. dispatch directly from the immutable kind to `lj_func_free()` or
94+
`lj_func_freeuv()` exactly once.
95+
96+
Recovery may cancel the tentative claim with `DESTRUCT -> RESCUE`. A lost claim
97+
or commit accepts `RESCUE` and a recovery-restored `LIVE` as ordinary outcomes;
98+
recovery owns the durable work publication and restoration. Rollback never
99+
steals that ownership. Unexpected lifetime loss or failure of the exact WHITE
100+
commit is a release-build fail-stop, not an assertion-only continuation.
101+
102+
The SC lifetime handshake prevents both a body reader and a racing rescue
103+
publisher from missing one another. The arena seal alone is not used as a body
104+
lease. Once `DESTRUCT -> FREE` succeeds, no competing semantic destructor can
105+
acquire the object, and the direct typed call performs accounting once. An
106+
observed exact `FREE` plus `FREEING` outcome is terminal ownership, not a reason
107+
to dispatch again.
108+
109+
## Accounting and physical completion
110+
111+
Pre-grace dispatch calls the normal semantic destructor with the lifetime
112+
already committed to `FREE`. Deferred arena free therefore:
113+
114+
- subtracts the exact `sizeLfunc(0)`, `sizeLfunc(1)`, or `sizeof(GCupval)`
115+
charge once;
116+
- preserves the body, allocation boundary, READY bit, and destructor identity;
117+
and
118+
- creates no `RETIRED`/`reclaim_deferred` ticket.
119+
120+
The sweep still requests an arena grace. Post-grace quarantine recognizes the
121+
already-owned `FREE|FREEING` start, removes its physical discovery state and
122+
kind, and does not charge or dispatch it again.
123+
124+
If any global, arena, sidecar, body, cdata, permanent-retention, or lifetime
125+
predicate disagrees, the unchanged fail-closed path classifies the start as
126+
`RETIRED`, increments the deferred ticket, and validates/destructs it after
127+
grace. A concurrent semantic mark rescues `RETIRED -> LIVE` with the existing
128+
accounting repair. Unsupported kinds are never inspected pre-grace.
129+
130+
## Compatibility boundary
131+
132+
There is no public LuaJIT API or ABI change and no new bytecode. The added
133+
exclusive entry point is internal. The original arena-scan function retains
134+
its historical zero return contract.
135+
136+
The separately documented temporary custom-`lua_Alloc` policy is unchanged:
137+
`lua_newstate()` currently uses the internal arena allocator and
138+
`lua_setallocf()` remains a no-op. Restoring arbitrary allocator callbacks is
139+
still required after the beta boundary described in
140+
`lua-alloc-temporarily-disabled-2026-07-10.md`; this optimization neither
141+
claims nor weakens that future requirement.
142+
143+
## Validation
144+
145+
Focused coverage constructs real rootless `LFUNC0`, `LFUNC1`, and adjacent
146+
closed-upvalue allocations, then verifies:
147+
148+
- exact pre-grace semantic accounting and `FREE|FREEING` ownership;
149+
- unchanged body bytes, block boundaries, READY bits, kinds, and root `NONE`
150+
before grace;
151+
- removal of physical discovery metadata after grace with no second charge;
152+
- independent closure destruction when the adjacent upvalue has an injected
153+
cdata disagreement, followed by only that upvalue's post-grace destruction;
154+
and
155+
- ordinary retirement when the global exclusive bit was pre-held instead of
156+
locally acquired or when the locally acquired global gate then lost SMR
157+
admission.
158+
159+
After the claim-before-body implementation, the forced-clean focused fixture
160+
passed once and that exact frozen binary passed 20/20. After the later
161+
release-build fail-stop and SMR-loss fallback hardening, a clean default rebuild
162+
and the expanded forced-clean focused fixture passed in 22.98 seconds. The
163+
following post-claim matrix also passed first try:
164+
165+
| Target | Result | Time |
166+
| --- | --- | ---: |
167+
| `m2_arena_sweep` | PASS | 1.47 s |
168+
| `m6_jit_fnew_bump` | PASS | 22.94 s |
169+
| `m3_gc2_recovery` | PASS (normal, paranoia, default restore) | 131.82 s |
170+
| `m3_gc2_paranoia` | PASS (all C fixtures, stock 509/509, no-JIT 387/387, restore) | 93.08 s |
171+
| `m6_jit_gc2_readiness` | PASS | 1.29 s |
172+
| `m3_gc2_worker_scheduler` | PASS | 42.72 s |
173+
174+
The known nonfatal GCC `gc2_root_rescan_later`/`la_load32_acq`
175+
`-Wstringop-overflow` diagnostic remains unchanged.
176+
177+
## Performance and follow-up
178+
179+
Five independent `BENCH_SCALE=.1 closures_upval` processes were measured for
180+
each interpreter/mode after the final default rebuild. Each process retains the
181+
benchmark's own best-of-five inner timing:
182+
183+
| Mode | Current ns/op (five runs; median) | Stock `/usr/bin/luajit` ns/op (five runs; median) | Ratio |
184+
| --- | --- | --- | ---: |
185+
| GC active | 487.01, 487.09, 489.12, 494.25, 488.80; **488.80** | 37.91, 37.60, 37.63, 37.45, 37.34; **37.60** | **13.00x** |
186+
| GC stopped | 102.83, 113.84, 105.72, 101.42, 94.52; **102.83** | 18.43, 20.68, 20.84, 20.93, 21.11; **20.84** | **4.93x** |
187+
188+
The stopped measurements require an explicit wrapper. `BENCH_GC_MODE=stop`
189+
is ignored by the current benchmark (it recognizes only `generational` and
190+
`incremental`), while a simple `-e 'collectgarbage("stop")'` is undone by the
191+
benchmark's next explicit `collect`. The valid wrapper runs each real
192+
`collect`, immediately stops automatic GC again, and was probed with
193+
`collectgarbage("isrunning") == false` before sampling:
194+
195+
```sh
196+
-e 'local real_collectgarbage=collectgarbage; collectgarbage=function(op,arg) local r=real_collectgarbage(op,arg); if op=="collect" then real_collectgarbage("stop") end; return r end; real_collectgarbage("stop")'
197+
```
198+
199+
The current active-minus-stopped delta is 385.97 ns/op; stock's is 16.76
200+
ns/op. The active median is also 12.72% slower than the preceding rootless-kind
201+
checkpoint's valid 433.63 ns/op active measurement while stock remained stable.
202+
The repeated full predicate/claim work has therefore not yet amortized the
203+
retirement it removes. This tranche is a correctness and ownership prerequisite,
204+
not a claim that the b1.2.0 performance gate has passed; both base closure
205+
allocation and active collector work remain release blockers.
206+
207+
The next likely closure-churn work is to reduce post-grace per-start scanning
208+
and then restore active-MARK inline FNEW only under an explicit current-cycle
209+
proto/environment/upvalue traversal certificate. Relaxing the mark lease or
210+
blindly raising collector thresholds would trade away the safety established
211+
here and is not an acceptable optimization.

0 commit comments

Comments
 (0)