Repository navigation
Conversation
Three decisions recorded before touching the runtime:
- size classes are implemented for dynamic sizes (static sizes keep exact bump)
- allocation failure traps with a witness; it never returns 0
- terminology: "arena slot free list" for whole-slot reuse; "size class" for
the dynamic allocation classes; "region" stays the memory region and
"SG region" the control-flow region
The table uses 1.25x steps from 16 to 8192 bytes (25 classes, 20% rounding
bound). region-model.md's 12.5% example needs 1.125x steps; the table is data,
so that can be swapped later without changing the allocation path.
Each arena carries a 26-entry class table (25 size classes plus the large object path) with base, cursor, and limit per entry, plus a slot cursor that carves class chunks out of the arena slot. A slot that runs out chains a new one, as today. Failure writes four witness globals (request, class, cursor, limit) and executes ud2. It never returns 0 and never falls back to the global heap. The implementation is one unit: arena.cr metadata, elf.cr allocator body, tests, and the canary re-lock.
Adds the per-arena class tables and the failure witness. Nothing reads them yet, so behavior is unchanged. ARENA_CLASS_COUNT = 26 (25 size classes plus the large-object path) g_class_base / g_class_cursor / g_class_limit: [arena * 26 + class] g_arena_slot_cursor: bytes carved from the current arena slot g_alloc_fail_request / _class / _cursor / _limit: trap witness arena_new initialises the tables (base = -1), arena_reset clears them; both grow with the existing metadata arrays. build passes, check src/compiler passes, canary 5/5 unchanged.
The allocator body pins physical registers by hand, so every change to the allocation path forces a register reshuffle of the whole byte sequence. This spec makes the stub's registers virtual, computes existence intervals over the instruction list, and assigns physical registers by linear scan. Coexistence is interval intersection, the same definition used by the register mapping instance in regalloc-cache-mapping.md. The target supplies only a register file description and instruction templates, so the same module applies to any register machine. Applying it to emit_alloc_body turns the size-class work into adding instructions and read/write sets instead of rewriting 300 bytes of encoding.
Runtime stubs declare virtual registers and instruction-order live intervals. The module assigns physical registers by interval intersection, the same coexistence relation as the compiler register allocator, and spills to home slots when the target register file is exhausted. Pins are hard constraints: SR_ERR_PIN_DISAGREE one entry pinned to two registers SR_ERR_PIN_NOT_IN_FILE pin outside the target register file SR_ERR_PIN_CONFLICT two coexisting pins on one register The target supplies only physical register numbers, so x86-64 is one description table rather than a dependency. corec selftest-stub-regalloc runs nine cases; the lower bound in tests/selfhost/test_stub_regalloc.py keeps the table from shrinking. emit_alloc_body is not wired to the module yet, so behavior and the canary are unchanged.
The CAG definition in regalloc-cache-mapping.md section 5 now has one implementation in src/lattice/regalloc_cag.cr: - live intervals and coexistence - first-fit placement with physical register classes - cost-aware eviction: min(depth * uses * (store + load), remat) - call points invalidate caller-saved entries - hard pins with explicit error values The IR allocator (src/arch/x86_64/regalloc.cr stage 4) and the stub adapter (src/compiler/stub_regalloc.cr) both call it. The IR side passes five callee-saved registers, no pins, and no eviction, so it stays interval-order First Fit until region depth and use counts are wired. The stub side derives intervals from its instruction list and keeps the existing spill behavior. Both allocators are structs with methods; there are no g_cag_* globals. The bootstrap IR generator now resolves self to the enclosing impl type. That was the missing piece for method calls inside the self-hosted compiler; without it every method call was emitted as a free function. Verification: selftest-stub-regalloc 9/9, check src/compiler, canary 5/5 unchanged, test_live_ranges passes.
Adds four cases to corec selftest-stub-regalloc: - evict the cheapest coexisting entry when the new entry is more expensive - rematerialise when the generation cost is below the spill cost - a call evicts caller-saved entries and keeps callee-saved entries - a pinned caller-saved entry across a call is a constraint violation The lower bound in tests/selfhost/test_stub_regalloc.py moves from 9 to 13. Verification: 13/13, canary 5/5, check src/compiler.
The IR allocator now counts uses per function-local var and passes them to CagAllocator with unit region depth. Eviction is enabled, so a candidate with more uses is more expensive to evict. There is still no remat method, so every eviction spills to the home slot. O2 verification: fib(10) returns 55, corearch --check-regalloc reports zero violations. Canary 5/5 unchanged because the canary builds do not pass -O2, and alloc_registers only runs at O2.
emit_alloc_body lives in corearch, but StubAllocator was in the corec-only compiler list. The adapter is now src/lattice/stub_regalloc.cr and is part of both manifests, so the ELF allocator stub can call it. No behavior change: build passes, selftest-stub-regalloc 13/13, canary 5/5.
emit_alloc_body now builds a 30-instruction arena stub, lets StubAllocator assign the registers, and encodes every register field from assigned(). The arena path is straight-line: requests above 64MB, a missing arena, and a cursor overrun execute ud2. The global bump path is kept for allocations made before an arena exists. The encoding helpers added to elf.cr cover mov/lea/cmp/test/add/and/mul, rip-relative loads and stores, indexed loads and stores, and short jumps. Verification: fib(10)=55 at O2, ptr_arith returns 0, --check-regalloc reports zero violations, selftest-stub-regalloc 13/13, canary re-locked to 20ffe46d... at 49334 bytes (D1 deterministic). Size classes are not in the emitter yet; it still uses a single cursor.
elf_gen now binds g_class_base, g_class_cursor, and g_class_limit by name, so the allocator body can address them with RIP-relative loads and stores. No behavior change: the arena path still uses the single cursor, and canary stays at 20ffe46d... / 49334 bytes.
The arena path now computes class = min(align_up(size + 8, 8) / 16, 25), indexes g_class_cursor by arena * 26 + class, and allocates from a 1KB slice per class. Class 25 uses the remainder of the slot after the 25 fixed slices. An overrun executes ud2. New encodings in elf.cr: shr/shl, imul with an imm8, mov imm32, cmp/add imm8 and imm32, sub reg/reg, and jne. Verification: fib(10)=55 at O2, ptr_arith returns 0, --check-regalloc reports zero violations, selftest-stub-regalloc 13/13, canary re-locked to 44df1d38... at 49334 bytes (D1 deterministic).
The arena path computes class = min(align_up(size + 8, 8) / 16, 25) and indexes g_class_cursor by arena * 26 + class. Classes 0..24 own a 1KB slice; class 25 owns the remainder of the slot. Allocation failures now write request, class, cursor, and limit to g_alloc_fail_request / _class / _cursor / _limit before ud2. Verification: fib(10)=55 at O2, ptr_arith returns 0, selftest-stub-regalloc 13/13, canary re-locked to 16b9cd0b... at 53430 bytes (D1 deterministic).
After the main scan, CagAllocator retries every spilled entry once. A later eviction may have released a register. Entries evicted at a call are excluded: their home is the call-invalidation result, not a placement failure. Adds cag_second_chance: an expensive entry evicts the cheap holder, then the previously spilled entry takes the freed register because its interval does not intersect the new holder. Verification: 14/14, O2 fib(10)=55, --check-regalloc zero violations, canary 5/5 unchanged.
The stub register file is reduced to seven registers. The class index cannot get a register at its definition point, so StubAllocator spills it. The encoder keeps the index in g_alloc_home_idx and loads or stores it around its two uses. r14 and r15 are pushed once and popped on both return paths. Two pushes keep the stack 16-byte aligned for the heap_expand call. Verification: selftest-stub-regalloc 14/14, O2 fib(10)=55, --check-regalloc zero violations, ptr_arith returns 0, canary re-locked to 88d706c3... at 49334 bytes (D1 deterministic).
The allocator scans g_class_sizes (25 entries, 16..3361) and picks the smallest entry that covers the aligned request. Classes 0..18 use 2KB slices, 19..24 use 4KB, and class 25 uses the slot remainder. The layout fits the default 64KB slot: 19 * 2KB + 6 * 4KB = 62KB, leaving 2KB for the large class. Verification: 14/14, O2 fib(10)=55, --check-regalloc zero violations, ptr_arith returns 0, canary 5/5 unchanged (the canary program takes the simple allocator path, so this body is not in its ELF).
The arena chunk grows to 128KB so 25 classes * 4KB fit with room for class 25. The allocator scans g_class_sizes and uses class * 4096 as the slice base; classes 0..24 own 4KB and class 25 owns the slot remainder. Verification: 14/14, O2 fib(10)=55, ptr_arith returns 0, canary 5/5 unchanged.
Each class now keeps class_base, class_cursor, and class_limit. The first allocation of a class carves a 4KB chunk from the arena slot; when the chunk is full, the allocator carves another chunk, so a class is no longer limited to one 4KB slice. Slot exhaustion still traps; slot chaining is the next layer. Verification: 14/14, O2 fib(10)=55, ptr_arith returns 0, canary 5/5 unchanged.
Each class keeps a current 4KB chunk and carves another when it fills. When the current slot is exhausted, the allocator takes a slot from g_arena_slot_free or bumps g_arena_slot_count, links it into the arena chain, and retries the chunk. arena_new assigns a slot per arena; arena_reset returns the whole slot chain to the free list. The allocator traps with a witness when the pool has no slot. Verification: 14/14, O2 fib(10)=55, ptr_arith returns 0, canary 5/5 unchanged.
arena_init raises arena_size to 128KB so the 25 4KB class slices and the large class fit. arena_avail reports the remaining bytes in the current slot; the old cursor/sizes fields are no longer updated by the allocator. Verification: arena_test ALL PASS, 14/14, O2 fib(10)=55, canary 5/5.
emit_alloc_body_simple now checks heap_ptr + aligned > heap_end, calls heap_expand once, rechecks, and traps with the four witness globals if the expanded heap still cannot hold the request. It no longer writes past heap_end. Canary re-locked to 23782420... at 49334 bytes (D1 deterministic).
IR_ARENA_NEW s1 now carries a peak in bytes when every allocation size in the region is known; -1 means unknown and uses the dynamic arena. arena_new_sized stores the peak and marks the arena static; emit_alloc_body takes a single bump cursor for static arenas. Constant alloc(n), array and struct sizes feed the peak; unknown sizes propagate the unknown flag to the parent region. Verification: alloc(64) produces s1=80, arena_test ALL PASS, 14/14, O2 fib(10)=55, canary 5/5 unchanged.
track_alloc_array, track_alloc_struct, and the constant-size alloc(n) path now add 16 bytes for the hidden header plus alignment. alloc(64) produces s1=96 instead of 80, so the static cursor cannot be one header short. Verification: arena_test ALL PASS, 14/14, O2 fib(10)=55, canary 5/5.
track_alloc_size now models allocation as a path sum. EXPR_IF snapshots the base, measures each branch, restores the base, and keeps max(then, else). EXPR_MATCH does the same per arm. Unknown in either branch marks the region unknown. alloc(n) uses align_up(n + 8, 8); array and struct sizes use the exact header. IR_ALLOC is a declaration marker and is no longer counted. alloc(64) produces s1=72; sequential alloc(64)+alloc(128) produces s1=208. Verification: arena_test ALL PASS, 14/14, O2 fib(10)=55, canary 5/5.
EXPR_WHILE used sg_push without initializing the allocation total, so its IR_ARENA_NEW read a stale slot. It now uses sg_alloc_push, giving the body a fresh total/unknown pair and a per-iteration peak. Verification: arena_test ALL PASS, O2 fib(10)=55, canary 5/5.
The match arm reset now happens at the start of each arm, before the pattern tests, so allocations in the condition path are included in that arm's delta. Previously the base was restored just before the body, dropping them. Verification: arena_test ALL PASS, canary 5/5.
g_class_lookup maps aligned / 16 (0..255) to a class; entries above 255 use class 25. The allocator replaces the 25-step scan with one shift, one table load, and a cap branch. Verification: arena_test ALL PASS, fib(10)=55, canary 5/5.
Large allocations take the current slot tail. If the tail is too small, the allocator allocates a fresh slot first. The slot cursor is then advanced to the slot end so later class chunks cannot overlap the large object. Requests above one slot still trap. Verification: 64KB and 120KB allocations read back, arena_test ALL PASS, fib(10)=55, canary 5/5.
The pool is now a 256-entry segment table. Each segment is 8MB and holds eight 1MB slots. slot_base = segment_base[slot / 8] + (slot % 8) * 1MB. arena_grow_pool appends a segment on demand; arena_new and the allocator's slot bump both call it. Large objects now have up to 1MB of slot tail, so 600KB and 950KB allocations work without multi-slot linking. Requests above 1MB still trap. Verification: arena_test ALL PASS, 12-arena growth, 950KB allocation, 14/14, O2 fib(10)=55, canary 5/5.
Segments are 64MB and hold 64 slots of 1MB. Objects above 1MB call arena_grow_pool, take N contiguous slots from the new segment, link them into the arena chain, and use the segment start as the object base. N is ceil(aligned / 1MB), capped at 64. Requests above 64MB still trap. Verification: 5MB, 20MB, and 60MB allocations read back, arena_test ALL PASS, 12-arena growth, 14/14, O2 fib(10)=55, canary 5/5.
Add IR_ALLOC_AT (opcode 55) and the `alloc_at(addr, size, align(n))` form. The address, size and alignment are compile-time facts; the node enters the graph as an allocation root and the existing passes track it. - checker recognizes `alloc_at`/`align` by name and allocates the `*int` row lazily, so programs that do not use placement keep the exact type/string table and canary bytes; - ir_gen requires literal arguments, a positive multiple of 8 bytes, and emits the node with the fixed address; - PointerAnalysis counts it as an allocation; ProvenanceVerify uses the declared byte size (8 bytes per IR cell in v1) for static proof and runtime bounds; - RegionCheck treats fixed-address memory as program lifetime: a surrounding loop/if is never its release point, and out-parameter classification does not mistake it for callee-local storage; - BytePermission starts it read-write; the backend materializes the address constant; the interpreter rejects the node loudly instead of faking a host pointer; ccr save/load accepts and validates opcode 55. Verified: alloc_at 11/11, region_escape 64/64, byte_permission 13/13, pointer_safety PASS, region_cfg 22/22, borrow 7/7, suite ALL PASS, corec check ok, canary 5/5 unchanged.
The successor/predecessor relation is derived from the HDFG's IR_MATCH case targets, IR_JUMP labels and IR_LABEL nodes. It is a cached index over the same nodes, not a second graph. Rename `rc_cfg_*`/`rc_succ_*` to `rc_ctl_*`/`rc_ctl_succ_*` and state that in the comments, so later analyses (liveness, post-dominance) extend the HDFG-derived adjacency rather than inventing a parallel control-flow graph. Verified: build ok, corec check ok, region_escape 64/64, byte_permission 13/13, canary 5/5 unchanged.
Define split/merge/share and early reset on top of the existing SG tree and arena runtime. Control adjacency is derived from HDFG IR_MATCH/IR_JUMP/ IR_LABEL nodes and cached by `rc_ctl_prepare`; the design does not introduce a parallel control-flow graph. The document stages V2.0 split through V2.4 zero-on-reset and lists the four decisions that need approval.
Early reclamation now clears the slot's cursors, size-class metadata and large extents while keeping the arena id live and current. This avoids slot allocation/free-list traffic and the arena-id reuse hazard between an early reset and the region-exit close. The region exit still performs the final IR_ARENA_RESET. Add IR_ARENA_CLEAR (opcode 56), the flat clear-point plan table, and the non-local-exit cleanup requirement. Also tighten the safety gates: @raw_int and g_current_arena count as identity observation, and unknown destination lifetimes reject split/clear instead of failing open.
Phase A generates v1 IR and runs PointerAnalysis/RegionCheck. `region_plan.cr` marks an SG_IF only when its direct allocations stay inside: no return/call/ unsafe/non-local jump, no address-taken local slot carrying the pointer, no `g_current_arena` read, and no pointer use outside the IF subtree. Nested loop/for/unsafe and nested IF allocations are not counted for the outer IF. Phase B regenerates the whole IR with `ir_gen_all` (no .cir cache), the IF emits `IR_ARENA_NEW`/`IR_ARENA_RESET`, and `rc_sg_releases` treats the marked IF as a releasing region so the re-validated RegionCheck sees uses after the merge. Points-to buffers and the derived control adjacency are invalidated before Phase B. Programs with no plan keep the exact v1 IR and artifacts. Verified: region_plan 8/8, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, suite ALL PASS, corec check ok, cold/warm .ccr identical, canary 5/5 unchanged.
Early clear resets a slot's current per-arena cursor, size-class metadata and large-object extents while keeping the arena id live and current. The region exit still performs the final arena_reset. This is the runtime-optimal clear-and-keep shape chosen earlier: no pool free/alloc and no arena-id reuse between an early clear and the exit close. The first plan slice is deliberately conservative: a clear label must follow the last use of every allocation in the slot, no later jump/match may target a node at or before it, and the same basic block must contain a later allocation from the same slot so the clear immediately enables reuse. Pointer uses after the label, backedges, non-local exits and same-slot allocations in other branches keep v1. IR_ARENA_CLEAR (opcode 56) is wired through ccr save/load, dataflow/state edges, the backend (`arena_clear`), the interpreter, and an ELF fallback stub emitted only when the program contains a clear. Phase B resets generation-time purity/usage/state-finalize facts so it matches a cold v1 generation. Verified: early_clear 4/4, region_plan 8/8, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, suite ALL PASS, corec check ok, cold/warm .ccr identical. Canary: gt/gt_static each +56 B for one legitimate arena_clear in generics_test main (label15, same-block b2 reuse); pa and ELF unchanged. Authorized reload recorded in canary_values.tsv.
Build post-dominator bitsets over the HDFG-derived control adjacency and require a clear label to post-dominate every use of every allocation that precedes it in the slot. The worklist is capped at 2000 nodes per function; exceeding the cap disables clear for that function (v1). Backedge rejection and the same-block reuse requirement remain. Pointer-use scans now treat any variable with a non-empty pts set as a pointer carrier, even when its IR type row is TI_UNIT (the declared type lives on the variable, not the alloc node). This fixes a missed `a := [..]` alias and makes the earlier gt +56B clear disappear; gt canary reverts to the 2026-10-29 baseline values. A clear at a loop-exit label could be the last state node and produce a self termination edge; skip that edge when source and target coincide. Verified: early_clear 7/7, region_plan 8/8, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, suite ALL PASS, corec check ok, canary 5/5.
A merge IF keeps its child arena for the whole branch, copies one eligible array into a parent-slot copy at the end of the assigning branch, and rebinds the outer slot before the merge reset. The non-assigning path keeps the old array, so no merge phi is needed. Eligibility is deliberately narrow: exactly one constant-size scalar array (int/bool/char/dex, 1..64) stored directly into an outer local at IF level; no call/return/unsafe/non-local jump; not inside a loop/for/unsafe or another planned IF arena. Element reads and writes may reach the child through local aliases inside the IF; outside they must go through the rebound slot. Any other allocation that lands in the merge arena must stay inside, otherwise the merge is rejected. Pointer identity observations, slices, stores into other objects, and pointer-valued element stores block the plan. The copy element count is known in Phase A and charged to the parent arena at IF entry; the element type row is patched at the assigning branch exit. This also fixes arena peak accounting: allocations accumulate on the current open subgraph (g_cur_sg) instead of the high-water neighbor, and a non-split child adds its total to its recorded parent. Split escape analysis now also covers nested non-releasing IF allocations that physically land in the parent's arena. Verified: merge_copy 18/18, early_clear 7/7, region_plan 9/9, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, suite ALL PASS, corec check ok, canary 5/5 unchanged.
IR_STORE_INDEX_VAR encodes the stored value in dest, the array in s1, and the index in s2; s3 is a literal zero. The DF builder treated s3 as the index, recorded dest as a definition, and produced a self-edge for `arr[i] = i` plus a bogus producer chain for the value variable. Keep the previous producer for the value, connect dest/s1/s2, and remove the dead s3 branch. Update the v7 data-edge golden (the store node is a consumer, not the value's producer) and the multibranch unknown-opcode fixture: 55/56 are now IR_ALLOC_AT/IR_ARENA_CLEAR, so use 57. Add V2.2 merge-copy boundary coverage: lengths 0/1/64/65, both branches, two merges in one function, dynamic index read/write, pre-assignment and condition reads, bool/char elements, loop after the merge, string arrays rejected. Hook test_merge_copy in selfhost-tests. Verified: merge_copy 29/29, ccr_v7 27/27, multibranch 79/79, region_plan 9/9, early_clear 7/7, corec check ok, canary 5/5 unchanged. selfhost-tests now only has the known stage0 backend-bootstrap red.
A top-level IF's single scalar array can stay in its child slot and be shared read-only by following consumer loops. The IF merge does not reset the slot; the last consumer loop's exit emits the child arena reset. No copy is made. Eligibility: top-level IF, merge-shaped array stored to an outer local, all post-IF uses are element reads through that slot; consumers are LOOP/FOR; the window from the IF exit to the consumer exit has no nested loop/unsafe, break/return, call/spawn, g_current_arena read, or allocation that lands in the shared slot. The candidate's own merge mark is replaced by share; the function must have no other merge plan, and one program gets at most one share. Early clear is skipped in share programs. RegionCheck resolves the share slot's lifetime exit to the last consumer's exit label (a stable label id) instead of the IF merge. The delayed reset is emitted after the consumer loop's own arena reset. IR_CALL/IR_SPAWN argument ranges are now checked by both merge and share escape scans, closing the multi-argument call hole. Add tests/selfhost/test_share.py 16 cases and hook it in selfhost-tests. Verified: share 16/16, merge_copy 29/29, early_clear 7/7, region_plan 9/9, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, ccr_v7 27/27, multibranch 79/79, suite ALL PASS, corec check ok, canary 5/5 unchanged. selfhost-tests only the known stage0 red.
Add g_arena_zero_on_reset (default 0) and arena_set_zero_on_reset(v). Before arena_reset returns slots to the pool, zero the current slot's used prefix (static: g_arena_cursors; dynamic: g_arena_slot_cursor) and each older chained slot in full. Large-object extents are still munmapped. Add --arena-zero-on-reset; inject_global_inits writes 1 to the runtime global after the ordinary global initializers when the flag is present. Without the flag, reset bytes and the canary are unchanged. Tests in test_arena_interp.py cover setter-on, default-off, and the compiler flag under both corec run and the static ELF. Verified: arena_interp PASS, share 16/16, merge_copy 29/29, region_plan 9/9, early_clear 7/7, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, suite ALL PASS, corec check ok, canary 5/5 unchanged.
Choose the reset point as the earliest safe label after the last use. Function-level/plain-IF labels use a label reset (mode 1, requiring post-dominance of the IF merge); top-level loop/for/split-IF exits use an after-SG reset (mode 0, relying on the structured exit rather than post-dominance, which excludes non-terminating loop paths). This supports split-IF consumers, nested loops without break/return/unsafe, parent-level reads, and wider LCPDs. Multiple non-overlapping shares are allowed. A later merge candidate in an earlier candidate's window keeps the earlier candidate on merge/v1, so chosen share slots never overlap. The window still rejects calls without a proof, g_current_arena reads, returns, breaks, unsafe, and allocations landing in the shared slot; Phase A split/merge IF arena ownership uses the SG entry because their child arena is emitted only in Phase B. Cross-function read-only proof: a direct leaf callee with no call/spawn, no unsafe, and no direct g_current_arena read/write; when the shared array is an argument, the callee is heap-readonly (no STORE_INDEX/INDEX_VAR/FIELD/PTR) and the BP/RegionCheck parameter write/escape/return/FFI masks are clear. The Phase A byte-permission summary now runs before planning. RegionCheck continues to resolve the share lifetime exit from the stable label id. A label hook emits mode-1 resets; mode-0 resets remain after the consumer SG reset. The hard check counts every planned share. test_share.py grows to 25 cases: IF consumer, wider label LCPD, multiple sequential shares, nested loop share / nested break rejection, and cross-function read-only / write / escape / current / leaf-call cases. Verified: share 25/25, merge_copy 29/29, early_clear 7/7, region_plan 9/9, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, ccr_v7 27/27, multibranch 79/79, arena_interp PASS, suite ALL PASS, corec check ok, canary 5/5 unchanged. selfhost-tests only the known stage0 red.
Compute per-function hazard bits (1=unsafe, 2=g_current_arena read/write, 4=arena/spawn/yield/await/unknown call, 8=heap/array/field/pointer store) and propagate them along the call graph to a fixed point. A call in a share window is accepted when the callee clears 1/2/4; when a shared array is passed, the callee must also clear 8 and the BP/RegionCheck parameter write/escape/return/ FFI masks must be empty. This replaces the direct-leaf-only proof and supports transitive chains. Process share candidates in descending SG order so an inner share is marked before an outer candidate tests its window. Overlapping shares now nest: an outer share's reset is deferred to the inner share's effective reset point and resets at a shared event are emitted in descending SG order (LIFO). The label and after-SG hooks share the stable label-id path. Early clear no longer skips share functions. A parent-slot clear is emitted at the same label before the share reset; arena_clear only resets the parent metadata and does not switch current, so the shared slot is unaffected. test_share.py grows to 30 cases: transitive read-only chain, transitive write/ current/arena rejection, overlapping LIFO reset, and share+early-clear coexistence. Verified: share 30/30, merge_copy 29/29, early_clear 7/7, region_plan 9/9, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, ccr_v7 27/27, multibranch 79/79, arena_interp PASS, suite ALL PASS, corec check ok, canary 5/5 unchanged.
When an if/else allocates one constant-size scalar array in each branch, both with the same element type and length, and both branches store their array into the same outer local slot, allocate one parent _merge_copy. Each assigning branch fills that copy at its own exit and rebinds the outer slot; the child arena still resets at the merge label. Eligibility now accepts up to two selected allocations. It uses the two jumps to the IF merge label to split then/else ranges, requires one allocation per branch, and requires exactly one bind per array to the shared outer slot. Other writes to the slot, aliases, escaping nested allocations, and mismatched types remain rejected. Share eligibility now requires exactly one allocation in the candidate SG so a two-branch IF cannot be shared without a per-branch proof. test_merge_copy.py grows to 30 cases: both value paths and branch-local mutations visible to the copy. Verified: merge_copy 30/30, share 30/30, early_clear 7/7, region_plan 9/9, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, ccr_v7 27/27, multibranch 79/79, arena_interp PASS, suite ALL PASS, corec check ok, canary 5/5 unchanged.
Extend merge candidates beyond arrays. A struct literal (IR_ALLOC_STRUCT) is accepted when every declared field is scalar (int/bool/char/dex); a tuple literal (IR_ALLOC_ARRAY without an array type row) is accepted when every element slot receives a scalar value. The parent copy is allocated at the IF entry: same struct name for structs, same element count for tuples. At each assigning branch exit every field/element is loaded and stored into the copy, then the outer slot is rebound. Plan tables now carry merge kind (array/struct/tuple) and struct name in addition to the element/field count. If/else branches must agree on kind; structs must name the same struct and tuples must have the same element count. Pointer, string, nested struct/array, and optional fields keep the IF on v1. Share candidates still require exactly one allocation, so POD merges do not become shares. test_merge_copy.py grows to 35 cases: POD struct and tuple true/false paths, branch-local mutation before the copy, and rejection of struct pointer/string fields and tuple pointer elements. Verified: merge_copy 35/35, share 30/30, early_clear 7/7, region_plan 9/9, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, ccr_v7 27/27, multibranch 79/79, arena_interp PASS, suite ALL PASS, corec check ok, canary 5/5 unchanged.
Accept an IF with no direct allocation when each assigning branch stores one string literal into the same outer local slot. At the IF entry, before the child arena exists, materialize a parent-arena copy of each distinct literal with str_sub(lit, 0, len); the branch exit only rebinds the outer slot to the matching copy. If/else branches may use different literal lengths, and the copy size is charged to the parent arena peak. Non-literal string expressions (variables, concat, dynamic values) remain on v1, as do nested string stores and branches with direct allocations. The interpreter path is covered because str_sub's builtin result is an interned string and the native path allocates in the parent arena. test_merge_copy.py grows to 39 cases: literal true/false paths, if/else with different literals, empty string, a 300-byte copy for parent peak accounting, non-literal rejection, and interpreter parity. Verified: merge_copy 39/39, share 30/30, early_clear 7/7, region_plan 9/9, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, ccr_v7 27/27, multibranch 79/79, arena_interp PASS, suite ALL PASS, corec check ok, canary 5/5 unchanged.
…dows Treat IR_CALL_EXTERN, IR_HOTPATCH_ROUTE, and IR_DYN_DISPATCH in a consumer window as unknown effects and refuse share. Extend the transitive hazard bits: IR_ADDR_INDEX/REF/SLICE/PTR_OF/STR_OF count as pointer-forming (bit 8), while callee allocations and non-scalar returns count as lifetime hazards (bit 4). This prevents a callee allocation from landing in the share slot and dying at the delayed reset. test_share.py grows to 32 cases: an FFI call in the consumer window and an allocating callee both keep the array on v1. Verified: share 32/32, suite ALL PASS, corec check ok, canary 5/5 unchanged.
String merge now accepts any TI_STR assignment, not only literals. Literal values are precomputed in the parent at the IF entry; dynamic values are copied at the branch exit by temporarily switching the allocator's current arena to the parent, calling str_sub, and restoring the child. Programs without an arena runtime take the global-heap path. Pure fmt builder calls (concat, str_sub, int_str, chr, ...) are allowed inside the string IF after a function-owner check; user calls, extern, spawn/yield/await still keep the IF on v1. Arrays, structs, and tuples may contain string leaves. Scalar leaves are moved directly; string leaves are deep-copied into the parent arena. Pointer, optional, and nested aggregate fields remain on v1. Array length is no longer capped at 64: counts <= 8 are unrolled and longer counts use a small IR copy loop. Zero-length array literals have no allocation node and remain v1. test_merge_copy.py grows to 43 cases: dynamic strings, arena switch path, pure concat, string arrays/fields/tuples, and a 65-element loop copy. Verified: merge_copy 43/43, share 32/32, early_clear 7/7, region_plan 9/9, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, ccr_v7 27/27, multibranch 79/79, arena_interp PASS, suite ALL PASS, corec check ok, canary 5/5 unchanged.
A merge IF may now sit inside loop/for/planned-IF ancestors. The copy is allocated in the nearest releasing ancestor's arena. Eligibility scans the outer slot after the IF: any use at or after that ancestor's exit rejects the merge, and any alias store of the slot rejects it as well. Unsafe ancestors remain rejected because pointer identity is observable there. Loop-local and for-local merge cases are covered; an array assigned inside a loop and read after the loop stays on v1. Verified: merge_copy 45/45, share 32/32, early_clear 7/7, region_plan 9/9, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, ccr_v7 27/27, multibranch 79/79, arena_interp PASS, suite ALL PASS, corec check ok, canary 5/5 unchanged.
arena_current_unchecked()/arena_set_current_unchecked(v) are intercepted at the call site in ir_gen and lowered to cell_load/cell_store on g_arena_scalars plus the g_current_arena global. A normal helper function would create its own function arena first, so it could not read or write the caller's actual current arena. This is the prerequisite for per-G arena context switching. The interpreter and ELF paths are both covered by a new arena_interp parity probe. Verified: arena_interp PASS, merge_copy 45/45, share 32/32, early_clear 7/7, region_plan 9/9, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, ccr_v7 27/27, multibranch 79/79, suite ALL PASS, corec check ok, canary 5/5 unchanged.
Extend the G struct to 88 bytes; offset 80 stores the goroutine's nested current arena. sched_schedule and sched_worker_run save the outgoing G's current arena before fiber_switch, install the incoming G's saved/base arena, and restore the outgoing current after the switch returns. sched_go and fiber_spawn restore the spawner's current after creating a new G arena. The raw arena_current_unchecked/arena_set_current_unchecked builtins make the save/restore touch the allocator's real scalar cell without creating a helper function arena. Add test_go_arena.py: build the scheduler corpus with corearch --raw-int, run go work(5) + chan_recv, and require main's current arena to survive the goroutine switch. Hook it into selfhost-tests. Verified: go_arena PASS, go_e2e/go_final/go_parallel/conc raw-int corpora PASS, arena_interp PASS, merge_copy 45/45, share 32/32, early_clear 7/7, region_plan 9/9, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, ccr_v7 27/27, multibranch 79/79, suite ALL PASS, corec check ok, canary 5/5 unchanged.
The raw-int go/chan_recv context-switch probe already ran in the check job; add it to selfhost-tests as well so the scheduling path is covered by the standard selfhost verification.
Add --region-plan-max-nodes (default 200000, 0 = unlimited). region_plan_analyze sums function IR node counts and skips planning above the budget, avoiding the two-phase IR/passes cost on very large programs. Add a regression that a limit of 1 suppresses split/merge/share/clear while the program still builds and runs. Raise the early-clear post-dominator function cap from 2000 to 8000 nodes. A 7601-node straight-line function now reaches a clear point and runs correctly; add it to test_early_clear.py. Verified: early_clear 8/8, region_plan 10/10, go_arena PASS, arena_interp PASS, merge_copy 45/45, share 32/32, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, ccr_v7 27/27, multibranch 79/79, suite ALL PASS, corec check ok, canary 5/5 unchanged.
Share's post-IF scan now accepts IR_STORE_INDEX/IR_STORE_INDEX_VAR when the base is the rebound outer slot, the stored value does not point into the shared array, and the index does not either. Writes go through the live child slot; Q moves to the earliest safe label after the last use including writes, and the child arena is reset there. Writes through an alias, slices/address-of/identity observations, unsafe, and unproven calls remain rejected. This is write-through, not copy-on-write: with a single outer slot and aliases/identity already excluded, a first-write copy would only add cost and move the object without a semantic benefit. Multi-owner or concurrent share would need a separate COW design. test_share.py grows to 34 cases: write consumer, write-only consumer, and write-through-alias rejection. Verified: share 34/34, merge_copy 45/45, early_clear 8/8, region_plan 10/10, go_arena PASS, arena_interp PASS, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, ccr_v7 27/27, multibranch 79/79, suite ALL PASS, corec check ok, canary 5/5 unchanged.
Fix the lazy fact layer first: compute_usage_counts now runs after df_state_finalize and zeroes/recomputes idempotently, and purity no longer treats an IR_STORE to a local scalar slot as an external effect. Global stores and array/field/pointer stores remain effectful. Phase A selects zero-argument pure scalar calls whose single force is used in a conditional branch/loop body inside the same loop iteration and whose callee has no allocation/call/extern. Phase B emits a deferred result at the original call site and materializes the call at the unique force point; `v := pure()` also defers the defining store and defines v at its unique read. Multi-use, straight-line, allocating-callee and global-effect cases stay eager. Add test_lazy.py (9 cases) and hook it into selfhost-tests. The purity selftest fixtures now use global stores for their effect cases. The canary .ccr values move by -24B (three state edges rewired around float_str_bits' pure fpow2i calls/local stores); NOD/data counts and the ELF are unchanged. Re-lock with per-entry attribution. Verified: lazy 9/9, ccr_v7 27/27, purity PASS, merge_copy 45/45, share 34/34, early_clear 8/8, region_plan 10/10, go_arena PASS, arena_interp PASS, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, multibranch 79/79, suite ALL PASS, canary 5/5 re-locked with attribution.
Extend control-flow lazy's plan to calls with arguments when each argument is a constant-valued temporary (or a value whose store chain reaches a constant) and is not rewritten before the force point. Phase B records the argument block and emits the deferred IR_CALL with it at the unique force point; the assignment-target deferral carries the argument block through. Add constant-argument, true-path and mutated-argument cases to test_lazy.py (12/12). Variable/loop-carried arguments, hoisting and allocation sinks remain future work. Verified: lazy 12/12, ccr_v7 27/27, purity PASS, merge_copy 45/45, share 34/34, early_clear 8/8, region_plan 10/10, go_arena PASS, arena_interp PASS, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, multibranch 79/79, suite ALL PASS, canary 5/5 unchanged.
…allees Deferred calls may now take scalar arguments: Phase B captures each argument into a consecutive temporary at the original evaluation point, so later mutation cannot change the call's inputs. Pointer arguments remain eager. A value with multiple reads in one basic block is memoized: the first force emits the call, later reads in the same block reuse the result. Uses split across branches remain eager (no runtime flag is introduced in this slice). Callees may allocate/write local arrays and structs. lazy_callee_safe permits local aggregate stores only when the base points to an allocation inside the callee; global stores, parameter/external pointer stores, calls, extern, spawn/yield/await, unsafe, g_current_arena access, pointer views and escaping allocations keep the call eager. test_lazy.py grows to 14 cases: mutable scalar args, same-block memo, local allocating callee, escaping allocating callee. Verified: lazy 14/14, ccr_v7 27/27, purity PASS, merge_copy 45/45, share 34/34, early_clear 8/8, region_plan 10/10, go_arena PASS, arena_interp PASS, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, multibranch 79/79, suite ALL PASS, canary 5/5 unchanged.
Add a loop memo plan for pure scalar calls inside a loop whose scalar arguments are invariant and whose uses stay inside that loop. The loop preheader initializes a memo slot and a flag; the first dynamic force emits the call and sets the flag, and later iterations reuse the memo. This is the loop-invariant hoist/memo path and also covers multi-use across branches with a runtime flag. Extend lazy_callee_safe to allow local array/struct allocation and writes when the store base points only at allocations inside the callee; global stores, parameter/external pointer stores, calls, extern, spawn/yield/await, unsafe, g_current_arena access and pointer views remain eager. Arguments are captured at the original evaluation point as scalar temporaries. test_lazy.py grows to 17 cases: mutable args, same-block memo, local/escaping allocating callees, loop memo false/true paths and mutated-argument rejection. Verified: lazy 17/17, ccr_v7 27/27, purity PASS, merge_copy 45/45, share 34/34, early_clear 8/8, region_plan 10/10, go_arena PASS, arena_interp PASS, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, multibranch 79/79, suite ALL PASS, canary 5/5 unchanged.
Add a site memo for pure scalar calls whose result is read from more than one basic block. The call site captures arguments and initializes a runtime flag; each read emits a guard, and the first dynamic read executes the call while later reads reuse the memo. Loop-invariant calls continue to use the preheader hoist memo. Allow pointer and array arguments when every pointee is a tracked allocation outside a releasing region and no aggregate/pointer store or call can change it between the call and any force point. Loop hoists require the whole loop to be free of such writes. Scalar arguments and assignment targets are captured or retyped at the original site. Reject loop hoisting for global or address-exposed arguments, and keep callees that read any IR global eager: the value at the force point need not equal the value at the call site. Fix two latent def bugs: deferred locals now inherit the result slot type instead of remaining TI_UNIT (which made `total + v` an OP_BYTE_ADD), and lazy_defer_store no longer copies a memo def as an empty plain call. test_lazy.py grows to 35 cases: mutable scalar args, same-block/site/loop memos, pointer and array args, pointee mutation rejection, global argument and global-reading callee rejection, local/escaping allocating callees, and the double-assignment def boundary. Canary relocked with attribution: gt_ccr 131250 -> 132546, gt_static_ccr 131472 -> 132768 (+1296B each) from the new site memos in generics_test; ELF and pa artifacts unchanged. D1 5/5 PASS. Verified: lazy 35/35, ccr_v7 27/27, purity PASS, merge_copy 45/45, share 34/34, early_clear 8/8, region_plan 10/10, go_arena PASS, arena_interp PASS, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, multibranch 79/79, suite ALL PASS, corec check src/compiler ok, canary 5/5.
Use the existing share COW plan flag to defer element copying until the first consumer write. At the share IF entry, before the child arena exists, allocate a same-length _cow_copy and a zeroed _cow_flag in the parent arena. After the IF, a write through the outer slot guards on the flag, copies the elements into the parent copy, rebinds the outer slot, and sets the flag; later writes go straight to the copy. Read-only shares allocate no copy. Writes inside the IF that build the share array are construction, not consumer writes, and are skipped by the SG ancestor check. Phase B records the outer slot by its current-generation variable id, so the plan survives regeneration id shifts from the COW allocation. Arrays up to 8 elements copy inline; longer arrays use a small IR loop. test_share.py grows to 37 cases: COW false-path (copy the old array), read before write, long-array loop copy, read-only share with no copy, and barrier count for the write-only-inside-IF case. COW cases also run through the interpreter. Verified: share 37/37, lazy 35/35, ccr_v7 27/27, purity PASS, merge_copy 45/45, early_clear 8/8, region_plan 10/10, go_arena PASS, arena_interp PASS, region_escape 64/64, byte_permission 13/13, alloc_at 11/11, pointer_safety PASS, region_cfg 22/22, borrow 7/7, multibranch 79/79, suite ALL PASS, corec check src/compiler ok, canary 5/5 unchanged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This branch lands the region reclamation V2 line plus the arena, safety-analysis, lazy-control-flow, and share-COW work that sits on top of it. It is based on
developat10aa8055.Region V2
ifregions get a child arena; Phase B regenerates IR from a conservative Phase A plan.IR_ARENA_CLEARplus clear-and-keep; PDOM cap raised to 8000 and a--region-plan-max-nodesbudget added._cow_copy/_cow_flagin the parent arena; the first consumer write copies elements, rebinds the outer slot, and later writes go to the copy. Read-only shares allocate no copy.--arena-zero-on-reset; current slot clears its used prefix, older slots clear whole 1MB slots.arena_current_uncheckedbuiltins avoid creating a helper function arena.Arena and safety analysis
alloc_atwith provenance and bounds checks.Lazy control flow
lazy_defer_storeno longer copies a memo def as an empty plain call.Verification
test_lazy.py35/35test_ccr_v7.py27/27test_purity.pyPASStest_merge_copy.py45/45test_share.py37/37test_early_clear.py8/8test_region_plan.py10/10test_go_arena.pyPASStest_arena_interp.pyPASStest_region_escape.py64/64test_byte_permission.py13/13test_alloc_at.py11/11test_pointer_safety.pyPASStest_region_cfg.py22/22test_borrow.py7/7test_multibranch_v10.py79/79CI_JOB_NAME=suite src/ci/run.shALL PASScorec check src/compilerokCanary
gt_ccr131250 -> 132546 andgt_static_ccr131472 -> 132768 (+1296B each) from the new cross-block site memo ingenerics_test.cr; ELF and pa artifacts unchanged. The old values and the per-item attribution are recorded intools/baseline/canary_values.tsv.Known pre-existing red
selfhost-testsstill has the pre-existingstage0 builds corearch stage1: exit 1failure; it is not introduced by this branch.