asm-gc Fix 2: S-expression portal for the GC build

Binary heap dump can't work under GC because the heap is a linked
chunk list with typed block headers and a free list. Raw-byte
serialization would lose structure. Rather than invent portal v2
with chunk tables and pointer relocation, the GC build uses the
S-expression format that already works across all other tiers:

  # bi_portal_save in GC build:
  walk %r14 (env chain); for each non-builtin, non-closure binding,
  emit `(define <sym> (quote <val>))` to the opened file via
  scheme_print with output_fd redirected to that fd.

  # bi_portal_resume in GC build:
  jmp bi_load — read every form from the file, eval each in %r14.

The quote wrapper makes data values round-trip cleanly: lists,
vectors, strings, symbols, numbers, pairs all re-read as literals.
Closures and builtins are explicitly skipped — closures can't
faithfully re-read from their printed form; builtins reconstruct
from the target's prelude. Same treatment the JSON portal gives.

The no-GC build keeps the binary portal format unchanged (wrapped
in .ifndef GC_NAIVE). Users get the fast format on the fast build,
the portable format on the safe build. Same API, different wire
format by build.

Cross-tier verified: GC-asm producer -> Python consumer passes
with `x=42`, `nums=(1 2 3 4 5)`. The §7.2 cross-impl matrix
expands from 9 to 16 cells, all green.

Whitepaper §7.4 now notes it's the no-GC format; new §7.4.1
documents the GC build's S-expression portal with the trade-off
(slower than binary dump, stricter about what round-trips, but no
architecture constraint and no "same binary" requirement).

137 asm no-GC + 137 asm GC + 189 shared functional tests pass.
All four HTTP cells from §6.6.4 still bounded under sustained
load (GC + no-snapshot at ~630 req/s peak, 1.1 MB steady state).
This commit is contained in:
russell@unturf.com 2026-04-18 19:41:52 -04:00
parent 5ec9eff5fe
commit 58025a37bc
7 changed files with 1142 additions and 792 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -407,6 +407,12 @@ s_port: .ascii "#<port>"
s_lparen: .ascii "("
s_rparen: .ascii ")"
s_space: .ascii " "
s_pdefine: .ascii "(define "
.equ s_pdefine_len, 8
s_quote_op: .ascii " (quote "
.equ s_quote_op_len, 8
s_rparen2_nl: .ascii "))\n"
s_rparen_nl: .ascii ")\n"
s_dotsp: .ascii " . "
s_minus: .ascii "-"
s_hashparen: .ascii "#("
@ -4836,6 +4842,7 @@ bi_substr:
# Carry on USB to air-gapped machine. Resume from exact state.
# ============================================================
.ifndef GC_NAIVE
bi_portal_save:
# (portal-save "filename") dump heap + state to file
GETARG %rdi # filename (string value)
@ -4926,7 +4933,118 @@ bi_portal_save:
.ps_fail:
movq $VAL_FALSE, %rax
RET_VAL
.endif
.ifdef GC_NAIVE
# GC build: binary-dump portal would require serializing the chunk
# list + headers + free list + pointer remapping. Instead we emit
# S-expressions walk the global env chain, write `(define <sym>
# <val>)` per binding. Works across tiers via the read-plus-eval
# loop that bi_load already implements.
#
# Skips builtins and closures whose bodies can't round-trip through
# scheme_print scheme_read without source-level reconstruction.
# Data values (pairs, strings, numbers, vectors) serialize cleanly.
bi_portal_save:
GETARG %rdi
andq $-8, %rdi
movq (%rdi), %rcx # filename length
leaq 8(%rdi), %rdi # filename bytes
subq $256, %rsp
movq %rsp, %rsi
pushq %rcx
.ps_gc_copy:
testq %rcx, %rcx
jz .ps_gc_named
movb (%rdi), %al
movb %al, (%rsi)
incq %rdi
incq %rsi
decq %rcx
jmp .ps_gc_copy
.ps_gc_named:
movb $0, (%rsi)
popq %rcx
movq $SYS_OPEN, %rax
movq %rsp, %rdi
movq $(O_WRONLY | O_CREAT | O_TRUNC), %rsi
movq $0644, %rdx
syscall
addq $256, %rsp
testq %rax, %rax
js .ps_gc_fail
movq %rax, %rbx # fd
# Redirect printer to the portal file.
movq output_fd(%rip), %r12 # save caller's fd
movq %rbx, output_fd(%rip)
# Walk env chain from %r14. Each node = [sym, val, parent].
movq %r14, %rbp # cursor
.ps_gc_loop:
testq %rbp, %rbp
jz .ps_gc_done
# Skip nodes whose val is a builtin builtins are recreated on
# resume by the target interpreter, no need to serialize.
movq 8(%rbp), %rax # val
movq %rax, %rcx
andq $TAG_MASK, %rcx
cmpq $TAG_BUILTIN, %rcx
je .ps_gc_next
cmpq $TAG_CLOSURE, %rcx
je .ps_gc_next # can't re-read a closure from its printed form
# Emit "(define "
leaq s_pdefine(%rip), %rsi
movq $s_pdefine_len, %rdx
movq $SYS_WRITE, %rax
movq output_fd(%rip), %rdi
syscall
# Print sym
movq (%rbp), %rdi # sym (tagged)
call scheme_print
# " (quote "
movq $SYS_WRITE, %rax
movq output_fd(%rip), %rdi
leaq s_quote_op(%rip), %rsi
movq $s_quote_op_len, %rdx
syscall
# Print val
movq 8(%rbp), %rdi
call scheme_print
# "))\n"
movq $SYS_WRITE, %rax
movq output_fd(%rip), %rdi
leaq s_rparen2_nl(%rip), %rsi
movq $3, %rdx
syscall
.ps_gc_next:
movq 16(%rbp), %rbp # parent
jmp .ps_gc_loop
.ps_gc_done:
# Restore printer fd.
movq %r12, output_fd(%rip)
# Close portal file.
movq $SYS_CLOSE, %rax
movq %rbx, %rdi
syscall
movq $VAL_VOID, %rax
RET_VAL
.ps_gc_fail:
movq $VAL_FALSE, %rax
RET_VAL
.endif
.ifdef GC_NAIVE
# GC build: portal-resume is equivalent to (load "filename") since
# the GC portal format is S-expressions. No magic header, no binary
# heap dump to splice back in just read+eval every form.
bi_portal_resume:
jmp bi_load
.endif
.ifndef GC_NAIVE
bi_portal_resume:
# (portal-resume "filename") restore heap + state from file
GETARG %rdi # filename string
@ -5030,6 +5148,7 @@ bi_portal_resume:
.pr_fail:
movq $VAL_FALSE, %rax
RET_VAL
.endif
# ============================================================
# bi_load: (load "path") read file, eval every form in r14 env