Roots, pushed where the addresses are stable and popped where the frame leaves

A precise collector has to be told where the live dyn words are, and the shadow
stack next door is the precedent for where that goes: set up in the entry block,
undone in ret, which is the one funnel all five exits pass through -- the tail,
both returns, the none arm of (some x), and the landing block a handled
condition unwinds through. A pop written only on the normal path would leave a
frame's roots on the stack after every handled error.

It differs from the shadow stack in two ways, and both are forced. It is not
gated on dev: a backtrace is a convenience and a collector that cannot find its
roots frees live values. And it is a count rather than a saved head pointer,
because the ABI offers root_pop(n) and no way to read the stack's height -- so
the number has to be known before the body is emitted, since ret runs during
emission and a tally accumulated as roots were discovered would be short at
every early return. dyn_roots works it out up front by walking the same nodes
the emission will visit, the slots are minted from that count at entry, and
dyn_tmp only hands them out. The pushes and the pops balance by construction
rather than by two walks agreeing.

Every dyn-producing call is spilled into a rooted slot the moment it exists. An
SSA value is invisible to a collector that finds roots by address, and the next
allocation could be the one that frees what it holds. Rooting all of them rather
than only those that outlive a call is conservative and is the only thing
available here: this file has no liveness and no lexical scope, the checker
having resolved both into flat slot indices long before. The cost is a stack
slot and a store per dyn value at every optimisation level, because a rooted
alloca has its address escape and mem2reg cannot promote it. That is the price
of an address-registration ABI rather than stack maps.

A function with no dyn emits nothing at all -- no push, no pop, not a pop of
zero -- which is what makes an annotated program's IR identical to what it was
before any of this existed.

Globals are rooted in main, before the startup function that fills them and
before any other push, because every pop takes the top of the stack and these
are the ones that must never be at the top. They are never popped, which is what
a global's extent means. A dyn global needed no new machinery otherwise: a call
is not a constant, so it is a computed global, and that already existed.
This commit is contained in:
Joseph Ferano 2026-09-19 06:12:41 +07:00
parent de792fe141
commit 9f2f0b1635
2 changed files with 211 additions and 2 deletions

View File

