Skip to content

Commit ecc15a0

Browse files
committed
jit: make trace readers and terminal cleanup nonwaiting
1 parent 8a122cc commit ecc15a0

19 files changed

Lines changed: 990 additions & 305 deletions

notes/gc2-jit-tactical-smr-marker-2026-07-13.md

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -70,30 +70,30 @@ active node.
7070
The same ordinary collision existed one level later in trace compilation.
7171
`lj_trace_free_unpublished()` retires a compact trace which has no public trace
7272
number, epoch, or retire-list link. Its recorder token/local construction owner
73-
is the sole body authority. Before publishing the epoch, the old path called
74-
the generic raw marker for the trace and non-mcode exit table; an IDLE writer
75-
loss could therefore pin `NO_RECLAIM` even though no semantic edge was missing.
76-
77-
Only that explicitly unpublished pre-claim step now uses the publication
78-
marker. It first tries one outer SMR admission covering every trace-body
79-
validation and exit-table load. On admission loss it publishes only T's root
80-
retry request and returns without dereferencing T or the exit table. On
81-
success, the nested T/exit-table publication marks are reentrant. The epoch
82-
claim is immediately followed by the existing mandatory
83-
`trace_preservebody()` pass and retire-list publication before the recorder
84-
token is released. That pass still marks KGC/prototype/snapshot/link children
85-
and retains its fail-closed behavior. Public trace retirement, aged retired-root
86-
scans, and exclusive-reclaimer requeue continue using their mandatory or
87-
reclaim-held marker variants.
88-
89-
The trace-retire fixture holds the IDLE writer while the recorder token owns a
90-
synthetic unpublished body and exit table. It proves both marks remain clear,
91-
SMR remains writer-owned with zero readers, and the activation snapshot is
92-
bit-for-bit unchanged. After release, it starts MARK and the exact runtime
93-
retirement path marks both allocations plus a real starting-prototype child,
94-
then claims/lists the trace for normal later grace/terminal drain. This
95-
distinguishes the pre-claim raw publication from the mandatory post-claim
96-
semantic traversal.
73+
is the sole body authority. The assembler copy is not a partially published
74+
semantic trace: it has copied IR bytes, but no trace slot, prototype/root-spine
75+
edge, snapshots, exit table, debugger descriptor or executable mcode. Its KGC
76+
operands remain rooted by token-private `J->cur` until the failed recording is
77+
aborted. Treating the copy as a semantic body required a looping post-claim
78+
traversal and manufactured ownership for fields the allocation never acquired.
79+
80+
Unpublished retirement now sets the immutable
81+
`TRACE_RETIRED_UNPUBLISHED` kind, claims an epoch, publishes the exact body on
82+
the token-owned retire list, and performs only one-shot pre/post raw marks.
83+
Admission loss requests a root retry and returns without reading the compact
84+
payload. There is deliberately no `trace_preservebody()` pass. Root scans mark
85+
the exact listed allocation without decoding children; mature reclaim skips
86+
public-slot, inbound-link, debugger, root-spine, exit-table and mcode teardown,
87+
validates the strict scratch shape, and frees that allocation exactly. Public
88+
trace retirement keeps the existing semantic preservation policy.
89+
90+
The trace-retire fixture holds the IDLE metadata writer while the recorder
91+
token calls the real `lj_trace_free_unpublished()` path. The scratch includes a
92+
deliberately unmarked KGC operand, while snapshots, start prototype, exit table
93+
and mcode remain NULL. Both tactical marks lose without waiting; the exact body
94+
and KGC marks remain clear, SMR stays writer-owned with zero readers, and the
95+
activation snapshot is unchanged. The tagged/listed body then survives young
96+
grace and is physically reclaimed at the mature epoch.
9797

