Skip to content

Commit 1c458b0

Browse files
cristianocclaude
andcommitted
Classify field use in scalar replacement
The pass replaced every field of an eligible block with a mutable binding, whether or not the field was ever read. Recording how each field is used costs nothing - the eligibility walk already visits every occurrence - and decides what each field needs. A field that is never read needs no storage: its initializer and its writes are kept only when they have effects. A field that is read but never written is immutable, so a refined let will do. Only a field that is both read and written needs a mutable scalar. test_ramification shows the shape this is for. A ref written in both branches of a match and never read afterwards disappears, and what is left folds: let v = ref(0) let y = switch x { | A(_) => v := 1; 3 | B(_) => v := 1; 4 } - let v = 0; let y; - if (x.TAG === "A") { v = 1; y = 3; } y = x.TAG === "A" ? 3 : 4; - else { v = 1; y = 4; } escapes becomes analyze, returning eligibility rather than escape so it can report through the same walk. The short circuit still holds: on success every occurrence has been visited, so the use table is complete, and on failure it is discarded with the rejection. Read-only fields go through refine_let, which may substitute the initializer at its use sites - but only when is_safe_to_alias admits it, which is variables, constants and module field reads. An effectful initializer read five times in a loop, or three times through a closure, is still evaluated once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
1 parent 6db0028 commit 1c458b0

6 files changed

Lines changed: 341 additions & 54 deletions

File tree

compiler/core/lam_pass_sroa.ml

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,41 +19,56 @@
1919
eligible. Analysis is kept separate from rewriting so a failed eligibility
2020
check cannot partially transform the term. *)
2121

22-
let valid_field field_count index = index >= 0 && index < field_count
22+
type field_use = {mutable read: bool; mutable written: bool}
2323

24-
(* Does the block appear anywhere other than as a direct, in-range field read
25-
or write? [escapes] and [rewrite] below are a matched pair: [rewrite] handles
26-
exactly the occurrences [escapes] accepts, and asserts on the rest. Extending
27-
one without the other is a compiler crash rather than a type error, so keep
28-
their cases in step. *)
29-
let rec escapes block field_count (lam : Lambda.t) =
24+
let valid_field uses index = index >= 0 && index < Array.length uses
25+
26+
(* Does the block appear only as direct, in-range field reads and writes? While
27+
answering, record how every field is used. [analyze] and [rewrite] below are
28+
a matched pair: [rewrite] handles exactly the occurrences [analyze] accepts,
29+
and asserts on the rest. Extending one without the other is a compiler crash
30+
rather than a type error, so keep their cases in step. *)
31+
let rec analyze block uses (lam : Lambda.t) =
3032
match lam with
31-
| Lvar id -> Ident.same id block
33+
| Lvar id -> not (Ident.same id block)
3234
| Lassign (id, value) ->
33-
Ident.same id block || escapes block field_count value
35+
(not (Ident.same id block)) && analyze block uses value
3436
| Lprim {primitive = Pfield (index, _); args = [Lvar id]}
3537
when Ident.same id block ->
36-
not (valid_field field_count index)
38+
if valid_field uses index then (
39+
uses.(index).read <- true;
40+
true)
41+
else false
3742
| Lprim {primitive = Psetfield (index, _); args = [Lvar id; value]}
3843
when Ident.same id block ->
39-
(not (valid_field field_count index)) || escapes block field_count value
40-
| _ -> Lambda.shallow_exists (escapes block field_count) lam
44+
if valid_field uses index then (
45+
uses.(index).written <- true;
46+
analyze block uses value)
47+
else false
48+
| _ ->
49+
not
50+
(Lambda.shallow_exists (fun child -> not (analyze block uses child)) lam)
4151

