Skip to content
ry

The typed runtime

This draft designs how the typed core of ry runs, built to the runtime contract of the native-types design (native-types.md in this folder, section “Runtime contract”). The bar is the typed benchmark programs within 1.5 to 2 times the time of equivalent Rust programs. That calls for Rust-shaped data and calls: values in registers, flat structs, direct calls, no per-value type checks, no tracing collector, and no allocation the program did not ask for.

The contract gives List<F64>, List<Bool> and List<Str> R’s vector layouts, so that they pass to C without a copy, and makes a Str a CHARSXP. That assumes typed values live on the R heap. This draft makes them native values freed by reference counts (decisions.md, “Typed values are native, immutable, acyclic and freed by reference counts”), so every list crosses to R by a copy, and the contract is amended to match.

type Node = :Num(value: F64) | :Neg(inner: Node) | :Add(left: Node, right: Node)
evaluate <- function(node: Node) -> F64 {
match (node) {
:Num(value = v) => v
:Neg(inner = e) => -evaluate(e)
:Add(left = a, right = b) => evaluate(a) + evaluate(b)
}
}

evaluate compiles ahead of time to one machine function that takes a Node reference in a register and returns a double in a register. The reference is a 32-bit index with the tag in its upper bits, so match jumps through a table on bits it already holds. Reading v is one load from the :Num value column at that index, and the recursive calls are direct calls. node is borrowed, so the function touches no reference count. Nothing checks a type, nothing allocates, and no R frame exists. The interpreter sees evaluate as an ordinary closure: when untyped R code calls it, an entry stub handles the R value it is given (see “Facts and guesses”) and calls a machine function.

Typed closures run as native functions from their first call (decisions.md, “Typed closures run as native functions over records, tags and match”), and typed orders and expr_tree run 13 and 80 times faster than GNU R. That path compiles R syntax with types it infers from the first call’s argument values, and it treats those types as speculation:

  • Every field read checks the slot’s representation, because a record’s slot kinds come from allocation profiles, not from its type.
  • Every native returns a status word, and a failure reruns the whole call in tier 0.
  • Every record is an R heap object, allocated through a runtime call that pushes a handle, and traced by the collector.
  • Strings, lists and the standard library are refused, so a typed program that builds a list runs at untyped speed.

The native-types contract makes the typed core sound: no NA, no attributes, no promises, no dynamic scoping, exhaustive match, and immutable values. A sound type leaves nothing to speculate on, so the end shape compiles from the checker’s types, and the checks above disappear instead of getting cheaper. Immutable values also cannot form a cycle, which is what lets the typed core drop the tracing collector (see “Memory”).

The typed core IR. For each strict-clean typed function, the analysis lowers the body to a typed core IR: a control-flow graph in SSA form over ry types, with every name resolved and every match compiled to a decision tree. The exhaustiveness checker runs Maranget’s usefulness algorithm, and the IR adds the matching compilation from the same paper. Standard library calls such as List.map and List.walk are IR operations, not calls, so the backend can fuse and inline them. Reference-count operations are inserted into the IR (see “Memory”), so the backend sees them as ordinary instructions it can move and cancel.

Where the IR lives. The runtime cannot link the analysis: the loader runs inside libR on ry’s runtime (decisions.md, “A strict reload re-checks through a persistent salsa database”), and the JIT never reads the checker’s structures (r-runtime.md, section 5). So the IR is a serialized per-function summary that the analysis computes, as a salsa query per function, and stores with the function’s bytecode. A generic function’s summary is its generic IR, which the runtime instantiates when untyped R code calls it with an instantiation the checker never saw.

Ahead of time. A typed function never runs in an interpreter. ry run and ry load check a strict file before it runs, and a typed function is compiled before its first call: when the lazy loader first forces its binding, so a session compiles only what it uses, as the mandate for ry load requires. While the program runs, worker threads compile the functions that a compiled function can call, which value tracking lists, so most calls find their callee compiled. ry install compiles all of a package’s typed functions and writes the machine code into the installed package, beside its R code, as part of the package’s build output. Each instantiation the checker sees is compiled once, as Roc does. Compiling a function at a later call happens only for a generic instantiation that untyped code creates and for the second copies in “Facts and guesses”.

No code cache. Compiled code is a build output, not a cache. An installed package holds one code file for its namespace, written by ry install and replaced only by reinstalling, and a standalone executable holds one for its program. A code file is stamped with the ry build that wrote it and with the interface hashes of the namespaces it calls into, and a stamp that does not match at load sends the namespace through compilation in memory with a warning to reinstall. Code that ry run and ry load compile for a script or a package under development lives in memory for the process. That costs Cranelift’s compile time, about a millisecond per function on a worker thread, for each function a session uses, which keeps the fast path free of a cache to key, invalidate and keep coherent. If start-up time on the benchmark code bases says otherwise, the fix is a faster baseline compiler (prior-art.md, the copy-and-patch tier), not a cache.

Mapped, shared code. A code file is position-independent, and everything that differs between processes (runtime entry points, interned CHARSXP pointers for string constants that cross to R, R symbols, the base addresses of the value pools) is loaded from a small table that each process fills when it maps the file. The code pages are mapped read-only straight from the file, so the four test processes a CI machine runs share one copy of every installed dependency’s typed code, and only the table is private to each. ry install writes the file to a temporary name and renames it into place, so a process never maps a half-written file.

Standalone executables. ry build --exe main.R writes one executable in the spirit of bun compile: ry’s runtime, the bytecode of the script and of the strict files it sources, and the code file for their typed code, appended as a section that the executable maps read-only at start. Running the executable runs the script as ry run main.R does, with its arguments in commandArgs(). Plain ry build on a package writes a source tarball as devtools::build() does, which existing workflows depend on, so the executable is an option of the same command for a script, which has an entry point, and not its default for a package, which has none. Packages with compiled code load from the library as they do under ry run, because their shared objects are built against libR and are not part of the program.

Value tracking. Inside one locked namespace or one strict file, the typed core is a closed program: it has no eval and no dynamic scoping, and its bindings are locked, so the analysis can work out, for each variable, parameter and field, which allocation sites and which closures can reach it, and for each call, which functions it can reach. Bun’s compiler does this for a whole program (prior art). ry does it per namespace, because code outside a namespace reaches it through every function that escapes: an exported function, a registered S3 method, and any closure the namespace hands to R. Those functions’ parameters only ever hold guesses. The results drive closure specialization across functions (see “Closures” below) and the facts the next section relies on. A locked binding can still be unlocked, so every result derived from one is discarded with the code that used it (see “Redefinition” below). Untyped R code is never closed, since eval, get and assign reach any binding, so it keeps the allocation profiles of the native path.

Backend. Cranelift, as for the JIT tier (r-runtime.md, section 5). LLVM would win some throughput on numeric loops, but the bar is set on programs that call functions and walk data, where layout and calls dominate.

A type in compiled code is either a fact or a guess, a split that comes from Bun’s compiler. A fact describes a value the typed core created, such as a record built by (x = 1, y = 2) in typed code or a list returned by List.map. Such a value is a native ry value with the layout its type fixes, so a field read is one load with no check, and a fact costs nothing at run time. A guess describes a value that arrives from outside the typed core: an argument from untyped R code, a value returned by an R function, or a value read from a binding R code can write. Such a value is an R value under the collector, and a guess costs one check where it arrives, and nowhere else.

The check reads the R value’s type code, length and attributes, which takes constant time for a scalar, a record, a tag or an R vector of the right type. Then:

  • a value that does not have the declared type is refused with the type-mismatch error the contract promises, so annotations keep their meaning at the boundary;
  • a value that has the type goes to a second compiled copy of the function in which only that parameter is dynamic. That copy reads the R value in place: a field by its name and its slot’s recorded representation, a list item through R’s accessors. Every other parameter stays a fact, and the value is neither converted nor copied. The check for a list’s items (no NA, the right layout for each record) runs as each item is read, so a function that reads three items of a long list pays for three.