@ -600,6 +600,30 @@ type f = {
what makes the pop happen on the transfer path as well as the normal one.
[None] in a release build, where there is no frame at all. *)
mutable frame : string option;
(* How many dyn roots this function pushed at entry, and so how many one
[flan_dyn_root_pop] at each exit takes off. Zero for every function with
no dyn in it, which is every function in every program written so far
and zero means *nothing is emitted at all*, neither push nor pop nor a
pop of zero. That is what keeps a fully annotated program's IR byte for
byte what it was before dyn existed, which is the thing [--no-gc]
promises and is tested for.
It is a count and not a saved depth because the ABI offers
[flan_dyn_root_pop(n)] and no way to read the stack's height; it can be a
count, rather than needing one, because the number is a static property of
the function that [dyn_roots] works out before a line of the body is
emitted. That matters: [ret] runs *during* emission, and a count
accumulated as roots were discovered would be short at every early
return. *)
mutable droots : int;
(* The root slots' addresses, in push order: the dyn slots first and then one
per dyn-producing runtime call, minted by [dyn_tmp] as the body is
emitted. Both kinds are entry-block allocas, so the addresses are good for
the function's whole extent which is why rooting is per function here
and not per scope. A slot that is not live any more holds a value the
collector keeps one cycle longer than it must, and that is the safe
direction to be wrong in. *)
mutable droot_ns : string list;
(* Where a dev build records each slot's address, so that a stopped frame's
locals can be read. [None] in a release build and in a function with no
named slot at all. Only *named* slots are recorded: a slot the compiler
@ -666,10 +690,67 @@ let label f name =
this frame, so a pop written only on the normal path leaves a dead frame on
the stack after every handled error, and the next backtrace is a lie. Same
lesson [emit_with_alloc] learned about the context allocator. *)
(* How many dyn roots a function will push, worked out before any of it is
emitted. One per dyn slot a parameter or a local of that type and one per
runtime call that answers a dyn, because the word a call hands back is live
from the moment it exists and the next allocation may be the one that
collects it.
Rooting every dyn-producing call, rather than only the ones whose value
outlives a call, is conservative and is the only thing available: this file
has no liveness and no lexical scope, both of which the checker resolved
away into flat slot indices long before anything got here. The cost is real
and is the cost of a precise collector with an address-registration ABI
rather than stack maps a rooted alloca has its address escape through
[flan_dyn_root_push], so mem2reg cannot promote it, and every dyn value
becomes a stack slot with a store at every optimisation level.
A count rather than a running tally for the reason [droots] gives: [ret] is
reached while the body is still being emitted. *)
let dyn_roots (fn : Tast.fn) =
let slots =
Array.fold_left
(fun acc t -> if t = Types.Dyn then acc + 1 else acc) 0 fn.Tast.slots
in
let temps = ref 0 in
let count (e : Tast.expr) =
match e.Tast.e with
| Tast.Prim (Tast.Rt _, _) when e.Tast.ty = Types.Dyn -> incr temps
| _ -> ()
in
List.iter (Tast.walk count) fn.Tast.body;
slots + !temps
(* The next pre-made root slot for a dyn temporary. They are all minted, zeroed
and pushed in the entry block before a line of the body is emitted, and this
only hands them out which is what makes the pushes and the pops balance by
construction rather than by the body being walked the same way twice.
[dyn_roots] counts the same nodes the emission visits, so the supply runs
out only if those two disagree. If it ever does, the fallback is an ordinary
unrooted slot: one temporary the collector cannot see is a bug to find,
where a root stack that pops more than it pushed is memory corruption. *)
let dyn_tmp f =
match f.droot_ns with
| n :: rest -> f.droot_ns <- rest; n
| [] ->
let name = Printf.sprintf "%%dx%d" f.n in
f.n <- f.n + 1;
Buffer.add_string f.allocas (Printf.sprintf " %s = alloca i64\n" name);
name
let ret f v =
(match f.frame with
| Some prev -> ins f "store ptr %s, ptr @flan_frame_head" prev
| None -> ());
(* The pop, on every path out, for exactly the reason the shadow stack's is
here: a condition handled further out unwinds through the landing block,
and a pop written only on the normal path would leave this function's
roots on the stack after every handled error. The [unreachable]
terminators emit none, and are right not to each of them dies inside C
and the process does not come back. *)
if f.droots > 0 then
ins f "call void @flan_dyn_root_pop(i64 %d)" f.droots;
term f "ret %s %s" (ll f.ret) v
(* The store that says "this slot is bound now". Emitted at each binding of a
@ -2262,6 +2343,18 @@ and prim f (e : Tast.expr) (p : Tast.prim) (args : Tast.expr list) =
let t = fresh f in
ins f "%s = call %s @%s(%s)" t (ll e.Tast.ty) sym args';
if signals then guard f;
(* A dyn word is spilled into a rooted slot the instant it exists. It is
an SSA value otherwise, and an SSA value is invisible to a collector
that finds its roots by address the next allocation could be the one
that frees what this is holding. [dyn_roots] counted this call, so the
slot below is one the entry block has already pushed.
The value carries on being used as a register: the store is what the
collector reads, and reading it back would only make the IR longer. *)
if e.Tast.ty = Types.Dyn then begin
let slot = dyn_tmp f in
ins f "store i64 %s, ptr %s" t slot
end;
t
end
| Tast.SizeOf t, [] -> Printf.sprintf "%d" (fst (lay f.md t))
@ -2431,6 +2524,7 @@ let emit_fn m ?(hidden = false) ?(pnames = []) (fn : Tast.fn) =
pads = []; loops = []; unwind = "unwind"; unwound = false;
defers = fn.Tast.fdefers;
frame = None; slotv = None; snames = fn.Tast.snames;
droots = 0; droot_ns = [];
dsub;
dline = (if fn.Tast.floc.Loc.line = 0 then 1 else fn.Tast.floc.Loc.line);
dloc = "";
@ -2449,6 +2543,54 @@ let emit_fn m ?(hidden = false) ?(pnames = []) (fn : Tast.fn) =
Buffer.add_string f.allocas
(Printf.sprintf " store %s %%p%d, ptr %s\n" (ll ty) i f.slots.(i)))
fn.Tast.params;
(* The dyn roots, and this is not gated on [m.dev]: the shadow stack below is
a debugging convenience and a release build does without it, while a
collector that cannot find its roots is a collector that frees live
values. Every build pays this, and only a function that has a dyn in it
pays anything [dyn_roots] is zero otherwise and not a line is emitted,
which is what makes an annotated program's IR identical with and without
--no-gc.
The dyn *slots* are already allocas from the loop above, so they are
pushed where they are; the temporaries need slots of their own, and they
are minted here, in order, so that [dyn_tmp] only has to hand them out.
Zeroed because the push happens at entry and the call that fills one may
be inside a branch that never runs runtime/flan_dyn.h says a rooted slot
holding 0 is not a value. *)
let nroots = dyn_roots fn in
if nroots > 0 then begin
let nparams = List.length fn.Tast.params in
let pushed = ref [] in
Array.iteri
(fun i t ->
if t = Types.Dyn then begin
(* A parameter's slot was filled from [%pN] a few lines above and
must not be zeroed over the top of it. Every other slot holds
whatever the stack held until its binding runs, and the binding
may be inside a branch that does not. *)
if i >= nparams then
Buffer.add_string f.allocas
(Printf.sprintf " store i64 0, ptr %s\n" f.slots.(i));
pushed := f.slots.(i) :: !pushed
end)
fn.Tast.slots;
let ntemps = nroots - List.length !pushed in
let temps =
List.init ntemps (fun i ->
let name = Printf.sprintf "%%dr%d" i in
Buffer.add_string f.allocas (Printf.sprintf " %s = alloca i64\n" name);
Buffer.add_string f.allocas
(Printf.sprintf " store i64 0, ptr %s\n" name);
name)
in
List.iter
(fun n ->
Buffer.add_string f.allocas
(Printf.sprintf " call void @flan_dyn_root_push(ptr %s)\n" n))
(List.rev !pushed @ temps);
f.droots <- nroots;
f.droot_ns <- temps
end;
(* The shadow stack's push, in the entry block, and the pop is at every
[ret] (see [ret]). plan.org has had *Frames: shadow stack* in the dev
column since the beginning; this is it, and it is dev-only, so a shipped
@ -2935,14 +3077,53 @@ declare i64 @flan_file_fail_reason()
declare i8 @flan_slurp_into(ptr, ptr, i64, i64, ptr, i64)
|}
(* Whether the program has a dyn in it anywhere, which is the one question
[main] asks before calling [flan_gc_init]. Asked of the whole program rather
than assumed, so that a program with no dyn emits no call and its [main] is
byte for byte the [main] it was before any of this existed.
Every shape a dyn can take is one of these: a global of that type, a
signature that mentions it, a slot that holds one, or an expression that
produces one. *)
let uses_dyn (p : Tast.program) =
let found = ref false in
let note t = if t = Types.Dyn then found := true in
List.iter (fun (g : Tast.global) -> note g.Tast.gty) p.Tast.globals;
List.iter
(fun (fn : Tast.fn) ->
List.iter note fn.Tast.params;
note fn.Tast.ret;
Array.iter note fn.Tast.slots;
List.iter (Tast.walk (fun (e : Tast.expr) -> note e.Tast.ty)) fn.Tast.body)
p.Tast.fns;
!found
(* C's main, adapting to whichever of the four shapes Flan's main has: argv and
the i32 status are each optional (plan.org, Milestone-2 primitives). *)
let emit_main m ?(startup = false) (fn : Tast.fn) =
let emit_main m ?(startup = false) ?(gc = false) ?(dyn_globals = []) (fn : Tast.fn) =
let b = Buffer.create 256 in
Buffer.add_string b
(Printf.sprintf "\ndefine i32 @main(i32 %%argc, ptr %%argv)%s {\nentry:\n"
(attrs m));
Buffer.add_string b " call void @flan_rt_init(i32 %argc, ptr %argv)\n";
(* Immediately after the host runtime and before anything that could box: a
dyn global's initialiser runs in the startup function below, and the very
first thing it does is allocate. *)
if gc then Buffer.add_string b " call void @flan_gc_init()\n";
(* The dyn globals, rooted here and never popped, which is the whole of what
a global's extent means. They go on the stack *before* the startup
function runs, because that function is what fills them and its first
allocation may be the one that collects and before any of it pushes a
root of its own, because every pop in the program takes the top of the
stack and these are the ones that must never be at the top.
Zero is what a global holds until its initialiser has run: BSS gives that
for free, and runtime/flan_dyn.h says a rooted slot holding 0 is not a
value. *)
List.iter
(fun g -> Buffer.add_string b
(Printf.sprintf " call void @flan_dyn_root_push(ptr %s)\n" (gname g)))
dyn_globals;
(* The program's own end of the transfer channel. Nothing can be transferring
when [main] returns: a restart is found by name on the restart stack, and
an [invoke-restart] that finds none fails at the invoke site rather than
@ -3247,7 +3428,14 @@ let program ?(checks = true) ?(dev = false) ?(debug = false) ?(pnames = [])
p.Tast.fns;
let startup = emit_startup m ~hidden p.Tast.globals in
(match List.find_opt (fun (f : Tast.fn) -> f.Tast.name = "main") p.Tast.fns with
| Some fn -> emit_main m ~startup fn
| Some fn ->
emit_main m ~startup ~gc:(uses_dyn p)
~dyn_globals:
(List.filter_map
(fun (g : Tast.global) ->
if g.Tast.gty = Types.Dyn then Some g.Tast.gname else None)
p.Tast.globals)
fn
(* A program with no [main] is linked into a C host that brings its own
entry point, and then nothing calls the startup function which is why
the constant image is a constant image on both backends and not a

View File

@ -0,0 +1,21 @@
;;;; A dyn global, which is the case that needs the startup function.
;;;;
;;;; A dyn value is made by a call into the runtime, and a call is not a
;;;; constant, so the initialiser cannot be a constant image the way a typed
;;;; global's is. It runs in flan..init-globals, which main calls after
;;;; flan_gc_init and before anything the programmer wrote — the same machinery
;;;; the computed globals already use, which is the point: a dyn global is a
;;;; computed global and needed no new mechanism.
(defvar counter dyn 0)
(defvar label dyn "start")
(defn bump [] ()
(set counter (+ counter 1)))
(defn main [] ()
(print counter) (print " ") (print label) (print "\n")
(bump)
(bump)
(set label "done")
(print counter) (print " ") (print label) (print "\n"))