The array an index or a slice is taken of is rooted while its index runs, when it is a temporary

This commit is contained in:
Joseph Ferano 2026-09-25 09:30:41 +07:00
parent 0653f341d0
commit 0d629d791f
5 changed files with 99 additions and 40 deletions

View File

@ -1226,9 +1226,10 @@ redefinition emitters. The stopgap refusal on a dyn field in a struct is lifted.
** DONE A dyn read out of a place is rooted while a sibling operand runs ** DONE A dyn read out of a place is rooted while a sibling operand runs
CLOSED: [2026-09-25] CLOSED: [2026-09-25]
An operand holding a dyn word — a call's or runtime call's argument, a struct, An operand holding a dyn word — a call's, runtime call's or primitive's
case or array literal's element — is spilled into a pushed root slot as soon as argument, a struct or array literal's element, the temporary array an =at= or
it is computed, whenever another operand in the same list does not settle. Same =slice= indexes — is spilled into a pushed root slot as soon as it is computed,
whenever another operand in the same list does not settle. Same
explicit-slot root stack on both backends and on wasm32; rules out anything that explicit-slot root stack on both backends and on wasm32; rules out anything that
scans the native stack or registers. See docs/BUILT.md, "Operands held beside a scans the native stack or registers. See docs/BUILT.md, "Operands held beside a
sibling". sibling".

View File

@ -7200,9 +7200,10 @@ freed block's contents on LLVM, LLVM `-O0`, `--x86` and wasm32 alike, and valgri
block `gc_sweep` had freed. block `gc_sweep` had freed.
The positions that hold a value while more code runs are the ordered operand lists — a call's arguments, a runtime The positions that hold a value while more code runs are the ordered operand lists — a call's arguments, a runtime
call's, a struct, case or array literal's fields. Everything else consumes its operand at once: a `let` stores it into call's, a primitive's, a struct or array literal's fields. The positions outside a list each have one operand and
a rooted slot, a `set` into its place (both backends compute the place before the value), a branch tests it, a nothing beside it to run: a `let` stores it into a rooted slot, a `set` into its place (both backends compute the
`return` hands it back. So `Emit.held_operands` looks only at those lists, and pins an operand when: place before the value), a branch tests it, a `return` hands it back. So `Emit.held_operands` looks only at those
lists, and pins an operand when:
- its type holds a dyn word, as a bare dyn or through a descriptor; - its type holds a dyn word, as a bare dyn or through a descriptor;
- nothing roots it already — a call's result has its own slot from the rule above; - nothing roots it already — a call's result has its own slot from the rule above;
@ -7213,6 +7214,15 @@ same question the x86 backend asks before building an aggregate in place. A sibl
nor overwrite a place, so a value beside it is safe where it is. Asking about every other operand, not only the later nor overwrite a place, so a value beside it is safe where it is. Asking about every other operand, not only the later
ones, means the plan does not depend on the order a backend evaluates operands in. ones, means the plan does not depend on the order a backend evaluates operands in.
Some operands are taken by address rather than by value: the array `at` and `slice` index, and an array handed to the
runtime. What such an operand holds is whatever its address was taken of. A place needs nothing, being rooted where it
was declared. Anything else — `(at [(.d other) (.d other)] (clobber-idx))`, or the same array reached through a field
of a struct literal — is copied into a temporary first, and that temporary sits unrooted while the index runs.
`addr_base` walks down through fields and elements to that temporary, and it is the node pinned.
A dyn inside a `defdata` payload and an `(Option dyn)` are both refused before code generation, so a case literal and
`some` are not positions this covers today.
A pinned operand is spilled into a root slot as soon as it is computed, in `Emit.value` and in `X86.lower`, which is A pinned operand is spilled into a root slot as soon as it is computed, in `Emit.value` and in `X86.lower`, which is
the same move a call's result gets and uses the same slots. The pinned nodes are keyed by physical identity. The the same move a call's result gets and uses the same slots. The pinned nodes are keyed by physical identity. The
checker occasionally shares one node between two positions, and a node pinned in one position is then spilled from checker occasionally shares one node between two positions, and a node pinned in one position is then spilled from
@ -7227,7 +7237,8 @@ What it costs is a store per pinned operand and a slot per pin in the entry bloc
and `edn-read.flan` the emitted root pushes went up by roughly a quarter to a half. Typed code is unaffected: nothing and `edn-read.flan` the emitted root pushes went up by roughly a quarter to a half. Typed code is unaffected: nothing
without a dyn word in it is ever pinned. without a dyn word in it is ever pinned.
`programs/dyn-held-operand.flan` is the fixture, eight cases on three rows: a call's argument, a struct literal's `programs/dyn-held-operand.flan` is the fixture, ten cases on LLVM, `-O0`, `--x86` and wasm32: a call's argument, a
field, an array literal's element, a runtime call's argument (dyn `=`), a local overwritten by the sibling, a whole struct literal's field, an array literal's element, a runtime call's argument (dyn `=`), a local overwritten by the
struct passed by value, a struct nested in a literal, and a call through a function value. Without the pins every sibling, a whole struct passed by value, a struct nested in a literal, a call through a function value, and an array
line prints freed memory. literal indexed while the index runs, directly and through a struct literal's field. Without the pins every line
prints freed memory.