42-
let rec rewrite block fields (lam : Lambda.t) =
52+
let discard_value value body =
53+
if Lam_analysis.no_side_effects value then body else Lambda.seq value body
54+
55+
let rec rewrite block fields uses (lam : Lambda.t) =
4356
match lam with
4457
| Lprim {primitive = Pfield (index, _); args = [Lvar id]}
4558
when Ident.same id block ->
4659
Lambda.var fields.(index)
4760
| Lprim {primitive = Psetfield (index, _); args = [Lvar id; value]}
4861
when Ident.same id block ->
49-
Lambda.assign fields.(index) (rewrite block fields value)
50-
(* Unreachable: [escapes] rejected the block for both of these, so [replace]
62+
let value = rewrite block fields uses value in
63+
if not uses.(index).read then discard_value value Lambda.lambda_unit
64+
else Lambda.assign fields.(index) value
65+
(* Unreachable: [analyze] rejected the block for both of these, so [replace]
5166
never reaches the rewrite. They are kept as assertions rather than dropped
52-
so that a future occurrence form added to [escapes] but not here fails
67+
so that a future occurrence form added to [analyze] but not here fails
5368
loudly instead of silently losing the write. *)
5469
| Lvar id when Ident.same id block -> assert false
5570
| Lassign (id, _) when Ident.same id block -> assert false
56-
| _ -> Lambda.shallow_map_sharing (rewrite block fields) lam
71+
| _ -> Lambda.shallow_map_sharing (rewrite block fields uses) lam
5772

5873
let fields_for_block block info field_count =
5974
let fallback () =
@@ -85,13 +100,29 @@ let replace ~block ~info ~initializers body =
85100
| [] -> None
86101
| _ ->
87102
let field_count = List.length initializers in
88-
if escapes block field_count body then None
103+
let uses =
104+
Array.init field_count (fun _ -> {read = false; written = false})
105+
in
106+
if not (analyze block uses body) then None
89107
else
90108
let fields = fields_for_block block info field_count in
91-
let body = rewrite block fields body in
92-
Some
93-
(Ext_list.fold_right2 (Array.to_list fields) initializers body
94-
(fun field init body -> Lambda.let_ Variable field init body))
109+
let body = rewrite block fields uses body in
110+
let rec bind_fields index initializers body =
111+
match initializers with
112+
| [] -> body
113+
| init :: rest ->
114+
let body = bind_fields (index + 1) rest body in
115+
let use = uses.(index) in
116+
(* A never-read field needs no storage; its initializer and writes are
117+
retained only when they have effects. A read-only field can use a
118+
normal refined let, while a field that is both read and written
119+
still needs a mutable scalar binding. *)
120+
if not use.read then discard_value init body
121+
else if not use.written then
122+
Lam_util.refine_let ~kind:Strict fields.(index) init body
123+
else Lambda.let_ Variable fields.(index) init body
124+
in
125+
Some (bind_fields 0 initializers body)
95126

96127
let rec simplify (lam : Lambda.t) =
97128
match lam with

compiler/core/lam_pass_sroa.mli

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@ val replace :
2929
Lambda.t ->
3030
Lambda.t option
3131
(** [replace ~block ~info ~initializers body] replaces a non-escaping local
32-
block with one mutable binding per field. The initializer order is
33-
preserved. Returns [None] when the block is used other than by direct field
34-
access. *)
32+
block with independent scalar values. Fields that are never read need no
33+
storage, but effects from their initializers and writes are preserved in
34+
order. Read-only fields use immutable bindings; fields that are both read
35+
and written use mutable bindings. Returns [None] when the block is used
36+
other than by direct field access. *)
3537

3638
val simplify : Lambda.t -> Lambda.t
3739
(** Scalar-replace eligible local mutable blocks throughout a Lambda term. *)

tests/ounit_tests/ounit_sroa_tests.ml

Lines changed: 91 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,32 @@ let write block index value =
1414
~args:[Lambda.var block; value]
1515
loc
1616

