Iter 14f: Boehm conservative GC

Decision 9 ships. Through Iter 14e every ADT box, lambda env, and
closure pair was leaked. This iter substitutes GC_malloc for malloc
in all four IR allocation sites and links -lgc. No language change,
no AST change, no schema change.

Diff: 5 files modified, ~30 LOC net.
- codegen/lib.rs: 4 substitutions @malloc -> @GC_malloc.
- ail/main.rs: .arg("-lgc") in the clang invocation.
- 5 IR snapshot files: mechanical s/@malloc/@GC_malloc/, 9
  occurrences. IR is bit-identical to pre-14f modulo this
  substitution — exactly Decision 9's promise.
- e2e.rs: new test gc_handles_recursive_list_construction.
- examples/gc_stress.{ailx,ail.json}: new fixture, builds a 50-
  element list via recursive Cons, sums it (1275).

Hash invariance verified: every existing fixture def hash
unchanged (codegen and link line are downstream of canonical
bytes; AST didn't move).

Tests 79 -> 80, all green. Existing 79 byte-identical stdout.
gc_stress -> 1275. list_map_poly -> 2/3/4 unchanged. sort
sorted-list unchanged. cargo doc 0 warnings.

GC notes (pertinent to future work):
- GC_INIT() not needed on Arch libgc 1.5.6 (auto-init via
  __attribute__((constructor))).
- No conservative-scan over-retention observed.
- -lgc alone sufficient for link (pthread/dl transitive).

Pattern-shape note from gc_stress fixture writing: the post-14d
"if-then-else" replacement is `(match (app == n 0) (case
(pat-lit true) ...) (case (pat-wild) ...))`. Three lines for
what `if` used to do in one, but uniform with the language.
Worth flagging for the stdlib brief.

Language is feature-complete enough for stdlib. The three
blockers identified at the 14b boundary (redundancy 14d, tail
calls 14e, GC 14f) are all done. Plan 15a: first stdlib module
std_list.ailx with length/append/reverse/map/filter/fold_left/
fold_right/head/tail/is_empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 17:25:07 +02:00
parent d64031c234
commit ba516b8b39
12 changed files with 240 additions and 14 deletions
+4
View File
@@ -1513,6 +1513,10 @@ fn build_to(path: &Path, out: Option<PathBuf>, opt: &str) -> Result<PathBuf> {
.arg("-o")
.arg(&out_bin)
.arg(&ll_path)
// Boehm conservative GC (Decision 9 / Iter 14f). The lowered IR
// calls @GC_malloc; libgc supplies it. Pthread/dl are pulled in
// transitively via libgc.so on Linux, so a single -lgc suffices.
.arg("-lgc")
.status()
.context("running clang")?;
if !status.success() {
+19
View File
@@ -107,6 +107,25 @@ fn list_map_poly_inc_then_prints() {
assert_eq!(lines, vec!["2", "3", "4"]);
}
/// Iter 14f: stress the Boehm conservative GC integration end-to-end.
/// `build 50` performs 50 `Cons` allocations via `GC_malloc`; `sum_list`
/// walks the resulting `List Int` and prints the sum (1275 = 50*51/2).
/// If GC is wired wrongly — missing `-lgc`, missing
/// `declare ptr @GC_malloc(i64)`, libgc misbehaving on this build host —
/// the test fails at link or run time. The match-on-Bool against
/// `(== n 0)` shape is used because `(pat-lit 0)` against an `Int`
/// scrutinee with a wildcard fallback is not currently in the grammar.
#[test]
fn gc_handles_recursive_list_construction() {
let stdout = build_and_run("gc_stress.ail.json");
assert_eq!(
stdout.trim(),
"1275",
"expected sum [50,49,..,1] = 1275; \
GC_malloc / -lgc integration may be broken"
);
}
/// Iter 14e: `tail: true` annotation on `print_list`'s recursive
/// call must reach LLVM as a `musttail call`. Asserted by emitting IR
/// for list_map_poly and grepping for the exact instruction. This is
+1 -1
View File
@@ -6,7 +6,7 @@ target triple = "<NORMALIZED>"
declare i32 @printf(ptr, ...)
declare i32 @puts(ptr)
declare ptr @malloc(i64)
declare ptr @GC_malloc(i64)
@ail_hello_main_clos = private unnamed_addr constant { ptr, ptr } { ptr @ail_hello_main_adapter, ptr null }
define i8 @ail_hello_main() {
+5 -5
View File
@@ -6,7 +6,7 @@ target triple = "<NORMALIZED>"
declare i32 @printf(ptr, ...)
declare i32 @puts(ptr)
declare ptr @malloc(i64)
declare ptr @GC_malloc(i64)
@ail_list_sum_list_clos = private unnamed_addr constant { ptr, ptr } { ptr @ail_list_sum_list_adapter, ptr null }
@ail_list_main_clos = private unnamed_addr constant { ptr, ptr } { ptr @ail_list_main_adapter, ptr null }
@@ -42,21 +42,21 @@ entry:
define i8 @ail_list_main() {
entry:
%v1 = call ptr @malloc(i64 8)
%v1 = call ptr @GC_malloc(i64 8)
store i64 0, ptr %v1, align 8
%v2 = call ptr @malloc(i64 24)
%v2 = call ptr @GC_malloc(i64 24)
store i64 1, ptr %v2, align 8
%v3 = getelementptr inbounds i8, ptr %v2, i64 8
store i64 12, ptr %v3, align 8
%v4 = getelementptr inbounds i8, ptr %v2, i64 16
store ptr %v1, ptr %v4, align 8
%v5 = call ptr @malloc(i64 24)
%v5 = call ptr @GC_malloc(i64 24)
store i64 1, ptr %v5, align 8
%v6 = getelementptr inbounds i8, ptr %v5, i64 8
store i64 20, ptr %v6, align 8
%v7 = getelementptr inbounds i8, ptr %v5, i64 16
store ptr %v2, ptr %v7, align 8
%v8 = call ptr @malloc(i64 24)
%v8 = call ptr @GC_malloc(i64 24)
store i64 1, ptr %v8, align 8
%v9 = getelementptr inbounds i8, ptr %v8, i64 8
store i64 10, ptr %v9, align 8
+1 -1
View File
@@ -6,7 +6,7 @@ target triple = "<NORMALIZED>"
declare i32 @printf(ptr, ...)
declare i32 @puts(ptr)
declare ptr @malloc(i64)
declare ptr @GC_malloc(i64)
@ail_max3_max_clos = private unnamed_addr constant { ptr, ptr } { ptr @ail_max3_max_adapter, ptr null }
@ail_max3_max3_clos = private unnamed_addr constant { ptr, ptr } { ptr @ail_max3_max3_adapter, ptr null }
+1 -1
View File
@@ -6,7 +6,7 @@ target triple = "<NORMALIZED>"
declare i32 @printf(ptr, ...)
declare i32 @puts(ptr)
declare ptr @malloc(i64)
declare ptr @GC_malloc(i64)
@ail_sum_sum_clos = private unnamed_addr constant { ptr, ptr } { ptr @ail_sum_sum_adapter, ptr null }
@ail_sum_main_clos = private unnamed_addr constant { ptr, ptr } { ptr @ail_sum_main_adapter, ptr null }
+1 -1
View File
@@ -6,7 +6,7 @@ target triple = "<NORMALIZED>"
declare i32 @printf(ptr, ...)
declare i32 @puts(ptr)
declare ptr @malloc(i64)
declare ptr @GC_malloc(i64)
@ail_ws_lib_add_clos = private unnamed_addr constant { ptr, ptr } { ptr @ail_ws_lib_add_adapter, ptr null }
@ail_ws_main_main_clos = private unnamed_addr constant { ptr, ptr } { ptr @ail_ws_main_main_adapter, ptr null }
+5 -5
View File
@@ -291,7 +291,7 @@ pub fn lower_workspace(ws: &Workspace) -> Result<String> {
out.push_str("declare i32 @printf(ptr, ...)\n");
out.push_str("declare i32 @puts(ptr)\n");
out.push_str("declare ptr @malloc(i64)\n\n");
out.push_str("declare ptr @GC_malloc(i64)\n\n");
out.push_str(&header);
out.push_str(&body);
@@ -522,7 +522,7 @@ impl<'a> Emitter<'a> {
Def::Type(_) => {
// No LLVM definition needed: the ADT exists only as a
// logical type. Heap boxes are allocated ad hoc via
// malloc.
// GC_malloc (Boehm conservative collector, Iter 14f).
}
}
}
@@ -935,7 +935,7 @@ impl<'a> Emitter<'a> {
let size_bytes = 8 + (compiled.len() * 8) as i64;
let p = self.fresh_ssa();
self.body.push_str(&format!(
" {p} = call ptr @malloc(i64 {size_bytes})\n"
" {p} = call ptr @GC_malloc(i64 {size_bytes})\n"
));
// Write tag.
self.body.push_str(&format!(
@@ -1787,7 +1787,7 @@ impl<'a> Emitter<'a> {
let env_ssa = if env_size > 0 {
let env = self.fresh_ssa();
self.body
.push_str(&format!(" {env} = call ptr @malloc(i64 {env_size})\n"));
.push_str(&format!(" {env} = call ptr @GC_malloc(i64 {env_size})\n"));
for (i, (_cname, outer_ssa, cty, _c_ail, _sig)) in cap_meta.iter().enumerate() {
let offset = (i * 8) as i64;
let slot = self.fresh_ssa();
@@ -1805,7 +1805,7 @@ impl<'a> Emitter<'a> {
let clos = self.fresh_ssa();
self.body
.push_str(&format!(" {clos} = call ptr @malloc(i64 16)\n"));
.push_str(&format!(" {clos} = call ptr @GC_malloc(i64 16)\n"));
let cs_t = self.fresh_ssa();
self.body.push_str(&format!(
" {cs_t} = getelementptr inbounds {{ ptr, ptr }}, ptr {clos}, i64 0, i32 0\n"
+56
View File
@@ -526,6 +526,62 @@ constructor-blocked recursions in `map`, `sort`, `insert`
remain unmarked — they cannot benefit from `musttail`
without a source-level rewrite.
## Decision 9: memory management — Boehm conservative GC
Through Iter 14e, every ADT box, lambda env, and closure pair was
allocated with bare `malloc` and never freed. That worked for the
17 test fixtures (all small, all short-lived) but is incompatible
with any real workload — a stdlib `fold` over a million-element
list would leak a million boxes. The "Goal" section's "no GC for
the MVP" framing predates the parameterised-ADT pipeline (Iter 13)
and the explicit-recursion expectation (Iter 14e); both make a
collector necessary.
**Choice: Boehm-Demers-Weiser conservative GC.** The simplest
working option:
- Replace `malloc(...)` with `GC_malloc(...)` in every IR site
(currently `lower_ctor`'s ADT box, `lower_lambda`'s env block
and closure pair).
- Replace the IR-level `declare ptr @malloc(i64)` with
`declare ptr @GC_malloc(i64)`.
- Add `-lgc` to the `clang` link command (in
`crates/ail/src/main.rs`'s `Build` / `Run` paths).
- No language-level change. No AST change. No schema change.
Rationale:
- **Mature.** Boehm has been the default conservative GC for
decades. Linux distros ship it as `libgc` / `libgc-dev` /
`gc` (Arch).
- **No language work.** Conservative scan of the C stack handles
AILang's stack frames without LLVM stack-map infrastructure
(which is its own multi-iter design).
- **Single-iter integration.** Lift-and-shift of the four
allocation sites; all existing tests must still pass with
identical output.
Trade-offs accepted:
- **Conservative over-retention.** A user-supplied `Int` field
whose value happens to coincide with a heap address will pin
that allocation. In practice, vanishingly rare for typical
values; survivable.
- **Pause time non-deterministic.** Boehm uses stop-the-world
mark-sweep. For LLM-author-written stdlib code at MVP scale,
pause times are not the bottleneck.
- **Build-time dependency.** `libgc` must be installed on the
build host. Users without it get a link-time error from
clang, not a silent failure.
A future iter may layer a per-fn-arena optimisation on top: when
a fn's return type contains no boxed ADT, ADT boxes allocated
inside that fn cannot escape, so an arena freed at fn return is
sound by construction (per the 14e GC notes). That requires
escape analysis, the corresponding AST/IR plumbing, and is its
own design pass. Boehm-everything is the floor; arena is an
optimisation above it.
## Mangling scheme (Iter 5c)
All AILang functions are mangled to `@ail_<module>_<def>` — even in the
+96
View File
@@ -2002,6 +2002,102 @@ Anything else (records as a primitive, nested patterns, local
recursive let, type classes) can layer on later without forcing
stdlib rewrites.
## Iter 14f — Boehm conservative GC
Decision 9 ships. Through Iter 14e every ADT box, lambda env,
and closure pair was leaked. This iter substitutes
`GC_malloc` for `malloc` in all four IR allocation sites and
links `-lgc`. No language change, no AST change, no schema
change.
**Diff: 5 files, ~30 LOC net.**
- `crates/ailang-codegen/src/lib.rs`: 4× `@malloc` → `@GC_malloc`
(declare line + 3 call sites: `lower_ctor`, `lower_lambda`'s
env, `lower_lambda`'s closure pair).
- `crates/ail/src/main.rs`: `.arg("-lgc")` added to the clang
invocation in `build_to`.
- `crates/ail/tests/snapshots/{hello,sum,list,max3,ws_main}.ll`:
mechanical s/@malloc/@GC_malloc/, 9 occurrences across 5
files. The IR is bit-identical to pre-14f modulo this
substitution — exactly Decision 9's promise.
- `crates/ail/tests/e2e.rs`: new test
`gc_handles_recursive_list_construction` (+19 LOC).
- `examples/gc_stress.{ailx,ail.json}`: new fixture.
**Hash invariance verified.** Every existing fixture's def
hashes are unchanged. The codegen and link line are downstream
of canonical bytes; the AST schema didn't move; nothing on
disk in `examples/*.ail.json` was touched. The new
`gc_stress` module adds 4 new hashes, all unrelated.
**Tests: 80/80 (was 79).** Existing 79 produce byte-identical
stdout — only the allocator changed, semantics unchanged. New:
`gc_handles_recursive_list_construction` builds a List<Int> of
length 50 via recursive `Cons`, sums it (`1275`). Manual smoke:
- `gc_stress.ail.json` → `1275`.
- `list_map_poly` → `2 3 4` (unchanged).
- `sort` → sorted list (unchanged).
`cargo doc --no-deps`: 0 warnings (DESIGN.md item 6 invariant
preserved through nine iters of feature work).
**Pattern shape used in `gc_stress`** (caught a small typechecker
constraint). The first proposed shape `(case (lit-int 0) Nil)`
doesn't parse — `pat-lit` takes the bare literal token, not a
keyword-prefixed form, and `case` requires a pattern. Worked
shape: comparison-and-bool-match, mirroring `sort.ail.json`'s
`<=` arm:
```
(match (app == n 0)
(case (pat-lit true) (term-ctor List Nil))
(case (pat-wild) (term-ctor List Cons n (app build (app - n 1)))))
```
This is the canonical "if-then-else" pattern post-14d. Worth
flagging for the stdlib brief: predicates that need to branch
go through the `==` / `<` / `<=` builtin returning Bool, then
match on that Bool with a wildcard fallback. Three lines for
what `if` used to do in one — but uniform with the rest of the
language, no special case.
**GC integration notes.**
- `GC_INIT()` is **not needed** on this build host (Arch
with `libgc 1.5.6`). libgc auto-inits via
`__attribute__((constructor))`.
- No conservative-scan over-retention symptom observed: every
existing test's stdout byte-identical; behaviour preserved.
- `-lgc` alone is sufficient for the link; pthread/dl come in
transitively from libgc.so's NEEDED entries.
**Language is feature-complete enough for stdlib.** Iters 14d
(redundancy removal), 14e (explicit tail calls), 14f (GC) are
the three blockers identified at the 14b boundary. They are
all done. Anything else (records as primitive, nested patterns,
local rec let, type classes) layers on later without forcing
stdlib rewrite.
**Plan 15a.** First stdlib module: `examples/std/std_list.ailx`.
Combinators: `length`, `append`, `reverse`, `map`, `filter`,
`fold_left`, `fold_right`, `head`, `tail`, `is_empty`. Each
combinator a fresh test vector for the parameterised-ADT +
GC + tail-call combination. Authored in form (A) from day one;
`.ail.json` produced via `ail parse`. Each combinator gets a
dedicated e2e test.
If 15a surfaces compiler bugs (likely — every prior dogfood
iter has, see 14a's monomorphisation bug), debugger handles
them inline. If a compiler limitation surfaces that genuinely
blocks the stdlib (e.g. nested patterns turn out to be needed),
that becomes its own iter before 15a continues.
The architectural pin from Decision 6 governs: stdlib lives
under `examples/std/` as `.ailx` source; tests load the
generated `.ail.json`. `ailang-check` and `ailang-codegen`
remain projection-agnostic.
+1
View File
@@ -0,0 +1 @@
{"defs":[{"ctors":[{"fields":[],"name":"Nil"},{"fields":[{"k":"var","name":"a"},{"args":[{"k":"var","name":"a"}],"k":"con","name":"List"}],"name":"Cons"}],"doc":"Polymorphic singly-linked list (re-declared locally).","kind":"type","name":"List","vars":["a"]},{"body":{"arms":[{"body":{"args":[],"ctor":"Nil","t":"ctor","type":"List"},"pat":{"lit":{"kind":"bool","value":true},"p":"lit"}},{"body":{"args":[{"name":"n","t":"var"},{"args":[{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"-","t":"var"},"t":"app"}],"fn":{"name":"build","t":"var"},"t":"app"}],"ctor":"Cons","t":"ctor","type":"List"},"pat":{"p":"wild"}}],"scrutinee":{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":0},"t":"lit"}],"fn":{"name":"==","t":"var"},"t":"app"},"t":"match"},"doc":"Build [n, n-1, ..., 1] :: List Int via Cons recursion.","kind":"fn","name":"build","params":["n"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"args":[{"k":"con","name":"Int"}],"k":"con","name":"List"}}},{"body":{"arms":[{"body":{"lit":{"kind":"int","value":0},"t":"lit"},"pat":{"ctor":"Nil","fields":[],"p":"ctor"}},{"body":{"args":[{"name":"h","t":"var"},{"args":[{"name":"t","t":"var"}],"fn":{"name":"sum_list","t":"var"},"t":"app"}],"fn":{"name":"+","t":"var"},"t":"app"},"pat":{"ctor":"Cons","fields":[{"name":"h","p":"var"},{"name":"t","p":"var"}],"p":"ctor"}}],"scrutinee":{"name":"xs","t":"var"},"t":"match"},"doc":"Recursively sum a List Int.","kind":"fn","name":"sum_list","params":["xs"],"type":{"effects":[],"k":"fn","params":[{"args":[{"k":"con","name":"Int"}],"k":"con","name":"List"}],"ret":{"k":"con","name":"Int"}}},{"body":{"args":[{"args":[{"args":[{"lit":{"kind":"int","value":50},"t":"lit"}],"fn":{"name":"build","t":"var"},"t":"app"}],"fn":{"name":"sum_list","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"doc":"Build [50..1], sum, print 1275.","kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[],"name":"gc_stress","schema":"ailang/v0"}
+50
View File
@@ -0,0 +1,50 @@
; Iter 14f stress fixture for the Boehm conservative GC integration.
; Builds a List Int of length 50 by recursive Cons construction,
; sums it, prints the sum (1275 = 50*51/2).
;
; Match-on-Int isn't supported as a pattern shape; we drop into a
; match-on-Bool against (== n 0), mirroring the sort fixture's
; (<= y h) idiom. (pat-wild) catches the false case.
(module gc_stress
(data List (vars a)
(doc "Polymorphic singly-linked list (re-declared locally).")
(ctor Nil)
(ctor Cons a (con List a)))
(fn build
(doc "Build [n, n-1, ..., 1] :: List Int via Cons recursion.")
(type
(fn-type
(params (con Int))
(ret (con List (con Int)))))
(params n)
(body
(match (app == n 0)
(case (pat-lit true)
(term-ctor List Nil))
(case _
(term-ctor List Cons
n
(app build (app - n 1)))))))
(fn sum_list
(doc "Recursively sum a List Int.")
(type
(fn-type
(params (con List (con Int)))
(ret (con Int))))
(params xs)
(body
(match xs
(case (pat-ctor Nil) 0)
(case (pat-ctor Cons h t)
(app + h (app sum_list t))))))
(fn main
(doc "Build [50..1], sum, print 1275.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(do io/print_int (app sum_list (app build 50))))))