Skip to content

Roadmap + spikes (R1/R3/R5) + M1 tail calls + M2 value-model/GC migration (S0–S2) - #118

Open
pmatos wants to merge 20 commits into
mainfrom
nora/session-49f3f26f
Open

Roadmap + spikes (R1/R3/R5) + M1 tail calls + M2 value-model/GC migration (S0–S2)#118
pmatos wants to merge 20 commits into
mainfrom
nora/session-49f3f26f

Conversation

@pmatos

@pmatos pmatos commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Overview

A foundational branch that lays out the road to a compiled #lang racket/base
hello-world and starts down it: the roadmap + design docs, the three
de-risking spikes (R1/R3/R5), M1 (proper tail calls), the first three
M2 value-model slices, and the first three slices of the M2 Boehm-GC
migration
(S0–S2). All work was done test-first (TDD); the suite is 35/35
green under debug, asan, and ubsan
at every commit, and the default build
plus norac end-to-end are unchanged.

This is intentionally a large, mostly-additive PR (54 files, ~+3.5k/−250). M2 is
the multi-month "pivot" and is not finished here — remaining GC-migration
slices S3–S18 are filed as tracked, dependency-ordered issues (see below).

What's done

Planning & design

Spikes (closed issues)

M1 — proper tail calls (#93, closed)

  • O(1)-space self/mutual tail calls in the CEK machine (Seq early-pop + Call
    frame reuse). Interpreter::getPeakKont() hook; minimal zero?. Unit tests +
    tailcall.rkt.

M2 — shared value model (#94, in progress)

  • Slice 1 — mutable boxes + eq? identity (shared cell survives clone-on-lookup).
  • Slice 2 — mutable pairs (cons/car/cdr/set-car!/set-cdr!) + pair eq?.
  • Slice 3 — interned symbols: identity-based eq?, gensym,
    string->uninterned-symbol; also wired parseString into parseExpr
    (string literals now parse as expressions).
  • GC S0 — link Boehm GC (pkg-config bdw-gc), GC_INIT, promote the
    nr_value immediate ABI to src/nora_rt.*, heap hooks.
  • GC S1 — the continuation (Kont) now lives on the GC heap (exception-free
    GcAllocator, since Boehm's own needs -fexceptions).
  • GC S2 — introduce the Value migration handle in the Val/Result registers.

Not done here / follow-ups

  • M2 GC migration S3–S18 — the rest of the value-model rewrite (share instead
    of clone in the environment; immediates; the collectable-leaf scaffolding;
    containers-as-GC-cells; the scope cutover that deletes AllScopes/clone()).
    Filed as dependency-ordered issues blocking M2 — Shared value model + GC + closure representation (PIVOT) #94; see
    docs/value-model-gc-migration.md.
  • Deferred within M2: List/Pair unification so car/cdr work on quoted
    lists; Frame::Done/mark containers onto the GC heap (later slices).

Testing

ctest --preset debug / asan / ubsan: 35/35. norac end-to-end
integration tests (box.rkt, pair.rkt, symbol.rkt, tailcall.rkt) pass.
clang-format clean. The mlir preset (opt-in) builds and round-trips.

Closes #88, closes #89, closes #90, closes #93. Advances #94.

pmatos added 20 commits July 2, 2026 10:21
Milestone ladder (M0..M17 interpreter/self-hosted-expander track, B0..B5
NIR/LLVM AOT backend track) over a shared value-model+GC+tail-call substrate,
targeting a standalone AOT executable via the self-hosted expander and the
NIR/MLIR backend. Produced via a multi-agent research+synthesis+critique pass.
tools/freevars.py is a scope-aware s-expression walker that performs a real
free-variable analysis over expander/expander.rktl (tracking lambda/case-lambda/
let-values/letrec-values/define-values binders, skipping quoted data). Robust to
the M3 lexing gap that stops the production C++ parser from ingesting the
artifact. It reports the exact 544 distinct kernel primitives the expander
references (22,173 refs), ranked and categorized in
docs/expander-primitive-surface.md. Seeds M4-M7 (issues #96-99).
…count 544

Add an Issue tracking section and per-milestone issue links (#88-#115), and
correct the expander primitive-surface estimate from ~350-370 to the measured
544 (R3 spike). Marks R3 done and points to docs/expander-primitive-surface.md.
…closure)

Standalone spike (spike/r1-value-model/, not in the norac build) that freezes
the shared runtime representation M2 and B0/B2 both consume:
- tagged 64-bit nr_value (odd=fixnum, 000=8-aligned heap ptr, 010=singleton,
  110=char), 8-byte ObjHeader, flat NrClosure with B2's nr_code signature;
- Boehm-Demers-Weiser conservative GC committed for interpreter AND compiled
  code (moving/precise GC explicitly out of scope).

Proofs (all pass, -O2 and ASan): a tree-walking interpreter and a compiled C++
function compute identical results over the same heap and entry points; a flat
closure applies identically in both; a garbage loop churns 2.98 GiB through a
0.1 MiB live heap (~24000x) with RSS 4 MiB. ABI frozen in docs/value-model-abi.md.
…keleton

Rewrites the broken/stale NIR scaffold into a clean, minimal, representation-
agnostic MLIR dialect (the seed of B0):
- delete stale duplicate Ops.td (obsolete list<OpTrait> API);
- NirOps.td: nir.constant (i64 skeleton) + nir.return terminator, no
  nr_value-tagged types yet (those follow the M2 ABI freeze);
- add nir-opt.cpp (minimal mlir-opt clone registering the dialect) + CMake
  wiring; add round-trip test test/mlir/nir-roundtrip.mlir.

NOT YET VERIFIED: MLIR 22 is not installed on this machine (LLVM 22 is; MLIR is
a separate package), so this cannot be built or round-tripped yet. All of it is
behind NORA_ENABLE_MLIR=OFF; the default norac build + tests remain green
(21/21). See src/mlir/README.md for the one-command verify once MLIR is
installed. Issue #90 stays open.
Resolves the build against the now-installed MLIR 22:
- src/include/nir/CMakeLists.txt: pass -dialect=nir to the dialect-decls/defs
  generators (NirOps.td transitively pulls in the builtin dialect);
- src/mlir/CMakeLists.txt: PARTIAL_SOURCES_INTENDED on nirLib and nir-opt
  (two targets share the dir); link MLIRRegisterAllDialects/AllPasses;
- fix the nir.constant custom-form example (result type i64 is fixed, so it
  prints 'nir.constant 42', not '... : i64'); update the round-trip test.

Verified: cmake --preset mlir && build nir-opt; nir-opt test/mlir/nir-roundtrip.mlir
| nir-opt | FileCheck passes. Default norac build + tests unaffected (still green).
The machine grew Kont by one Frame::Call per call (popped only when the
activation returned), so tail loops were O(depth) in continuation space. Two
local changes make self- and mutual tail recursion O(1):
- continueStep(Frame::Seq): pop the sequence frame before its final expression
  (non-begin0), mirroring IfBranch, so a tail call there sees the enclosing
  activation frame on top;
- applyProcedure(): when the top of Kont is that activation (Frame::Call),
  reuse it (move in the new callee, clear its marks) instead of pushing a new
  Call frame.

Added test-first (Catch2 test_interpreter target + lit): tail loop returns the
right value; peak Kont is constant across depths (100 vs 100000) and < 16;
non-tail recursion still grows the continuation; mutual ev/od recursion is
bounded and correct. Exposed Interpreter::getPeakKont() for the assertion and a
minimal zero? predicate so a terminating loop can be written (full numeric
predicates are M4). 25/25 tests green under debug, asan and ubsan.
First slice of the shared value model. The interpreter clones values on every
environment lookup, so mutation and object identity were impossible. Introduce
a Box value whose single-slot cell is heap-allocated and *shared*: cloning a Box
shares the same cell, so set-box! through one reference is visible through
another, and (eq? b b) holds while (eq? (box 0) (box 0)) does not.

- ast::Box (ClonableNode) holding a shared_ptr<Cell>; get/set/identity.
- box / unbox / set-box! / eq? primitives (eq? uses cell identity for boxes,
  the structural valueEq otherwise).
- visitor plumbing (ASTVisitor/Interpreter/AnalysisFreeVars), AST_Box kind.

Built test-first: box round-trip; set-box! visible through a shared reference;
eq? distinguishes identity; + integration test box.rkt. 28/28 green under
debug/asan/ubsan. The shared_ptr cell is interim; a later M2 slice moves cells
onto the Boehm GC heap behind these same tests.
…q? (TDD)

Second slice of the shared value model, mirroring the Box slice. ast::Pair is a
cons cell whose car/cdr live in a heap-allocated shared_ptr<Cell>; cloning a
Pair shares the cell, so set-car!/set-cdr! through one reference are visible
through another and (eq? p p) holds while (eq? (cons 1 2) (cons 1 2)) does not.

- ast::Pair (ClonableNode) + car/cdr/setCar/setCdr/identity; AST_Pair kind and
  visitor plumbing.
- cons/car/cdr/set-car!/set-cdr! primitives; eq? extended with a pair-identity
  branch.

Built test-first: cons/car/cdr round-trip; set-car!/set-cdr! through a shared
reference; eq? distinguishes pair identity; + integration test pair.rkt. 31/31
green under debug/asan/ubsan. (List/Pair unification and car/cdr on quoted
lists are a later slice; the shared_ptr cell still moves onto the Boehm GC heap
in a subsequent slice.)
Symbols now have object identity, not just name equality. An interned symbol is
canonical by name (a global intern table hands out one stable pointer per name);
an uninterned symbol (gensym / string->uninterned-symbol) carries a unique token
shared across its clones. eq? compares that identity, so:
- (eq? 'a 'a) => #t   (interned, canonical)
- (eq? (string->uninterned-symbol "s") (string->uninterned-symbol "s")) => #f
- (eq? (gensym) (gensym)) => #f

- ast::Symbol gains identity()/isInterned()/makeUninterned() + a shared_ptr
  uninterned token; eq? extended with a symbol-identity branch.
- string->uninterned-symbol and gensym primitives.
- Wired the existing parseString into parseExpr: string literals now parse as
  expressions (a pre-existing gap that blocked string->uninterned-symbol "s";
  a '"'-token matches no other expression parser, so this is safe).

Built test-first: symbol eq? is identity not name; gensym distinctness; +
integration symbol.rkt. 33/33 green under debug/asan/ubsan.
Multi-agent design pass. Decision: GC-backed ValueNode (keep the class
hierarchy + visitor RTTI; GC-allocate, strip RAII members, share instead of
clone), reusing only R1's nr_value *immediate* encoding (the vptr sits at
offset 0, not an ObjHeader, so R1's object accessors are NOT reused in M2).
Transition scaffolding (legacy pin table + GC keep-alive root) keeps every
slice green with no destructor-leaking / cross-heap-dangling intermediate.
Forcing seam: a GC-heap-size hook (depth-independent live-heap plateau), not
RSS. Slices S0..S18; capstones at S13 (value garbage) and S17 (scope garbage,
deletes AllScopes).
… (TDD)

First slice of the value-model+GC migration (docs/value-model-gc-migration.md).
Brings the collector up without touching the value representation yet:
- src/nora_rt.{h,cpp}: the R1 nr_value IMMEDIATE ABI (fixnum/char/bool/singleton
  tags) promoted into the build. R1's ObjHeader-based object accessors are
  deliberately NOT promoted (M2 cells are polymorphic, vptr at offset 0, not an
  ObjHeader) and GC_set_all_interior_pointers is dropped.
- CMake: pkg-config bdw-gc (PkgConfig::BDWGC) wired into norac + both unit exes.
- GC_INIT() first in norac main and a shared CATCH_CONFIG_RUNNER test main
  (both test exes switched off CATCH_CONFIG_MAIN) so Boehm records the stack
  bottom on the main thread.
- Interpreter::getGCHeapSize()/getGCTotalBytes() hooks for the forcing seam.

Test-first: test_gc.cpp exercises the immediate ABI + GC_MALLOC/heap-size (RED
before libgc was linked). 34/34 green under debug, asan, ubsan; norac end-to-end
unchanged. GMP-through-GC hook is deferred to S11 per the plan.
Move the Kont vector's backing store onto the Boehm heap so that, as values
migrate onto the GC heap in later slices, in-flight values held in continuation
frames stay reachable (the Kont header lives in the stack-resident Interpreter,
so Boehm's stack scan roots the buffer). Behaviour-preserving; frame elements
are still legacy unique_ptr/shared_ptr and are destructed normally.

- src/include/gc_alloc.h: GcAllocator<T>, a minimal exception-free allocator
  over GC_MALLOC (scanned). Boehm's own gc_allocator needs -fexceptions, which
  this codebase disables; deallocate is a no-op (conservative-GC-safe).
- Kont: std::vector<Frame> -> std::vector<Frame, GcAllocator<Frame>>.

Scoped to Kont (self-contained: Interpreter.cpp uses only the vector API on it).
Frame::Done and the mark containers move in later slices (they flow into
default-allocator params / have the keep-alive-root backstop). Forcing test at
the GC-heap seam: a deep non-tail loop now churns >100 KB of GC bytes (~0
before, since nothing was GC-allocated during eval). 35/35 green debug/asan/ubsan.
Thread a Value handle (src/include/Value.h) through the machine's value register
(Val) and linklet Result. Value is the migration vehicle
(docs/value-model-gc-migration.md §3): it will become a bare nr_value word
(immediate | GC pointer | legacy pin-index) so GC cells can hold it, but in this
phase it simply carries the legacy heap ValueNode by unique_ptr and is
behaviourally identical to it.

Move-into-register sites (deliver, = nullptr, = Last) are unchanged via implicit
ctors; the 14 move-out sites use Value::takeLegacy() to hand the unique_ptr to
the still-legacy frame/env slots (those migrate in S3/S4). Behaviour-preserving
refactor; the whole suite is the guard: 35/35 green under debug/asan/ubsan.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1740c9d94e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Interpreter.cpp
// activation (Frame::Call), reuse it instead of stacking a new one. Together
// with popping Seq/if/let-body frames before their tail sub-expression, this
// makes self- and mutual tail recursion run in O(1) continuation space.
if (!Kont.empty() && Kont.back().K == Frame::Call) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make tail calls through continuation marks constant-space

When the tail call occurs as the result expression of with-continuation-mark, Frame::WcmMark is still on top of Kont while that expression is evaluated, so this check fails even though the application is in tail position and the code falls through to Kont.emplace_back(Frame::Call). A loop such as (lambda (n) (with-continuation-mark 'k n (if (zero? n) 0 (loop (- n 1))))) therefore grows one activation per iteration rather than satisfying M1's proper-tail-call guarantee for this existing form.

Useful? React with 👍 / 👎.

Comment thread src/Runtime.cpp
auto const *SB = llvm::dyn_cast<ast::Symbol>(B);
Eq = (SB != nullptr) && SA->identity() == SB->identity();
} else {
Eq = ast::valueEq(*A, *B);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve symbol identity for quoted eq? operands

When one operand to eq? is a quoted symbol, it reaches this fallback as an ast::QuotedExpr, so the symbol identity branch above is skipped and valueEq unwraps it then compares Symbol::operator== by name. This makes (eq? 's (string->uninterned-symbol "s")) return #t, contradicting the new uninterned-symbol identity semantics; unwrap quoted datums before dispatching or make this path use Symbol::identity() for symbols.

Useful? React with 👍 / 👎.

Comment thread src/Runtime.cpp
const llvm::SmallVector<const ast::ValueNode *> &Args) const override {
static unsigned Counter = 0;
std::string Base = "g";
if (Args.size() == 1) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject extra arguments to gensym

For calls with two or more arguments this condition is simply skipped, leaving the default base and returning a fresh symbol. That means (gensym 'a 'b) succeeds instead of reporting the same invalid-arguments error pattern used by the other primitives, so arity mistakes are silently accepted; reject Args.size() > 1 before constructing the symbol.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant