diff --git a/wasm/asm/lumbda.wat b/wasm/asm/lumbda.wat index 1609a43..62d26dd 100644 --- a/wasm/asm/lumbda.wat +++ b/wasm/asm/lumbda.wat @@ -63,6 +63,12 @@ (global $rat_tmp_n (mut i32) (i32.const 0)) (global $rat_tmp_d (mut i32) (i32.const 1)) + ;; Garbage collector scratch. $gc_forward_ptr is the next free slot in + ;; to-space; $gc_active flips between 0 and 1 to track which half of + ;; memory currently hosts the live heap. + (global $gc_forward_ptr (mut i32) (i32.const 0)) + (global $gc_delta (mut i32) (i32.const 0)) + ;; Pre-allocated symbol pointers for special forms — filled at init. (global $sym_quote (mut i32) (i32.const 0)) (global $sym_if (mut i32) (i32.const 0)) @@ -93,6 +99,252 @@ (global $FALSE i32 (i32.const 12)) (global $VOID i32 (i32.const 16)) + ;; ─── Garbage collector ───────────────────────────────────────── + ;; Cheney-style copying collector. Runs at end of lumbda_eval (between + ;; top-level forms) when the heap exceeds 60% of available memory. + ;; + ;; Roots: $global_env, $intern_list, plus every special-form symbol + ;; ($sym_*) — those are also reachable via intern_list but forwarding + ;; them explicitly avoids a missed update if intern_list collection + ;; somehow drops one. + ;; + ;; Forwarding marker: an already-copied object has its tag overwritten + ;; with 0xCAFEBABE and the new (to-space) address stored at offset 4. + + ;; Round up a length to the nearest multiple of 4. + (func $round4 (param $n i32) (result i32) + (i32.and (i32.add (local.get $n) (i32.const 3)) (i32.const 0xFFFFFFFC))) + + ;; Size of the heap object at $ptr in bytes (including the tag word). + (func $object_size (param $ptr i32) (result i32) + (local $tag i32) + (local $n i32) + (local.set $tag (i32.load (local.get $ptr))) + (if (i32.eq (local.get $tag) (i32.const 1)) (then (return (i32.const 12)))) ;; pair + (if (i32.eq (local.get $tag) (i32.const 2)) (then + (return (i32.add (i32.const 8) (call $round4 (i32.load offset=4 (local.get $ptr))))))) ;; symbol + (if (i32.eq (local.get $tag) (i32.const 3)) (then (return (i32.const 16)))) ;; closure + (if (i32.eq (local.get $tag) (i32.const 4)) (then (return (i32.const 8)))) ;; primitive + (if (i32.eq (local.get $tag) (i32.const 5)) (then + (return (i32.add (i32.const 8) (call $round4 (i32.load offset=4 (local.get $ptr))))))) ;; string + (if (i32.eq (local.get $tag) (i32.const 6)) (then (return (i32.const 8)))) ;; char + (if (i32.eq (local.get $tag) (i32.const 7)) (then + (return (i32.add (i32.const 8) (i32.mul (i32.load offset=4 (local.get $ptr)) (i32.const 4)))))) ;; vector + (if (i32.eq (local.get $tag) (i32.const 8)) (then (return (i32.const 12)))) ;; hashtable + (if (i32.eq (local.get $tag) (i32.const 9)) (then (return (i32.const 12)))) ;; rational + (if (i32.eq (local.get $tag) (i32.const 10)) (then + (return (i32.add (i32.const 12) (i32.mul (i32.load offset=8 (local.get $ptr)) (i32.const 4)))))) ;; bignum + ;; Forwarded sentinel — caller shouldn't ever ask the size of one, + ;; but if it does we return 8 (tag + forward pointer). + (i32.const 8)) + + ;; Copy a heap object into to-space and leave a forwarding pointer at + ;; the old location. Returns the new (to-space) address. Pass-through + ;; for fixnums, immediates, and NIL/TRUE/FALSE/VOID. + (func $gc_forward (param $v i32) (result i32) + (local $tag i32) + (local $size i32) + (local $new i32) + (local $i i32) + (if (call $is_fixnum (local.get $v)) (then (return (local.get $v)))) + (if (i32.lt_u (local.get $v) (i32.const 32)) (then (return (local.get $v)))) + (local.set $tag (i32.load (local.get $v))) + ;; Already forwarded? + (if (i32.eq (local.get $tag) (i32.const 0xCAFEBABE)) + (then (return (i32.load offset=4 (local.get $v))))) + (local.set $size (call $object_size (local.get $v))) + (local.set $new (global.get $gc_forward_ptr)) + (global.set $gc_forward_ptr (i32.add (local.get $new) (local.get $size))) + (local.set $i (i32.const 0)) + (block $done + (loop $l + (br_if $done (i32.ge_u (local.get $i) (local.get $size))) + (i32.store (i32.add (local.get $new) (local.get $i)) + (i32.load (i32.add (local.get $v) (local.get $i)))) + (local.set $i (i32.add (local.get $i) (i32.const 4))) + (br $l))) + ;; Leave forwarding tombstone. + (i32.store (local.get $v) (i32.const 0xCAFEBABE)) + (i32.store offset=4 (local.get $v) (local.get $new)) + (local.get $new)) + + ;; Walk the pointer fields of an object at $ptr, replacing each with + ;; the forwarded version. Returns ptr + object_size for the loop. + (func $gc_scan_object (param $ptr i32) (result i32) + (local $tag i32) + (local $n i32) + (local $i i32) + (local.set $tag (i32.load (local.get $ptr))) + ;; pair + (if (i32.eq (local.get $tag) (i32.const 1)) + (then + (i32.store offset=4 (local.get $ptr) (call $gc_forward (i32.load offset=4 (local.get $ptr)))) + (i32.store offset=8 (local.get $ptr) (call $gc_forward (i32.load offset=8 (local.get $ptr)))))) + ;; closure: params, body, env + (if (i32.eq (local.get $tag) (i32.const 3)) + (then + (i32.store offset=4 (local.get $ptr) (call $gc_forward (i32.load offset=4 (local.get $ptr)))) + (i32.store offset=8 (local.get $ptr) (call $gc_forward (i32.load offset=8 (local.get $ptr)))) + (i32.store offset=12 (local.get $ptr) (call $gc_forward (i32.load offset=12 (local.get $ptr)))))) + ;; vector + (if (i32.eq (local.get $tag) (i32.const 7)) + (then + (local.set $n (i32.load offset=4 (local.get $ptr))) + (local.set $i (i32.const 0)) + (block $vdone + (loop $vl + (br_if $vdone (i32.ge_u (local.get $i) (local.get $n))) + (i32.store + (i32.add (i32.add (local.get $ptr) (i32.const 8)) (i32.mul (local.get $i) (i32.const 4))) + (call $gc_forward + (i32.load + (i32.add (i32.add (local.get $ptr) (i32.const 8)) + (i32.mul (local.get $i) (i32.const 4)))))) + (local.set $i (i32.add (local.get $i) (i32.const 1))) + (br $vl))))) + ;; hashtable: alist + (if (i32.eq (local.get $tag) (i32.const 8)) + (then + (i32.store offset=8 (local.get $ptr) (call $gc_forward (i32.load offset=8 (local.get $ptr)))))) + (i32.add (local.get $ptr) (call $object_size (local.get $ptr)))) + + ;; Run a full collection. After this, $heap_ptr reflects the live size + ;; only; everything previously allocated but unreachable is reclaimed. + (func $gc_collect + (local $to_space i32) + (local $heap_start i32) + (local $size i32) + (local $scan i32) + (local $i i32) + (local $needed i32) + (local $cur_pages i32) + (local $new_pages i32) + (local.set $heap_start (i32.const 0x30000)) + ;; Pick to-space at the current heap end so it doesn't overlap. + (local.set $to_space (global.get $heap_ptr)) + ;; Make sure to-space has room. Worst case = current live size, + ;; bound below by 1MB so we always have slack. memory.grow is + ;; idempotent up to 4096 pages so this never overshoots. + (local.set $needed + (i32.add (local.get $to_space) + (i32.sub (global.get $heap_ptr) (local.get $heap_start)))) + (local.set $cur_pages (memory.size)) + (if (i32.gt_u (local.get $needed) (i32.mul (local.get $cur_pages) (i32.const 65536))) + (then + (local.set $new_pages + (i32.div_u (i32.add (local.get $needed) (i32.const 65535)) (i32.const 65536))) + (drop (memory.grow (i32.sub (local.get $new_pages) (local.get $cur_pages)))))) + (global.set $gc_forward_ptr (local.get $to_space)) + ;; Forward roots. + (global.set $global_env (call $gc_forward (global.get $global_env))) + (global.set $intern_list (call $gc_forward (global.get $intern_list))) + (global.set $sym_quote (call $gc_forward (global.get $sym_quote))) + (global.set $sym_if (call $gc_forward (global.get $sym_if))) + (global.set $sym_lambda (call $gc_forward (global.get $sym_lambda))) + (global.set $sym_define (call $gc_forward (global.get $sym_define))) + (global.set $sym_begin (call $gc_forward (global.get $sym_begin))) + (global.set $sym_cond (call $gc_forward (global.get $sym_cond))) + (global.set $sym_else (call $gc_forward (global.get $sym_else))) + (global.set $sym_let (call $gc_forward (global.get $sym_let))) + (global.set $sym_and (call $gc_forward (global.get $sym_and))) + (global.set $sym_or (call $gc_forward (global.get $sym_or))) + (global.set $sym_set (call $gc_forward (global.get $sym_set))) + (global.set $sym_letstar (call $gc_forward (global.get $sym_letstar))) + (global.set $sym_letrec (call $gc_forward (global.get $sym_letrec))) + (global.set $sym_when (call $gc_forward (global.get $sym_when))) + (global.set $sym_unless (call $gc_forward (global.get $sym_unless))) + (global.set $sym_case (call $gc_forward (global.get $sym_case))) + (global.set $sym_do (call $gc_forward (global.get $sym_do))) + ;; Scan to-space, forwarding inner pointer fields. + (local.set $scan (local.get $to_space)) + (block $done + (loop $l + (br_if $done (i32.ge_u (local.get $scan) (global.get $gc_forward_ptr))) + (local.set $scan (call $gc_scan_object (local.get $scan))) + (br $l))) + ;; Compact: memmove to-space back to 0x30000 and shift every pointer + ;; field by the same delta. The shift is done in a second scan over + ;; the relocated objects. + (local.set $size (i32.sub (global.get $gc_forward_ptr) (local.get $to_space))) + (global.set $gc_delta (i32.sub (local.get $to_space) (local.get $heap_start))) + (local.set $i (i32.const 0)) + (block $cpdone + (loop $cpl + (br_if $cpdone (i32.ge_u (local.get $i) (local.get $size))) + (i32.store (i32.add (local.get $heap_start) (local.get $i)) + (i32.load (i32.add (local.get $to_space) (local.get $i)))) + (local.set $i (i32.add (local.get $i) (i32.const 4))) + (br $cpl))) + ;; Shift roots. + (global.set $global_env (call $gc_shift (global.get $global_env))) + (global.set $intern_list (call $gc_shift (global.get $intern_list))) + (global.set $sym_quote (call $gc_shift (global.get $sym_quote))) + (global.set $sym_if (call $gc_shift (global.get $sym_if))) + (global.set $sym_lambda (call $gc_shift (global.get $sym_lambda))) + (global.set $sym_define (call $gc_shift (global.get $sym_define))) + (global.set $sym_begin (call $gc_shift (global.get $sym_begin))) + (global.set $sym_cond (call $gc_shift (global.get $sym_cond))) + (global.set $sym_else (call $gc_shift (global.get $sym_else))) + (global.set $sym_let (call $gc_shift (global.get $sym_let))) + (global.set $sym_and (call $gc_shift (global.get $sym_and))) + (global.set $sym_or (call $gc_shift (global.get $sym_or))) + (global.set $sym_set (call $gc_shift (global.get $sym_set))) + (global.set $sym_letstar (call $gc_shift (global.get $sym_letstar))) + (global.set $sym_letrec (call $gc_shift (global.get $sym_letrec))) + (global.set $sym_when (call $gc_shift (global.get $sym_when))) + (global.set $sym_unless (call $gc_shift (global.get $sym_unless))) + (global.set $sym_case (call $gc_shift (global.get $sym_case))) + (global.set $sym_do (call $gc_shift (global.get $sym_do))) + ;; Shift internal pointers in the compacted region. + (local.set $scan (local.get $heap_start)) + (block $sdone + (loop $sl + (br_if $sdone (i32.ge_u (local.get $scan) (i32.add (local.get $heap_start) (local.get $size)))) + (local.set $scan (call $gc_shift_object (local.get $scan))) + (br $sl))) + (global.set $heap_ptr (i32.add (local.get $heap_start) (local.get $size)))) + + ;; Shift a single Value by $gc_delta (pass-through for non-pointers). + (func $gc_shift (param $v i32) (result i32) + (if (call $is_fixnum (local.get $v)) (then (return (local.get $v)))) + (if (i32.lt_u (local.get $v) (i32.const 32)) (then (return (local.get $v)))) + (i32.sub (local.get $v) (global.get $gc_delta))) + + ;; Like $gc_scan_object but applies the gc_shift correction instead. + (func $gc_shift_object (param $ptr i32) (result i32) + (local $tag i32) + (local $n i32) + (local $i i32) + (local.set $tag (i32.load (local.get $ptr))) + (if (i32.eq (local.get $tag) (i32.const 1)) + (then + (i32.store offset=4 (local.get $ptr) (call $gc_shift (i32.load offset=4 (local.get $ptr)))) + (i32.store offset=8 (local.get $ptr) (call $gc_shift (i32.load offset=8 (local.get $ptr)))))) + (if (i32.eq (local.get $tag) (i32.const 3)) + (then + (i32.store offset=4 (local.get $ptr) (call $gc_shift (i32.load offset=4 (local.get $ptr)))) + (i32.store offset=8 (local.get $ptr) (call $gc_shift (i32.load offset=8 (local.get $ptr)))) + (i32.store offset=12 (local.get $ptr) (call $gc_shift (i32.load offset=12 (local.get $ptr)))))) + (if (i32.eq (local.get $tag) (i32.const 7)) + (then + (local.set $n (i32.load offset=4 (local.get $ptr))) + (local.set $i (i32.const 0)) + (block $vdone + (loop $vl + (br_if $vdone (i32.ge_u (local.get $i) (local.get $n))) + (i32.store + (i32.add (i32.add (local.get $ptr) (i32.const 8)) (i32.mul (local.get $i) (i32.const 4))) + (call $gc_shift + (i32.load + (i32.add (i32.add (local.get $ptr) (i32.const 8)) + (i32.mul (local.get $i) (i32.const 4)))))) + (local.set $i (i32.add (local.get $i) (i32.const 1))) + (br $vl))))) + (if (i32.eq (local.get $tag) (i32.const 8)) + (then + (i32.store offset=8 (local.get $ptr) (call $gc_shift (i32.load offset=8 (local.get $ptr)))))) + (i32.add (local.get $ptr) (call $object_size (local.get $ptr)))) + ;; ─── Allocator ───────────────────────────────────────────────── ;; Bump-only. Grows linear memory by 16 pages (1 MB) at a time when ;; heap_ptr nears the limit. No free, no GC — fine for browser demos @@ -3862,7 +4114,20 @@ (i32.const 1))) (i32.const 10)) (then (call $out_char (i32.const 10)))))) - (call $print_value (local.get $val))))) + (call $print_value (local.get $val)))) + ;; Trigger GC when the heap exceeds 60% of available memory. This is + ;; the only safe collection point — the eval call stack has unwound, + ;; so the only roots are the globals the collector knows about. + (if (i32.gt_u + (i32.sub (global.get $heap_ptr) (i32.const 0x30000)) + (i32.div_u (i32.mul (memory.size) (i32.const 65536)) (i32.const 2))) + (then (call $gc_collect)))) + + ;; Manual GC trigger, exposed so JS can force-collect (e.g. the REPL's + ;; "reboot tier" button could call this instead of terminating the + ;; worker for a softer reclaim). + (func $lumbda_gc (export "lumbda_gc") + (call $gc_collect)) (func $lumbda_output_ptr (export "lumbda_output_ptr") (result i32) (i32.const 0x10000)) diff --git a/wasm/c/lumbda_wasm_entry.c b/wasm/c/lumbda_wasm_entry.c index d8c2c34..b4648e2 100644 --- a/wasm/c/lumbda_wasm_entry.c +++ b/wasm/c/lumbda_wasm_entry.c @@ -39,6 +39,12 @@ static const char *MINI_STDLIB = void lumbda_wasm_init(void) { if (g_wasm_env) return; init_symbols(); + /* Auto-compile every define to bytecode so deep recursion uses + * the VM's explicit frame stack (TCO) instead of growing the host + * C stack. Without this, (let loop ((i 0)) ... (loop (+ i 1))) + * blows out the WASM linear-memory stack around N=500. */ + extern bool g_auto_compile; + g_auto_compile = true; g_wasm_env = make_global_env(); /* Load PRELUDE (built into eval/builtins via make_global_env). */ diff --git a/wasm/dist-repl/asm/lumbda-asm.wasm b/wasm/dist-repl/asm/lumbda-asm.wasm index 55c8e9c..f1374d1 100644 Binary files a/wasm/dist-repl/asm/lumbda-asm.wasm and b/wasm/dist-repl/asm/lumbda-asm.wasm differ diff --git a/wasm/dist-repl/c/lumbda-c.wasm b/wasm/dist-repl/c/lumbda-c.wasm index 4f4ad55..01d63e3 100755 Binary files a/wasm/dist-repl/c/lumbda-c.wasm and b/wasm/dist-repl/c/lumbda-c.wasm differ diff --git a/wasm/tests/parity-corpus.mjs b/wasm/tests/parity-corpus.mjs index b6581b7..1979f3c 100644 --- a/wasm/tests/parity-corpus.mjs +++ b/wasm/tests/parity-corpus.mjs @@ -40,6 +40,16 @@ export const CORPUS = [ { tag: "big-int?", src: "(integer? (expt 2 100))", expected: "#t" }, { tag: "big-num?", src: "(number? (expt 2 100))", expected: "#t" }, + // ─── GC / memory pressure (allocate-and-drop) ──────────────────── + // These exercise the asm-wasm copying collector and verify the + // Python + C tier GCs handle the same workload identically. + { tag: "gc-throwaway", expected: "ok", + src: "(let loop ((i 0)) (if (= i 500) (quote ok) (begin (cons i i) (loop (+ i 1)))))" }, + { tag: "gc-retained-length", expected: "500", + src: "(define lst (let loop ((i 0) (acc (quote ()))) (if (= i 500) acc (loop (+ i 1) (cons i acc))))) (length lst)" }, + { tag: "gc-survives-eval", expected: "(1 4 9 16)", + src: "(let loop ((i 0)) (if (= i 200) 0 (begin (cons i i) (loop (+ i 1))))) (map (lambda (x) (* x x)) (list 1 2 3 4))" }, + // ─── division semantics ────────────────────────────────────────── { tag: "quotient-pos", src: "(quotient 17 5)", expected: "3" }, { tag: "quotient-neg", src: "(quotient -17 5)", expected: "-3" }, diff --git a/www/playground/asm/lumbda-asm.wasm b/www/playground/asm/lumbda-asm.wasm index 55c8e9c..f1374d1 100644 Binary files a/www/playground/asm/lumbda-asm.wasm and b/www/playground/asm/lumbda-asm.wasm differ diff --git a/www/playground/c/lumbda-c.wasm b/www/playground/c/lumbda-c.wasm index 4f4ad55..01d63e3 100755 Binary files a/www/playground/c/lumbda-c.wasm and b/www/playground/c/lumbda-c.wasm differ diff --git a/www/repl/asm/lumbda-asm.wasm b/www/repl/asm/lumbda-asm.wasm index 55c8e9c..f1374d1 100644 Binary files a/www/repl/asm/lumbda-asm.wasm and b/www/repl/asm/lumbda-asm.wasm differ diff --git a/www/repl/c/lumbda-c.wasm b/www/repl/c/lumbda-c.wasm index 4f4ad55..01d63e3 100755 Binary files a/www/repl/c/lumbda-c.wasm and b/www/repl/c/lumbda-c.wasm differ