An aggregate that reads its own destination sees the old value on x86, as it does on LLVM

This commit is contained in:
Joseph Ferano 2026-09-25 07:39:03 +07:00
commit f83e803ef8
5 changed files with 193 additions and 18 deletions

View File

@ -1030,22 +1030,32 @@ is built into a frame temporary and copied over now, unless lowering is bound to
reach the end. Separately, the transfer exit zeroed an aggregate return value, reach the end. Separately, the transfer exit zeroed an aggregate return value,
which for an aggregate is the caller's storage; it zeroes scalars only. which for an aggregate is the caller's storage; it zeroes scalars only.
** TODO A global initialiser still builds an aggregate straight into its destination on x86 ** DONE A global initialiser builds an aggregate through the same temporary on x86
The statement-level assignment goes through a frame temporary now, so the CLOSED: [2026-09-25]
half-write is closed where a program can observe it. The release build's Already true when the entry was written. Every computed initialiser, release
globals-init path was not converted and still writes in place. build included, reaches =emit_globals_init= as =Emit.startup_plan='s
=(set g init)= and so goes through =assign=. The only initialisers lowered
straight into their symbol are =Tast.const_init= ones, which neither transfer
nor read anything. Nothing to change.
** TODO An aggregate built in place can read its own destination ** DONE An aggregate built in place never reads its own destination
=(set p (P {.a (.b p) .b (.a p)}))= answers =2 1= under LLVM and =2 2= under CLOSED: [2026-09-25]
=--x86=: lowering asks whether it can transfer and not whether it can alias. The =assign= builds in place only when the right-hand side settles *and* reads no
fix has a shape already — the same temporary, chosen by an aliasing question. storage the destination lies in; otherwise it goes through the temporary. Two
Whether the two become one predicate or two is the lane to decide. predicates, not one: =settles= is about leaving part-way, =reads= about a value
reading itself, and a read through a pointer counts as reading everything.
=test/programs/self-read.flan= runs on both backends.
** TODO The aggregate temporary is an unrooted buffer while it is filled ** TODO A dyn value read out of a place is unrooted until it is stored, on both backends
No root table names it. Harmless only while a dyn field in a struct was refused, First filed as the x86 aggregate temporary being unrooted. It is wider than
and per-type descriptors have since lifted that. Whoever relies on a dyn field that: a dyn loaded from a place and held while a later sibling allocates is
reaching this path has to root the buffer, or a collection running inside the unrooted on LLVM -O0 as well, in an aggregate literal or an argument list alike.
construction will not see what has been built so far. =(pick (.d other) (do (set other (T {.n 0})) (churn)))= prints a different
object's contents on both backends. =Emit.root_plan= roots dyn call results
only. Decision for the author: root such intermediates on both backends (a
=root_plan= change, plus LLVM spilling them to rooted allocas), or declare the
shape unsupported. Rooting only the x86 temporary would fork the backends and
leave the argument case open.
** TODO Marking through a descriptor an x86 reload module emitted ** TODO Marking through a descriptor an x86 reload module emitted
The module links and runs. What is not proved is a collection running while a live The module links and runs. What is not proved is a collection running while a live

View File

@ -6104,6 +6104,14 @@ Scalars were never affected, on either backend, and `half-write.flan`'s last row
being read as one: a scalar's store is a single instruction and it happens after the check for a transfer, so a call being read as one: a scalar's store is a single instruction and it happens after the check for a transfer, so a call
that signalled never reaches it. that signalled never reaches it.
**Settling is not enough on its own.** A right-hand side that reads its own destination sees whatever has been
written so far: `(set p (P {.a (.b p) .b (.a p)}))` built in place writes `.a` and then reads it back for `.b`,
answering `2 2` where LLVM answers `2 1`. So `assign` asks a second question, `X86.reads`: whether the value reads
storage the destination lies in, where the destination is named as one local, one global, or anywhere at all when it is
reached through a pointer. A read through a pointer counts as reading everything. The two questions stay separate
predicates because they are about different things, and in-place construction needs a no from both.
`test/programs/self-read.flan` crosses the destinations with that self-read on both backends.
## Ghost text finds its anchor in the buffer, not in the table ## Ghost text finds its anchor in the buffer, not in the table
`M-x flan-watch-ghost-mode` paints each watched value inline, after the line holding the call that wrote it, as an `M-x flan-watch-ghost-mode` paints each watched value inline, after the line holding the call that wrote it, as an

View File