Bun compiles every second copy at build time, because its executables carry no compiler. ry has a compiler at run time, so a second copy is compiled at the first call that needs it and kept beside the first. A function whose parameter receives only R values runs its second copy on every call, which is slower than converting once when the function reads the value many times. So ry check --explain reports such a parameter (see “Explaining slow paths”) with the fix to convert explicitly where the value enters, with List.from_r or as.record.

Each ry type has one native representation in compiled code and one R form, the form the interpreter and R code see. Native values live outside the R heap (see “Memory”). The R forms are the contract’s, and this section fixes the native side.

Type In registers and stack slots Storage R form
F64, F32 one float register none length-one double (F64), or a native number object
I8 to I64, U8 to U64 one integer register none native number object
I128, U128, Dec two integer registers none native number object
Bool one byte one bit in a list or record length-one logical
Str three words, the bytes inline up to 23 UTF-8 bytes past 23 in a counted buffer length-one character
List<T> a pointer, or inline on the stack a counted buffer, one column per field of T an R list
Dict<K, V>, Set<T> a pointer entry columns and an index array, or a flat array and a bitset for dense keys ry’s dict or set kind
record, tuple its fields, flattened, when they fit in four words, else a reference a slot in its type’s pool R record
tag union the tag and payload, when they fit in four words, else a reference with the tag in its upper bits a slot in its variant’s pool R tag
function a code pointer and a captures reference a slot in its lambda set’s pool closure

A reference is a 32-bit index into a pool, not a pointer. A pool holds every heap value of one record type or one variant, as a struct of arrays: one column per field and one column of reference counts, each a flat array indexed by the reference. A variant is a tag name with its payload types, and tags are structural, so one :Num(value: F64) pool serves every union that contains that variant. A reference uses its lower 28 bits as the index into its pool, which allows 2^28 live values per pool, and for a value of a closed union its upper 4 bits hold the variant’s index in that union’s sorted tag list, so match reads the tag without a load. Coercing a value from one closed union to a wider one re-encodes the upper bits through a table the compiler builds for that coercion. A union of more than 16 variants, and an open union (..), whose variants are not known, use a 64-bit reference instead: a 32-bit variant id, unique in the process, and the 32-bit index. A pool that outgrows its index space is a run-time error that names the type.

Pools are per process, and each column’s base address is in the table each code file reads at load. A field read is one load at base + index * width, with the base kept in a register across code that cannot allocate from that pool. A column grows by reallocation, so the base is read again after any call or allocation that may grow it, which costs one load from the table, a cache line that stays hot.

Strings. A Str is UTF-8 bytes with no terminator, as in Roc and Rust. A string of up to 23 bytes is stored inline in its three-word value, so most keys, labels and identifiers never allocate. A longer one points at a counted buffer of bytes, since strings have no fixed size to pool by. Equality compares the lengths and then the bytes, and Str.concat, Str.join_with and interpolation append into a uniquely held string in place. A string crosses to R by interning its bytes as a UTF-8 CHARSXP, and Str.from_r translates a latin1, native or bytes-encoded CHARSXP to UTF-8 and copies the bytes, refusing a string that has no UTF-8 form.

Lists. A List<T> is a counted buffer with the length, the capacity and the items as a struct of arrays: doubles and integers at their natural width, Bool as a bitset, strings as three-word values, and a record or tag item as one column per field, so a List<Record{x: F64, y: F64}> is an array of x values followed by an array of y values, and a loop that sums x reads contiguous doubles. A list of a union type whose values fit in four words stores a tag column, one byte per item, and payload columns overlaid across variants: payload column k holds the kth field of whichever variant the item is, at the widest width any variant gives that position. An item of a type that is a reference, a recursive union such as Node included, is a single column of references, which already carry their tags. List.append grows in place when the list is uniquely held and its capacity allows, and doubles the capacity otherwise. A list that does not escape its function, and is passed only to borrowing parameters, gets inline capacity on the stack, eight items, when the IR bounds its length by that constant or the list is built by a loop with no proven bound. It moves to a heap buffer if it grows past the inline capacity. A stack list is never dropped: its storage ends with the frame, and passing it to an owning parameter is an escape, which gives it a heap buffer from the start.

Dicts and sets. When the key type is Bool, U8, I8 or a union of payload-free tags, which have at most 256 values, a Dict is a flat array of values indexed by the key, a bitset of the keys present, and a byte array of the keys in insertion order, and a Set drops the value array. A lookup is a bit test and a load with no hashing, and iteration walks the order array, so the order is insertion order as Dict.keys promises. Every other Dict keeps its entries in insertion order as a key column and a value column, plus an open-addressing index array of 32-bit entries, which is the layout of Roc’s Dict and of Python’s. The lower 25 bits of an index entry are the position of its entry in the columns, and the upper 7 bits are a fragment of the key’s hash, so a probe rejects almost every wrong slot without reading the key column. An entry of all ones marks an empty slot, and a Dict past 2^25 entries moves to 64-bit index entries. Iteration walks the dense columns and a lookup probes the index. Removal marks the entry’s position in a bitset of removed entries instead of moving another entry into it, so iteration stays in insertion order, and the columns are compacted when removed entries reach half of them. Iteration order is insertion order, so the hash seed never reaches output. The seed is drawn once per process, which keeps input from a web request or a parsed file from forcing collisions on purpose, and the test harnesses fix it from their one seed so a failing run replays. A hashed Set<T> is a Dict<T, {}> with no value column.

Records and tags. A record’s fields are stored in name order, the order R-side records use (crates/interp/src/records.rs), so converting between the two never reorders. A tag stores its tag as the index of its name in the union’s sorted tag list, as records-and-tags.md assigns it, and each variant has its own pool with exactly that variant’s fields. A slot has no header: its count is in the pool’s count column and its type is the pool’s, which compiled code knows statically, so a value carries no layout id and no type word.

Flattening. A record or tag that never escapes a function is never allocated: its fields are SSA values. An escaping one that fits in four words is passed and returned in registers, as Rust’s ABI does for small structs, and stored inline in lists and other records. A recursive type such as Node is always a reference, and each :Num costs 12 bytes, a double in the value column and a count in the count column, while an :Add costs two 4-byte references and a count. Evaluating a tree reads only the columns of the variants it meets.

A compiled typed function takes its arguments flattened by the table above and returns its result the same way, using Cranelift’s multi-value returns for up to four words. A larger result is written to a destination the caller passes as a hidden first parameter, which is destination passing as in goose and Roc’s reuse analysis. The function takes no environment, no promise and no argument list.

Errors are values. An R error is a value inside Rust, and a longjmp across a Rust frame is undefined behavior (r-runtime.md, “Errors across the boundary”). Compiled code sits between Rust frames, both under it (the interpreter that called the entry stub) and above it (an R function that typed code calls). So a function that can fail (overflow-checked arithmetic, a bounds-checked read, or a call of an R function or of another fallible function) returns a failure flag in an extra register, and its caller tests it after the call and branches to a cold block that drops its live native values and returns the failure in turn. The entry stub turns it into the R error. A function the IR proves infallible returns no flag, and Cranelift’s exception edges (try_call, in the pinned version) can replace the flag test once measured.

Redefinition. R code can rebind any name at any time (r-runtime.md, section 5). A typed call is a direct call when its callee is a binding in a locked namespace, which is every function of a loaded package. In a script, where globals can be rebound, the call loads the code pointer from the callee’s binding cell, so it is one load and an indirect call, and rebinding the name clears the cell’s code pointer, which sends the next call through the interpreter.

