GC roots: a precise root model for the interpreter and both JITs
Written 2026-10-29 against master c816f3f9ae, before any implementation. It was prompted by two suspected JIT root bugs (jit_stale_spill_slot, jit_inline_local_root) and a run of collector fixes that each patched one consequence of the same assumption. This was a design for review when it was written, and nothing below had landed. Phases 0 to 6 have since merged (#1511 was the last); the status blocks that follow are the record of what was built and how it differs, and the rest of the page is the design they implemented.
Contents
Status, 2026-10-30. The user chose the full redesign over a targeted fix.
- Phase 0 is merged (#1455):
--jit=offnow compiles nothing. - Phase 1 is built, on branch
gc-roots/phase1. What changed against this document:- The knob is
OBJECK_GC_ROOTS=audit, notOBJECK_GC_VERIFY=roots. It reports, at every collection, where the maps and the live stacks disagree. Its per-method dump isOBJECK_GC_ROOTS_DUMP=<name>. - The interpreter is not free (§6.2).
frame->ipis written only at calls, and error traces read it. The instruction in progress now goes toStackFrame::cur_ip: a union withjit_offset, so both JITs' frame records keep their layout. It is written on the dispatch loop's default path, which every instruction that can collect takes, and at the safepoint poll, encoded as "between instructions". The measured cost is within noise: -0.5% on a call- and allocation-heavy interpreted benchmark. - The operand stack is one array per thread, the frames' segments end to end. The audit splits it with each frame's height at its pc. A caller's segment is its height less what its call popped; the top frame's segment is anywhere between that and its height.
- A trap's stack effect:
TRAP_RTRNpopsargsand pushes one word, as the JIT models it. A plainTRAPpushes nothing, except right before theRTRNof a value-returning method, whose value it pushes (Deserializer->ReadInt). GetParamCount()counts stack words: a func-ref parameter is two.- The and/or slot starts as the zero the frame is entered with, on every path, so what two paths store meets where they join.
- #1457 is a prerequisite, found by the audit. Three trap handlers left their declared arguments on the operand stack for good, interpreted and compiled, keeping objects alive and shifting every frame above them. Fixed in #1458.
- The knob is
- Phase 3 is built (AMD64), 2026-10-31, on branch
gc-roots-amd64, after a layout header PR that moved every frame constant of both backends and the collector's window assumptions intocore/vm/arch/jit/jit_frame_layout.hwithstatic_asserts. What changed against §6.3:- Three slot classes, not two.
TMP_REF_0..9(references and anything the map leaves unknown; zeroed at entry, cleared on reload, the only words beside the locals the collector reads),TMP_VAL_0..9(pending values the map provesint; never read) andTMP_SCR_0..3(the scratch saves of §5.2 rule 6: the division'sRAX/RDX, a shift'sRCX, the inline allocator'sRAX-RDX). Scratch got its own class so a reference saved around an inline allocation never sits in a value slot, which keeps the audit below exact. The frame grows by 14 words; the entry zeroing covers the same ten slots it did. - The libm spills are typed like the others, not treated as scratch, so a value slot only ever holds what the map proved.
- The map is used only where it lines up with the JIT's working stack (
JitAmd64::SpillPending): its height at the instruction, less what the site popped already, must equal the working stack's size, and no entry may be a float where the map saysint/refor an integer register where it saysfloat. Otherwise the whole site spills to reference slots.OBJECK_JIT_REPORT=1prints per methodspills sites= ref= val= unknown= mismatch=. - The audit covers compiled frames: under
OBJECK_GC_ROOTS=auditaTMP_VAL_*word that is a real object's address counts asjit_value_slot_at_object(a mistyped reference, or a working stack the check above missed). In audit mode the entry zeroing covers the value slots too, so a slot the frame never wrote cannot report what an earlier, deeper frame left there (without that the count was 79,663 across the suite, all stale stack words).gc_address_int_pendingtrips it by construction; the regression suite must not. Measured on the suite (771 tests, every method compiled, 64k nursery): 293,818 collections, 3,235,280 compiled frames,jit_value_slot_at_object=0; 455,462 spill sites, 107,306 to reference slots, 7,921 to value slots, 303 unknown, 31 sites where the map did not line up (all inFile:GetTempName,UDPSocketServer:Bind, whose maps do not build, andGuarded:Read/ReadNested), every one of which spilled to reference slots. OBJECK_GC_ROOTS=conservativescans the old window over every slot class on x64, so it is the negative control for compiled code as well:gc_address_int_pendingmust FAIL under it with--jit=1, and PASS without it.- The and/or slot is still scanned conservatively on x64, as before; it is typed in phase 5. ARM64 is untouched (phase 4).
- Three slot classes, not two.
- Phase 4 is built (ARM64), 2026-11-01, on branch
gc-roots-arm64, the same design as phase 3 with the ARM64 frame:TMP_SCR_0..2(X9 across a libm call; the write barrier's destination and pending values, whose overflow now fails the compile instead of being skipped),TMP_VAL_0..5, the D8-D15 save area,TMP_REF_0..5right under the RED_ZONE word, so the entry zeroing is one range fromTMP_REF_0through the locals (the save area is outside it, as before). The FP save/restore blocks are encoded from the layout rather than hand-written immediates. The frame no longer addsRED_ZONE(256 bytes) above the outgoing area, slack that only the X19 save word at the top used: a small method's frame is 168 bytes smaller than before despite the nine new slots. The collector readsTMP_REF_0..5by their distance below the RED_ZONE word (JitFrameLayout::RefSlot), the audit checksTMP_VAL_0..5, andOBJECK_GC_ROOTS=conservativescans the old window over every slot. A libm call parks a pending caller-saved float in a callee-saved register, as before; ints go to the typed slots. There is no ARM64 machine here: compile-checked withcl /Zsunder the ARM64 cross toolchain (with and without_ARM64), proven on CI's three arm64 legs. - Phase 6 (heap side), the
Int[]tag, is built 2026-11-01 on branchgc-roots-int-array: the compiler allocates anInt[]withNEW_INT_ARY(heap tagINT_ARY_TYPE, never marked, never relocated) and a class, string, function or generic array with the newNEW_OBJ_ARY(INT_TYPE, scanned as before).MAGIC_NUM_EXEandMAGIC_NUM_LIBmoved (0xffbf, 0xffba) so an .obe or .obl from before is refused with a message instead of having its object arrays read asInt[]; the committed.oblwere rebuilt. An Int array a native or a trap allocates staysINT_TYPE, conservative as before.OBJECK_GC_ROOTS=conservativescans anINT_ARY_TYPEarray the old way, the negative control forgc_address_int_array(run_vm_flag_tests.py section 31). Element access is untouched: only the allocation's tag differs, and the collector does less. The closure capture block's own type (193f076fdd(b)) is still open. - Phase 5 is built 2026-11-01 (branch
gc-roots-phase5), narrower than the table below says, for a reason stated here. Done: the dead AMD64 inliner (ProcessInlineMethod,CanInlineMethod,ComputeInlineLocalSpace,extra_inline_space,inline_local_offset,is_inlining) is deleted; the compiled frame's and/or temp is no longer scanned: at every point that can collect, a compiled method carries the temp through aTMP_REF_*slot when the map says it may hold a reference there (ref_map->andor[ip]), and brings it back relocated (AMD64 throughpush [rbp+andor]/pop [rbp+slot], since no register is free while a call's arguments are still pending; ARM64 through X10), so the 9cdc656566 conservative scan is gone fromCheckJitRoots/FixupRootsexcept under the conservative control. Not done, on purpose: the operand-stack words the maps leaveunknown(42 across the suite) and the segments above the first compiled frame on a thread are still scanned conservatively, because no map types them yet; removing that scan would drop roots, not precision. With it,CheckUntypedSlot's class validation (181d478f70) and theFixupSelfchecks (4542ec7e41) stay as guards rather than becoming verifier-only assertions. Typing those segments (the bridge's stack effect per callback) is the remaining root-side item. - Suite-wide audit (665 tests, interpreted, 256k nursery, on the #1457 fix):
- 2,568 maps, 0 failed;
- 0 threads whose segments do not add up;
- 42 words left unknown, of about 417,000;
- 0 reference-typed words at nothing;
- every Int-typed word at an object is
gc_conservative_bad_class, which keeps an address in an Int on purpose.
1. Summary
The collector does not know which words in a frame are references. It finds roots in three ways:
- it walks the declared locals by their declared types;
- it scans untyped words conservatively, marking anything that
IsAllocatedaccepts; - it relocates anything whose target has been forwarded (
FixupSlot).
The untyped words are:
- the interpreter's operand stacks;
- the and/or slot;
- a fixed window of
JIT_TMP_LOOK_BACK(16) words beside every JIT frame, whether or not the frame wrote them.
Neither JIT backend tells the collector which of those words hold a live reference at a safepoint.
That guess has three consequences. Each is demonstrated below on a fresh Windows x64 build of master:
- Dead objects stay reachable. A spill slot that is never cleared, or never zeroed at entry, keeps whatever it last held.
jit_stale_spill_slotkeeps a dead 32 MB array alive (§3.1). - Integers are rewritten. The nursery moves objects, and fixup "relocates" any untyped word equal to a forwarded object's address. That includes an
Intthat holds an address, such asGetInstanceID(). In the reproducer, 242 of 2,000 such integers changed value across a minor collection, interpreted and compiled alike (§3.3). This is a correctness bug, not a leak. - Every new slot kind became a separate fix. The and/or slot, pending registers at a loop poll, self's frame copy, frame layout and closure captures were each fixed one at a time (§4).
The proposal has three parts:
- one shared bytecode analysis that says, for every instruction, which operand-stack depths and which untyped slots hold references;
- interpreter frames read that analysis as a stack map;
- JIT frames spill references only to reference-only slots that the collector scans by type, and clear them after reload.
Conservative scanning of frames and stacks is then removed, along with the window and the symptom patches that exist only because of it (§7, phase 5).
Verdicts on the two suspected bugs:
jit_stale_spill_slotis real, JIT-only, and has two paths. First, AMD64 entry zeroing skipsTMP_REG_0on both ABIs. Second, spill slots are never cleared, so a frame keeps what it spilled for as long as it runs. ARM64 has only the second path.jit_inline_local_rootis refuted. The AMD64 JIT inliner has been switched off since f871a89252 (2026-04-01):ProcessInlineMethodhas no caller. ARM64 has no JIT inliner. The test never reached either. The dead inliner should be deleted (phase 5), because enabling it again would introduce exactly the defect the test describes.
2. Evidence: method and environment
- Build: Windows 11, VS 2022, MSBuild of
core/release/objeck.slnatc816f3f9ae, deployed to a scratchdeploy-x64. - Configurations: each test was compiled at
-opt s0and-opt s3, and run with--jit=off,--jit=1and the default threshold.
2.1 --jit=off does not mean "interpreter only"
--jit=off (and OBJECK_JIT_DISABLE=1) only raises the auto-JIT threshold. A native method is still compiled. Under --jit=off, OBJECK_JIT_REPORT=1 prints [jit] Probe2:Leave:: compiled for a native method, and System.String:Get is compiled too, because library methods are native as well.
The usage text says "'off' runs the interpreter only". The earlier finding that jit_stale_spill_slot "fails with the JIT off" came from this: the test's methods are all native. With native removed:
jit_stale_spill_slot, no native | --jit=off | --jit=1 | default (10) |
|---|---|---|---|
-opt s0 | PASS (1,049,378 B) | FAIL (34,603,835 B) | PASS |
-opt s3 | PASS | FAIL | PASS |
Two consequences follow:
- The nightly differential's
s0/offconfiguration (#1443's table) is not interpreter-only either. --jit=offneeds its own fix, or its help text needs to change. That is reported separately and is not part of this design.
3. What the two tests actually show
3.1 Stale spill slots (real)
Path 1: entry zeroing misses TMP_REG_0 (AMD64, both ABIs).
JitAmd64::RegisterRoot zeroes ((offset - 8) >> 3) + 10 words upward from the lowest local. On Win64 the locals plus the RED_ZONE word take offset / 8 words, so the count stops one word short of TMP_REG_0 (RBP-32). POSIX misses TMP_REG_0 (RBP-88) the same way.
The - 8 dates from the six-slot layout (+ 6). 5555f2960b (2026-09-09) widened the slots to ten without changing it.
In the test, Leave() spills the 32 MB array to TMP_REG_0 across the StaleSmall constructor and returns. Churn() is then entered at the same frame pointer and never writes that slot, so every collection under Churn() finds the array there.
Changing the count to (offset >> 3) + 10 makes s0 pass. That one-line change is kept only as evidence, on the local branch scratch/amd64-tmp-reg0-entry-zeroing-evidence; it is not the fix. The main checkout's deploy build from 2026-10-22, which has the same count, passes the same test, so frame placement decides whether this path triggers. The 10-24 call-stack change (13daaacfb2) is the likely trigger; this was not bisected.
Path 2: spills are never cleared (AMD64 and ARM64).
At -opt s3, the compiler inlines Leave into Run as bytecode. MakeBig's result is pending in Run's own frame across StaleSmall:New, so ProcessStackCallback spills it to Run's TMP_REG_0, reloads it for Keep, and leaves the slot as it is. Run stays live while Garbage() runs.
A trace build that prints the window words the collector finds allocated shows the array in every collection:
[gc] jit frame 'JitStaleSpillSlot:Run:' mem=...EB60 dclrs=2 [gc] window 10 @...EBC0 -> 0000027E5B6D0060 size=33554457
Window word 10 on Win64 is RBP-32, which is TMP_REG_0. ARM64 zeroes all six TMP_X slots at entry (RegisterRoot), so it has path 2 only.
Severity: the effect is retention, at most one object per stale slot per live frame. Nothing is freed while still in use. It becomes serious when the object is large, as with the 32 MB array here.
3.2 Inlined callee locals (refuted)
The claim is that the AMD64 JIT pastes small callees into the caller and puts their locals below the walked region. The layout part is true of the code: inline_local_offset starts below the caller's locals, and the collector walks only org_local_space. But that code never runs:
- The JIT inliner never runs.
ProcessInlineMethodhas no caller. f871a89252 replaced the call with the callback path ("disable ProcessInlineMethod for MTHD_CALL").CanInlineMethodis still called, but only to sizeextra_inline_space, which is dead frame space today. - At
-opt s0,Fillis compiled as a separate method. Itss->Size()is aMTHD_CALL, soCanInlineMethodwould refuse it anyway. - At
-opt s3, the compiler inlinesFillas bytecode. Its locals becomeRun's declared locals, which the collector walks. - A rewritten test that the JIT inliner would accept still passes. It has no calls and no branches, at most 20 instructions, and returns
s[0]. It passes at s0, s1 and s3 under--jit=offand--jit=1, because nothing inlines it. - ARM64 has no JIT inliner.
The latent hazard is real: re-enabling ProcessInlineMethod would reintroduce exactly this defect. Phase 5 deletes it. Any future JIT inliner must give inlined locals declared, walked slots.
3.3 Integers rewritten by fixup (new; interpreter and JIT)
FixupRoots runs FixupSlot over every operand-stack word and every window word. The comment there says it "validates the forwarding target so a plain integer that merely aliases the young address range is not misread". But the check cannot tell an integer from a reference: an integer equal to a young object's start address passes it.
GetInstanceID() returns exactly such an integer (LOAD_INST_UID compiles to LOAD_INST_MEM). The reproducer:
o := Box->New(i);
before := o->GetInstanceID();
# o->GetInstanceID() is pending (operand stack / spill slot) while Churn allocates
if(Same(o->GetInstanceID(), Churn(i), before) = false) { bad += 1; };
Run with --nursery=64k, 2,000 iterations, no native:
--jit=off | --jit=1 | |
|---|---|---|
-opt s0 | FAIL, 242 changed | FAIL, 242 |
-opt s3 | FAIL, 242 | FAIL, 242 |
With the default 128 MB nursery it passes, because no minor collection lands in the window. The 2026-10-22 binary fails the same way, so this is not a regression of this cycle.
A related but separate problem: an instance ID is an address, so it changes when the object is promoted. That is an identity-stability bug, out of scope here.
4. The nine patches, traced to the assumption each worked around
| commit | what it fixed | the root-discovery assumption that failed |
|---|---|---|
| 9cdc656566 (#1429-#1431) | the and/or slot was neither marked nor relocated in interpreted and ARM64 JIT frames | "declarations describe every slot". The and/or slot has no declaration; its type depends on the pc. The fix marks it conservatively. |
| 613e68a63f / 8617fda009 | pending values in registers at a loop's safepoint park were lost | "nothing is live in registers at a safepoint". The fix borrows the conservative window by spilling there. |
| 0f0c6b8bfe (#746) | the frame's INSTANCE_MEM copy of self went stale after a park | "self has one home". The JIT keeps a second copy that the collector cannot see. |
| 55504a6fcd | ARM64 read every declared slot one word off when slot 0 was missing | "the collector can infer the frame layout from declarations". The JIT laid the frame out from references. |
| 4b67aa39ba | an unreferenced declared local shifted every slot below it | the same assumption, in the other direction |
| 4542ec7e41 (#820) | self "relocated" through a word that was not a forwarding address | "any word can be relocated if it looks forwarded". This is hardening of the guess. |
| 181d478f70 (G5) | a conservative nursery word was read as a class pointer and dereferenced | "a word that looks allocated is an object". The fix validates the guess. |
| 5d92132687 (#880, #881) | a stale window word marked a closure block first, so the typed walk skipped its captures | "conservative and typed marking are interchangeable". A stale window word won the race. |
| 193f076fdd | (a) zero-field objects at the nursery end; (b) a capture block typed BYTE_ARY_TYPE holding references | (a) is an allocator bound, unrelated to roots. (b) is the heap-side twin of this design: a reference container without a reference type. |
The framing is confirmed for eight of the nine. Each fix either told the collector about one more slot kind, or made the conservative guess safer. None gave the collector a precise statement of what is live. 193f076fdd(a) is a separate allocator fault.
5. Root model
5.1 Slot kinds today
| slot kind | interpreter | AMD64 JIT | ARM64 JIT | how found today |
|---|---|---|---|---|
| declared locals | frame->mem[1..] | jit_mem upward, typed | jit_mem upward, typed | precise, by declaration |
| and/or slot (id 0) | frame->mem[1] | lowest local; also window word 0 | jit_mem[-1] | conservative (9cdc656566) |
| self | frame->mem[0] | frame->mem[0], plus INSTANCE_MEM copy | same | precise for mem[0]; the copy is reloaded after every park or callback |
| operand stack | op_stack[0..pos], per thread | values handed to callbacks | same | conservative, and relocated |
| pending values (working stack) | not applicable | registers; spilled to TMP_REG_0..9 / TMP_XMM_0..2 at callbacks, loop parks and libm calls | TMP_X0..5 / TMP_D0..3 | conservative window |
scratch saves (div, shift, RCX save) | not applicable | TMP_REG_0/1 | not applicable | conservative window, never cleared |
| saved frame words | not applicable | Win64 window reaches saved RBP and the return address; POSIX reaches OP_STACK/STACK_POS | window covers the caller's D8-D15 save area | conservative window (harmless today, never meaningful) |
| func-ref locals | two words | two words | two words | precise (FUNC_PARM) plus closure declarations |
| closure capture block | old BYTE_ARY_TYPE | same | same | typed via GetClosureDeclarations, conservative when dirty (193f076fdd) |
| native / trap C++ locals | on the operand stack by convention | bridge into C++ | same | operand stack, conservative |
5.2 Target rules
A word is a root if and only if the collector is told it is a reference at the safepoint where the collection happens. No word is ever relocated on a guess.
- Declared locals. Precise by declaration, as today. Both backends already lay frames out from declarations (4b67aa39ba), and entry zeroing makes a not-yet-written slot hold 0. Liveness is not required: a dead local that holds a reference is retention, not corruption, and the language makes it visible.
- The and/or slot. Typed per pc by the shared analysis (§6.1): a reference where the join merges references, an integer otherwise. It is no longer conservative.
- Self.
frame->mem[0]is the only root.INSTANCE_MEMis a derived cache that is never scanned and is reloaded after every GC-reachable call. That rule exists today; the design states it as an invariant and the verifier checks it. - Operand stacks. Typed per (method, pc) by the shared analysis. A depth is marked and relocated only if it is a reference. Integers are never touched, which fixes §3.3.
- Pending values in JIT code. A reference goes to a reference-only spill slot, which is typed, scanned precisely, zeroed at entry and cleared after reload. An integer or float goes to slots the collector never scans. This fixes §3.1 and §3.3 in compiled code.
- Scratch saves. These go to non-scanned slots. A scratch value is never a reference the collector needs: no collection can happen between a save and its restore inside one instruction.
- Saved frame words and float temps. Never scanned.
- Closure captures. Unchanged for roots. The capture block gets a type of its own in phase 6 (the heap side), which retires 193f076fdd's conservative dirty-block path.
- Native and trap C++ code. The convention stays: a reference held across an allocation lives on the operand stack. The analysis types an instruction's inputs as of the instruction's start, and a trap that pushes intermediate references must declare them. Phase 2 audits the traps; the verifier runs report-only until that audit is done.
5.3 Conservative scanning afterwards
There is none in frame or stack root discovery. The old scan survives in one place: the audit mode OBJECK_GC_ROOTS=audit, which runs the conservative scan beside the precise one and reports any allocated word the conservative scan would mark that the precise scan did not. That is the migration safety net. During phases 1-4 it reports, and it never changes what is marked.
6. How live references are reported
6.1 One shared analysis: RefMap (in jit_common)
The bytecode does not carry reference types on the stack. LOAD_INT_VAR loads objects and integers alike, and Int[] and object arrays share INT_TYPE. So the first piece is an abstract interpretation over a method's bytecode that tracks, for each operand-stack depth and the and/or slot, one of {int, ref, float, unknown}:
| source | result |
|---|---|
LOAD_INT_VAR | the slot's declared kind |
NEW_* | ref |
LOAD_INST_MEM | ref |
MTHD_CALL | the callee's return type |
| arithmetic | int |
| array element loads | the array's declared element kind, where the declaration knows it |
| joins | the meet of the incoming kinds |
The analysis runs once per method on first need and is cached. It has three consumers: the interpreter (read lazily at collection time), AMD64 (at compile time) and ARM64 (at compile time).
unknown is the open question. Phase 1 measures how often it occurs across the regression suite and the library before anything depends on it. Any residue gets a compiler-emitted annotation instead of a guess.
6.2 Interpreter: stack maps per (method, pc)
The map is read only when a collection walks the frame, and indexed by the instruction in progress, StackFrame::cur_ip (see the status note at the top). Recording it is one store on the dispatch loop's default path, which only instructions that can collect take, and one at the safepoint poll.
6.3 JIT: three options compared
| A. stack maps per safepoint | B. clear-after-reload, keep the window | C. typed spill areas (recommended) | |
|---|---|---|---|
| mutator cost | one store per GC-reachable call (the site id into a frame word) | one store per spilled value | one store per spilled reference (the clear) |
| precision | exact, including liveness | retention fixed; integers still rewritten (§3.3) | exact for spills; locals by declaration |
| failure mode of a bug | wrong map, so a live object is missed: corruption | missed clear: retention | missed clear: retention; wrong slot class: caught by roots verify |
| new machinery | site-id word, per-method tables, lookup in CheckJitRoots/FixupRoots | none | split TMP_* into TMP_REF_* and TMP_VAL_*; a RegInstr ref bit from RefMap |
| backends | both, plus table encoding | both | both; frame layout constants change in both .h files |
B is the minimum patch. It is rejected as a root model because it keeps the conservative window and does not fix §3.3.
A is the most general, but a wrong map entry fails toward corruption, and it adds a table format to two hand-written backends.
C gets the same safety from a smaller change, and every bug it can have fails toward retention. Recommendation: C. A can come later if liveness inside long frames ever measures as a problem.
Under C, the collector walks each JIT frame as: the declared slots (typed), the and/or slot (typed by RefMap at the frame's current pc), then the TMP_REF_* slots (typed ref, 0 when empty). Nothing else. The and/or slot needs the pc, and this design has not yet confirmed that a JIT frame records it at every GC-reachable call. Phase 1 checks this. If it does not, phase 3 adds one store of the instruction index per such call, which is option A's cost for this one slot only.
7. Phases
Each phase is one PR against master. The full regression suite, the GC verifier legs and the stress tests (gc_*_stress, jit_gc_stress, gc_mt_small_nursery_stress, the nightly verify-major and --nursery configurations) stay green. Before/after numbers are recorded in each PR (§8).
| phase | content | fixes | risk |
|---|---|---|---|
| 0 | Fix --jit=off so it disables native compilation too, or rename its meaning; the differential's s0/off depends on it. Add the measurement baseline (§8). | the evidence gap in §2.1 | low |
| 1 | RefMap analysis (core/vm/arch/ref_map.h, header-only so no build file changes) plus the OBJECK_GC_ROOTS=audit report-only mode. Report how often unknown occurs per suite. No behaviour change. | nothing yet; measures the design | low |
| 2 | Interpreter operand stacks and the interpreted and/or slot marked and relocated by RefMap; audit traps that hold references across allocations. Lands gc_int_not_relocated (interpreter configurations). | §3.3 in interpreted code; retires 9cdc656566's interpreter half | medium |
| 3 | AMD64: typed spill areas (TMP_REF_* / TMP_VAL_* / TMP_SCR_*), the kind read from RefMap at the spill site, clear after reload, entry zeroing of the reference slots; CheckJitRoots/FixupRoots walk them by type on x64. Lands jit_stale_spill_slot at s3 and the JIT configurations of gc_address_int_pending. Built (see the status note). | §3.1 paths 1 and 2, §3.3 compiled, on AMD64 | medium-high (frame constants) |
| 4 | ARM64: the same. Compile-checked locally with cl /Zs and the cross toolchain; proven on CI's arm64 legs. Built (see the status note). | the same, on ARM64 | medium-high |
| 5 | Remove JIT_TMP_LOOK_BACK and every CheckUntypedSlot/FixupSlot call on frame and stack words. Delete the dead JIT inliner (ProcessInlineMethod, CanInlineMethod, extra_inline_space). Remove the symptom code made redundant: the conservative and/or marking (9cdc656566) and the window spill comment paths in the loop safepoints (613e68a63f/8617fda009, now TMP_REF_*). Demote the conservative-word class validation (181d478f70) and the FixupSelf checks (4542ec7e41) to verifier assertions. | the whole class | medium |
| 6 | Heap side: a reference type for closure capture blocks, retiring 193f076fdd's conservative dirty-block path. Separately, stable instance IDs. | 193f076fdd(b); ID identity | medium |
5d92132687's GC_TRACED_BIT is kept either way. It makes typed tracing of a closure happen exactly once, which stays useful with precise roots.
What v2026.10.2 needs
Recommendation: none of phases 1-6. Ship 10.2 without them.
- The spill-slot defect is retention: at most one object per slot per live frame, nothing freed while in use.
- The integer rewrite needs an address-valued
Intto be pending across a minor collection. It is in v2026.10.1 and the 10-22 build alike, so it is not a regression of this cycle. - Phases 3-4 change frame layout constants in both hand-written backends, and phase 4 can only be proven on CI's arm64 legs. That is the wrong risk to take the day before a tag.
If one item must go in, take phase 0's --jit=off fix. It is small and VM-only, and it makes the nightly differential's "interpreter" column mean what it says.
The one-line entry-zeroing change would also be safe, but it fixes one path of one symptom, which is the pattern this design replaces. It stays on the evidence branch unless the user decides otherwise.
8. Measurement
- GC pause.
OBJECK_GC_STATS=1(minor/major counts, pause p50/p95/max, promoted bytes, peak RSS) on:gc_mt_small_nursery_stress,jit_gc_stress,gc_closure_ids_stress;query_expr_concurrent;- CLBG
binarytreesandfannkuchat default and--nursery=256k.
Recorded before and after phases 2, 3 and 5. Precise scanning should lower mark work (no IsAllocated set lookups per untyped word); the map read is per frame.
- Codegen. Per method: code bytes and compile time, added to the
OBJECK_JIT_REPORTline, summed over the regression suite underOBJECK_JIT_THRESHOLD=1. Run time: the CLBG suite through thebenchmarkskill (median of runs, flagged againstdocs/performance.md). The only new instruction on a hot path is the clear after a reference reload at a call site, which is next to a call. - Interpreter. Dispatch is unchanged. The analysis cost is measured once per method and cached, and reported as a total per suite run.
9. Tests
| test | catches | lands in |
|---|---|---|
jit_stale_spill_slot (rewritten without native, so --jit=off is a true control; checks both the s0 and s3 paths) | §3.1 | phase 3 (AMD64), extended in 4 |
gc_int_not_relocated, the §3.3 reproducer, all four configurations at --nursery=64k | §3.3 | phase 2 (interpreter), phase 3 (JIT) |
jit_inline_local_root | nothing today (§3.2) | not landed; phase 5 deletes the code it would test |
gc_root_coverage, randomized (below) | the whole class | phase 1 runs it report-only; it gates from phase 2 |
gc_root_coverage generates, from a seed (printed on failure, reproducible with --seed), methods that:
- keep a random mix of
Intvalues (includingGetInstanceID()), objects, arrays and closures pending on the stack; - put them in and/or joins, conditional constructor arguments and loop safepoints;
- allocate between every pair of uses.
It checks every value afterwards: integers unchanged, objects with their fields intact. It runs under OBJECK_GC_VERIFY=checkmark with --nursery=64k, at s0 and s3, with --jit=off and with OBJECK_JIT_THRESHOLD=1, plus a two-thread variant that parks at loop polls. The fixed seeds run in the regression suite, and the nightly runs fresh seeds. During phase 1 OBJECK_GC_ROOTS=audit must report zero differences across the suite before phase 2 starts.
10. Not established
- #1443 (
query_url_cache_processes, Windows only, parent ats3/jit1, children get "no response from the server"). Neither mechanism here is shown to cause it:- retention cannot produce a wrong or missing response;
- the integer rewrite is not JIT-only and not Windows-only.
One link to the harness is real: that differential's s0/off column is not interpreter-only (§2.1), so "passes with the JIT off" has meant less than it appeared. #1443 was not reproduced here.
- ARM64 behaviour of
jit_stale_spill_slot. Inferred from the code (path 2 only), not run. CI's arm64 legs will run it when the test lands. - When the s0 trigger appeared. It is absent on 10-22 and present on master, and it was not bisected.
- Heap
Int[]elements are rewritten by a minor collection (found 2026-10-31; fixed by phase 6, theInt[]tag, 2026-11-01). AnInt[]and an object array shareINT_TYPEon the heap (§6.1 says the same of the bytecode), so the collector's fixup of a reachable array's elements cannot tell an integer from a reference and relocates any element equal to a forwarded young object's address. Evidence, with the test hook from PR 1483 (Runtime->TestAddressOf,OBJECK_GC_TEST_HOOKS=1): a test that stores an object's address in anInt[]element and then allocates enough for a minor collection sees the element change in 242 of 2,000 iterations, in default mode (noOBJECK_GC_ROOTS), interpreted and compiled alike. This is the heap-side twin of §3.3: it is not a root problem, and the reference maps cannot reach it, because the array header is what lacks the type. It needs a distinct reference-array tag from the compiler, which is phase 6 territory (the heap side), and is not fixed in phases 1-5.