@ -1677,6 +1677,82 @@ and settles_place (p : Tast.place) =
(* An index is bounds-checked, and the check signals. *) (* An index is bounds-checked, and the check signals. *)
| Tast.Pindex _ -> false | Tast.Pindex _ -> false
(* The storage a destination lies in, as far as it can be named without
running anything: one local's slot, one global, or somewhere behind a
pointer, which could be any of them.
Building an aggregate in its destination needs a second answer besides
[settles]. A right-hand side that reads the destination sees the fields
already written: [(set p (P {.a (.b p) .b (.a p)}))] writes [.a] and then
reads it back as the new [.b]. LLVM builds the value before it stores any
of it, so the read sees the old [p]. The two questions stay two predicates
because they are about different things — one about leaving part-way, the
other about the value reading itself — and [assign] asks both. *)
type root = Rlocal of int | Rglobal of string | Rany
let rec expr_root (e : Tast.expr) =
match e.Tast.e with
| Tast.Local i -> Rlocal i
| Tast.Global n -> Rglobal n
| Tast.Field (x, _) | Tast.CaseField (x, _, _) -> through x
| Tast.Prim (Tast.At, a :: _ :: _) -> through a
| _ -> Rany
(* A field or an element is in its container's storage unless the container
is reached through a pointer or is a slice, both of which are a pointer. *)
and through (x : Tast.expr) =
match x.Tast.ty with
| Types.Ptr _ | Types.Slice _ | Types.String -> Rany
| _ -> expr_root x
let place_root (p : Tast.place) =
match p with
| Tast.Plocal i -> Rlocal i
| Tast.Pglobal n -> Rglobal n
| Tast.Pfield (x, _) | Tast.Pindex (x, _) -> through x
| Tast.Pderef _ -> Rany
let overlaps a b =
match a, b with
| Rany, _ | _, Rany -> true
| Rlocal i, Rlocal j -> i = j
| Rglobal m, Rglobal n -> String.equal m n
| _ -> false
(* Whether evaluating [e] can read storage in [root]. Only asked of an
expression that [settles], so the shapes are the ones listed there; a
shape this does not recognise answers yes. A read through a pointer can
reach anything. [Addr] is counted as a read of its place, which it is not,
because being wrong that way costs a copy. *)
let rec reads root (e : Tast.expr) =
match e.Tast.e with
| Tast.Int _ | Tast.Float _ | Tast.Bool _ | Tast.Str _ | Tast.Unit
| Tast.Zero _ | Tast.Uninit _ | Tast.None_ | Tast.FnAddr _ -> false
| Tast.Local i -> overlaps root (Rlocal i)
| Tast.Global n -> overlaps root (Rglobal n)
| Tast.Deref _ -> true
| Tast.Field (x, _) | Tast.CaseField (x, _, _) ->
(match x.Tast.ty with Types.Ptr _ -> true | _ -> false) || reads root x
| Tast.Some_ x -> reads root x
| Tast.Make (_, es) | Tast.MakeCase (_, _, es) | Tast.Arr es | Tast.Do es
| Tast.Prim (_, es) -> List.exists (reads root) es
| Tast.If (c, a, b) -> reads root c || reads root a || reads root b
| Tast.Addr p -> reads_place root p
| _ -> true
and reads_place root (p : Tast.place) =
match p with
| Tast.Plocal i -> overlaps root (Rlocal i)
| Tast.Pglobal n -> overlaps root (Rglobal n)
| Tast.Pderef _ -> true
| Tast.Pfield (x, _) ->
(match x.Tast.ty with Types.Ptr _ -> true | _ -> false) || reads root x
| Tast.Pindex (x, is) ->
(match x.Tast.ty with
| Types.Ptr _ | Types.Slice _ | Types.String -> true
| _ -> false)
|| reads root x || List.exists (reads root) is
let rec lower f (e : Tast.expr) (dst : loc) : unit = let rec lower f (e : Tast.expr) (dst : loc) : unit =
(* The one hook the line table needs, and it is here rather than at (* The one hook the line table needs, and it is here rather than at
statement granularity on purpose: the same recursion that lowers a nested statement granularity on purpose: the same recursion that lowers a nested
@ -1817,7 +1893,8 @@ and lower_at f (e : Tast.expr) (dst : loc) : unit =
nowhere else. *) nowhere else. *)
scoped f (fun () -> scoped f (fun () ->
let d = Lf f.slots.(slot) in let d = Lf f.slots.(slot) in
if f.loops = [] then lower f v d else assign f ~dst:d v); if f.loops = [] then lower f v d
else assign f ~root:(Rlocal slot) ~dst:d v);
bind_slot f slot) bind_slot f slot)
bs; bs;
block f body dst t block f body dst t
@ -1862,7 +1939,7 @@ and lower_at f (e : Tast.expr) (dst : loc) : unit =
| None -> unsupported "continue %d outside a loop" n) | None -> unsupported "continue %d outside a loop" n)
| Tast.Set (p, v) -> | Tast.Set (p, v) ->
let l = place f p in let l = place f p in
scoped f (fun () -> assign f ~dst:l v) scoped f (fun () -> assign f ~root:(place_root p) ~dst:l v)
| Tast.Make (sn, xs) -> | Tast.Make (sn, xs) ->
let offs = field_offsets f sn in let offs = field_offsets f sn in
List.iteri List.iteri
@ -2386,11 +2463,15 @@ and block f body dst t =
instruction and it happens after the transfer guard, so a call that instruction and it happens after the transfer guard, so a call that
signalled never reaches it. signalled never reaches it.
The same temporary answers a right-hand side that reads its own
destination, which [reads] asks of [root]: built in place, the fields
written first would be what the later ones read.
[dst] is worked out by the caller and outlives this: [scoped] reclaims the [dst] is worked out by the caller and outlives this: [scoped] reclaims the
temporary, not the destination. *) temporary, not the destination. *)
and assign f ~(dst : loc) (v : Tast.expr) : unit = and assign f ~(root : root) ~(dst : loc) (v : Tast.expr) : unit =
let t = v.Tast.ty in let t = v.Tast.ty in
if (not (is_agg t)) || settles v then lower f v dst if (not (is_agg t)) || (settles v && not (reads root v)) then lower f v dst
else begin else begin
let o = Lf (tmp f t) in let o = Lf (tmp f t) in
lower f v o; lower f v o;

