From ba516b8b39689111948a087d07badb72642725bf Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 7 May 2026 17:25:07 +0200 Subject: [PATCH] Iter 14f: Boehm conservative GC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/ail/src/main.rs | 4 ++ crates/ail/tests/e2e.rs | 19 ++++++ crates/ail/tests/snapshots/hello.ll | 2 +- crates/ail/tests/snapshots/list.ll | 10 +-- crates/ail/tests/snapshots/max3.ll | 2 +- crates/ail/tests/snapshots/sum.ll | 2 +- crates/ail/tests/snapshots/ws_main.ll | 2 +- crates/ailang-codegen/src/lib.rs | 10 +-- docs/DESIGN.md | 56 ++++++++++++++++ docs/JOURNAL.md | 96 +++++++++++++++++++++++++++ examples/gc_stress.ail.json | 1 + examples/gc_stress.ailx | 50 ++++++++++++++ 12 files changed, 240 insertions(+), 14 deletions(-) create mode 100644 examples/gc_stress.ail.json create mode 100644 examples/gc_stress.ailx diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index 59342ef..77703d7 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -1513,6 +1513,10 @@ fn build_to(path: &Path, out: Option, opt: &str) -> Result { .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() { diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index a46e91e..6505b39 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -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 diff --git a/crates/ail/tests/snapshots/hello.ll b/crates/ail/tests/snapshots/hello.ll index 1d3acb4..ae8533b 100644 --- a/crates/ail/tests/snapshots/hello.ll +++ b/crates/ail/tests/snapshots/hello.ll @@ -6,7 +6,7 @@ target triple = "" 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() { diff --git a/crates/ail/tests/snapshots/list.ll b/crates/ail/tests/snapshots/list.ll index 3ea2ca4..d913c15 100644 --- a/crates/ail/tests/snapshots/list.ll +++ b/crates/ail/tests/snapshots/list.ll @@ -6,7 +6,7 @@ target triple = "" 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 diff --git a/crates/ail/tests/snapshots/max3.ll b/crates/ail/tests/snapshots/max3.ll index 2fecd1d..c5755c1 100644 --- a/crates/ail/tests/snapshots/max3.ll +++ b/crates/ail/tests/snapshots/max3.ll @@ -6,7 +6,7 @@ target triple = "" 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 } diff --git a/crates/ail/tests/snapshots/sum.ll b/crates/ail/tests/snapshots/sum.ll index abc386b..1b27760 100644 --- a/crates/ail/tests/snapshots/sum.ll +++ b/crates/ail/tests/snapshots/sum.ll @@ -6,7 +6,7 @@ target triple = "" 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 } diff --git a/crates/ail/tests/snapshots/ws_main.ll b/crates/ail/tests/snapshots/ws_main.ll index a833f37..a93a013 100644 --- a/crates/ail/tests/snapshots/ws_main.ll +++ b/crates/ail/tests/snapshots/ws_main.ll @@ -6,7 +6,7 @@ target triple = "" 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 } diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index 71c76f0..54574fa 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -291,7 +291,7 @@ pub fn lower_workspace(ws: &Workspace) -> Result { 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" diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b7da728..040bcdc 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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__` — even in the diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 4d0628b..f48e9d0 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -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 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. + diff --git a/examples/gc_stress.ail.json b/examples/gc_stress.ail.json new file mode 100644 index 0000000..fbeac57 --- /dev/null +++ b/examples/gc_stress.ail.json @@ -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"} \ No newline at end of file diff --git a/examples/gc_stress.ailx b/examples/gc_stress.ailx new file mode 100644 index 0000000..045c9af --- /dev/null +++ b/examples/gc_stress.ailx @@ -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))))))