Bun cannot discard compiled code, so it makes its assumptions true by freezing built-ins: replacing a member of Array.prototype throws. ry cannot freeze R’s bindings, because existing code changes locked bindings on purpose: testthat’s local_mocked_bindings (through rlang’s env_binding_unlock) and assignInNamespace both unlock a namespace binding and rebind it. So each compiled file records which locked bindings its direct calls, closure sets and facts assume, and the binding-unlock primitive (R_unlockBinding, which the R function unlockBinding and rlang both reach) discards the code that assumed the binding. That code recompiles with a cell load at the affected call. A test suite that mocks in every test recompiles every function that called a mocked binding directly, once per test, and code that never unlocks pays nothing.

ry’s own standard library is frozen instead: its namespaces cannot be unlocked, and no existing R code can depend on rebinding them. A program or package may still define its own List.map, which comes before the standard library’s binding, so a call is direct and inlinable only where the checker resolves the name to the standard library. Elsewhere it takes the binding-cell path.

Entry and exit stubs. A call from untyped R code into typed code goes through an entry stub, and a call from typed code to an R function converts the arguments to their R forms, calls the interpreter, and checks the result against the call’s declared type. The typed core has no lazy evaluation, so an entry stub forces each argument promise at entry, in order, and checks it once as “Facts and guesses” describes. This differs from checking an annotated argument when it is forced, which is the rule for typed R code, and the change is recorded in decisions.md when it is built. A function-typed parameter cannot be checked at entry: an untyped closure passed to it gets a wrapper that checks each call’s arguments and result, and a typed closure passes as its code pointer and captures.

One call machinery. A typed call from typed code needs no frame. A call that crosses into or out of typed code uses the frame machinery of the untyped call path (stack-arena frames with lazily materialized environments), so typed code adds stubs to that machinery, not a second call path. The stubs reach that machinery through one narrow interface (enter an R call with these arguments, return this value or this failure), so the frame redesign can land without touching compiled code.

Closures. A typed closure that captures by value under the contract’s rule is a code pointer and a reference to a slot in its lambda set’s pool, which holds the captured values at their natural width, one column per capture. Where the lambda set is not known, as for a function value received from outside the namespace, the code pointer points just past a small header that holds the closure’s drop function, so dropping the value calls through it. A closure never captures itself: recursive and mutually recursive local functions are lifted to top-level definitions that call each other by name and take their captures as extra parameters, as in Roc, which keeps closures acyclic. Where the checker knows the exact closure at a call site, as for a lambda passed to List.map in the same function, the call is direct and the lambda inlines, which is Roc’s lambda-set specialization. Value tracking extends it across functions in one namespace: a function-typed parameter that only lambdas of its own namespace reach is specialized on that set, so each call is a direct call or a jump on a small closure id. A parameter that also receives closures from outside, which every exported function’s parameters do, keeps the indirect call through the code pointer. A closure that fails the capture rule keeps R’s environment semantics and is called through the interpreter.

Loops. Interrupts are tested at loop back edges with one load. A list read is a bounds check and a load, and the IR removes bounds checks it proves redundant, with goose’s difference constraints over loop indices (prior-art.md, goose). Recursion depth is checked at function entry against a stack limit the runtime derives from R’s expressions option and the thread’s stack size.

Typed values are immutable and acyclic, so a reference count is exact: a value is freed when its count reaches zero, and nothing can keep a dead value alive in a cycle. Native typed values are therefore freed deterministically by reference counts that the compiler inserts, in the style of Perceus (Koka), Roc and Lean 4, and no tracing collector ever sees them. R values that typed code receives or produces stay R heap objects under ry’s mark-sweep collector.

Allocation. A fixed-size value takes a slot in its type’s pool: compiled code pops a free index from the pool’s free stack, a flat array of indices, or bumps the pool’s length when the stack is empty, and calls the runtime only to grow the columns. Freeing pushes the index back. A list buffer or a long string comes from a size-class allocator apart from the R heap, with the same inlined pop and push. Neither returns memory while a program runs, so a steady loop reuses the slots the previous iteration freed and touches only warm memory.

Counts. The IR inserts a dup where a value gains an owner and a drop where an owner’s last use passes, and then removes what it can:

  • Borrowing. A parameter the callee only reads, or passes on to a borrowing parameter, is borrowed: the caller keeps ownership, and neither side touches the count. Lean 4’s borrow inference decides this per parameter from the function’s body, so most reads of a list, a record or a string touch no count.
  • Moves. A variable’s last use is a move, so passing it costs nothing, and a dup followed by a drop of the same value cancels.
  • Field moves. An update (..r, f = e) whose source r dies at that point takes every field out of r instead of duplicating them, and the reads of r inside e read the taken fields. So in state <- (..state, items = List.append(state$items, x)), items is held uniquely and appends in place, and the new record reuses the old one’s slot, instead of copying the list each iteration.
  • Drop specialization. A drop of a value whose type is known inlines the decrement and, on zero, drops each field and pushes the slot on its free stack. A recursive type is freed by a loop over a work list, not by recursion, so dropping a long chain of Nodes cannot overflow the stack.

Reuse. xs[[i]] <- v, List.set, List.append, Dict.insert, Str.concat and a record update (..r, x = 1) update their argument in place when its count is one, and copy it otherwise, as the contract requires (decisions.md, “Standard library updates reuse a uniquely held value”). Beyond those, a slot that dies at a point where a value of the same type or variant is built is reused for the new value without going through the free stack: a match arm that takes a :Neg apart and builds a :Neg writes into the old slot. This is Perceus’s reuse analysis, which Roc applies the same way.

References go one way. A native value can hold an R value, through the explicit boundary types such as an opaque R value field, but an R value never holds a native one: every native value that crosses to R is converted to its R form, a typed closure included, whose R form is an R closure over the R forms of its captures that calls the compiled body through an entry stub. Without references from R into the native heap, no cycle can run through both heaps, so a preserved R value can never keep the native value that preserves it alive.

R values inside typed code. An R field in a native value holds a preserved reference: it is registered with the collector’s precious set when stored and released when its value is dropped. An R value in a compiled function’s locals is rooted on the collector’s handle stack: the entry stub, an exit stub’s result and every conversion to an R form push it, and the function releases to its handle mark at return and on the failure path. Native values need no rooting at all, so compiled frames carry no stack maps, and the roots r-runtime.md lists for JIT frames are handle-stack entries for typed code.

Threads. R evaluation is single-threaded, so counts are plain 32-bit integers with no atomic operations. A count that would overflow saturates, and a saturated value is never freed, which leaks it rather than corrupting memory, and is reported by the leak check below.

The entry and exit stubs are the boundary for calls, and Num.from_r, List.from_r, List.to_r, Str.from_r and as.record are the boundary for data. A guess that a function only reads crosses with no conversion, through the second copy. Converting copies: an R vector becomes a native list in one pass that also checks for NA, attributes and encoding, and a native list becomes an R vector in one pass. F64 and Bool cross in constant time in both directions, because their R form is R’s own scalar.

A List<F64> that crosses to R by conversion becomes a REALSXP copy. An ALTREP vector over the native buffer would avoid the copy for R code that only reads, but most C code asks for a writable pointer through REAL(), which would force the copy anyway, so the zero-copy wrapper is deferred until the benchmark code bases show the copy matters.

Typed code can still run slowly where it meets R, and the reason is invisible in the source. So ry check --explain lists every place in strict-clean typed code that compiles to a slow path, worst first, with the fix for each, as Bun’s --explain does. It reads the same facts the backend uses, so it needs no run and no profile. The places it reports are:

  • a parameter that receives only R values and runs its second copy, with the fix to convert once with List.from_r or as.record where the value enters;
  • a conversion inside a loop, such as a List.to_r whose result goes back into typed code;
  • a call from typed code to an R function, which converts its arguments and checks its result;
  • a closure that fails the capture rule, which runs through the interpreter;
  • a function-typed parameter called indirectly because closures from outside the namespace reach it;
  • a call through a binding cell, in a script whose globals can be rebound.

