Skip to content
ry

Prior art from new language implementations

This page studies four recent language implementations 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

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. Roc applies the Perceus “functional but in place” scheme to do this.2 The decision is still made at runtime: Roc’s design notes say that “runtime mutation still uses refcount == 1 to decide whether in-place mutation is allowed”. The compiler’s part is to make the counts precise, inserting increments and decrements exactly where they are needed and solving which arguments are only borrowed, so the runtime check succeeds as often as it can.1 That 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 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

What ry adopts.

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

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 Its JIT is LLVM run through lli.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.

This page is an Open Knowledge Format v0.2 concept document.8 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

  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

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

  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

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