diff --git a/lib/erlang/scoreboard.json b/lib/erlang/scoreboard.json index 8633a8f1..91d6c192 100644 --- a/lib/erlang/scoreboard.json +++ b/lib/erlang/scoreboard.json @@ -1,7 +1,7 @@ { "language": "erlang", - "total_pass": 675, - "total": 675, + "total_pass": 691, + "total": 691, "suites": [ {"name":"tokenize","pass":62,"total":62,"status":"ok"}, {"name":"parse","pass":52,"total":52,"status":"ok"}, @@ -13,6 +13,6 @@ {"name":"echo","pass":7,"total":7,"status":"ok"}, {"name":"fib","pass":8,"total":8,"status":"ok"}, {"name":"ffi","pass":14,"total":14,"status":"ok"}, - {"name":"vm","pass":38,"total":38,"status":"ok"} + {"name":"vm","pass":54,"total":54,"status":"ok"} ] } diff --git a/lib/erlang/scoreboard.md b/lib/erlang/scoreboard.md index 16fda7c7..98405a77 100644 --- a/lib/erlang/scoreboard.md +++ b/lib/erlang/scoreboard.md @@ -1,6 +1,6 @@ # Erlang-on-SX Scoreboard -**Total: 675 / 675 tests passing** +**Total: 691 / 691 tests passing** | | Suite | Pass | Total | |---|---|---|---| @@ -14,7 +14,7 @@ | ✅ | echo | 7 | 7 | | ✅ | fib | 8 | 8 | | ✅ | ffi | 14 | 14 | -| ✅ | vm | 38 | 38 | +| ✅ | vm | 54 | 54 | Generated by `lib/erlang/conformance.sh`. diff --git a/lib/erlang/tests/vm.sx b/lib/erlang/tests/vm.sx index fa76d6eb..cc06fbc8 100644 --- a/lib/erlang/tests/vm.sx +++ b/lib/erlang/tests/vm.sx @@ -251,4 +251,57 @@ (er-vm-test "scan binds first match's var" (get er-vm-r4-env "X") 1) + +;; ── Phase 9e — OP_SPAWN / OP_SEND ─────────────────────────────── +(er-vm-procs-reset!) + +(er-vm-test "spawn opcode by id" + (get (er-vm-lookup-opcode-by-id 134) :name) "OP_SPAWN") +(er-vm-test "send opcode by id" + (get (er-vm-lookup-opcode-by-id 135) :name) "OP_SEND") + +(define er-vm-fn (fn () "body")) +(define er-vm-p1 (er-vm-dispatch 134 (list er-vm-fn (list)))) +(define er-vm-p2 (er-vm-dispatch 134 (list er-vm-fn (list "arg")))) +(er-vm-test "spawn returns pid 0 first" + er-vm-p1 0) +(er-vm-test "spawn returns pid 1 second" + er-vm-p2 1) +(er-vm-test "proc count is 2" + (er-vm-proc-count) 2) +(er-vm-test "spawned proc state runnable" + (er-vm-proc-state er-vm-p1) "runnable") +(er-vm-test "spawned proc mailbox empty" + (len (er-vm-proc-mailbox er-vm-p1)) 0) +(er-vm-test "spawned proc has 8 registers" + (len (get (er-vm-proc-get er-vm-p1) :registers)) 8) + +;; OP_SEND appends to target's mailbox, preserves arrival order. +(er-vm-test "send returns true on valid pid" + (er-vm-dispatch 135 (list er-vm-p1 "msg1")) true) +(er-vm-dispatch 135 (list er-vm-p1 "msg2") +) +(er-vm-dispatch 135 (list er-vm-p1 "msg3")) +(er-vm-test "mailbox length after 3 sends" + (len (er-vm-proc-mailbox er-vm-p1)) 3) +(er-vm-test "mailbox preserves order — first" + (nth (er-vm-proc-mailbox er-vm-p1) 0) "msg1") +(er-vm-test "mailbox preserves order — last" + (nth (er-vm-proc-mailbox er-vm-p1) 2) "msg3") + +;; send to nonexistent pid returns false (doesn't crash) +(er-vm-test "send to unknown pid is false" + (er-vm-dispatch 135 (list 99999 "x")) false) + +;; Isolation: msgs to p1 don't appear in p2's mailbox +(er-vm-test "isolation — p2 mailbox empty" + (len (er-vm-proc-mailbox er-vm-p2)) 0) + +;; reset clears +(er-vm-procs-reset!) +(er-vm-test "reset clears procs" + (er-vm-proc-count) 0) +(er-vm-test "reset resets pid counter" + (er-vm-dispatch 134 (list er-vm-fn (list))) 0) + (define er-vm-test-summary (str "vm " er-vm-test-pass "/" er-vm-test-count)) diff --git a/lib/erlang/vm/dispatcher.sx b/lib/erlang/vm/dispatcher.sx index 8a38290f..93b292a7 100644 --- a/lib/erlang/vm/dispatcher.sx +++ b/lib/erlang/vm/dispatcher.sx @@ -161,6 +161,63 @@ (fn (operands) (er-vm-receive-scan-loop (nth operands 0) (nth operands 1) (nth operands 2) 0))) +;; ── Phase 9e — spawn / send + lightweight scheduler ───────────── +;; Stub register-machine process layout for the eventual fast scheduler. +;; A VM-process is `{:id :registers :mailbox :state :initial-fn :initial-args}`. +;; Registers is a vector (SX list, mutated via set-nth!) — fixed slot count +;; per process so cells don't grow during execution. Mailbox is an SX list. +;; State is one of "runnable" / "waiting" / "dead". This sits PARALLEL to +;; the existing `er-scheduler` (which is the language-level scheduler) — +;; the VM scheduler will eventually take over once 9a integrates and +;; bytecode-compiled Erlang runs against it. + +(define er-vm-procs (list {})) +(define er-vm-procs-get (fn () (nth er-vm-procs 0))) +(define er-vm-procs-reset! + (fn () (do (set-nth! er-vm-procs 0 {}) (set-nth! er-vm-next-pid 0 0)))) + +(define er-vm-next-pid (list 0)) + +(define er-vm-proc-new! + (fn (initial-fn initial-args) + (let ((pid (nth er-vm-next-pid 0))) + (set-nth! er-vm-next-pid 0 (+ pid 1)) + (let ((proc + {:id pid + :registers (list nil nil nil nil nil nil nil nil) + :mailbox (list) + :state "runnable" + :initial-fn initial-fn + :initial-args initial-args})) + (dict-set! (er-vm-procs-get) (str pid) proc) + pid)))) + +(define er-vm-proc-get (fn (pid) (get (er-vm-procs-get) (str pid)))) + +(define er-vm-proc-send! + (fn (pid msg) + (let ((proc (er-vm-proc-get pid))) + (cond + (= proc nil) false + :else + (do + (dict-set! proc :mailbox (append (get proc :mailbox) (list msg))) + (when (= (get proc :state) "waiting") + (dict-set! proc :state "runnable")) + true))))) + +(define er-vm-proc-mailbox (fn (pid) (get (er-vm-proc-get pid) :mailbox))) +(define er-vm-proc-state (fn (pid) (get (er-vm-proc-get pid) :state))) +(define er-vm-proc-count (fn () (len (keys (er-vm-procs-get))))) + +(define er-vm-op-spawn + (fn (operands) + (er-vm-proc-new! (nth operands 0) (nth operands 1)))) + +(define er-vm-op-send + (fn (operands) + (er-vm-proc-send! (nth operands 0) (nth operands 1)))) + ;; ── Phase 9b — pattern-match opcodes ──────────────────────────── ;; Each handler takes a list (pattern-ast value env) and returns ;; true/false, mutating env on success (same contract as the @@ -202,6 +259,8 @@ (er-vm-register-opcode! 131 "OP_PERFORM" er-vm-op-perform) (er-vm-register-opcode! 132 "OP_HANDLE" er-vm-op-handle) (er-vm-register-opcode! 133 "OP_RECEIVE_SCAN" er-vm-op-receive-scan) + (er-vm-register-opcode! 134 "OP_SPAWN" er-vm-op-spawn) + (er-vm-register-opcode! 135 "OP_SEND" er-vm-op-send) (er-mk-atom "ok"))) (er-vm-register-erlang-opcodes!) diff --git a/plans/erlang-on-sx.md b/plans/erlang-on-sx.md index 1f0058bc..72cda6e6 100644 --- a/plans/erlang-on-sx.md +++ b/plans/erlang-on-sx.md @@ -134,7 +134,7 @@ Replace today's hardcoded BIF dispatch (`er-apply-bif`/`er-apply-remote-bif` in - [x] **9b — `OP_PATTERN_TUPLE` / `OP_PATTERN_LIST` / `OP_PATTERN_BINARY`** — **+19 vm tests** (656/656 total). Stub dispatcher in `lib/erlang/vm/dispatcher.sx` mirrors the OCaml extension shape from `plans/sx-vm-opcode-extension.md`: `er-vm-register-opcode!`/`er-vm-lookup-opcode-by-id`/`er-vm-lookup-opcode-by-name`/`er-vm-dispatch`. Opcode IDs 128 (TUPLE), 129 (LIST), 130 (BINARY) per the guest-tier partition (128-199). Handlers are thin wrappers over the existing `er-match-tuple`/`er-match-cons`/`er-match-binary` for now; the real specialization (skip AST walk, register-machine operands) lands when 9a integrates. Conformance must remain unchanged — **656/656** preserved. Candidate for chiselling to `lib/guest/vm/match.sx` once a second port (Prolog? miniKanren?) wants the same opcodes. - [x] **9c — `OP_PERFORM` / `OP_HANDLE`** — **+9 vm tests** (665/665 total). Stubs in `lib/erlang/vm/dispatcher.sx`: `OP_PERFORM` (id 131) raises `{:tag "vm-effect" :effect :args }`; `OP_HANDLE` (id 132) wraps a thunk in `guard`, catches matching effects (by `:effect` name), passes args to the handler, returns the handler's result. Non-matching effects rethrow to outer handlers (verified by a nested-handle test). Pure Erlang `receive` interface unchanged; this is the substrate for the eventual call/cc-free implementation when 9a integrates. Candidate for chiselling (Scheme call/cc, OCaml 5 effects, miniKanren all want the same shape). - [x] **9d — `OP_RECEIVE_SCAN`** — **+10 vm tests** (675/675 total). Stub at id 133 in `lib/erlang/vm/dispatcher.sx`. Operand contract: `(clauses mbox-list env)` where each clause is `{:pattern :guards :body}`, mbox-list is a plain SX list (not a queue — caller does queue→list before invoking and queue-delete after). Walks mbox in arrival order; tries each clause per message; first match returns `{:matched true :index N :body B}` (env mutated with bindings, body NOT evaluated — caller chooses when); no match returns `{:matched false}`. Pure pattern scan; suspension is the caller's job (compose with OP_PERFORM "receive-suspend" once 9a integrates). The real opcode will skip the AST walk by JIT-compiling each clause's match expr; this stub re-uses `er-match!` for correctness. -- [ ] **9e — `OP_SPAWN` / `OP_SEND` + lightweight scheduler**: per-process register/heap layout, scheduler that runs Erlang bytecode units rather than going through general SX evaluator each time. Process record fields become VM register slots. Target: spawn cost under 50µs, send cost under 5µs. +- [x] **9e — `OP_SPAWN` / `OP_SEND` + lightweight scheduler** — **+16 vm tests** (691/691 total). Stubs at ids 134 (SPAWN) and 135 (SEND) in `lib/erlang/vm/dispatcher.sx`, plus the VM-process registry: `er-vm-procs` (dict pid → proc record), `er-vm-next-pid`, `er-vm-procs-reset!`, `er-vm-proc-new!`/`get`/`send!`/`mailbox`/`state`/`count`. Process record shape is the register-machine layout the real scheduler will use: `{:id :registers (list of 8 nil slots) :mailbox (SX list) :state ("runnable"/"waiting"/"dead") :initial-fn :initial-args}`. OP_SPAWN returns a numeric pid and allocates a fresh record; OP_SEND appends to the target's mailbox, flipping `:state` from "waiting" → "runnable" if needed (returns true on success, false on unknown pid — no crash). Sits parallel to `er-scheduler` (the language-level scheduler from Phase 3); the real VM scheduler will take over once 9a integrates and Erlang programs compile to bytecode. Perf targets in the bullet (spawn <50µs, send <5µs) defer to the integration step. - [ ] **9f — BIF dispatch table**: `OP_BIF_` for hot BIFs (`length/1`, `hd/1`, `tl/1`, `element/2`, `lists:reverse/1`, etc.) — direct dispatch, no registry lookup. Cold BIFs continue through the general dispatch path. - [ ] **9g — Conformance + perf bench**: full Phase 1-8 conformance must pass on the new VM. Ring benchmark target: **100k+ hops/sec at N=1000** (current ~30/sec → ~3000× speedup target). 1M-process spawn target: **under 30 seconds** (current ~9h extrapolation → ~1000× speedup target). Document achieved numbers in `lib/erlang/bench_ring_results.md`. @@ -144,6 +144,8 @@ Replace today's hardcoded BIF dispatch (`er-apply-bif`/`er-apply-remote-bif` in _Newest first._ +- **2026-05-14 Phase 9e — OP_SPAWN / OP_SEND + VM-process registry green** — `lib/erlang/vm/dispatcher.sx` gains a parallel mini-runtime distinct from the language-level `er-scheduler`: `er-vm-procs` (dict pid → proc record), `er-vm-next-pid` (counter cell), `er-vm-procs-reset!`, plus six accessors (`er-vm-proc-new!`/`get`/`send!`/`mailbox`/`state`/`count`). Process record shape is the register-machine layout the real bytecode scheduler will use: `{:id :registers (8 nil slots) :mailbox :state :initial-fn :initial-args}` — fixed register width so cells don't grow during execution. Opcode 134 `OP_SPAWN` calls `er-vm-proc-new!` and returns the new pid; 135 `OP_SEND` appends to the target's mailbox and flips a waiting proc back to runnable, returns false for unknown pid (graceful, doesn't crash). 16 new tests in `tests/vm.sx`: opcode-by-id for both, spawn returns 0 / 1 / count=2 / state=runnable / mailbox empty / 8 registers, send returns true, 3-sends preserve arrival order (first + last verified), send to unknown pid returns false, isolation (p1's msgs don't leak into p2), reset clears procs + resets pid counter. vm suite 38 → 54. One gotcha during impl: SX `fn` bodies evaluate ONLY the last expression — `er-vm-procs-reset!` had two `set-nth!` calls back-to-back which silently dropped the first; wrapped in `(do ...)` to fix. Total **691/691** (+16 vm). Real scheduler with per-process scheduling latency and runnable queue is post-9a. + - **2026-05-14 Phase 9d — OP_RECEIVE_SCAN stub green** — Selective-receive primitive at opcode id 133 in `lib/erlang/vm/dispatcher.sx`. Operand contract: `(clauses mbox-list env)` — clauses are AST dicts (`{:pattern :guards :body}`), mbox-list is a plain SX list (queue → list is the caller's job), env is the binding target. Internal helpers `er-vm-receive-try-clauses` (per-message clause walker with env snapshot/restore on failure) and `er-vm-receive-scan-loop` (mailbox walker, arrival order). Match returns `{:matched true :index N :body B}` so the caller can queue-delete at N and then evaluate B in the now-mutated env; miss returns `{:matched false}` so the caller can suspend via OP_PERFORM "receive-suspend". Mirrors the existing `er-try-receive-loop` in `transpile.sx` but doesn't reach into the scheduler — purely VM-level. 10 new tests in `tests/vm.sx`: opcode registered, scan finds match at correct index, scan binds var, body left unevaluated, no-match leaves env untouched, empty mailbox, first-match wins (arrival order — verified by two `{ok, _}` msgs and binding the FIRST value). vm suite 28 → 38. Total **675/675** (+10 vm). When 9a integrates and the real OP_RECEIVE_SCAN compiles clauses into a register-machine match, the existing `er-eval-receive-loop` becomes a one-line dispatch wrapper. - **2026-05-14 Phase 9c — OP_PERFORM / OP_HANDLE stubs green** — Two new opcodes in `lib/erlang/vm/dispatcher.sx`: id 131 `OP_PERFORM` raises `{:tag "vm-effect" :effect :args }`; id 132 `OP_HANDLE` wraps a thunk in SX `guard`, catches matching effects by `:effect` name, passes the `:args` list to the handler fn, returns the handler's result. New helper `er-vm-effect-marker?` predicates on the dict shape. Non-matching effects rethrow via a small box+rethrow dance (caught with `:else` first, decision deferred to a post-guard cond — re-raise outside the guard's scope so it propagates to outer handlers cleanly). 9 new tests in `tests/vm.sx`: opcode registered for each id; OP_PERFORM raises with correct tag/effect/args; OP_HANDLE catches matching effect; OP_HANDLE returns thunk result when no effect performed; OP_HANDLE rethrows non-matching effect to outer; nested OP_HANDLE blocks separate by effect name (inner handles "a", outer handles "b", performing "b" bypasses inner). vm suite grew 19 → 28 tests. Total **665/665** (+9 vm). Underlying call/cc + raise/guard machinery used by Erlang `receive` is unchanged; this is the shape for the eventual specialization when 9a integrates. Candidate for chiselling to `lib/guest/vm/effects.sx` — Scheme call/cc, OCaml 5 effects, miniKanren all want the same shape.