Skip to content

region: v2 reclamation, share COW, and lazy control flow - #224

Open
dslsdzc wants to merge 106 commits into
developfrom
feature/region-v2
Open

dslsdzc wants to merge 106 commits into
developfrom
feature/region-v2

Conversation

@dslsdzc

@dslsdzc dslsdzc commented Oct 31, 2026

Copy link
Copy Markdown
Owner

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 develop at 10aa8055.

Region V2

  • V2.0 split: non-escaping if regions get a child arena; Phase B regenerates IR from a conservative Phase A plan.
  • V2.1 early clear / V2.1b post-dominance: IR_ARENA_CLEAR plus clear-and-keep; PDOM cap raised to 8000 and a --region-plan-max-nodes budget added.
  • V2.2 merge copy: constant-length scalar arrays, multi-branch IFs, POD structs/tuples, literal/dynamic strings, string leaves, arbitrary constant lengths, and merge inside releasing ancestors with a lifetime proof.
  • V2.3 share: read-only share with LCPD reset, transitive cross-function proof, LIFO nested shares, parent-level reads, early-clear coexistence, and safe write consumers.
  • V2.3 COW: a write share preallocates _cow_copy/_cow_flag in 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.
  • V2.4 zero-on-reset: runtime switch and --arena-zero-on-reset; current slot clears its used prefix, older slots clear whole 1MB slots.
  • Per-G arena: each G saves/restores the nested current arena at fiber switch; raw arena_current_unchecked builtins avoid creating a helper function arena.

Arena and safety analysis

  • Arena size classes, boundary handling, multi-extent/large-object paths, tagged-int arena, and the interpreter arena run path.
  • RegionCheck hardening: reaching definitions, scalar/REF lifetime, parameter/return/out-parameter/FFI/content summaries, dynamic-width points-to sets.
  • Verifier-side byte permissions and declarative alloc_at with provenance and bounds checks.

Lazy control flow

  • Pure scalar calls sink to their unique if/loop force point; arguments are captured at the original site.
  • Same-block memo, cross-block site memo with a runtime flag, loop-invariant preheader memo, and stable pointer/array arguments.
  • Callees may allocate/write local aggregates; global-reading callees and escaping allocations stay eager.
  • Fixed latent issues: deferred locals inherit the result slot type, and lazy_defer_store no longer copies a memo def as an empty plain call.

Verification

  • test_lazy.py 35/35
  • test_ccr_v7.py 27/27
  • test_purity.py PASS
  • test_merge_copy.py 45/45
  • test_share.py 37/37
  • test_early_clear.py 8/8
  • test_region_plan.py 10/10
  • test_go_arena.py PASS
  • test_arena_interp.py PASS
  • test_region_escape.py 64/64
  • test_byte_permission.py 13/13
  • test_alloc_at.py 11/11
  • test_pointer_safety.py PASS
  • test_region_cfg.py 22/22
  • test_borrow.py 7/7
  • test_multibranch_v10.py 79/79
  • CI_JOB_NAME=suite src/ci/run.sh ALL PASS
  • corec check src/compiler ok
  • canary 5/5, D1 deterministic

Canary

gt_ccr 131250 -> 132546 and gt_static_ccr 131472 -> 132768 (+1296B each) from the new cross-block site memo in generics_test.cr; ELF and pa artifacts unchanged. The old values and the per-item attribution are recorded in tools/baseline/canary_values.tsv.

Known pre-existing red

selfhost-tests still has the pre-existing stage0 builds corearch stage1: exit 1 failure; it is not introduced by this branch.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant