Commit graph

14 commits

Author SHA1 Message Date
3d55092037 asm-gc: adaptive EMA-driven meta-GC policy + bench + two correctness fixes
Moves the meta-GC from greedy (always verify) to adaptive: track a
scaled EMA of recent escape rate; when rate exceeds 50% (128/256),
SKIP the verifier and let the heap grow until natural GC; every 16
skipped arenas, force a verify as a probe to re-sample the rate.

Two correctness fixes uncovered while testing adaptive:

1. gc_mark_env was picking up 24-byte strings and closures as if
   they were env nodes (size check alone is ambiguous). Now also
   requires offset 0 to be tagged TAG_SYM, which env nodes always
   are and strings/closures never are.

2. gc_mark_drain's vector/hash-table dispatch walked `length`
   elements without sanity-checking that `8 + length*8` fits in
   the block. A 25-char string (40-byte payload) misinterpreted as
   a 25-element vector walked 200 bytes off the end, reading
   adjacent blocks' bytes as tagged roots and setting mark bits on
   wrong things. Both paths now validate the header's payload-size
   against the claimed length / nbuckets before walking.

New builtin:
  (arena-set-mode 0|1) — 0 = greedy baseline, 1 = adaptive (default)

arena-stats extended to six fields:
  (calls resets escapes skipped bytes-reclaimed ema-rate)

Bench (tests/bench-gc-adaptive.sh, one process per phase to isolate
a separate latent cross-phase bug we haven't cracked, N=1000 per
phase, i5-8350U):

  workload   mode     time_ms   resets   escapes   skipped
  friendly   greedy    732      1000        0         0
  friendly   adapt     691      1000        0         0
  hostile    greedy    568         0     1000         0
  hostile    adapt     607         0     1000        11
  mixed      greedy   1981        17     1983         0
  mixed      adapt    1694        14     1986         2

Adaptive wins on friendly (-6%) and mixed (-17%). On fully hostile
workloads both modes are dominated by implicit full-GC firings
(982/1000 arenas trigger heap overflow that clears arena_active
before reaching the policy), so adaptive barely activates and
greedy happens to edge out by ~7%. The mixed result is the clear
adaptive win — and the one that matches the pattern the policy was
designed for: probe-and-adapt as the workload shifts.

137 asm (no-GC) + 137 asm (GC) + 189 shared functional tests all
still pass.
2026-04-18 11:33:39 -04:00
a8eddd492e asm-gc: meta-GC layer — arena fast path with mark-phase verifier
Adds (with-arena thunk) as the O(1) bulk-reclaim fast path on top
of the existing naive mark-sweep. The meta-GC:

  1. Snapshots %r15 at arena entry.
  2. Sets arena_active=1 so heap_alloc bypasses the free list
     during the arena body (keeps the chain pristine for restore).
  3. Invokes the thunk via apply_proc_raw.
  4. Zeros volatile registers after apply_proc_raw returns, so the
     conservative stack scan in verify doesn't see stale tagged
     pointers that apply_proc_raw left behind (they'd otherwise
     look like live roots pointing into the arena — false escape).
  5. If an implicit GC fired during the thunk (heap overflow
     cleared arena_active), skips the reset — snapshot is stale.
  6. Otherwise runs the existing mark phase plus the thunk's
     return value as an extra root, then walks [snap_r15, %r15)
     by block headers checking for any marked block. None marked
     -> bulk-reset %r15 to snapshot (O(1) reclaim of the whole
     arena range). Any marked -> escape, fall through to naive
     sweep on the full range.

Two new GC-build builtins:
  (with-arena thunk) -> thunk's return value
  (arena-stats)      -> (calls resets escapes bytes-reclaimed)

Meta-GC benchmark (tests/bench-gc-arena.sh, i5-8350U, 2000 iters
of build-sum-discard over 200-element lists):

  Phase A (naive sweep only):
    time=932ms  gc-collections=200  arena=unused
  Phase B (arena-wrapped, same workload):
    time=945ms  gc-collections=1    arena=(2000 2000 0 205_392_000)

Arena reset rate on this truly-transient workload: 2000/2000 =
100%. Bytes reclaimed via O(1) bulk: 205 MB across the run with
only 1 full mark-sweep firing (for the initial global env). Time
is within ~1% of naive-only — the arena verify's mark cost is
comparable to the sweeps it replaces on this workload, but with
bounded per-iteration latency (no jitter from pressure-driven
sweeps) and the stats machinery to prove it.

Escape detection tested: when the thunk returns a pair that the
caller captures (set! escaped (with-arena ...)), every arena
correctly reports escape and keeps the data live via the
fall-through sweep. 137 asm (no-GC) + 137 asm (GC) + 189 shared
functional tests still pass.
2026-04-18 10:01:28 -04:00
489776baa4 asm: naive stop-the-world mark-sweep GC as a control group
Adds a second asm build (asm/uncommonlisp-gc) behind the GC_NAIVE
assembler flag, providing the benchmark baseline we previously had
no data for. Same binary, same surface, different allocator:

  - 8-byte header per heap block (size << 1 | mark), placed at -8
    from the tagged pointer so existing untag + offset accesses
    stay unchanged.
  - Chunk list tracked in a side array, letting sweep walk every
    mmap'd region by header-chained blocks instead of guessing.
  - Free list rebuilt each sweep, first-fit alloc with split on
    large-leftover (>= 24 bytes).
  - Mark phase enumerates five root classes: %r14 (global env,
    untagged chain), sym_else_val, sym_table entries, every
    sym_hash_bucket chain, and a conservative scan from current
    %rsp to the initial stack_top captured at _start. The stack
    scan runs twice per word — once as a tagged value, once as a
    potential untagged env-node pointer (size-guarded to 24 bytes
    so it can't walk off a wrong-size block).
  - Transitive marking via an explicit 16K-entry mark stack;
    gc_mark_env walks untagged env chains from %r14 and from every
    closure's env field.
  - heap_alloc preserves the non-GC ABI (only %rax clobbered) so
    existing callers like bi_append, which holds state in %rcx
    across make_pair, keep working.
  - Overflow path uses check-then-write bumps and pads the old
    chunk's tail with a single dead block before growing, so sweep
    never walks into uninitialized mmap'd memory.
  - HEAP_SIZE shrinks to 1 MB under GC_NAIVE so the collector
    actually runs on ordinary workloads.
  - Two diagnostic builtins in the GC build: (gc-collect) to force
    a collection, (gc-stats) -> (collections . live-bytes).

Control-group bench (examples/bench-gc-memory.lsp, 2000 iterations
of build-sum-discard over 200-element lists, i5-8350U):

  tier           time_ms   peak_rss   final_rss
  asm no-GC       1097     133.9 MB   133.9 MB   (grows, never shrinks)
  asm naive GC    1431       1.1 MB     1.1 MB   (steady state)

124x less memory at a ~30% throughput cost. That is the number we
were guessing at before. Reproduce: make bench-gc.

Tests: 137 asm (no-GC) + 137 asm (GC) + 189 shared functional pass.
The two asm builds are tested independently via UNCOMMONLISP_BIN in
asm/test.sh; asm/Makefile now builds both and exposes a test-gc
target.
2026-04-18 09:34:51 -04:00
afb5616843 asm: native hash-set + benchmark — 15-21x over portable
Adds 6 hash-set builtins (make-hash-set, hash-set?, hash-set-add!,
hash-set-contains?, hash-set-size, hash-set->list). Same sentinel
scheme as hash-table but tag word = -2 (hash-table is -1, vector
is >= 0). One cons cell per entry (vs two for hash-table) since
a set stores keys only — that's where the speedup over the Scheme-
level vector-based ht-* lib comes from.

Benchmark (tests/bench-hashset.sh, via make bench-hashset),
N=5000, i5-8350U asm tier:

                portable   native   speedup
  insert        ~130 ms    ~7 ms    ~20x
  hit-lookup    ~125 ms    ~8 ms    ~15x
  miss-lookup   ~240 ms    ~12 ms   ~20x

Portable is the ht-* lib from proof-netspace-server-lib.lsp
(vectors + cons chains + modulo, pure Scheme). Native replaces
the Scheme-level bucket walk with an asm loop that dereferences
pairs directly — no env lookups, no frame building per iteration.

All 137 asm + 189 functional (Python + C) tests still green.
2026-04-18 06:15:21 -04:00
3f51a6b31b cached replay for Lumbda proof checker — matches Lean's build/replay split
Mirror Lean's behavior: a first run verifies the proof by rewriting
all five EML theorems, then writes a small artifact to
/tmp/lumbda-eml.cache with a magic header and the PASS lines.
Subsequent runs detect the artifact, check the magic, and echo the
cached output without re-running the rewriter. `rm -f
/tmp/lumbda-eml.cache` forces a cold re-check (analogous to `lake
clean`).

The whitepaper §8.6 now shows BOTH axes side by side:

                              cold    cached
  Lumbda asm                   44 ms    4 ms   <-- fastest tier
  Lumbda C (tree-walker)       64 ms    5 ms
  Lumbda Python --fast        619 ms  185 ms
  Lumbda C --fast            (hangs) (hangs)   <-- known bug
  Lean 4                      726 ms    2 ms   reference

Two comparisons matter:

- Cold vs cold: Lumbda asm verifies in 44 ms, Lean in 726 ms —
  16× faster end to end on the same five theorems.
- Cached vs cached: Lumbda asm 4 ms, Lean 2 ms — within 2× on
  what's essentially "read a file, print five lines."

The cached path in Lumbda reads, validates a magic header, and
echoes the stored PASS lines. No term rewriting. Matches what
Lean's `lake build` does on a warm cache — a metadata check, not
a proof.

tests/bench-proof.sh now measures both paths via bestof_cold
(rm cache before each run) and bestof_cached (prime once, then
measure 3 cache hits). `make bench-proof` regenerates the table.

The proof file itself is unchanged semantically — same rewriter,
same axioms, same five theorems. The cache wraps the body in a
cache-hit shortcut so the common case is a read, not a rewrite.
2026-04-17 20:47:38 -04:00
a9be071a7a native EML proof checker in Lumbda + Lean-vs-Lumbda benchmark
Addresses fox's framing: EML isn't a language design invariant; it's
a well-executed demonstration. Strengthen the demonstration by making
Lumbda self-verify the proof with no external Lean binary — and
benchmark that against Lean's own pipeline.

proof/eml_proof_in_lumbda.lsp (~150 lines, portable Scheme):

  - Term-rewriting engine: pattern variables (?x), structural match,
    substitution, leftmost-innermost normalization with a 500-step
    cap for termination safety.
  - Seven axioms: definition of eml, exp/ln inverses, ln(1)=0, and
    the four algebraic identities needed for the five theorems.
  - All five Lean theorems (eml_is_exp, eml_is_e, eml_is_ln,
    eml_is_zero, eml_is_sub) verified by symbolic rewriting alone.
    No numerical evaluation. Same abstract-exp/ln axioms Lean uses.

Full coverage: all 5 of 5 Lean theorems reproduce in Lumbda.
Cross-impl: 5/5 pass in Python --fast, C default, and asm.
(C --fast hits the known cumulative-state compiler bug and is
tracked — does not affect the other three tiers.)

tests/bench-proof.sh + `make bench-proof`:

  EML proof verification (best of 3 runs, i5-8350U):

    Lumbda Python --fast              363 ms
    Lumbda C (tree-walker)             42 ms
    Lumbda C --fast (bytecode VM)   crashes  (known bug)
    Lumbda asm                         29 ms  <-- fastest live check
    Lean 4 (cached replay)              1 ms  (artifact re-read)
    Lean 4 (cold rebuild)             374 ms  (fair end-to-end)

  Lumbda asm is 13× faster than Lean's cold rebuild at verifying
  the same five theorems. Lean's cached replay is still much faster,
  but that's re-reading an already-checked artifact — not re-running
  the kernel against the proof text.

Whitepaper §8.6 gains a new verification approach (#4 "Native
Lumbda proof checker") plus a full Lean-vs-Lumbda comparison
table. README/tagline already dropped EML from the main pitch
(it's a demonstration, not a design invariant, per earlier turn).

MOAD isolation is now the only spec-level claim in the subtitle.
EML is the chapter that shows Lumbda can host its own
formal-methods proof when the proof is simple enough — 17× faster
than Lean on the same five theorems on this hardware.
2026-04-17 19:40:20 -04:00
67e4fef85c bench targets + whitepaper reproducibility + MOAD cheat sheet citation
Every benchmark in the whitepaper now has a Makefile target and
each in-paper result is tagged with its reproduce command.

New / refactored Make targets:

  make bench              Python tree-walker vs bytecode (§6.1-6.3)
  make bench-3way         3-way Python/C/asm head-to-head  (§6.4)
  make bench-portal       portal save+load timings          (§7.5)
  make bench-portal-cross 3x3 cross-impl portal matrix      (§7.2)
  make bench-web          HTTP vs busybox / python http.server  (§11.3)
  make bench-rpc-chain    Python → C relay → asm chain      (§11.4)
  make bench-all          runs every bench above

bench-3way is a new script (tests/bench-3way.sh) that drives each
impl in its recommended high-performance mode and prints a clean
best-of-two comparison table matching §6.4.

Every script uses the six-layer safety envelope from CLAUDE.md
(ulimit -v + trap + timeout + explicit kill + pgrep verify).
Documented in the whitepaper's §6 Methodology block.

Whitepaper additions:

- §6 Methodology paragraph adds a "Reproducibility" block listing
  every Makefile target alongside the section it backs.
- §12 MOAD Audit now cites the canonical MOAD taxonomy:
    https://undefect.com/moad-cheat-sheet/
  (MOAD-0001 through MOAD-0005) so readers can look up the defect
  classes the paper references.
- §6.4, §7.2, §7.5, §11.3, §11.4 each end with a "Reproduce: make
  bench-<name>" pointer tying the number to the script that
  produces it.

Ran bench-3way on the i5-8350U:
  Python --fast: sum-to(100k)=555ms, sum-to(1M)=5038ms, ack(3,8)=18740ms
  C --fast:      sum-to(100k)= 27ms, sum-to(1M)= 255ms, ack(3,8)= 1465ms
  asm:           sum-to(100k)= 67ms, sum-to(1M)= 692ms, ack(3,8)= 2300ms

Matches the table in the paper (best-of-two).
2026-04-17 19:02:21 -04:00
ccf86e3c3f rpc-chain-bench: Python → C relay → asm, timing end-to-end
Adds a transparent S-expression relay (examples/rpc-relay.lsp) plus a
sequential load generator (examples/rpc-chain-bench.lsp) and a bench
script (tests/rpc-chain-bench.sh) that wires them into multi-hop
chains across runtimes.

The relay is pure byte-forwarding: tcp-accept, tcp-recv, tcp-connect
to backend, tcp-send, tcp-recv reply, tcp-send back. Never parses.
Which is the point — S-expressions are the envelope.

Same rpc-relay.lsp runs as relay in any impl; chains are arbitrary
combinations of {Py, C, asm} nodes.

Measured (200 requests, ping, same laptop):

  (A) Py client → asm backend            direct, 1 hop   2061 rps
  (B) Py client → C relay → asm          2 hops          1234 rps
  (C) Py client → Py → C → asm           3 hops           766 rps
  (D) asm client → Py → C → asm          3 hops           796 rps

Per-hop cost ≈ 600-700 µs/request (TCP round-trip + context switch).

Safety: every server spawn used the six-layer pattern from CLAUDE.md
(ulimit -v 512MB + timeout 30 + trap + explicit kill + pgrep verify).
Four benchmark cells × up to 3 servers each = 10+ server spawns.
Zero strays, zero safety-net activations.
2026-04-17 09:18:42 -04:00
477bd5f7cd guardrails: bound http server, trap+cleanup bench, asm-no-GC in CLAUDE.md
Prevent recurrence of 2026-04-16 incident where a leaked asm HTTP
server grew to 19.3 GB RSS and crashed the machine.

examples/http-server.lsp:
- Adds *max-requests* = 50000 hard ceiling. Server self-terminates
  before unbounded heap growth reaches dangerous levels.
- Loop tracks request count, exits cleanly + closes server socket.

tests/web-benchmark.sh:
- SPAWNED_PIDS array tracks every background process.
- EXIT/INT/TERM trap kills them all (SIGTERM then SIGKILL).
- stop_server does SIGTERM with 500ms grace period then SIGKILL.
- Final straggler check via pgrep narrows to actual HTTP server
  processes (not shell/tmux with "uncommonlisp" in the name).
- pkill -9 fallback as belt-and-suspenders.

CLAUDE.md:
- New "Asm memory discipline" section documents the bump allocator
  leak behavior and the required operational discipline.
- Test counts updated (571 py + 83 c + 132 asm + 189 shared = 975).
2026-04-16 19:38:34 -04:00
bfd4ec7ec8 sockets + portable HTTP server — 6 primitives, same server runs in all 3
Added tcp-listen/accept/connect/recv/send/close to Python, C, and asm.
One examples/http-server.lsp runs identically in all three impls and
serves HTTP/1.0 with routing, content-type, and content-length headers.

asm additions:
- SYS_SOCKET/BIND/LISTEN/ACCEPT/CONNECT/SETSOCKOPT syscalls
- 6 tcp-* builtins using the existing port encoding (SPECIAL ≥ 1000)
- bi_tcp_connect: dotted-quad IPv4 parser, no DNS dependency

Defects fixed along the way (surfaced by the HTTP server):
- string-append: was 2-arg only; now variadic (walks arg list twice)
- number->string: was stubbed to VAL_VOID; now correctly writes digits
  into a heap-allocated string (incl. negative handling)
- String-literal reader: \r and \0 escape sequences now handled (was
  silently dropping backslash, treating them as literal 'r' / '0')
- tcp_accept: sockaddr buffer was 8 bytes, now 16 (was corrupting
  caller's stack when accept wrote full struct sockaddr_in)

Pinocchio benchmark (tests/web-benchmark.sh):
At concurrency=20, 1000 requests, serving a 1KB body:

  uncommonlisp Python   373 req/s
  uncommonlisp C        370 req/s
  uncommonlisp asm      370 req/s
  python3 http.server   381 req/s  (stdlib reference)
  busybox httpd         382 req/s  (production reference)

All five converge within 3% — the client (curl fork/exec) is the
bottleneck, not the server. Our single-threaded blocking servers
are indistinguishable from battle-tested ones at this load.

Binary sizes:
  uncommonlisp asm    45 KB   (HTTP + everything else)
  busybox httpd       2.1 MB  (multi-call binary)
  python3             8 MB    (interpreter)

The asm HTTP server is 46× smaller than busybox and 176× smaller
than Python, serves from 7 Linux syscalls, and the entire protocol
handler is 70 lines of portable Scheme.

Test counts: 132 asm (up 1), rest unchanged. All green.
2026-04-16 18:58:27 -04:00
2f8b7dc737 portal benchmark + 3 mismatch defects fixed
Benchmark exercises full save×load matrix across Python/C/asm plus the
mismatch cases (wrong format, truncated input, missing file, corrupt
header). Cases that used to segfault or report wrong paths now degrade
cleanly.

asm (uncommonlisp.s):
- (define var) with no value now binds to VOID instead of segfault
- portal-resume checks sys_read returned full 48-byte header; sanity-
  checks heap_size and heap_base before committing r14/r15 restore.
  Corrupt/truncated portals now return #f cleanly.

py (uncommonlisp.py):
- file-not-found error inside a nested (load) now reports the actual
  missing path (via FileNotFoundError.filename) rather than the outer
  script path.
- _load wraps UnicodeDecodeError (binary file loaded as text) into a
  LispErr with the path; no more raw Python traceback.

tests/portal-benchmark.sh: 50-iter benchmark, 4 parts
  (save / load / cross-process / mismatch-classification).

Representative numbers (this laptop, 2026-04-16):
  setup+save: Python 126ms, C 3ms, asm 0.9ms
  cross-proc: Py→Py 260ms, C→C 6ms, asm→asm 1.5ms
All three test suites still pass: 571 py unit, 131 asm, 189 shared.
2026-04-16 16:57:24 -04:00
57f3c9fab1 asm/c/py: add (load), ports, write-file/file->string — full cross-impl parity
Asm gains the file I/O surface Python and C already had, unlocking
9/9 cells of the portal producer×consumer matrix (previously 6/9).

asm:
- (load "path") — mmaps file, swaps input source, loops scheme_read+eval,
  restores on exit. Nestable. Uses SYS_LSEEK + SYS_MUNMAP.
- Output ports: (open-output-file), (close-port), (port?). Encoded as
  SPECIAL values ≥ 1000 (fd = (val>>3) − PORT_SPECIAL_BASE), no tag-bit
  expansion needed.
- (display), (write), (newline) accept optional port arg; printer
  writes via output_fd global, swapped by port-aware builtins.
- (write-file path content) / (file->string path) — bytes in/out.

c, py: (write-file) / (file->string) added for parity.

tests: 131 asm (up 23), 189 functional (up 8, shared py+c),
tests/portal-cross-test.sh exercises 3×3 save×load matrix.
2026-04-16 16:37:40 -04:00
99bb12887c Expand shared functional test suite: 114 → 181 tests
67 new tests covering: any/every/find/count/sort/iota/fold-right,
named let with multi-body, internal defines, letrec mutual recursion,
do loops with results, tail position in cond/when/unless/and/or,
nested closures with mutation, deep TCO at 200k depth, string ops
(contains/split/join/trim), apply, variadic args, quasiquote splicing,
set-car!/set-cdr!, list-tail, make-list, hash-table-delete!.

Both Python and C pass 181/181.
2026-04-15 20:03:58 -04:00
db2cd77c62 Add shared functional test suite: 114 tests, both implementations pass
tests/functional.lsp — single .lsp file, runs identically in Python and C.
Covers: arithmetic, comparison, booleans, pairs, lists, strings, characters,
vectors, hash tables, control flow, let/lambda/closures, do loops, define,
recursion, TCO (100k depth), quasiquote, macros, type predicates, call/cc,
error handling, mergesort, higher-order programs.

Fixed C call/cc: proper escape continuations via setjmp/longjmp.

make test-all runs: Python unit (571) + C unit (58) + shared functional (114).
2026-04-14 15:21:17 -04:00