View File

@ -0,0 +1,62 @@
;;;; An aggregate whose value reads the place it is assigned to sees the old
;;;; value of that place, in every field.
;;;;
;;;; (set p (P {.a (.b p) .b (.a p)})) swaps the two fields. It does only if
;;;; the whole right-hand side is read before any of it is stored. A backend
;;;; that builds the struct straight into [p] writes [.a] first, and the read
;;;; of [(.a p)] for [.b] then sees the new value: the answer comes back 2 2
;;;; rather than 2 1.
;;;;
;;;; Each row is a different destination: a global, a field of a global, a
;;;; local, a field of a local, a place behind a pointer, a variable read
;;;; through a pointer that points at it, and a local rebound inside a loop
;;;; whose previous turn the new value reads. The last row reads a different
;;;; variable, which is the case still built in place.
(defstruct P [a i32 b i32])
(defstruct Q [tag i32 inner P])
(defonce g P (P {.a 1 .b 2}))
(defonce gq Q (Q {.tag 0 .inner (P {.a 1 .b 2})}))
(defn show [p P] ()
(print (.a p)) (print " ") (print (.b p)) (print "\n"))
(defn main [] ()
;; A global.
(set g (P {.a (.b g) .b (.a g)}))
(show g)
;; A field of a global.
(set (.inner gq) (P {.a (.b (.inner gq)) .b (.a (.inner gq))}))
(show (.inner gq))
(let [p (P {.a 1 .b 2})
q (Q {.tag 0 .inner (P {.a 1 .b 2})})
r (P {.a 1 .b 2})
s (P {.a 1 .b 2})
t (P {.a 1 .b 2})
other (P {.a 3 .b 4})]
;; A local.
(set p (P {.a (.b p) .b (.a p)}))
(show p)
;; A field of a local.
(set (.inner q) (P {.a (.b (.inner q)) .b (.a (.inner q))}))
(show (.inner q))
;; A place behind a pointer, reading the variable it points at.
(let [pr (addr r)]
(set (deref pr) (P {.a (.b r) .b (.a r)}))
(show r))
;; A variable, reading itself through a pointer.
(let [ps (addr s)]
(set s (P {.a (.b (deref ps)) .b (.a (deref ps))}))
(show s))
;; A local rebound in a loop, reading its own previous turn.
(let [pt (addr t)
i 0]
(while (< i 2)
(let [u (P {.a (.b (deref pt)) .b (.a (deref pt))})]
(show u)
(set pt (addr u)))
(set i (+ i 1))))
;; No self-read.
(set p (P {.a (.b other) .b (.a other)}))
(show p)))

View File

@ -2720,6 +2720,20 @@ let () =
outputs ~x86:true outputs ~x86:true
"an assignment signalled through leaves the old value, --x86" "an assignment signalled through leaves the old value, --x86"
"programs/half-write.flan" half_write_out; "programs/half-write.flan" half_write_out;
(* The other half of building in place: a value that reads its own
destination has to see the old value in every field. x86 built
[(set p (P {.a (.b p) .b (.a p)}))] into [p] and answered 2 2. *)
let self_read_out =
"2 1\n2 1\n2 1\n2 1\n2 1\n2 1\n2 1\n1 2\n4 3\n"
in
outputs "an aggregate that reads its destination sees the old value"
"programs/self-read.flan" self_read_out;
outputs ~opt:"-O0"
"an aggregate that reads its destination sees the old value, -O0"
"programs/self-read.flan" self_read_out;
outputs ~x86:true
"an aggregate that reads its destination sees the old value, --x86"
"programs/self-read.flan" self_read_out;
(* ── Packages: the link follows the program ──────────────────────── (* ── Packages: the link follows the program ────────────────────────
A package's C and linker arguments used to come with the import, A package's C and linker arguments used to come with the import,