View File

@ -1228,11 +1228,14 @@ type rootplan = {
missing is a root for the value between being produced and being consumed. missing is a root for the value between being produced and being consumed.
The positions that hold a value while more code runs are the ordered The positions that hold a value while more code runs are the ordered
operand lists: a call's arguments, a runtime call's, a struct, case or operand lists: a call's arguments, a runtime call's, a primitive's, a
array literal's fields. Every other position consumes its operand at once struct or array literal's fields. An operand taken by address — the array
— a [let] stores it into a rooted slot, a [set] into its place (both [at] and [slice] index, an array handed to the runtime — holds whatever its
address was taken of, which is a temporary when that is not a place. The
positions outside a list each have one operand and nothing beside it to
run: a [let] stores it into a rooted slot, a [set] into its place (both
backends compute the place first), a branch tests it, a [return] hands it backends compute the place first), a branch tests it, a [return] hands it
back — so these lists are the whole of it. back.
An operand is pinned when its type holds a dyn word, when nothing already An operand is pinned when its type holds a dyn word, when nothing already
roots it — a call's result and a dyn-producing runtime call's are spilled roots it — a call's result and a dyn-producing runtime call's are spilled
@ -1244,11 +1247,9 @@ type rootplan = {
The node itself is the key, by physical identity, and the backends spill The node itself is the key, by physical identity, and the backends spill
its value after computing it — [value] in this file, [lower] in the other — its value after computing it — [value] in this file, [lower] in the other —
exactly as they spill a call's result. An array argument to a runtime call exactly as they spill a call's result. For an operand taken by address the
is not a candidate: it crosses by address, which the x86 backend takes node pinned is the temporary under it, found by [addr_base]; a place under
without lowering it and this file takes by way of [value] only when it is it needs nothing, and neither backend evaluates one through either hook. *)
not a place, so pinning it would draw a slot on one backend and not the
other. *)
let held_operands m (e : Tast.expr) : Tast.expr list = let held_operands m (e : Tast.expr) : Tast.expr list =
let holds (x : Tast.expr) = let holds (x : Tast.expr) =
(x.Tast.ty = Types.Dyn || dyn_offsets m x.Tast.ty <> []) (x.Tast.ty = Types.Dyn || dyn_offsets m x.Tast.ty <> [])
@ -1258,19 +1259,42 @@ let held_operands m (e : Tast.expr) : Tast.expr list =
| Tast.Zero _ | Tast.Uninit _ | Tast.None_ | Tast.Unit -> false | Tast.Zero _ | Tast.Uninit _ | Tast.None_ | Tast.Unit -> false
| _ -> true) | _ -> true)
in in
let pick ?(by_addr = false) siblings candidates = (* An operand taken by address holds the value its address is taken of.
List.filter A place holds nothing new: its storage is rooted where it was declared.
(fun (x : Tast.expr) -> A field or an element of something else is an address into that
holds x something, and a value that is not a place is copied into a temporary —
&& (not (by_addr && (match x.Tast.ty with Types.Array _ -> true | _ -> false))) which is the value held, and the node both backends evaluate. *)
&& List.exists (fun s -> s != x && not (settles s)) siblings) let rec addr_base (x : Tast.expr) =
candidates match x.Tast.e with
| Tast.Local _ | Tast.Global _ | Tast.Deref _ | Tast.CaseField _ -> None
| Tast.Field (t, _) -> addr_base t
| Tast.Prim (Tast.At, t :: _ :: _) -> addr_base t
| _ -> Some x
in
(* [ops] is the list with, for each operand, whether it crosses by address. *)
let pick ops =
List.filter_map
(fun ((x : Tast.expr), by_addr) ->
let held = if by_addr then addr_base x else Some x in
match held with
| Some h when holds h
&& List.exists
(fun ((s : Tast.expr), _) -> s != x && not (settles s))
ops -> Some h
| _ -> None)
ops
in
let by_value es = List.map (fun x -> (x, false)) es in
let is_array (x : Tast.expr) =
match x.Tast.ty with Types.Array _ -> true | _ -> false
in in
match e.Tast.e with match e.Tast.e with
| Tast.Prim (Tast.Rt _, es) -> pick ~by_addr:true es es | Tast.Prim (Tast.Rt _, es) -> pick (List.map (fun x -> (x, is_array x)) es)
| Tast.Call (_, es) | Tast.Make (_, es) | Tast.MakeCase (_, _, es) | Tast.Prim ((Tast.At | Tast.Slice), t :: rest) ->
| Tast.Arr es -> pick es es pick ((t, true) :: by_value rest)
| Tast.CallPtr (c, es) -> pick (c :: es) es | Tast.Prim (_, es) | Tast.Call (_, es) | Tast.Make (_, es)
| Tast.MakeCase (_, _, es) | Tast.Arr es -> pick (by_value es)
| Tast.CallPtr (c, es) -> pick (by_value (c :: es))
| _ -> [] | _ -> []
let root_plan m (fn : Tast.fn) : rootplan = let root_plan m (fn : Tast.fn) : rootplan =

View File

@ -2,7 +2,8 @@
;;;; ;;;;
;;;; Every line below reads the vector in [other] into an operand position — ;;;; Every line below reads the vector in [other] into an operand position —
;;;; a call's argument, a struct literal's field, an array literal's element, ;;;; a call's argument, a struct literal's field, an array literal's element,
;;;; a runtime call's argument, a function value's argument — and then runs a ;;;; a runtime call's argument, a function value's argument, the array an
;;;; index is taken of — and then runs a
;;;; sibling that overwrites [other] and allocates enough to collect. Once ;;;; sibling that overwrites [other] and allocates enough to collect. Once
;;;; [other] is overwritten, the only reference to the vector is the operand ;;;; [other] is overwritten, the only reference to the vector is the operand
;;;; being held, so unless that operand has a root of its own the collection ;;;; being held, so unless that operand has a root of its own the collection
@ -13,6 +14,7 @@
(defstruct T [d dyn n i64]) (defstruct T [d dyn n i64])
(defstruct W [t T k i64]) (defstruct W [t T k i64])
(defstruct A2 [xs [2 dyn]])
(declare gc-collect [] () "flan_gc_collect") (declare gc-collect [] () "flan_gc_collect")
(defonce other T) (defonce other T)
@ -38,6 +40,7 @@
;;; Overwrite [other], then allocate and collect. ;;; Overwrite [other], then allocate and collect.
(defn clobber [] i64 (set other (T {.n 0})) (churn)) (defn clobber [] i64 (set other (T {.n 0})) (churn))
(defn clobber-dyn [] dyn (clobber) (vec-new dyn)) (defn clobber-dyn [] dyn (clobber) (vec-new dyn))
(defn clobber-idx [] i32 (clobber) 0)
(defn pick [x dyn n i64] dyn x) (defn pick [x dyn n i64] dyn x)
(defn pick-t [t T n i64] dyn (.d t)) (defn pick-t [t T n i64] dyn (.d t))
@ -87,4 +90,15 @@
(fill) (fill)
(let [g pick] (let [g pick]
(set (.d dst) (g (.d other) (clobber))) (set (.d dst) (g (.d other) (clobber)))
(show "fn value:" (.d dst)))) (show "fn value:" (.d dst)))
;; An array literal indexed while the index runs: the array is a temporary
;; taken by address, and the temporary is what is held.
(fill)
(set (.d dst) (at [(.d other) (.d other)] (clobber-idx)))
(show "index:" (.d dst))
;; The same through a field of a struct literal.
(fill)
(set (.d dst) (at (.xs (A2 {.xs [(.d other) (.d other)]})) (clobber-idx)))
(show "field index:" (.d dst)))