17+
let debugger = Lambda.prim ~primitive:Pdebugger ~args:[] loc
18+
19+
let count_debuggers lam =
20+
let count = ref 0 in
21+
let rec loop (lam : Lambda.t) =
22+
(match lam with
23+
| Lprim {primitive = Pdebugger} -> incr count
24+
| _ -> ());
25+
ignore
26+
(Lambda.shallow_exists
27+
(fun child ->
28+
loop child;
29+
false)
30+
lam)
31+
in
32+
loop lam;
33+
!count
34+
35+
let contains_storage lam =
36+
let rec loop (lam : Lambda.t) =
37+
match lam with
38+
| Llet _ | Lassign _ -> true
39+
| _ -> Lambda.shallow_exists loop lam
40+
in
41+
loop lam
42+
1743
let pair_info =
1844
Lambda.Blk_record
1945
{
@@ -33,36 +59,37 @@ let suites =
3359
]
3460
in
3561
let body =
36-
Lambda.seq
37-
(write block 1 (Lambda.const (Lambda.const_int 30)))
38-
(read block 0)
62+
Lambda.seq (write block 1 (read block 0)) (read block 1)
3963
in
4064
match
4165
Lam_pass_sroa.replace ~block ~info:pair_info ~initializers body
4266
with
4367
| Some
4468
(Llet
45-
( Variable,
69+
( Alias,
4670
field0,
4771
Lconst (Const_int 10l),
4872
Llet
4973
( Variable,
5074
field1,
5175
Lconst (Const_int 20l),
52-
Lsequence (Lassign (assigned, Lconst _), Lvar returned)
76+
Lsequence (Lassign (assigned, Lvar read), Lvar returned)
5377
) )) ->
5478
assert_equal "pair_left" (Ident.name field0);
5579
assert_equal "pair_right" (Ident.name field1);
5680
assert_bool "field one gets its own binding"
5781
(Ident.same field1 assigned);
58-
assert_bool "the read uses field zero" (Ident.same field0 returned)
82+
assert_bool "the assignment reads field zero"
83+
(Ident.same field0 read);
84+
assert_bool "the result reads field one"
85+
(Ident.same field1 returned)
5986
| _ -> assert_failure "expected two scalar bindings" );
6087
( "replaces fields captured by a closure" >:: fun _ ->
6188
let block = Ident.create "pair" in
6289
let closure =
6390
Lambda.function_ ~loc ~attr:Lambda.default_function_attribute
6491
~params:[]
65-
~body:(write block 1 (read block 0))
92+
~body:(Lambda.seq (write block 1 (read block 0)) (read block 1))
6693
in
6794
match
6895
Lam_pass_sroa.replace ~block ~info:pair_info
@@ -75,19 +102,24 @@ let suites =
75102
with
76103
| Some
77104
(Llet
78-
( Variable,
105+
( Alias,
79106
field0,
80107
_,
81108
Llet
82109
( Variable,
83110
field1,
84111
_,
85-
Lfunction {body = Lassign (assigned, Lvar returned)} )
86-
)) ->
112+
Lfunction
113+
{
114+
body =
115+
Lsequence
116+
(Lassign (assigned, Lvar read), Lvar returned);
117+
} ) )) ->
87118
assert_bool "the closure writes field one"
88119
(Ident.same field1 assigned);
89-
assert_bool "the closure reads field zero"
90-
(Ident.same field0 returned)
120+
assert_bool "the closure reads field zero" (Ident.same field0 read);
121+
assert_bool "the closure reads field one"
122+
(Ident.same field1 returned)
91123
| _ -> assert_failure "expected scalar closure captures" );
92124
( "preserves unrelated closure subtrees" >:: fun _ ->
93125
let block = Ident.create "pair" in
@@ -101,13 +133,58 @@ let suites =
101133
~initializers:[Lambda.const (Lambda.const_int 10)]
102134
body
103135
with
104-
| Some
105-
(Llet (Variable, _, _, Lsequence (Lvar _, preserved_unrelated)))
136+
| Some (Llet (Alias, _, _, Lsequence (Lvar _, preserved_unrelated)))
106137
->
107138
assert_bool "the unrelated subtree is physically shared"
108139
(preserved_unrelated == unrelated)
109140
| _ -> assert_failure "expected a scalar read followed by a closure"
110141
);
142+
( "keeps effectful read-only fields strict" >:: fun _ ->
143+
let block = Ident.create "cell" in
144+
let body = Lambda.seq (read block 0) (read block 0) in
145+
match
146+
Lam_pass_sroa.replace ~block ~info:Lambda.ref_tag_info
147+
~initializers:[debugger] body
148+
with
149+
| Some
150+
(Llet
151+
( Strict,
152+
field,
153+
Lprim {primitive = Pdebugger},
154+
Lsequence (Lvar first, Lvar second) )) ->
155+
assert_bool "both reads use the strict scalar"
156+
(Ident.same field first && Ident.same field second)
157+
| _ -> assert_failure "expected one strict scalar binding" );
158+
( "removes write-only storage but preserves effects" >:: fun _ ->
159+
let block = Ident.create "pair" in
160+
let body =
161+
Lambda.seq (write block 0 debugger) (write block 1 debugger)
162+
in
163+
match
164+
Lam_pass_sroa.replace ~block ~info:pair_info
165+
~initializers:[Lambda.const (Lambda.const_int 10); debugger]
166+
body
167+
with
168+
| Some replacement ->
169+
assert_equal 3 (count_debuggers replacement);
170+
assert_bool "write-only fields have no scalar storage"
171+
(not (contains_storage replacement))
172+
| None -> assert_failure "expected write-only fields to scalarize" );
173+
( "removes write-only storage captured by a closure" >:: fun _ ->
174+
let block = Ident.create "cell" in
175+
let closure =
176+
Lambda.function_ ~loc ~attr:Lambda.default_function_attribute
177+
~params:[] ~body:(write block 0 debugger)
178+
in
179+
match
180+
Lam_pass_sroa.replace ~block ~info:Lambda.ref_tag_info
181+
~initializers:[debugger] closure
182+
with
183+
| Some replacement ->
184+
assert_equal 2 (count_debuggers replacement);
185+
assert_bool "the closure captures no scalar storage"
186+
(not (contains_storage replacement))
187+
| None -> assert_failure "expected the closure write to scalarize" );
111188
( "rejects a whole-block use" >:: fun _ ->
112189
let block = Ident.create "pair" in
113190
assert_equal None

0 commit comments

Comments
 (0)