Iter 18e: (drop-iterative) annotation + worklist allocator

Closes the 18-arc's stack-recursion limit. Recursive drop
cascades from 18c.4 overflow on long ADT chains (Linux's 8 MB
default stack maxes out around 1M cells of List). The new
opt-in (drop-iterative) annotation on a Def::Type switches the
synthesised drop_<m>_<T> body from recursive to iterative-with-
explicit-worklist for that type.

Schema:
- TypeDef.drop_iterative: bool. Default false; serde-skip
  when false so existing fixtures' canonical JSON hashes stay
  stable.
- Form-A: (drop-iterative) clause inside (data T ...).

Worklist runtime (4 new ABI symbols in runtime/rc.c):
- ailang_drop_worklist_new(initial_capacity)
- ailang_drop_worklist_push(wl, ptr)
- ailang_drop_worklist_pop(wl) -> ptr
- ailang_drop_worklist_free(wl)

Heap stretchy buffer, doubling on overflow, null-filtering on
push. Lean 4 / Roc precedent documented in the runtime; the
slot-repurposing strategy was considered and rejected because
not every box has a free pointer-typed slot to thread the
worklist through (Cons head is i64, slot 1 is ptr but it's
the field we're following — no free slot).

Codegen (emit_iterative_drop_fn_for_type): for a
drop_iterative type, drop_<m>_<T>(ptr %p) emits a worklist
loop. Fields of the SAME annotated type push onto the
worklist (mono-typed); fields of DIFFERENT types call their
own drop fn directly (recursive on those, but only if THEY
are themselves recursive — i.e. one level of cascade jump
maximum). Mono-typed-worklist is sound for the deep-self-
recursion case the iter targets (List of List of T just
needs the spine flattened).

Tests:
- examples/rc_drop_iterative_long_list — 1M-cell List of Int
  with (drop-iterative) annotation.
- alloc_rc_drop_iterative_handles_million_cell_list E2E —
  builds + runs under --alloc=rc, asserts clean exit. With
  annotation: exits 0. Without annotation (control): SIGSEGV
  at exit code 139 (verified by hand). Worklist is load-
  bearing.
- iter18e_drop_iterative_emits_worklist_body_no_self_recursion
  IR-shape: worklist body has br to loop_head AND no direct
  recursive call into drop_<m>_<T>.
- iter18e_no_annotation_keeps_recursive_drop_body — control:
  unannotated variant still emits the 18c.4 recursive shape.
- 3 surface parse-tests for the annotation round-trip.

Test deltas: e2e 58 -> 61 (+3), surface 18 -> 21 (+3). All
other buckets unchanged. cargo test --workspace green.

Known debt (deliberate):
- Mono-typed worklist: cross-type drop-iterative fields call
  the other type's drop fn directly. A heterogeneous
  worklist would be more general but adds tag tracking
  complexity for a case (drop-iterative T containing
  drop-iterative T') that's narrower than the deep-self-
  recursion target. Documented in
  emit_iterative_drop_fn_for_type's doc.
- Closure / Type::Var / Type::Forall fields fall back to
  shallow ailang_rc_dec via field_drop_call — same as the
  recursive variant.
- Dynamic-tag partial-drop fallback (head_or_zero epilogue
  shallow dec when moved_slots non-empty) — out of scope per
  brief.
This commit is contained in:
2026-05-08 12:53:09 +02:00
parent 5e401a3520
commit ce6ab8ee44
12 changed files with 763 additions and 2 deletions
+225 -1
View File
@@ -421,6 +421,14 @@ fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy) -> Result<String>
if matches!(alloc, AllocStrategy::Rc) {
out.push_str("declare void @ailang_rc_inc(ptr)\n");
out.push_str("declare void @ailang_rc_dec(ptr)\n");
// Iter 18e: drop-worklist ABI for `(drop-iterative)` types.
// Declared unconditionally under `--alloc=rc` (the linker
// drops symbols if no emitted fn references them); symmetric
// with inc/dec above. See `runtime/rc.c` for the strategy.
out.push_str("declare ptr @ailang_drop_worklist_new()\n");
out.push_str("declare void @ailang_drop_worklist_push(ptr, ptr)\n");
out.push_str("declare ptr @ailang_drop_worklist_pop(ptr)\n");
out.push_str("declare void @ailang_drop_worklist_free(ptr)\n");
}
// Iter 16e: `==` on `Str` lowers to `@strcmp` followed by
// `icmp eq i32 0`. NUL-terminated strings make this a one-liner;
@@ -752,7 +760,16 @@ impl<'a> Emitter<'a> {
if matches!(self.alloc, AllocStrategy::Rc) {
for def in &self.module.defs {
if let Def::Type(td) = def {
self.emit_drop_fn_for_type(td);
if td.drop_iterative {
// Iter 18e: opt-in iterative-drop body. The
// recursive cascade overflows the C stack on
// long chains (a million-cell list ≈ 8MB
// stack); the iterative variant uses an
// explicit heap-allocated worklist instead.
self.emit_iterative_drop_fn_for_type(td);
} else {
self.emit_drop_fn_for_type(td);
}
}
}
}
@@ -877,6 +894,211 @@ impl<'a> Emitter<'a> {
self.body.push_str(&out);
}
/// Iter 18e: emit `drop_<m>_<T>` for a `(drop-iterative)` type.
/// Replaces the recursive cascade in [`Self::emit_drop_fn_for_type`]
/// with an iterative-with-explicit-worklist body so cells of
/// arbitrary chain depth can free without consuming proportional
/// C stack.
///
/// IR shape:
/// ```text
/// define void @drop_<m>_<T>(ptr %p) {
/// entry:
/// %is_null = icmp eq ptr %p, null
/// br i1 %is_null, label %ret, label %init_wl
/// init_wl:
/// %wl = call ptr @ailang_drop_worklist_new()
/// call void @ailang_drop_worklist_push(ptr %wl, ptr %p)
/// br label %loop_head
/// loop_head:
/// %cur = call ptr @ailang_drop_worklist_pop(ptr %wl)
/// %done = icmp eq ptr %cur, null
/// br i1 %done, label %finish, label %dispatch
/// dispatch:
/// %tag = load i64, ptr %cur, align 8
/// switch i64 %tag, label %dflt [
/// i64 0, label %arm_0
/// ...
/// ]
/// arm_i:
/// for each pointer-typed field f_j of ctor i:
/// %addr = gep %cur, 8 + 8*j
/// %v = load ptr, ptr %addr
/// if field type is T (same as the type being dropped):
/// call void @ailang_drop_worklist_push(ptr %wl, ptr %v)
/// else:
/// call void @drop_<owner>_<F>(ptr %v) ; or @ailang_rc_dec
/// call void @ailang_rc_dec(ptr %cur)
/// br label %loop_head
/// dflt:
/// unreachable
/// finish:
/// call void @ailang_drop_worklist_free(ptr %wl)
/// br label %ret
/// ret:
/// ret void
/// }
/// ```
///
/// Mono-typed worklist. Every pointer pushed onto `%wl` is a `T`
/// (the type being dropped). For a field whose type is `T` itself
/// → push (continues the iterative cascade). For any other ADT
/// field type `T'` → call `drop_<m'>_<T'>` directly: if `T'` is
/// also `(drop-iterative)`, that fn allocates its own worklist
/// instance (no nesting); if `T'` is non-iterative, it recurses
/// stack-wise (depth bounded by the number of *distinct* nested
/// ADTs reachable from `T`, which is small in practice).
///
/// This interpretation of the assignment's "should also use the
/// worklist" clause was chosen because a heterogeneously-typed
/// worklist would require storing a (ptr, drop-handler) tuple per
/// entry plus a vtable dispatch on pop — significant complexity
/// for the case where two distinct ADTs are mutually recursive
/// AND both are drop-iterative AND the chain is millions deep.
/// That triple-conjunct is not on the 18-arc's critical path; if
/// it surfaces in practice, a follow-up iter can extend the
/// worklist entry shape. The mono-typed version captures the
/// stack-overflow-on-long-self-chains problem fully.
fn emit_iterative_drop_fn_for_type(&mut self, td: &TypeDef) {
let m = self.module_name;
let tname = &td.name;
let mut out = String::new();
out.push_str(&format!("define void @drop_{m}_{tname}(ptr %p) {{\n"));
out.push_str("entry:\n");
// Null guard — symmetric with the recursive variant. A null
// payload skips worklist allocation entirely.
out.push_str(" %is_null = icmp eq ptr %p, null\n");
out.push_str(" br i1 %is_null, label %ret, label %init_wl\n");
out.push_str("init_wl:\n");
out.push_str(" %wl = call ptr @ailang_drop_worklist_new()\n");
out.push_str(
" call void @ailang_drop_worklist_push(ptr %wl, ptr %p)\n",
);
out.push_str(" br label %loop_head\n");
out.push_str("loop_head:\n");
out.push_str(
" %cur = call ptr @ailang_drop_worklist_pop(ptr %wl)\n",
);
out.push_str(" %done = icmp eq ptr %cur, null\n");
out.push_str(" br i1 %done, label %finish, label %dispatch\n");
out.push_str("dispatch:\n");
out.push_str(" %tag = load i64, ptr %cur, align 8\n");
let n_ctors = td.ctors.len();
out.push_str(" switch i64 %tag, label %dflt [\n");
for (i, _) in td.ctors.iter().enumerate() {
out.push_str(&format!(" i64 {i}, label %arm_{i}\n"));
}
out.push_str(" ]\n");
// Per-ctor arms. For each pointer-typed field decide push vs
// direct call based on whether the field's type is the same
// as the type being dropped.
let mut local = 0u64;
for (i, ctor) in td.ctors.iter().enumerate() {
out.push_str(&format!("arm_{i}:\n"));
for (j, fty) in ctor.fields.iter().enumerate() {
let lty = llvm_type(fty).unwrap_or_else(|_| "ptr".into());
if lty != "ptr" {
continue;
}
let off = 8 + (j as i64) * 8;
let addr_id = local;
local += 1;
let val_id = local;
local += 1;
out.push_str(&format!(
" %a{addr_id} = getelementptr inbounds i8, ptr %cur, i64 {off}\n"
));
out.push_str(&format!(
" %v{val_id} = load ptr, ptr %a{addr_id}, align 8\n"
));
if self.field_is_same_type(fty, &td.name) {
// Same-type field: push onto the worklist —
// continues the iterative cascade. Null-guarding
// is handled inside `ailang_drop_worklist_push`
// itself (skips null payloads).
out.push_str(&format!(
" call void @ailang_drop_worklist_push(ptr %wl, ptr %v{val_id})\n"
));
} else {
// Different-type field: dispatch to that type's
// own drop fn (which itself decides recursive vs.
// iterative). `field_drop_call` resolves the
// symbol; its null-guard semantics are the same
// as the recursive variant.
let drop_call = self.field_drop_call(fty);
out.push_str(&format!(
" call void @{drop_call}(ptr %v{val_id})\n"
));
}
}
// Dec the outer cell. Worklist holds no other reference
// to this pointer (push happened exactly once on the
// parent's cascade, and pop just removed that entry), so
// the cell's refcount drops by exactly one here. Children
// pushed above keep their own refcounts pending until
// their loop iteration.
out.push_str(" call void @ailang_rc_dec(ptr %cur)\n");
out.push_str(" br label %loop_head\n");
}
// Default arm: unreachable when the typechecker has accepted
// the input. Same shape as the recursive variant.
out.push_str("dflt:\n");
if n_ctors == 0 {
out.push_str(" br label %finish\n");
} else {
out.push_str(" unreachable\n");
}
out.push_str("finish:\n");
out.push_str(" call void @ailang_drop_worklist_free(ptr %wl)\n");
out.push_str(" br label %ret\n");
out.push_str("ret:\n");
out.push_str(" ret void\n");
out.push_str("}\n\n");
self.body.push_str(&out);
}
/// Iter 18e helper: is `fty` the same ADT as `td_name` in the
/// current module? Used by the iterative-drop body to decide
/// "push to worklist" (same type) vs. "call its drop fn directly"
/// (different type).
///
/// Returns `true` only when the field type is a `Type::Con`
/// referencing `td_name` AND the reference resolves to the
/// current module (bare or qualified-but-self). Qualified names
/// pointing at *other* modules are different types — even when
/// they spell the same suffix. Type-vars and fn-types are never
/// the same as the ADT being dropped (parametric self-recursion
/// could bind a var to T, but the bound is invisible at codegen
/// since we don't monomorphise drop fns).
fn field_is_same_type(&self, fty: &Type, td_name: &str) -> bool {
match fty {
Type::Con { name, .. } => {
if name.matches('.').count() == 1 {
let (prefix, suffix) = name.split_once('.').expect("checked");
if suffix != td_name {
return false;
}
// Resolve the prefix; same-module iff the resolved
// target equals self.module_name.
let target = self
.import_map
.get(prefix)
.map(|s| s.as_str())
.unwrap_or(prefix);
target == self.module_name
} else {
name == td_name
}
}
_ => false,
}
}
/// Iter 18c.4: pick the drop-fn symbol to call for a single
/// pointer-typed field. Routes ADT fields to their own
/// `drop_<owner>_<T>` symbol so the recursion cascades through
@@ -4577,6 +4799,7 @@ mod tests {
fields: vec![],
}],
doc: None,
drop_iterative: false,
}),
Def::Fn(FnDef {
name: "main".into(),
@@ -4735,6 +4958,7 @@ mod tests {
},
],
doc: None,
drop_iterative: false,
}),
Def::Fn(FnDef {
name: "main".into(),