View File

@ -3142,6 +3142,14 @@ let () =
refuses "a Vec crossing to C" "programs/vec-to-c.flan" refuses "a Vec crossing to C" "programs/vec-to-c.flan"
"is a Vec, which owns its storage"; "is a Vec, which owns its storage";
(* What [programs/dyn-held-operand.flan] prints, here rather than beside its
three native rows further down because the wasm32 case below runs it
too. *)
let dyn_held_out =
"call: kept 42\nstruct: kept 42\narray: kept 42\nruntime: true\n\
local: kept 42\naggregate: kept 42\nnested: kept 42\n\
fn value: kept 42\nindex: kept 42\nfield index: kept 42\n"
in
(* ── wasm32 ──────────────────────────────────────────────────────── (* ── wasm32 ────────────────────────────────────────────────────────
TODO.org, "The web target does not reach four things". TODO.org, "The web target does not reach four things".
@ -3256,7 +3264,12 @@ let () =
wasm_case "an imported package nothing calls, wasm32" wasm_case "an imported package nothing calls, wasm32"
"programs/pkg-unused.flan" "ok\n"; "programs/pkg-unused.flan" "ok\n";
wasm_case "an imported package nothing calls, wasm32, -O0" wasm_case "an imported package nothing calls, wasm32, -O0"
~opt:"-O0" "programs/pkg-unused.flan" "ok\n")); ~opt:"-O0" "programs/pkg-unused.flan" "ok\n";
(* The dyn roots, on the target that cannot scan its own stack: a
value held beside a sibling that collects is rooted through the
same explicit slots there as natively. *)
wasm_case "dyn: an operand held while a sibling collects, wasm32"
"programs/dyn-held-operand.flan" dyn_held_out));
(* The EDN tokenizer, and the struct reader written by hand against it (* The EDN tokenizer, and the struct reader written by hand against it
(vendor/edn, test/programs/edn.flan). The expected output is a raw (vendor/edn, test/programs/edn.flan). The expected output is a raw
@ -4440,15 +4453,11 @@ level "1"
(* A dyn read out of a place and held in an operand position while a (* A dyn read out of a place and held in an operand position while a
sibling overwrites the place and collects: a call's argument, a struct sibling overwrites the place and collects: a call's argument, a struct
and an array literal's element, a runtime call's argument, a local, a and an array literal's element, a runtime call's argument, a local, a
whole struct passed by value, a struct nested in a literal, and a call whole struct passed by value, a struct nested in a literal, a call
through a function value. [Emit.held_operands] roots each of them. With through a function value, and an array literal indexed — directly and
through a struct literal's field — while the index runs. [Emit.held_operands] roots each of them. With
that function answering nothing, every line comes out as a freed that function answering nothing, every line comes out as a freed
block's contents and the runtime line as false, on all three rows. *) block's contents and the runtime line as false, on all three rows. *)
let dyn_held_out =
"call: kept 42\nstruct: kept 42\narray: kept 42\nruntime: true\n\
local: kept 42\naggregate: kept 42\nnested: kept 42\n\
fn value: kept 42\n"
in
outputs "dyn: an operand held while a sibling collects" outputs "dyn: an operand held while a sibling collects"
"programs/dyn-held-operand.flan" dyn_held_out; "programs/dyn-held-operand.flan" dyn_held_out;
outputs ~opt:"-O0" "dyn: an operand held while a sibling collects, -O0" outputs ~opt:"-O0" "dyn: an operand held while a sibling collects, -O0"