Worst first means a slow path inside more nested loops ranks higher, because no profile exists to rank by measured time. --explain=json prints one finding per line, for editors and CI. The user-facing wording and the diagnostic codes follow the native-types contract.

  • Rust references. Each typed benchmark program has a Rust twin in tools/bench/why/rust/, and just bench-why reports the typed time against it.
  • Three runs per program. Every typed benchmark program, every typed oracle program and every program the fuzzer generates (records, tags, lists, strings, dicts, closures) runs three ways: in the interpreter with annotations checked, compiled, and compiled with every annotation erased, which runs it as untyped ry. All three must print the same output and exit with the same status, and the oracle adds GNU R where the erased program is plain R. This is Bun’s test matrix.
  • Every guess fails. RY_FORCE_GUESS_FAIL makes every fast-path check at a boundary choose the second copy, so every second copy runs, and the three runs must still agree.
  • Every fact checked. RY_CHECK_FACTS compiles a check before each use of a fact: a count of one before each in-place update and each reuse, a live count before each read, and the bound before each list read whose bounds check was removed. In this mode every pool also keeps a generation column, bumped when a slot is freed, and every reference carries the generation it was made with, so a read through a reference to a freed and reused slot fails the check instead of reading another value. A failed check panics with the function and the fact, so a soundness bug in the IR shows up as a precise failure instead of corrupt memory.
  • No leaks. Reference counting is deterministic and R holds no references into the native heap, so a program that returns no native value frees every native value it allocated. Each run in the matrix ends by asserting that the native pools hold no live slot apart from saturated ones, which catches a missing drop on the run that introduced it.

The typed core IR and its lowering in the analysis, its serialized form beside the bytecode, the native representations (pools with their columns and free stacks, column lists, bitsets, stack lists, dense and hashed dicts), count insertion with borrow inference, field moves, drop specialization and reuse, the Cranelift backend over the IR, the entry and exit stubs with the second copies, value tracking per namespace, lazy compilation with background workers, mapped code files with their stamps and per-process table, compilation at ry install into the installed package, ry build --exe, the invalidation of direct calls on unlocking, ry check --explain, the test modes and the Rust twins. The native path in crates/jit/src/native.rs keeps serving typed code over R types that is not strict-clean.

The build starts with native List, Str and Dict and ahead-of-time compilation of the typed benchmark programs, because those give the largest measured wins: the typed programs that gain nothing from the native path today (bond_book, report, closures) are the ones that need lists, strings and captures.

  • Dropping the profile-driven native path. Once the typed core compiles ahead of time, the native path from R syntax serves only typed code over R types. Whether that is worth keeping depends on how much of the benchmark code bases is rewritten in ry’s types.
  • Wider references. A 32-bit reference limits a variant to 2^(32 - tag bits) live values. If a benchmark code base approaches that, references widen to 64 bits for that type, with the tag still in the upper bits.
  • Zero-copy lists to R. An ALTREP wrapper over a native List<F64>, which hands out a read-only pointer and copies only when C asks to write, is the candidate if conversion cost shows up at the boundary.
  • Packages in the executable. ry build --exe loads packages from the library. Embedding the R code of the program’s packages, and their shared objects, would make the executable portable between machines.
  • First-call compile time. A function compiled at its first call costs about a millisecond on that call unless a worker compiled it first. Whether a 300,000-line code base under development needs the copy-and-patch baseline for its first calls is to be measured.
  • The bytecode cache. r-runtime.md caches untyped bytecode on disk under content hashes. Under the performance principles that cache is to be replaced the same way, by bytecode written at install as build output and a first tier fast enough to compile the rest at load.