lumbda/c/gc.c
russell@unturf.com b841b30bc4
c: precise GC tracing for NaN-boxed Values
Boehm's conservative pointer scan cannot recognize lumbda's Value
layout — heap pointers live in the low 48 bits with QNAN + tag bits
in the upper mantissa, so a raw word never looks like a heap address.
Until now main.c neutralized this with GC_disable(): every allocation
leaked, OOMing any long-running workload.

Add precise tracing via a custom Boehm kind:

- New c/gc.c: mark proc walks 8-byte words in mixed mode — when the
  QNAN bits are set with a pointer-bearing tag (0/2/4/5/6) extract
  the low-48 pointer; otherwise fall through to raw-pointer
  validation. GC_set_push_other_roots callback decodes NaN-boxed
  Values on the C stack via setjmp anchor + scan up to the stack
  base captured at process start.

- Allocations holding Values (Pair, Env bindings, ValueStack data,
  ULVector data, HTEntry, Proc params + body, FullCont stack,
  CodeObj instrs, SymbolEntry) route through lumbda_value_malloc.
  Pure-byte sites (bignum limbs, char buffers, source files) stay
  on regular GC_MALLOC.

- main.c / test.c / bench.c capture stack-base then drop GC_disable.

types.c also zeros popped slots on the value stack so stale pointers
do not survive a vs_pop and pin freed objects — independent
correctness fix that pays off once GC actually runs.

Build: USE_GC=1 (default when /usr/include/gc.h exists).

Tests with GC enabled:
- 88/88 c-test
- 4/4 regression-named-let-leak (test that motivated GC_disable)
- 205/205 functional (Python + C)
- zoe-favorites all tiers (Python + C + asm + asm-full)

alloc-test 1M cons drop-loop:
- Before: 0.60s wall, 156 MB RSS, leaks every cell
- After:  0.37s wall,   4 MB RSS, ~1500 GC cycles each freeing ~370 KB
2026-06-07 17:18:45 -04:00

170 lines
7.3 KiB
C