9898
## Adjacent audit item
9999

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# Nonwaiting JIT trace-reader SMR tranche
2+
3+
Date: 2026-07-19
4+
5+
This b1.2.1 checkpoint removes the looping GC2 SMR admission API from the JIT
6+
reflection, recorder lookup, assembler lookup and optional GDB-JIT metadata
7+
files. It is a bounded tranche, not a claim that trace publication, flush,
8+
retirement or the repository-wide wait inventory is complete.
9+
10+
## Runtime policy
11+
12+
The `jit.util` trace-reflection functions are observational under concurrent
13+
mutation. They already used a one-shot recorder-token acquisition and returned
14+
no result when a peer owned it. They now apply the same rule to SMR: failure to
15+
enter the current trace-body generation is a transient no-result observation.
16+
If the function acquired the recorder token, it releases that token before
17+
returning. This prevents an exclusive reclaimer that also needs the token from
18+
forming a token/SMR dependency cycle.
19+
20+
Reflection does not allocate while retaining trace-body SMR. `tracesnap`
21+
copies its at-most-255 immutable map entries to bounded C-stack storage before
22+
building the result table. `tracemc` first sizes the current observation,
23+
grows the owner TG's managed scratch buffer outside SMR, and then performs a
24+
second one-shot lookup which copies only a body that fits. String interning is
25+
therefore outside both trace SMR and the temporary recorder-token claim; an
26+
allocation error cannot leak a trace pin or strand a reclaimer. The second
27+
admitted interval also converts the mcode address to an integer scalar before
28+
release; no expired trace or mcode pointer value is evaluated afterward.
29+
`traceexitstub` applies the same rule to its borrowed exit-stub address.
30+
`tracek` reduces scalar constants to copied IR words; for `IR_KGC`, whose word
31+
still names a reclaimable child, it additionally acquires an exact GC2
32+
allocation lease under trace SMR, publishes the copied TValue as a Lua stack
33+
root after leaving trace SMR, and only then releases the child lease. A
34+
transient or stale child admission is another no-result observation.
35+
36+
Recorder and assembler lookups have a stronger semantic obligation, but both
37+
are speculative. A failed one-shot SMR admission aborts that recording turn
38+
with the admission-specific `LJ_TRERR_SMRRETRY`, allowing execution to continue
39+
in the interpreter and a later hot turn to record again. The new error was
40+
appended to the catalog so every pre-existing numeric trace-error value remains
41+
unchanged. It gets the same immediate-hotcount policy as `LJ_TRERR_RETRY`, but
42+
only `SMRRETRY` suppresses the TRACE-abort event: ordinary optimizer, stale-body
43+
and table retries remain observable through the existing API. The assembler's
44+
target-return-PC shortcut is optional and simply retains the snapshot PC when
45+
admission loses.
46+
47+
The hot-side probe likewise remains interpreted when admission loses. After a
48+
snapshot has already been restored, a `JLOOP` target revalidation collision
49+
returns to bytecode redispatch. Neither optimization can make an exiting
50+
mutator wait for a trace reclaimer.
51+
52+
GDB-JIT metadata is optional. If a side trace's parent cannot be admitted, its
53+
debug entry is omitted instead of waiting or publishing an object with a
54+
guessed parent stack adjustment. Normal trace execution and retirement are
55+
unchanged.
56+
57+
Debug trace-PC recovery similarly reports no bytecode position on contention.
58+
The x86/x64 final link assembler retries the recording turn. Bytecode dumping
59+
recovers patched root instructions from the prototype's immutable sidecar;
60+
only a compatibility fallback tries trace-body SMR, and that admission is
61+
one-shot with an already bounded live-bytecode resample.
62+
63+
There are now no `lj_gc2_smr_read_enter()` call sites in `lib_jit.c`,
64+
`lj_record.c`, `lj_asm.c`, `lj_asm_x86.h`, `lj_bcwrite.c`, `lj_debug.c` or
65+
`lj_gdbjit.c`. A monotonic source gate protects this completed domain. Blocking
66+
admissions remain in `lj_trace.c` and GC/safepoint lifecycle files and must be
67+
converted with their matching flush/retirement descriptors; this tranche does
68+
not hide that debt behind an allowlist.
69+
70+
## Terminal cleanup
71+
72+
Failed assembler copies now retire as raw unpublished scratch descriptors: they
73+
carry no semantic snapshot/prototype/exit-table graph and use one-shot raw marks
74+
around token-owned list publication. Successful assembly, ordinary cancel and
75+
owner-abort paths publish INTERP+IDLE and release the recorder token before
76+
dispatch repair. First-area `MCODEAL` and max-trace exhaustion likewise defer a
77+
full flush until after that terminal release. An asynchronously aborted
78+
`MCODELM` restart falls through full slot cleanup instead of leaking `J->cur`,
79+
and down-recursion retries only when its restarted recorder remains non-IDLE.
80+
81+
The normal active `TRACE_START` path still installs its recording dispatch
82+
overlay while retaining the token. Any wait/dependency hidden in that active
83+
overlay update is explicit remaining debt; silent-IDLE start exits no longer
84+
share it.
85+
86+
## Evidence
87+
88+
The recorder-token fixture closes SMR in `LJ_GC2_SMR_META_EXCLUSIVE` and calls
89+
every trace-numbered `jit.util` reader. Each call returns without a result,
90+
does not increment the reader count and leaves the recorder token unowned. A
91+
regression to the looping admission API hangs this deliberately closed scope
92+
and is terminated by the focused suite timeout.
93+
94+
The immutable-start-instruction fixture also publishes a real patched `JLOOP`,
95+
closes SMR exclusively, dumps the owning function, reloads the bytecode and
96+
executes it. This proves that bytecode serialization uses the prototype
97+
sidecar rather than waiting for or dereferencing the trace vector. Serialization
98+
now acquire-loads every mutable `BCIns` independently and always runs a captured
99+
JIT opcode through sidecar/unpatch recovery. It no longer races a bulk `memcpy`
100+
or a stale `PROTO_ILOOP`/prototype-trace fast-path decision against publication
101+
and flush.
102+
103+
The same fixture exposed a stale XPOLL assertion after mutable-global tracing
104+
became conservative. A safe runnable side trace may now end at the interpreter
105+
instead of using `LJ_TRLINK_ROOT`; the test now checks the actual invariant, an
106+
exact snapshot on a non-loop XPOLL in any runnable side-trace topology.
107+
108+
## Remaining event debt
109+
110+
The exact closed-gate `SMRRETRY` path cannot safely call arbitrary TRACE-abort
111+
handlers while it still owns the recorder token, because a handler may enter a
112+
reader needed by the already-admitted writer. Other abort events retain their
113+
historical visibility. They still run token-held and therefore retain a more
114+
general close-after-check dependency race if a metadata-exclusive owner closes
115+
after event preparation begins. Moving those callbacks requires preserving
116+
`jit.dump` and user abort-observation ordering and is intentionally recorded as
117+
follow-up debt rather than hidden by suppressing ordinary `RETRY` events.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Raw unpublished JIT scratch retirement
2+
3+
Date: 2026-07-19
4+
5+
## Problem and object kind
6+
7+
Assembler retry/abort can leave `J->curfinal`, a compact allocation made by
8+
`lj_trace_alloc()`. It contains a copied IR span, but it has never acquired the
9+
semantic ownership of a published `GCtrace`: there is no trace number or slot,
10+
prototype/root-spine edge, snapshot/snapmap publication, exit table, debugger
11+
entry, executable mcode or native-entry pin. The live recorder's `J->cur` is the
12+
semantic root for KGC operands until abort cleanup ends.
13+
14+
The old unpublished path routed this allocation through ordinary retired-trace
15+
preservation. That could loop in `trace_preservebody()` while the recorder token
16+
was held and an exclusive SMR owner needed the same token. It also interpreted
17+
uninitialized/non-owned fields as if this were a published trace.
18+
19+
## Retirement protocol
20+
21+
`TRACE_RETIRED_UNPUBLISHED` is an immutable atomic kind bit. The recorder owner
22+
clears `J->curfinal` first, sets the kind, performs a one-shot raw pre-mark,
23+
claims the retirement epoch, closes native-pin admission, publishes the exact
24+
allocation on the token-owned retire list, and performs a one-shot raw post-mark.
25+
Either mark may lose SMR admission; the local owner before list publication and
26+
the list owner afterward are the lifetime authority, while the miss reopens the
27+
active root certificate. Neither mark decodes the IR or any child field.
28+
29+
All three runtime retirement sites clear the token-private pointer before this
30+
handoff: assembler IR-buffer replacement, synchronous `trace_abort()` and
31+
`lj_trace_abort_owner()`. `lj_trace_alloc()` initializes `snap`, `snapmap`, GC
32+
links, optional pointer-auth/GDB fields and every other scratch discriminator to
33+
NULL/zero so validation never reads indeterminate storage.
34+
35+
## Retire-list consumer audit
36+
37+
Every production traversal recognizes the immutable kind:
38+
39+
- GC root marking preserves only the exact allocation.
40+
- Mature reclaim bypasses inbound-link scanning, slot release, debugger removal
41+
and root-spine unlink, then validates and destroys the exact allocation.
42+
- Mcode-area reference scans report no reference for scratch bodies.
43+
- Stale-start-instruction recovery skips them.
44+
- Terminal pin preflight validates their shape; close skips debugger teardown.
45+
- Runtime/terminal destruction never frees an exit table for this kind.
46+
- Requeue and payload-preservation decisions remain raw and nonsemantic.
47+
48+
The strict validator requires zero trace/link/root identities, NULL semantic
49+
side pointers, zero mcode size and pin count, CLOSED pin admission, a nonzero
50+
retirement epoch, no debugger entry, and exactly the unpublished kind bit.
51+
Runtime corruption requeues fail-closed; terminal corruption aborts rather than
52+
guessing ownership.
53+
54+
## Adjacent recorder terminal ordering
55+
56+
Successful assembly and terminal error/owner-abort cleanup now publish INTERP
57+
and IDLE, release the recorder token, and only then run ordinary dispatch repair.
58+
First-area `MCODEAL` and trace-number exhaustion defer full flush until after
59+
that release. `trace_start()` returns ACTIVE, IDLE or FLUSH_ALL so invalid
60+
bytecode, GC gating, `PROTO_NOJIT` and max-trace exits cannot refresh dispatch
61+
while token-held. Down-recursion retries only when the restarted recorder
62+
actually remains non-IDLE. An asynchronously aborted `MCODELM` restart falls
63+
through ordinary slot cleanup instead of publishing IDLE with a leaked pending
64+
trace. A stale hot dispatch also checks `JIT_F_ON` before attempting the token.
65+
66+
## Evidence and remaining debt
67+
68+
The focused retire fixture invokes the real unpublished retirement path while
69+
holding both the recorder token and `LJ_GC2_SMR_META_EXCLUSIVE`. A deliberately
70+
unmarked KGC operand proves there is no semantic traversal. The call returns
71+
with zero readers, unchanged activation, an unmarked but tagged/listed exact
72+
body, and then reclaims it only after full trace grace.
73+
74+
`LJ_TRERR_SMRRETRY` separately identifies failed recorder/assembler SMR
75+
admission. Only that known closed-gate path suppresses TRACE-abort callbacks;
76+
ordinary `LJ_TRERR_RETRY` remains observable. Generic abort callbacks still run
77+
while the recorder token is held and retain a close-after-check dependency race.
78+
Relocating them must preserve `jit.dump` and user abort-event semantics and is
79+
explicit follow-up work.

0 commit comments

Comments
 (0)