From 9f2f0b1635961b8a1e24524d96dd438b525d5189 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 19 Sep 2026 06:12:41 +0700 Subject: [PATCH] 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. --- lib/emit.ml | 192 +++++++++++++++++++++++++++++++++- test/programs/dyn-global.flan | 21 ++++ 2 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 test/programs/dyn-global.flan diff --git a/lib/emit.ml b/lib/emit.ml index 6d4264f..55b98b5 100644 --- a/lib/emit.ml +++ b/lib/emit.ml @@ -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 diff --git a/test/programs/dyn-global.flan b/test/programs/dyn-global.flan new file mode 100644 index 0000000..e16f16d --- /dev/null +++ b/test/programs/dyn-global.flan @@ -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"))