/*
* gc.c — precise tracing for NaN-boxed Values under Boehm GC.
*
* Boehm's conservative scan treats every machine word as a maybe-pointer:
* if its bit pattern looks like a heap address, the target stays alive.
* Lumbda's Values are NaN-boxed — pointers live in the low 48 bits with
* tag bits in the upper mantissa, so the raw word never looks like a
* pointer to Boehm. Live Pairs, Strings, Procs, etc. get reclaimed mid
* iteration unless we tell Boehm how to walk our Value-bearing buffers.
*
* We register a custom mark kind. Any allocation that carries Values goes
* through lumbda_value_malloc — Boehm tracks the kind on the block & calls
* our mark proc when it scans the block. Our proc walks 8-byte words,
* decodes the tag, & pushes the underlying pointer for each Value whose
* tag identifies a pointer-bearing type.
*
* Pure-byte allocations (strings, bignum limbs, symbol names) stay on the
* normal GC_MALLOC path — Boehm scans them as plain pointers correctly.
*/
#ifdef USE_BOEHM_GC
#include "lumbda.h"
#include <gc.h>
#include <gc/gc_mark.h>
int lumbda_value_kind = -1;
static void *lumbda_value_free_list = NULL;
/* Stack root scanning: Boehm's conservative scan over the C stack sees
* raw 8-byte words & accepts those that fall inside heap bounds. A
* NaN-boxed Value living in a C local has its high bits set (QNAN
* pattern) so the raw word does NOT look like a valid heap address &
* Boehm skips it — the underlying object dies even though the C frame
* still holds the Value. We add a second pass via push_other_roots:
* walk the same stack range Boehm already tracks, decode any NaN-boxed
* Value & push its low-48 payload as a root. main() captures the stack
* base at process start; the current SP comes from setjmp inside the
* callback. */
static void *g_stack_base = NULL;
static GC_push_other_roots_proc g_prev_push_other_roots = NULL;
static void lumbda_push_other_roots(void) {
if (g_prev_push_other_roots) g_prev_push_other_roots();
if (!g_stack_base) return;
/* setjmp serves only to anchor an address (&snap) inside our own
* frame — the actual stack walk runs from there up to g_stack_base,
* so it covers every C frame above us at the moment GC fired. Lisp
* Values held in caller-saved regs are spilled to memory by callers
* around any GC-triggering call (cons → make_pair → ul_malloc_values),
* so the stack walk catches them too. */
jmp_buf snap;
setjmp(snap);
Value *sp = (Value *)&snap;
Value *base = (Value *)g_stack_base;
if (sp > base) { Value *t = sp; sp = base; base = t; }
for (Value *p = sp; p < base; p++) {
Value v = *p;
if ((v & QNAN) != QNAN) continue;
uint64_t tag = (v >> TAG_SHIFT) & 7ULL;
if (tag == TAG_INT || tag == TAG_SPECIAL) continue;
void *ptr = (void *)(uintptr_t)(v & PAYLOAD_MASK);
if (!ptr) continue;
/* GC_push_all_eager marks the location immediately rather than
* deferring to the regular mark stack — safe to point at a
* stack-local since the mark happens before we return. */
GC_push_all_eager(&ptr, (char *)&ptr + sizeof(void *));
}
}
void lumbda_gc_set_stack_base(void *base) { g_stack_base = base; }
/*
* Mark proc: scan a block of memory containing a mix of NaN-boxed Values
* & plain pointer fields (struct headers, embedded raw pointers).
*
* `addr` points at the head of the block. `GC_size(addr)` gives the block
* size in bytes. We treat the block as an array of 8-byte words & decode
* each one in two passes:
*
* 1. NaN-boxed pointer Value: upper QNAN bits set, tag ∈
* {PTR(0), SYM(2), BUILTIN(4), RATIONAL(5), BIGNUM(6)}. Extract the
* low 48 bits as the pointer & push that.
* 2. Plain raw pointer: anything else. Push the word as-is —
* GC_MARK_AND_PUSH validates against heap bounds, so ints, enums,
* fixnums, etc. fall outside & are silently skipped.
*
* Heap addresses on Linux user-space sit below 2^47, so bits 48..63 of a
* plain pointer are zero — `(ptr & QNAN) == 0 != QNAN`, never confused
* with a NaN-boxed Value. Inversely, a NaN-boxed pointer has bits 48..62
* set & its low 48 hold the real address; treating the raw word as a
* pointer in pass 2 would walk into nowhere (heap base + tag bits = bad
* address that fails the heap-bounds check anyway).
*
* This makes the precise kind safe for ANY struct (header + Values +
* raw pointers) — strictly a superset of what conservative NORMAL kind
* would catch, plus precise NaN-box decoding.
*/
struct GC_ms_entry *
mark_lumbda_value_block(GC_word *addr,
struct GC_ms_entry *mark_stack_ptr,
struct GC_ms_entry *mark_stack_limit,
GC_word env) {
(void)env;
size_t bytes = GC_size((const void *)addr);
size_t nwords = bytes / sizeof(Value);
Value *p = (Value *)addr;
for (size_t i = 0; i < nwords; i++) {
Value v = p[i];
if ((v & QNAN) == QNAN) {
uint64_t tag = (v >> TAG_SHIFT) & 7ULL;
if (tag == TAG_INT || tag == TAG_SPECIAL) continue;
/* NaN-boxed pointer-bearing tag — decode low 48 bits. */
void *ptr = (void *)(uintptr_t)(v & PAYLOAD_MASK);
if (!ptr) continue;
mark_stack_ptr = GC_MARK_AND_PUSH(ptr,
mark_stack_ptr,
mark_stack_limit,
(void **)&p[i]);
} else {
/* Not NaN-boxed: treat as raw pointer (or non-pointer int that
* the heap-bounds check inside GC_MARK_AND_PUSH will reject). */
mark_stack_ptr = GC_MARK_AND_PUSH((void *)(uintptr_t)v,
mark_stack_ptr,
mark_stack_limit,
(void **)&p[i]);
}
}
return mark_stack_ptr;
}
void lumbda_gc_init(void) {
if (lumbda_value_kind != -1) return;
int proc_idx = GC_new_proc(mark_lumbda_value_block);
/* GC_new_kind(free_list, mark_descriptor, add_size_to_descriptor,
* clear_new_objects)
* - mark_descriptor: GC_MAKE_PROC(proc_idx, 0) — call our proc.
* - add_size_to_descriptor: 0 — DS_PROC descriptors do not survive
* the bdwgc per-object `descr += sz` adjustment; bytes would
* overflow into proc_idx bits & dispatch through a NULL proc
* slot. The proc derives size via GC_size(addr) instead.
* - clear_new_objects: 1 — zero-init so we never decode garbage as
* a stale pointer before the caller writes the first Value. */
lumbda_value_kind = GC_new_kind(&lumbda_value_free_list,
GC_MAKE_PROC(proc_idx, 0),
0, 1);
g_prev_push_other_roots = GC_get_push_other_roots();
GC_set_push_other_roots(lumbda_push_other_roots);
}
void *lumbda_value_malloc(size_t sz) {
if (lumbda_value_kind == -1) lumbda_gc_init();
return GC_generic_malloc(sz, lumbda_value_kind);
}
#else /* !USE_BOEHM_GC */
#include "lumbda.h"
int lumbda_value_kind = -1;
void lumbda_gc_init(void) { /* no-op */ }
void lumbda_gc_set_stack_base(void *base) { (void)base; }
void *lumbda_value_malloc(size_t sz) {
return malloc(sz);
}
#endif