Prior art from new language implementations
This page studies four recent language implementations and one new compiler for ideas that ry’s R
runtime can use, and decides which ones it takes. One constraint decides every verdict, and it comes from
the runtime design: R’s C API must work unchanged. Any object a
native package can reach is therefore a non-moving heap object with a reference count, reached
through a raw SEXP pointer, rooted by PROTECT, possibly an ALTREP vector, and possibly written to
by native code without regard for its reference count.
What ry takes, in one table
Section titled “What ry takes, in one table”| Idea | From | Verdict | Where it lands in ry |
|---|---|---|---|
| Precise reference counts, so the runtime “count is one” check succeeds more often | Roc (Perceus) | Adopt | JIT-compiled code; copy-on-modify for vectors and the table type |
| Borrowed arguments: a callee that only reads an argument leaves its count alone | Roc, Bend | Adopt, as a speculation | The JIT, for callees it can see entirely |
| Escape analysis, inspired by Bend’s share inference | Bend | Adopt, scoped | Temporaries in compiled code that native code never sees |
| Call-site specialization of known closures, inspired by lambda sets | Roc | Adopt, scoped | lapply, vapply, Map, Reduce in compiled code |
| One IR feeding several backends | Roc | Adopt | Bytecode feeds the interpreter and the Cranelift tier |
| A fast dev backend beside an optimizing one | Roc | Keep as an option | The optional copy-and-patch baseline tier |
| Static dispatch only | Roc | Reject | S3, S4 and R6 are dynamic; ry speculates on the class instead |
| Bounds-check elimination over difference constraints | goose | Adopt | Subscript checks in compiled loops |
| Values built in their final destination | goose | Adopt | Result preallocation in fused loops |
| No heap, data stacks only, no reference counts | goose | Reject | Incompatible with SEXP identity and R_PreserveObject |
| Static multi-frame returns instead of an unwinder | goose | Reject | R’s conditions and restarts are dynamic |
Compile to one C file, call C through extern fn or spliced effects |
goose, Bend | Reject | ry hosts the existing C ABI; it does not emit C |
| Even-split parallel forks with no work stealing | Bend | Reject | R code makes no balance promise |
| Residency of data as a tracked fact | Vx | Adopt the idea, not the syntax | Table columns: Arrow buffer or R vector, plus a lint |
| Content-hashed identifiers | Vx | Adopt for on-disk caches only | Cache keys for bytecode and runtime summaries |
| LLVM and MLIR as the JIT backend | Vx | Reject | Compile latency; Cranelift is the choice |
| A top level that cannot perform effects | Roc | Adopt, planned for strict mode | A strict library file’s top level holds only definitions and pure constants |
Purity inference: effectful types use =>, effectful names end in ! |
Roc | Reject | Freedom from effects stays a runtime body fact the JIT reads, outside the type |
| Macros | Vx (macro_rules), Bend (templates) |
Reject | ry has no macros, for checking speed; R’s own non-standard evaluation keeps working |
| Types as facts (free) or guesses (one check), with a second compiled copy that reads a guess in place | Bun | Adopt, keeping errors for non-conforming values | Typed entry stubs (typed-runtime.md, “Facts and guesses”) |
| Whole-program value tracking | Bun | Adopt per namespace | The typed core of a locked namespace or strict file; untyped code keeps profiles |
| Freezing built-ins instead of deoptimizing | Bun | Adopt for ry’s standard library only | R bindings get invalidation on unlockBinding, because mocking rebinds them |
| Machine code mapped read-only from the file and shared between processes | Bun | Adopt | Typed code files in installed packages and executables |
| A report of code compiled to a slow path, worst first, with fixes | Bun | Adopt | ry check --explain |
| Each program runs interpreted, compiled, and compiled with types erased; guesses forced to fail; facts checked | Bun | Adopt | Typed differential tests and fuzzing |
| No compiler at run time, so every fallback is compiled at build time | Bun | Reject | ry compiles a second copy at the first call that needs it |
| A standalone executable with its machine code mapped from the file | Bun | Adopt | ry build --exe |
| Reference counting with no tracing collector, for immutable acyclic values | Roc, Koka, Lean 4 | Adopt for native typed values | Typed ry’s own values; R values stay under the collector (typed-runtime.md, “Memory”) |
What it is. Roc (roc-lang/roc) is a pure functional language whose compiler is being rewritten in Zig. It has no 0.1 release yet.1
What it does best for ry. Roc’s memory model is the closest match to R’s own value semantics.
Lists and other builtins are reference counted, and a builtin that receives a value whose count is
one updates it in place, while a shared value is shallowly cloned first.1 So List.set on a
unique list costs nothing extra. The older Rust compiler does this with the Perceus “functional but
in place” scheme (inc_dec.rs, borrow.rs, reset_reuse.rs), and its reuse still tests the count at
runtime.2 The Zig compiler replaces Perceus with its own pass (src/lir/arc.zig), which runs
after all specialization: it infers borrowed parameters, moves fields out of aggregates that die, and
reuses dying allocations as the destination of a new value. In optimized builds it also infers
uniqueness, so where it can prove a value is unique it removes the refcount == 1 check
altogether.1 Roc’s alias analysis (Morphic) was built to decide in-place updates for list
operations, but every optimization level calls its trivial solver, which answers “immutable”, because
of a loop bug and little measured gain.1 So in practice uniqueness is either proven by the ARC
pass or checked at runtime, which is R’s copy-on-modify contract (R decides by MAYBE_SHARED, which
reads the count) with a compiler that keeps the counts tight.
When a function only reads an argument, passing it leaves its count unchanged, and the compiler works
this out by itself.1 Higher-order functions compile by lambda set specialization, a form of
defunctionalization: by code generation time a closure is a tagged union whose tag names the
procedure and whose payload is the captured environment, so calls through it are direct (Roc also
has boxed erased callables for the cases this does not cover).1 The Zig compiler monomorphizes
by default, and it has an experimental --specialize=no mode, .boxy, that passes runtime type
descriptors instead, which trades speed for compile time.1 Its inference is Hindley-Milner,
and its only ad hoc polymorphism is static dispatch on nominal types; dynamic dispatch is
unsupported by design.1
Roc has an interpreter, a dev backend that emits machine code directly for fast builds (also for
wasm32), and LLVM for optimized builds, all fed from shared lower-level IRs.1 Its platforms
supply the allocator: the host implements malloc and free for the Roc code it embeds, which lets
a web server allocate each request into an arena and reset it afterwards.1 Roc has no stackless
coroutines or continuation transform: its interpreter recurses on the native stack, tail calls
compile through tail-recursion-modulo-cons and a trampoline, and asynchronous IO lives only in hosts
(basic-cli blocks on a tokio runtime for each hosted call).1 Effectful functions are direct calls
into the host through the C ABI.
What ry adopts.
- Monomorphized natives with an unboxed calling convention. A closure typed
#: (x: double) -> doublecompiles, at its first call, to a native function per argument type list. Its arguments and result travel as machine words, with no promises, no environment and no argument matching, and only the boundary to untyped code boxes them. Records, tuples and tags travel as pointers whose field types come from an allocation-site profile, andmatchcompiles to a branch on the layout id. This is Roc’s specialization applied at runtime, where R loads code: the cache key is the closure and the argument types seen, not a whole-program instantiation (decisions.md, “Typed closures run as native functions over records, tags and match”). - Precise counts in compiled code. The interpreter keeps R’s count updates at every binding,
which is what native code observes. The JIT tracks counts precisely inside a compiled function and
drops increments and decrements that cancel. Three rules keep this correct for R. A binding in an
elided environment still counts as a reference, so
y <- x; x[1] <- 0copies. Every elided count is materialized before a native call. Deoptimization rebuilds the exact counts. The effect on a loop likefor (i in ...) x[i] <- vis that it copies at most once, on entry ifxis shared, and the per-iteration sharing check moves out of the loop. (GNU R already updates an unshared local in place; the gain is the hoisted check and dispatch.) - Call-site specialization of known closures. R code passes functions to
lapply,vapply,MapandReduceconstantly. When the JIT seesvapply(xs, function(x) x * 2, double(1))with a known closure, it compiles a loop that calls the closure directly. This is ordinary callback inlining with a deoptimization exit, inspired by lambda sets rather than a copy of them, because R semantics require the closure object to exist if anything reflects on it. It covers base R’s higher-order functions, which ry implements itself.purrr::mapruns its loop in purrr’s own C code, so the JIT cannot specialize it. - One IR for every tier. ry’s bytecode feeds the tier 0 interpreter, and tier 1 compiles from the same bytecode (through its own SSA form) to Cranelift, as Roc’s backends share their lower-level IR. Roc’s fast dev backend corresponds to the optional copy-and-patch baseline tier in the runtime design.
What ry rejects.
- Static-only dispatch, because R’s S3 dispatch on
class(x), S4 generics and R6 methods are dynamic by definition, and existing code depends on that. ry gets most of the benefit by speculating on the class seen at a call site, guarded by the class attribute. - Whole-program monomorphization, because R loads code at runtime. ry specializes per closure and argument type list at its first call instead.
- Runtime type descriptors in place of specialization (
.boxy), because ry’s tier 0 interpreter already plays that role and the native tier exists to remove it. - Purity inference. Roc infers which functions are effectful, lets only those call each other,
and so guarantees that a top-level constant performs no effect and can be evaluated at compile
time. It has no effect polymorphism, so a higher-order function exists twice, as
Try.map_okandTry.map_ok!.1 R cannot splitlapplyorMapthat way, effect polymorphism would put an extra variable in the types R code calls most, and an inferred effect in a function’s type would let a body edit change every caller’s interface. ry takes the guarantee without the inference: strict mode is planned to limit a library file’s top level to definitions and constants that call only functions declared pure. - A host-provided allocator as a design principle. R already has the parts it needs:
R_allocis an arena by R’s own contract (freed when the foreign call returns), andallocVector3with anR_allocator_tis R’s hook for custom vector allocation. ry implements both as specified.
What it is. Bend (HigherOrderCO/Bend) is Higher Order
Company’s pure functional language with an affine dependent type system. Its current runtime is no
longer the interaction-combinator evaluator of HVM: the runtime paper says it “keeps the goals and
drops the mechanism”, replacing graph reduction with ownership.3 The compiler emits one C
file that runs on one thread, on CPU threads, or on the GPU through Metal or CUDA, over one heap,
with no garbage collector.3 Bend also checks proofs of user-declared laws (LAWS.bend),
which is outside ry’s scope.4
What it does best for ry. Bend’s runtime is an argument that knowing ownership statically
removes most runtime costs. Because the type system is affine, a value has one owner, and the match
that consumes it frees it, so deallocation is compiled code.3 Two whole-program analyses
decide the remaining memory traffic.
- Share inference finds the constructor types that need reference counts at all. A type is “hot” when some binder of it is used more than once, when a hot type’s live field reaches it, or when it is boxed in an array; every other type is built and consumed with plain stores and loads.3
- Borrow inference finds the arguments a callee only reads. Each argument starts borrowed and flips to owned on any escape, to a fixpoint, and a borrowed argument is lent raw, so “a fold over a reused tree costs no count traffic”.3
A term is one 64-bit word, and small values (a machine word, a constructor with one word-sized
field) are packed into the word and never touch the heap.3 Parallelism is explicit: a
parallel let that binds several calls means, in the paper’s words, “run these calls in parallel;
they split the work into roughly equal parts”, and the scheduler trusts that promise, with no shared
queue, no locks and no work stealing.3 On the authors’ benchmarks, the sequential build runs
within 0.8 to 1.5 times the time of hand-written C, sixteen threads give 9 to 12 times one thread,
and an integrated GPU up to 67 times.3 Foreign functionality enters through effects: an
effect is a definition of type IO(R) whose body imports a .c (and a .js) file that registers a
C implementation, which the compiler splices into the generated program.4
Bend’s runtime is also stackless: user code never grows the C stack. A call compiles to a tail call,
a cut or a fork of a segment (with preserve_none and musttail in the generated C), a pending
continuation is a heap node, and IO is written in continuation-passing style.3 Its
predecessor HVM2 packs a term into 32-bit ports with 24-bit numbers, and balances work through
per-thread redex bags that neighbouring threads steal from.4
What ry adopts.
- Borrowed arguments, as a speculation. Bend can borrow soundly because it is affine and its
analysis sees every escape. R’s escape facts are not sound in general: a callee can capture an
argument through
<<-orassign, by storing it in a list or attribute, through a closure over its frame, throughparent.frame(),sys.frames(),evalordelayedAssign, through the promise itself (substitute,match.call), or in native code (R_PreserveObject, an external pointer’s protected slot). If a borrowed value were captured, a later in-place update by the caller would mutate the captured copy. So ry borrows only when the whole callee is compiled and visible, treats any call to unknown R code or to.Callor.Externalas an escape, and undoes the borrow on deoptimization by restoring the counts. The runtime design’s body facts (section 8 there) are where the “only reads” fact will live; the checker does not compute it today. - Escape analysis for temporaries, inspired by share inference. Share inference classifies types, which does not transfer to R. What does transfer is its premise that a value nobody else can see needs no count. A vector that a compiled loop creates and consumes, and that never reaches an environment, an attribute or a native call, needs no count, and often no heap object at all. The JIT’s scalar replacement and fusion are meant to handle that case.
What ry rejects.
- Ownership as the memory model. R values are freely aliased, and the C API exposes aliasing
directly (
R_PreserveObject, external pointer protection slots, environments). Affine typing cannot be retrofitted onto existing R code, so ry keeps a tracing collector plus reference counts and uses ownership only as an optimization inside compiled code. - Even-split forks without work stealing. The balance contract suits a language designed around
it, but R’s parallel code (
foreach,parallel,future) makes no such promise, and table operations need a work-stealing pool, which Polars already brings. - Effects spliced into generated C, because ry hosts existing compiled packages through the C ABI rather than emitting C.
What it is. This page studies aardappel/goose, a
memory-safe systems language by Wouter van Oortmerssen.5 The name is ambiguous. The most
prominent “goose” on GitHub is Block’s AI agent, which is not a language, and goose-lang/goose is a
subset of Go with a semantics in Coq, built for verification rather than as an implementation to
learn runtime techniques from.6 aardappel/goose is the one that matches the comparison.
What it does best for ry. goose has no heap at all. Every dynamic value lives inline on data
stacks the compiler assigns statically, growth advances a pointer, and memory is freed when its scope
ends. There is no allocator, garbage collector, reference counting or destructor, and the type rules
make the model memory safe without lifetime annotations.5 Values are built in their final
destination through any depth of calls, so items.push(parse(line)) writes the parsed record
directly into the array.5
Its bounds-check elimination pass removes every check it can prove cannot fire. It uses a
flow-sensitive domain of difference constraints, facts of the form l <= r + c over integer
variables and array lengths, and answers a query as a shortest path over those facts. Every base,
whether a variable or an array length, carries a generation, so a fact dies when the variable or
array it describes changes.5 The pass is “required
only to be sound”: a program’s meaning never depends on it.5 The README reports that over
sixteen benchmarks goose runs 1.16 times as fast as hand-optimized C++ and 1.12 times as fast as the
best safe Rust, while using less memory.5 It compiles to one C file and calls C through
extern fn, with a bundled TinyCC backend that compiles and runs a program in-process.5
What ry adopts.
- Bounds-check elimination over difference constraints. R’s subscripts are checked (an
out-of-range read with
[returnsNA, and an out-of-range write extends the vector), so a compiledfor (i in seq_along(x)) y[i] <- x[i] * 2carries checks on every iteration unless they are proven redundant. goose’s domain fits:forevaluatesseq_along(x)once, soiranges over1..length(x), and after preallocationlength(y) >= length(x), which removes both checks. The facts need goose’s generations, because rebindingxoryin the body kills them. Only positive in-range subscripts are bounds facts. Zero, negative andNAsubscripts,[[(which errors out of range), and a classed vector with its own[<-method all stay behind guards. Like goose’s, the pass need only be sound. - Construction in place. When the JIT fuses
z <- x * y + 1, it allocateszonce, at its final size, and the fused loop writes into it. When a function’s result is assigned straight into a list element or a column, the JIT can pass the destination down.
What ry rejects.
- The heapless memory model, because R needs stable object identity and native code needs
pointers that outlive scopes (
R_PreserveObject). - Static multi-frame returns, because R’s condition system is dynamic.
- Shared-nothing threads as the language model, because R code already shares through environments, and ry keeps R-level evaluation single-threaded.
- In-process C compilation as the build path. It is attractive for compiling package code quickly during development, but it does not replace building packages with the platform’s real compiler, which the C++-heavy part of the benchmark dependency set requires.
What it is. Vx (vx-lang/Vx) is a research systems language, written in Rust and compiling through MLIR and LLVM, that puts hardware topology and memory placement into the type system. Its README describes it as an early research compiler at version 0.0.3.7
What it does best for ry. A Vx tensor’s type carries its element type, its dimensions and its
residency, for example Tensor<f32, [4, 4], Memory::GPU_HBM>, and the compiler rejects host code
that reads device memory, transfers with no declared hardware path, and working sets that exceed a
device’s declared capacity.7 Machines are described declaratively (memory capacities,
bandwidths, links), so a program can be checked against a target it is not running on.7 Its
frontend identifies every symbol and instantiation by a 256-bit content hash and type-checks modules
in parallel without locks, with output that is byte-identical across thread counts.7 An identifier
packs a module hash, a symbol hash, a generic hash and flags; an instantiation gets an arena index,
and content digests are used only inside evaluation, never merged into the identity.7 Its “JIT”
compiles the module with llc to an object file, links it with clang and runs the executable;
lli appears only in its roadmap.7
What ry adopts.
- Residency as a tracked fact. ry’s table columns live either in an Arrow buffer owned by Polars or in an R vector. Integer and double columns without nulls can be read in place, while logical, character and dictionary columns, and any column native code wants to write, must become R vectors first (section 7 of the runtime design). Vx makes every transfer explicit in source and checks it at compile time; ry keeps the transfer implicit, because R users should not have to write it, and borrows the checking side instead: a lint when typed code forces a column to materialize inside a loop.
- Content hashes as on-disk cache keys. The runtime caches bytecode and per-function summaries across processes. Salsa’s interned ids are stable only inside one database, so these caches need a key derived from content, which is what Vx’s identifiers are. A hash derived from content is not a second source of truth.
What ry rejects.
- Content hashes as the in-memory identity, because salsa’s interned ids already give every item and type a stable identity within a session.
- MLIR and LLVM as the JIT backend, for the compile-latency reasons in the runtime design.
- Placement types as a language feature, because no benchmark code base targets accelerators.
Bun’s compiler
Section titled “Bun’s compiler”What it is. bun compile (shorthand for bun build --compile --aot) compiles TypeScript and
JavaScript to machine code at build time and writes one executable with no JIT, no bytecode and no
profiling data in it. Bun documents it as experimental.8 It type-checks the whole program,
tracks values through it, optimizes each function and generates code with the backend of
JavaScriptCore’s fastest JIT.8
What it does best for ry. It solves the problem ry’s typed runtime has: typed code that must run fast without trusting values that arrive from untyped code.
- Facts and guesses. A type describing a value the program creates is a fact: objects get fixed
layouts and a property read is one load with no check. A type describing a value from outside
(
JSON.parse,any,as, a.d.ts-only package) is a guess and costs one layout check where the value arrives. A failed check sends the call to a second copy of the function that treats only that parameter as plain JavaScript. A mismatch never throws or converts, so types change speed and never behavior.8 - Whole-program value tracking. For each variable, parameter and property it works out which objects can reach it, and for each call which functions it can reach. Untyped JavaScript gets fixed layouts the same way, and code the compiler cannot resolve looks properties up at run time.8
- Freezing instead of deoptimizing. An executable cannot discard compiled code, so replacing or
deleting a member of a built-in object such as
Array.prototypethrows aTypeError, which makes the compiler’s assumption true. Adding members, or patchingconsole,processorfetch, works.8 - Shared code. The machine code and its tables are read-only and mapped straight from the file, so processes and workers share them.8
- Explaining slow paths.
--explainlists code that compiled to a slow path, worst first, with a fix for each, and--explain=jsonprints one object per line.8 - The test matrix. Each test runs under
bun run, compiled, and compiled with every annotation erased, and all three must match in output and exit status. Programs are also compiled with every guess forced to fail, and a mode checks every fact the executable relies on at run time.8
What ry adopts. All six, fitted to R (typed-runtime.md holds the design):
- Facts and guesses, but with ry’s contract: an annotation means the value has the type, so a value that does not conform is refused with a type-mismatch error. A conforming R value takes the second copy, which reads it in place, because typed ry’s own values are native values outside the R heap and an R value never has their layout.
- Value tracking per namespace. The typed core of a locked namespace or a strict file has no
evaland its bindings are locked, so it is closed apart from the functions that escape to R (exports, registered S3 methods and closures it hands out). Untyped R code is never closed, becauseeval,getandassignreach any binding, so it keeps allocation profiles. - Freezing for ry’s standard library. No existing R code rebinds
List.map, so its namespaces cannot be unlocked, and a call the checker resolves to it is direct. R’s own locked bindings are not frozen, because testthat’slocal_mocked_bindingsandassignInNamespacerebind them on purpose. Instead, the binding-unlock primitive discards the compiled code whose direct calls assumed the binding. - Mapped, shared code for the typed code that
ry installwrites into each installed package, which matters for CI machines that run several test processes and are bound by memory, and forry build --exe, which writes a standalone executable asbun compiledoes. ry check --explainfor the slow-path report. With no profile, it ranks by loop depth.- The test matrix for typed differential tests and the typed fuzzer.
What ry rejects.
- Compiling every fallback at build time, because ry has a compiler at run time and compiles a second copy at the first call that needs it.
- Types that never change behavior, because ry’s annotations are checked at the boundary and report mismatches.
About this page’s format
Section titled “About this page’s format”This page is an Open Knowledge Format
v0.2 concept document.9 The frontmatter carries the type, the provenance (sources) and the
trust (generated, verified) fields that format defines, and each claim about a project cites its
source with a footnote keyed to a sources entry. The docs site does not render frontmatter, so each
footnote also carries the source’s link.
Footnotes
Section titled “Footnotes”-
Roc repository: docs/langref, design.md, design/lambda-set-specialization.md, https://github.com/roc-lang/roc ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12
-
Reinking, Xie, de Moura and Leijen: Perceus, Garbage Free Reference Counting with Reuse, https://www.microsoft.com/en-us/research/wp-content/uploads/2020/11/perceus-tr-v4.pdf ↩
-
Taelin et al.: BendRT, a Parallel Runtime for CPUs and GPUs, https://github.com/HigherOrderCO/Bend/blob/main/paper/BendRT.pdf ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9
-
Bend repository: README.md, guide/EFFECTS.md, https://github.com/HigherOrderCO/Bend ↩ ↩2 ↩3
-
goose repository: README.md, docs/implementation.md, docs/reference.md, https://github.com/aardappel/goose ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
goose-lang/goose: a Go subset with a semantics in Coq, https://github.com/goose-lang/goose ↩
-
Vx repository: README.md, ROADMAP.md, docs/language_rules_for_a_parallel_compiler.md, www/book/src/quick-reference.md, https://github.com/vx-lang/Vx ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Bun documentation: the ahead-of-time compiler, and its internals page, https://bun.sh/docs/compiler/aot, https://bun.sh/docs/compiler/internals ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
Open Knowledge Format v0.2, https://github.com/GoogleCloudPlatform/open-knowledge-format/blob/main/SPEC.md ↩