Iter 18d.2: codegen reuse-as in-place rewrite under --alloc=rc
Lowers Term::ReuseAs { source, body=Term::Ctor } under
--alloc=rc to a runtime refcount-1 dispatch. At the reuse-as
site:
%hdr_ptr = getelementptr i8, ptr %src, i64 -8
%refcnt = load i64, ptr %hdr_ptr
%is_one = icmp eq i64 %refcnt, 1
br i1 %is_one, label %reuse, label %fresh
The reuse arm overwrites the source's box in place: stores the
new tag at offset 0 and the new field values at offsets 8, 16,
... — no malloc, no outer drop. The fresh arm allocates a brand-
new box via ailang_rc_alloc, stores tag and fields, then shallow-
dec's the source. Both arms phi-join to the same `ptr` result.
Other allocators (gc, bump) keep 18d.1's identity behaviour
unchanged.
Static shape compatibility is enforced by a new pre-codegen
pass crates/ailang-check/src/reuse_shape.rs. It tracks the
path-resolved ctor of every let-bound and pattern-matched
binder, then checks each Term::ReuseAs site against the body's
ctor. Mismatches emit reuse-as-shape-mismatch with stable
ctx.reason sub-codes (field-count-mismatch, field-type-mismatch,
indeterminate-source-ctor, cross-module-body-ctor, ctor-not-in-
module) and a drop-the-wrapper SuggestedRewrite. Build fails on
mismatch — the structured diagnostic tells the author whether
to fix the shapes or remove the hint.
Field-cleanup design — deliberate scope cut:
The reuse arm does NOT dec the old pointer-typed field values
before overwriting them. Doing so on the canonical fixture
`(reuse-as xs (Cons (+h 1) (map_inc t)))` causes use-after-free:
the recursive map_inc(t) returns t's in-place-rewritten box, so
the "new tail" being stored is the same pointer as the "old
tail". A dec-old-then-store-new schedule would dec the box (to
zero, free), then store the freed pointer.
The root cause is upstream: 18c.4 doesn't yet null-out pattern-
moved field slots, so codegen can't statically distinguish
"old field still owned" from "already moved out". The chosen
trade-off: skip the field dec entirely; pattern-wildcarded
fields (rare in shipping fixtures) leak; pattern-moved fields
are the body's responsibility (consistent with the 18c.4 leak
baseline — Own params still leak today). 18d.2 ships zero
regression vs 18c.4, plus the perf win on the alloc + outer
cascade. Closing the leak is the move-aware-pattern story
queued for 18d.3 / 18e.
Tests:
- e2e reuse_as_demo_under_rc_uses_inplace_rewrite — runs
examples/reuse_as_demo.ail.json under --alloc=rc, asserts
stdout 9 and locks in the IR shape (icmp eq i64 ..., 1; the
reuse arm has no @ailang_rc_alloc call).
- workspace reuse_as_shape_mismatch_is_reported_on_cons_to_nil
— happy negative for the new diagnostic.
- 3 unit tests in reuse_shape::tests covering same-ctor-clean,
cons-to-nil-reject, and indeterminate-source-ctor-reject.
Test deltas: e2e 56, ailang-check unit 46 -> 49 (+3), workspace
9 -> 10 (+1). cargo test --workspace green. Hand-verified
--alloc=rc binary on reuse_as_demo: stdout 9, exit 0.
This commit is contained in:
@@ -1512,17 +1512,28 @@ impl<'a> Emitter<'a> {
|
||||
}
|
||||
Ok((val_ssa, val_ty))
|
||||
}
|
||||
Term::ReuseAs { source: _, body } => {
|
||||
// Iter 18d.1: identity. Iter 18d.2 will lower this as
|
||||
// in-place rewrite under --alloc=rc. For now we simply
|
||||
// lower `body` and return its `(ssa, ty)`. The `source`
|
||||
// is dropped on the floor — in shipping fixtures it is
|
||||
// always a `Term::Var` (no IR side effects), so the
|
||||
// identity lowering is observably equivalent to lowering
|
||||
// `body` alone. The linearity check (18c.2) and
|
||||
// typecheck (18d.1) reject ill-formed shapes before we
|
||||
// get here.
|
||||
self.lower_term(body)
|
||||
Term::ReuseAs { source, body } => {
|
||||
// Iter 18d.2: under --alloc=rc, lower as a runtime-
|
||||
// refcount-1 dispatch — if the source's box is
|
||||
// unique we overwrite it in place (skipping the
|
||||
// alloc-and-cascade-dec round-trip); otherwise we
|
||||
// allocate a fresh box and dec the source. Other
|
||||
// allocators keep the 18d.1 identity behaviour.
|
||||
if !matches!(self.alloc, AllocStrategy::Rc) {
|
||||
return self.lower_term(body);
|
||||
}
|
||||
// The body must be a Term::Ctor for the in-place
|
||||
// rewrite to make sense. 18d.1 typecheck rejects
|
||||
// any other shape; lams are accepted by typecheck
|
||||
// but not yet supported by reuse codegen — fall
|
||||
// back to identity for those (the body still
|
||||
// allocates via ailang_rc_alloc, just without the
|
||||
// reuse fast path).
|
||||
let (body_type_name, body_ctor, body_args) = match body.as_ref() {
|
||||
Term::Ctor { type_name, ctor, args } => (type_name, ctor, args),
|
||||
_ => return self.lower_term(body),
|
||||
};
|
||||
self.lower_reuse_as_rc(source, body_type_name, body_ctor, body_args)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1751,6 +1762,263 @@ impl<'a> Emitter<'a> {
|
||||
Ok((p, "ptr".into()))
|
||||
}
|
||||
|
||||
/// Iter 18d.2: lower `Term::ReuseAs { source, body = Term::Ctor }`
|
||||
/// under `--alloc=rc` as a runtime refcount-1 dispatch.
|
||||
///
|
||||
/// IR shape (Lean 4 / Roc lineage):
|
||||
/// ```text
|
||||
/// %src = <source's SSA>
|
||||
/// %hdr_ptr = getelementptr inbounds i8, ptr %src, i64 -8
|
||||
/// %refcnt = load i64, ptr %hdr_ptr
|
||||
/// %is_one = icmp eq i64 %refcnt, 1
|
||||
/// br i1 %is_one, label %reuse, label %fresh
|
||||
///
|
||||
/// reuse:
|
||||
/// ; for each pointer-typed slot j: load old, drop_<field-T>(old)
|
||||
/// ; store new tag at offset 0
|
||||
/// ; store new field values at offsets 8, 16, ...
|
||||
/// br label %join
|
||||
///
|
||||
/// fresh:
|
||||
/// %newp = call ptr @ailang_rc_alloc(i64 SIZE)
|
||||
/// ; store new tag + new fields (mirrors lower_ctor)
|
||||
/// ; dec the now-superseded source
|
||||
/// call void @drop_<m>_<source-type>(ptr %src)
|
||||
/// br label %join
|
||||
///
|
||||
/// join:
|
||||
/// %result = phi ptr [ %src, %reuse_end ], [ %newp, %fresh_end ]
|
||||
/// ```
|
||||
///
|
||||
/// The shape-mismatch invariant (source's ctor has the same field
|
||||
/// count and per-field LLVM types as `body`'s ctor) is enforced
|
||||
/// upstream by `ailang_check::reuse_shape::check_module`. Codegen
|
||||
/// trusts the check; if a mismatched shape ever reaches us, the
|
||||
/// `reuse` arm's per-slot field iteration is bounded by
|
||||
/// `expected_llvm_tys.len()` and the source-cell layout
|
||||
/// guaranteed by `lower_ctor`/`@ailang_rc_alloc` — we won't read
|
||||
/// past the end of the source, but we will silently corrupt
|
||||
/// fields. The shape check ensures that path is unreachable.
|
||||
fn lower_reuse_as_rc(
|
||||
&mut self,
|
||||
source: &Term,
|
||||
body_type_name: &str,
|
||||
body_ctor_name: &str,
|
||||
body_args: &[Term],
|
||||
) -> Result<(String, String)> {
|
||||
// 1. Lower the source. Linearity (18d.1) guarantees this is a
|
||||
// bare Var of an in-scope binder; lower_term resolves it
|
||||
// to the binder's SSA.
|
||||
let (src_ssa, src_ty) = self.lower_term(source)?;
|
||||
if src_ty != "ptr" {
|
||||
return Err(CodegenError::Internal(format!(
|
||||
"reuse-as source type must be ptr, got {src_ty}"
|
||||
)));
|
||||
}
|
||||
// The source's drop symbol — needed on the fresh arm to
|
||||
// release this caller's share of the source (refcount > 1,
|
||||
// by the branch we are inside). We deliberately use the
|
||||
// shallow `ailang_rc_dec` here, NOT the per-type cascading
|
||||
// drop:
|
||||
//
|
||||
// The fresh arm only fires when the source's refcount is
|
||||
// > 1, i.e. another holder also has a reference. In that
|
||||
// case the source's children are still owned by the other
|
||||
// holder; cascading dec would dec children we don't have
|
||||
// exclusive ownership over, and on aliasing with the new
|
||||
// box's fields could leave a use-after-free in the new box.
|
||||
// A shallow dec on the source's refcount preserves the
|
||||
// 18c.4 baseline: source.refcount goes down by one; the
|
||||
// box only frees when the LAST holder dec's it; children
|
||||
// are dec'd by that final dec's cascade, not by ours.
|
||||
//
|
||||
// Identical to the unused-source-after-reuse-misfire
|
||||
// pattern in Lean 4's reset/reuse codegen: the
|
||||
// refcount-decrement is the contract; the drop fn cascade
|
||||
// is owned by the last release, not by intermediate ones.
|
||||
let _ = source; // synth_arg_type not needed for shallow dec
|
||||
let src_drop_call = "ailang_rc_dec".to_string();
|
||||
|
||||
// 2. Resolve the body ctor and its expected per-field LLVM
|
||||
// types. Mirrors the head of lower_ctor — same type-var
|
||||
// substitution path so parameterised ADT bodies work the
|
||||
// same way reuse-as lowers as plain ctor lowers.
|
||||
let cref = self.lookup_ctor_by_type(body_type_name, body_ctor_name)?;
|
||||
if body_args.len() != cref.ail_fields.len() {
|
||||
return Err(CodegenError::Internal(format!(
|
||||
"reuse-as body ctor `{body_type_name}/{body_ctor_name}` arity"
|
||||
)));
|
||||
}
|
||||
let qualified_ail_fields: Vec<Type> = if body_type_name.matches('.').count() == 1 {
|
||||
let (prefix, _) = body_type_name.split_once('.').expect("checked");
|
||||
if let Some(target) = self.import_map.get(prefix) {
|
||||
let owner_local_types = self.collect_owner_local_types(target);
|
||||
cref.ail_fields
|
||||
.iter()
|
||||
.map(|f| qualify_local_types_codegen(f, target, &owner_local_types))
|
||||
.collect()
|
||||
} else {
|
||||
cref.ail_fields.clone()
|
||||
}
|
||||
} else {
|
||||
cref.ail_fields.clone()
|
||||
};
|
||||
let expected_llvm_tys: Vec<String> = if cref.type_vars.is_empty() {
|
||||
cref.fields.clone()
|
||||
} else {
|
||||
let arg_ail_tys: Vec<Type> = body_args
|
||||
.iter()
|
||||
.map(|a| self.synth_arg_type(a))
|
||||
.collect::<Result<_>>()?;
|
||||
let var_set: BTreeSet<&str> =
|
||||
cref.type_vars.iter().map(|s| s.as_str()).collect();
|
||||
let mut subst: BTreeMap<String, Type> = BTreeMap::new();
|
||||
for (exp, actual) in qualified_ail_fields.iter().zip(arg_ail_tys.iter()) {
|
||||
unify_for_subst(exp, actual, &var_set, &mut subst)?;
|
||||
}
|
||||
qualified_ail_fields
|
||||
.iter()
|
||||
.map(|f| llvm_type(&apply_subst_to_type(f, &subst)))
|
||||
.collect::<Result<_>>()?
|
||||
};
|
||||
// (18d.2 currently does not dec old fields in the reuse
|
||||
// arm — see the rationale at the reuse arm — so the post-
|
||||
// substitution per-field AILang types are not needed here.
|
||||
// 18d.3 / 18e will revisit when the move-aware pattern
|
||||
// story lands and the reuse arm can dec moved-out slots
|
||||
// safely.)
|
||||
|
||||
// 3. Evaluate the body's args. They live in SSAs that both
|
||||
// branches consume, so they are computed before the branch
|
||||
// on refcount.
|
||||
let mut compiled = Vec::new();
|
||||
for (a, exp) in body_args.iter().zip(expected_llvm_tys.iter()) {
|
||||
let (v, vty) = self.lower_term(a)?;
|
||||
if &vty != exp {
|
||||
return Err(CodegenError::Internal(format!(
|
||||
"reuse-as body ctor `{body_ctor_name}` field type {vty} != expected {exp}"
|
||||
)));
|
||||
}
|
||||
compiled.push((v, vty));
|
||||
}
|
||||
|
||||
// 4. Refcount-1 dispatch. Header is at offset -8 (see
|
||||
// runtime/rc.c::header_of). A static / data-segment ptr
|
||||
// (e.g. a top-level fn's static closure pair) would
|
||||
// dereference garbage here, but reuse-as on such a
|
||||
// pointer is meaningless — the linearity check would
|
||||
// have rejected `<source>` as not naming a heap binder.
|
||||
let id = self.fresh_id();
|
||||
let reuse_lbl = format!("reuse.{id}");
|
||||
let fresh_lbl = format!("fresh.{id}");
|
||||
let join_lbl = format!("rejoin.{id}");
|
||||
|
||||
let hdr_ptr = self.fresh_ssa();
|
||||
self.body.push_str(&format!(
|
||||
" {hdr_ptr} = getelementptr inbounds i8, ptr {src_ssa}, i64 -8\n"
|
||||
));
|
||||
let refcnt = self.fresh_ssa();
|
||||
self.body.push_str(&format!(
|
||||
" {refcnt} = load i64, ptr {hdr_ptr}, align 8\n"
|
||||
));
|
||||
let is_one = self.fresh_ssa();
|
||||
self.body.push_str(&format!(
|
||||
" {is_one} = icmp eq i64 {refcnt}, 1\n"
|
||||
));
|
||||
self.body.push_str(&format!(
|
||||
" br i1 {is_one}, label %{reuse_lbl}, label %{fresh_lbl}\n"
|
||||
));
|
||||
|
||||
// 5. Reuse arm: overwrite tag and field slots in place. The
|
||||
// old pointer-typed field values are NOT dec'd here.
|
||||
//
|
||||
// Rationale: 18c.4's RC story does not yet emit a dec on
|
||||
// pattern-bound fields at scope close (see 18c.4's "fn
|
||||
// parameters / pattern-move debt" — patterns LOAD field
|
||||
// pointers without inc'ing, treating the load as a
|
||||
// logical move-out into the binder). Under that model,
|
||||
// by the time control reaches the reuse-as site, every
|
||||
// pointer-typed old field that the body actually
|
||||
// consumes has already been "moved out" of the slot;
|
||||
// dec'ing it again here would either double-free (when
|
||||
// the body's evaluation freed it) or, worse, free a box
|
||||
// the new field value aliases (the canonical case:
|
||||
// `(reuse-as xs (Cons (+ h 1) (map_inc t)))` returns
|
||||
// `t`'s in-place-rewritten box as the new tail). A
|
||||
// self-dec that cascades through B.field[1]=C while C
|
||||
// is also the new tail produces a use-after-free.
|
||||
//
|
||||
// The cost: an old pointer-typed field that the body did
|
||||
// NOT pattern-move (e.g. `match xs (Cons _ _ → ...)`
|
||||
// with both fields wildcarded) leaks here. That leak is
|
||||
// the same shape 18c.4 already accepts for non-trackable
|
||||
// pattern fields, so 18d.2 doesn't regress the baseline.
|
||||
// Closing the leak requires the move-aware pattern story
|
||||
// (queued for 18d.3 / 18e alongside fn-parameter dec).
|
||||
self.start_block(&reuse_lbl);
|
||||
// Store new tag at offset 0. Even when the new tag equals the
|
||||
// old tag (most reuse-as fixtures: Cons → Cons), we emit the
|
||||
// store unconditionally — codegen doesn't have to special-
|
||||
// case "same tag", the store is cheap, and the IR is simpler.
|
||||
self.body.push_str(&format!(
|
||||
" store i64 {tag}, ptr {src_ssa}, align 8\n",
|
||||
tag = cref.tag
|
||||
));
|
||||
// Store new field values into the slots.
|
||||
for (i, (v, ty)) in compiled.iter().enumerate() {
|
||||
let off = 8 + i as i64 * 8;
|
||||
let addr = self.fresh_ssa();
|
||||
self.body.push_str(&format!(
|
||||
" {addr} = getelementptr inbounds i8, ptr {src_ssa}, i64 {off}\n"
|
||||
));
|
||||
self.body
|
||||
.push_str(&format!(" store {ty} {v}, ptr {addr}, align 8\n"));
|
||||
}
|
||||
let reuse_end = self.current_block.clone();
|
||||
self.body.push_str(&format!(" br label %{join_lbl}\n"));
|
||||
|
||||
// 6. Fresh arm: standard alloc + tag + field stores; then
|
||||
// dec the source (whose refcount was > 1, so this is the
|
||||
// callee's release of its share — may or may not free).
|
||||
self.start_block(&fresh_lbl);
|
||||
let size_bytes = 8 + (compiled.len() * 8) as i64;
|
||||
let newp = self.fresh_ssa();
|
||||
self.body.push_str(&format!(
|
||||
" {newp} = call ptr @{}(i64 {size_bytes})\n",
|
||||
self.alloc.fn_name()
|
||||
));
|
||||
self.body.push_str(&format!(
|
||||
" store i64 {tag}, ptr {newp}, align 8\n",
|
||||
tag = cref.tag
|
||||
));
|
||||
for (i, (v, ty)) in compiled.iter().enumerate() {
|
||||
let off = 8 + i as i64 * 8;
|
||||
let addr = self.fresh_ssa();
|
||||
self.body.push_str(&format!(
|
||||
" {addr} = getelementptr inbounds i8, ptr {newp}, i64 {off}\n"
|
||||
));
|
||||
self.body
|
||||
.push_str(&format!(" store {ty} {v}, ptr {addr}, align 8\n"));
|
||||
}
|
||||
// Dec the source — the user's reuse-hint failed because the
|
||||
// box was shared. The source's drop fn cascades through any
|
||||
// boxed children of the OLD ctor (the slot we couldn't reuse),
|
||||
// matching the cascade lower_ctor would do for any other
|
||||
// owned binder going out of scope.
|
||||
self.body
|
||||
.push_str(&format!(" call void @{src_drop_call}(ptr {src_ssa})\n"));
|
||||
let fresh_end = self.current_block.clone();
|
||||
self.body.push_str(&format!(" br label %{join_lbl}\n"));
|
||||
|
||||
// 7. Join: phi over the two arms' result pointers.
|
||||
self.start_block(&join_lbl);
|
||||
let phi = self.fresh_ssa();
|
||||
self.body.push_str(&format!(
|
||||
" {phi} = phi ptr [ {src_ssa}, %{reuse_end} ], [ {newp}, %{fresh_end} ]\n"
|
||||
));
|
||||
Ok((phi, "ptr".into()))
|
||||
}
|
||||
|
||||
fn lower_match(
|
||||
&mut self,
|
||||
scrutinee: &Term,
|
||||
|
||||
Reference in New Issue
Block a user