Skip to content
ry

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.

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) -> double compiles, 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, and match compiles 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] <- 0 copies. Every elided count is materialized before a native call. Deoptimization rebuilds the exact counts. The effect on a loop like for (i in ...) x[i] <- v is that it copies at most once, on entry if x is 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, Map and Reduce constantly. When the JIT sees vapply(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::map runs 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_ok and Try.map_ok!.1 R cannot split lapply or Map that 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_alloc is an arena by R’s own contract (freed when the foreign call returns), and allocVector3 with an R_allocator_t is 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 <<- or assign, by storing it in a list or attribute, through a closure over its frame, through parent.frame(), sys.frames(), eval or delayedAssign, 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 .Call or .External as 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 [ returns NA, and an out-of-range write extends the vector), so a compiled for (i in seq_along(x)) y[i] <- x[i] * 2 carries checks on every iteration unless they are proven redundant. goose’s domain fits: for evaluates seq_along(x) once, so i ranges over 1..length(x), and after preallocation length(y) >= length(x), which removes both checks. The facts need goose’s generations, because rebinding x or y in the body kills them. Only positive in-range subscripts are bounds facts. Zero, negative and NA subscripts, [[ (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 allocates z once, 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.

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.prototype throws a TypeError, which makes the compiler’s assumption true. Adding members, or patching console, process or fetch, 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. --explain lists code that compiled to a slow path, worst first, with a fix for each, and --explain=json prints 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 eval and 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, because eval, get and assign reach 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’s local_mocked_bindings and assignInNamespace rebind 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 install writes into each installed package, which matters for CI machines that run several test processes and are bound by memory, and for ry build --exe, which writes a standalone executable as bun compile does.
  • ry check --explain for 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.

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.

  1. 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

  2. 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 ↩

  3. 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

  4. Bend repository: README.md, guide/EFFECTS.md, https://github.com/HigherOrderCO/Bend ↩ ↩2 ↩3

  5. goose repository: README.md, docs/implementation.md, docs/reference.md, https://github.com/aardappel/goose ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  6. goose-lang/goose: a Go subset with a semantics in Coq, https://github.com/goose-lang/goose ↩

  7. 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

  8. 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

  9. Open Knowledge Format v0.2, https://github.com/GoogleCloudPlatform/open-knowledge-format/blob/main/SPEC.md ↩