Files
AILang/runtime/rc.c
T
Brummel ce6ab8ee44 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.
2026-05-08 12:53:09 +02:00

234 lines
9.5 KiB
C

/* AILang reference-counting runtime — Iter 18b.
*
* This is the allocator + counter primitives for `ail build --memory=rc`.
* It establishes the memory layout (8-byte refcount header preceding
* every allocation) and the three runtime entry points the codegen will
* eventually call: ailang_rc_alloc / ailang_rc_inc / ailang_rc_dec.
*
* Iter 18b deliberately stops at the *layout* and the *alloc*. The
* codegen routes `Term::Ctor` / `Term::Lam` env / closure-pair sites
* through `ailang_rc_alloc` instead of `GC_malloc` / `bump_malloc`, but
* does NOT yet emit `inc` or `dec` calls anywhere. Programs running
* under `--memory=rc` therefore leak every allocation — the same
* behaviour as the pre-Boehm era. This is intentional: the next iter
* (18c) ships uniqueness inference and the codegen pass that emits
* inc/dec. 18b is purely about plumbing the allocator and validating
* that compiled programs still produce correct output under the new
* allocator.
*
* Layout:
*
* high address ┐
* │ payload (size bytes, 8-byte aligned)
* ┤ ← returned pointer (`p`)
* │ uint64_t refcount ← header (8 bytes)
* low address ┘ ← ailang_rc_alloc's internal allocation
*
* The returned pointer points to the *payload*. The header is at
* `p - 8`. Codegen treats the returned pointer exactly like a
* `GC_malloc`-returned pointer; it stores the ADT tag at offset 0,
* fields from offset 8, env-cells from offset 0 in lambda envs, etc.
*
* Single-threaded: counter ops are non-atomic. AILang has no
* concurrency primitives yet; when it acquires them, atomic-vs-non-
* atomic becomes a separate decision per allocation kind (see
* Decision 10's "Does not commit to atomic refcounts" clause).
*/
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Header lives in the 8 bytes preceding every payload. */
typedef uint64_t ailang_rc_header_t;
#define HEADER_SIZE ((size_t)sizeof(ailang_rc_header_t))
static inline ailang_rc_header_t *header_of(void *payload) {
return (ailang_rc_header_t *)((uint8_t *)payload - HEADER_SIZE);
}
/* Allocate `size` bytes of payload, prefixed by an 8-byte refcount
* header initialised to 1. Returns a pointer to the payload.
*
* Aborts on out-of-memory; AILang has no exception machinery yet, and
* Boehm's behaviour on OOM is also "abort", so this matches.
*
* Zero-initialises the payload to match `GC_malloc`'s contract — codegen
* may rely on uninitialised fields reading as zero in some paths. */
void *ailang_rc_alloc(size_t size) {
void *block = malloc(HEADER_SIZE + size);
if (block == NULL) {
fprintf(stderr,
"ailang_rc_alloc: out of memory (requested payload %zu bytes)\n",
size);
abort();
}
ailang_rc_header_t *hdr = (ailang_rc_header_t *)block;
*hdr = 1;
void *payload = (uint8_t *)block + HEADER_SIZE;
memset(payload, 0, size);
return payload;
}
/* Refcount += 1. No-op on null (codegen never asks for inc on a known-
* null pointer, but defensive — top-level fn-value pointers may be
* null-env closure pairs in static memory which must not be incremented). */
void ailang_rc_inc(void *payload) {
if (payload == NULL) {
return;
}
/* Heuristic for "static, do not touch": the static closure-pair env
* pointers (Iter 8b) live in the LLVM data segment, not in heap
* memory we allocated. We cannot trivially distinguish them at
* runtime without a flag bit; for Iter 18b, we accept that inc on
* static memory is undefined behaviour. Iter 18c's codegen will
* elide inc/dec for known-static pointers, so this path will not
* be reached for them in practice. */
ailang_rc_header_t *hdr = header_of(payload);
*hdr += 1;
}
/* Refcount -= 1. If it reaches zero, frees the underlying block.
*
* Iter 18b deliberately does NOT recursively dec child references.
* That requires per-type traversal info (which fields are pointer-
* typed, which are unboxed), which is added in Iter 18c when the
* codegen learns to emit per-ctor `dec` cascades. For now, free-on-
* zero just frees the box; any boxed children leak.
*
* Iter 18b never emits `dec` calls from codegen, so this fn is
* effectively dead code in 18b. It exists so the runtime ABI is
* complete and 18c can wire codegen up against a stable surface. */
void ailang_rc_dec(void *payload) {
if (payload == NULL) {
return;
}
ailang_rc_header_t *hdr = header_of(payload);
if (*hdr == 0) {
fprintf(stderr,
"ailang_rc_dec: refcount underflow at %p (already zero)\n",
payload);
abort();
}
*hdr -= 1;
if (*hdr == 0) {
free(hdr);
}
}
/* ---------------------------------------------------------------------------
* Iter 18e: drop worklist.
*
* Backs the `(drop-iterative)` data attribute. When a type is annotated
* `(drop-iterative)`, codegen emits `drop_<m>_<T>` with an iterative-with-
* worklist body in place of the recursive cascade. The worklist is a
* heap-allocated stretchy buffer of `void*` pointers — one entry per
* not-yet-processed cell. Each entry is mono-typed to T (the annotated
* ADT being dropped); fields of T whose type is `T` itself are pushed,
* fields whose type is a different ADT call that ADT's drop fn directly.
* (See `Emitter::emit_iterative_drop_fn_for_type` in the codegen for the
* IR shape and the same-type / different-type dispatch.)
*
* Strategy: heap-allocated buffer, doubled on overflow. We chose this
* over a stack-allocated small buffer (overcomplicates the IR seam — the
* codegen body would need to track "which buffer is live") and over Lean
* 4's "thread the worklist through one of the cell's own pointer slots"
* technique (requires the codegen to know which slot of each ctor is
* "free to repurpose" — non-trivial since AILang ctors are heterogeneous
* and slot 0 is always the tag). The runtime-helper approach keeps the
* IR-level body of `drop_<m>_<T>` small: three calls (new / push / pop /
* free) drive the loop.
*
* Precedent: Lean 4's `lean_dec_ref_cold` and Roc's iterative-free path
* both use a worklist to break tail recursion in their drop cascades.
* Lean threads the worklist through field slots (the "in-place" variant);
* we use a separate heap buffer because AILang's ctor layout makes slot
* repurposing fragile. The semantic invariant matches: every cell whose
* refcount reaches zero is dec'd exactly once, regardless of cascade
* depth, without consuming proportional C stack space.
*
* Single-threaded; non-atomic. Same scope as the rest of `runtime/rc.c`.
* --------------------------------------------------------------------------- */
typedef struct {
void **data; /* heap buffer of `cap` pointers; null once freed */
size_t len; /* number of live entries (always <= cap) */
size_t cap; /* current capacity in slots */
} ailang_drop_worklist_t;
/* Initial capacity. 16 slots * 8 bytes = 128 bytes — small enough that
* very-shallow drops don't waste memory, large enough that 16-deep
* cascades (very common) never realloc. Doubled on overflow. */
#define DROP_WORKLIST_INIT_CAP ((size_t)16)
void *ailang_drop_worklist_new(void) {
ailang_drop_worklist_t *wl = malloc(sizeof(ailang_drop_worklist_t));
if (wl == NULL) {
fprintf(stderr,
"ailang_drop_worklist_new: out of memory (header)\n");
abort();
}
wl->data = malloc(DROP_WORKLIST_INIT_CAP * sizeof(void *));
if (wl->data == NULL) {
fprintf(stderr,
"ailang_drop_worklist_new: out of memory (initial buffer)\n");
abort();
}
wl->len = 0;
wl->cap = DROP_WORKLIST_INIT_CAP;
return (void *)wl;
}
/* Push `payload` onto the worklist. Skips null payloads — pushed nulls
* would dispatch on `load i64, ptr null` at pop time and segfault, so
* we filter here. The check is symmetric with `ailang_rc_dec`'s null
* guard (a null payload is a no-op everywhere in the rc runtime). */
void ailang_drop_worklist_push(void *wl_opaque, void *payload) {
if (payload == NULL) {
return;
}
ailang_drop_worklist_t *wl = (ailang_drop_worklist_t *)wl_opaque;
if (wl->len == wl->cap) {
size_t new_cap = wl->cap * 2;
void **new_data = realloc(wl->data, new_cap * sizeof(void *));
if (new_data == NULL) {
fprintf(stderr,
"ailang_drop_worklist_push: out of memory (grow to %zu slots)\n",
new_cap);
abort();
}
wl->data = new_data;
wl->cap = new_cap;
}
wl->data[wl->len] = payload;
wl->len += 1;
}
/* Pop one payload from the worklist. Returns NULL when the worklist is
* empty. Since `push` filters nulls, a returned null is unambiguous and
* can be used by the IR body as the loop-exit sentinel. */
void *ailang_drop_worklist_pop(void *wl_opaque) {
ailang_drop_worklist_t *wl = (ailang_drop_worklist_t *)wl_opaque;
if (wl->len == 0) {
return NULL;
}
wl->len -= 1;
return wl->data[wl->len];
}
/* Free the worklist itself. Called once at the end of the iterative
* drop loop. Does NOT free any payloads still in the buffer — the IR
* body must drain the buffer first via repeated `pop` calls before
* calling free. */
void ailang_drop_worklist_free(void *wl_opaque) {
if (wl_opaque == NULL) {
return;
}
ailang_drop_worklist_t *wl = (ailang_drop_worklist_t *)wl_opaque;
free(wl->data);
free(wl);
}