Skip to content
l3i
reference

Compatibility and performance

What the version promises, the supported Rust, the pinned dependencies, the license, what is tested, what each path through the binder costs in nanoseconds, instructions and bytes, and which lines of the C++ binder each fast path came from.

Versions#

l3i is on 2.x: every release in 2.x keeps the Rust API compatible, as Cargo’s semver rules expect, and the changelog lists what 2.0 changed against 1.3.1 and how to move. The Luau it embeds is release 0.742, as the luau/ submodule; bumping it is a deliberate change that re-audits the hand-declared C API and the flag list.

Rust and dependencies#

  • Rust 1.92 or newer, declared as rust-version and checked in CI. Edition 2024. The optional dream-soft-render dependency declares the same.
  • unsafe is used, at the FFI boundary and in the value-layout reads; every block states the invariant it relies on (see Safety).
  • Optional features, all off by default: jit, analysis, soft-render, bytes with bytes-codecs, bytes-digests, bytes-text and bytes-regex, intern, intl, syntax, tcp, dns and tls (see Building).
DependencyVersionFor
dream-net=1.1.0The dream.udp bridge every plan carries
dream-soft-render=1.0.0, optional (soft-render)The dream.soft_render extension
memchr, miniz_oxide, lz4_flex, ruzstd, lzma-rs, crc32fast, adler2, xxhash-rust, md-5, sha1, sha2, blake3, encoding_rsOptional, behind bytes and its bytes-* features@dream/bytes’s searches, codecs, digests and text encodings
icu_locale_core, icu_plurals, icu_decimal, fixed_decimal, writeable2.3 (fixed_decimal 0.7 with ryu, writeable 0.6), optional, behind intl@dream/intl’s locales, plural rules and decimal formatting, with ICU4X’s compiled CLDR data
mio, socket21.2 (os-poll, net), 0.6 (all), optional, behind tcp@dream/tcp’s nonblocking sockets and OS readiness; socket2 is already in the tree through dream-net
idna, libc (Unix)1.1, 0.2, optional, behind dns (idna also behind tls)Host names mapped to A-labels; getaddrinfo
rustls, rustls-platform-verifier, zeroize0.23.40 (ring, std, tls12), 0.7, 1.8, optional, behind tls@dream/tls’s sessions, the platform trust store, the zeroed copy of key input
cc1, parallel feature, build onlyCompiling the Luau submodule and csrc/
criterion0.8.2, dev onlyThe benchmarks
rcgen0.14 (ring, pem), dev onlyTest PKIs for @dream/tls, made per run

The two DreamWeave crates are pinned exactly: they define what goes on the wire and what a pixel is, and the byte-identical tests hold against those versions.

License#

l3i is MIT OR Apache-2.0, at your option.

Luau is Roblox’s, under the MIT license; its notice ships in the package as luau/LICENSE.txt and luau/lua_LICENSE.txt and travels with any product that ships l3i.

The dns and tls features link ICU4X’s normalizer data through idna, also under the Unicode License v3. tls links rustls (Apache-2.0, ISC or MIT), rustls-webpki (ISC) and ring (Apache-2.0 AND ISC: its own code ISC, code from BoringSSL under Apache-2.0 or ISC as each file states).

The intl feature links ICU4X and its compiled CLDR data, under the Unicode License v3 (Unicode-3.0); a product that enables it carries that notice too.

What is tested#

Every push to main, every pull request and every tag runs StroggForge’s library workflow, whose setup-llvm step gives every job that compiles a clang and lld whose LLVM major matches the active rustc: the tests on Windows, Linux and macOS; rustfmt; Clippy; a check against Rust 1.92 (msrv: auto); and a dry run of the crates.io publish. A bare version tag publishes that version to crates.io, runs the Criterion hot paths and attaches them to its GitHub release as BENCHMARKS.md.

A daily job on ubuntu-22.04 runs cargo fmt --all --check, cargo clippy --workspace --all-targets --all-features -- -W clippy::pedantic -D warnings (with the LLVM toolchain, since Clippy runs the build script), and cargo audit over a fresh lockfile.

The integration tests link as one binary (tests/main.rs), one file’s tests run with cargo test <file>::, and cargo test --all-features adds the feature-gated ones:

SuiteCovers
tests/tagged.rsOne tagged type with a method: instances created from Lua, dropped, collected, exactly one Rust drop per instance
tests/untagged.rsUntagged registration, storage, receivers (testluauregistration.cpp)
tests/metatable.rsBuilder conflicts, native methods, dispatch, script-visible identity, metamethod debug names
tests/call.rstestluaucall.cpp: the call family’s success semantics, error parity and stack balance, the result budget, result visitors and argument edges, integer kinds across the call boundary, yields outside a coroutine
tests/iterator.rsStateless, keyed, and cursor iterators
tests/direct.rsDirect callbacks shadowing the metamethods for atom-keyed access, the per-instruction cache validated before it is trusted, direct fields bypassing both
tests/module.rsThe component registration contract and read-only packages
tests/sandbox.rsSandboxes, instances, and templates
tests/watchdog.rsWatchdog limits, memory categories, and call scopes
tests/thread.rsCoroutines driven from the host, and yields from bound functions
tests/debug.rsActivation records, locals and upvalues, breakpoints that stop a host-driven coroutine, single stepping, coverage, lifecycle hooks
tests/memory.rsGC controls, dumps, the buffer cage, embedder GC integration, light userdata, finalizers
tests/libraries.rsSelective library opening, Luau’s own sandbox, registered C libraries, table utilities, metamethod comparison and concatenation, the string builder, chunk environments, compile-time library members, the inliner toggle
tests/require.rsRequire-by-string over a host navigator: relative paths, caching, proxy requires, registered aliases, cache control
tests/vector_writer.rsvector:writef32x3 on the interpreter path
tests/extension.rsThe planner’s gates: dependency order, composition, direct dispatch, tags and atoms per VM, the stale-cache check, compiler metadata, services, capabilities, state and drop order, every rejected composition, compiler type slots
tests/primitives.rsZero-copy bytes, strict options, sequence and stream views, exact integers and bit patterns, packed scalars
tests/tcp.rs (tcp)@dream/tcp over real loopback sockets: the capability gates, IPv4 and IPv6 addresses, connects settled by the socket error, echo of binary payloads across several clients, end of stream against would-block, buffer windows, partial writes under backpressure, half-close, level-triggered readiness and stale tokens, bounded and fair waits, the watchdog, close and collection, host-made handles, and a Luau HTTP/1.1 server answering standard clients
tests/dns.rs (dns)@dream/dns: literals, localhost through the OS resolver, a deterministic resolver for ordering, deduplication, every failure kind, the bounded queue and pool, cancellation before and during a lookup, timeouts while queued and while running, late results thrown away, one-shot results, the per-runtime limit, collection, the watchdog, and (with tcp) completions waking a poller, deadlines without events, and tokens shared with sockets
tests/tls.rs (tls)@dream/tls with a PKI made per run: TLS 1.3 and 1.2, ALPN and SNI, IP identities, rotated configurations, name mismatch, expiry, not-yet-valid, untrusted and incomplete chains, ALPN without overlap, configuration errors, the TCP stream handed over whole, close_notify against truncation, buffered plaintext and bounded backpressure under the poller, handshake deadlines, hang-ups and non-TLS peers, the platform store, and a stock rustls server
tests/https.rs (dns, tls)DNS → raced TCP connects → verified TLS → HTTP/1.1 in Luau through a fake resolver, and a Luau HTTPS server answering concurrent keep-alive rustls clients
tests/udp.rsThe dream-net bridge over real localhost UDP: a schema from Luau, a host-created server, a script-created client, events both ways, stats, the capability gate
tests/quat.rsPacked quaternions against the f32 userdata baseline, kind checks, and (jit) the native lowering
tests/raster.rsColors and clip rectangles: construction, kind checks, the byte layout, and (jit) the lowered color math
tests/bytes.rs@dream/bytes (bytes): searching, record strings, varints, every width and order against buffer, the codecs against Python-made fixtures, the digests against their published vectors, the text codepages, and (jit) the lowered integer reads and writes
tests/intern.rs (intern)@dream/intern: one token per identity across spellings, strings and buffer spans, dense keys in a table’s array part, the first spelling back from resolve, interner against the method and across a collection, named errors, and (jit) the lowered intern and find against the binder for both policies over every length to 40, every fallback, a miss then a hit, growth between calls, duplicates that never reach the binder, and the A64 build
tests/intl.rs (intl)@dream/intl: canonical spellings, subtags and equality of locales, malformed tags by name and reason; English, Polish, Russian, Arabic and French categories, visible fraction digits, integers, large numbers and refused non-numbers; English, German, French, Arabic-Egyptian and Polish formatting, every grouping strategy, padding, trimming, half-even ties, signs, negative zero, bad options by name; and in src/intl the same against the Rust types directly
tests/syntax.rs (syntax)@dream/luau: Luau’s kinds, members and exact spans, one table per local for its declaration and every use, quote styles, functions, tables and types, errors as data with a recovered tree, declaration syntax only on request, the token stream over every byte that is not whitespace, the parser’s nesting limit, and trees built while the collector steps on every allocation
tests/soft_render.rs (soft-render)A Luau scene byte-identical to the Rust scene, malformed input, textures freeing themselves, two runtimes with different tags, the vertex writer on all three paths
tests/native_code.rs (jit)The code generator with the binder’s hooks, the writef32x3 lowering, a userdata field lowering in Rust, modes, module ids, assembly dumps, the perf log
tests/typed_definitions.rs (analysis)The generated .d.luau checked by Luau’s frontend, strict scripts against every built-in module through require, typed views and forward module references
tests/analysis.rs (analysis)The type checker, linter, autocomplete, and parser

What it costs#

Criterion wall-clock means from the hot-path groups of cargo bench, generated on 2026-09-28 by scripts/gen_benchmarks.py on an Intel Core i7-10870H at 2.20 GHz, with debug assertions off and default features (interpreter only), under the verified toolchain. Every bench is one binary, benches/main.rs, because each binary pays a full cross-language LTO link; a group or harness is selected by name, cargo bench -- udp_bridge, -- packed_quat, -- raster_, --features soft-render -- soft_render, -- instructions.

Rust to Luau#

Function::invoke from the host, per call.

VariantMean± Std Dev
scalar (f64, f64) -> f6463.29 ns1.95 ns
table argument, view result69.95 ns1.31 ns
table argument, pinned result107.3 ns4.54 ns

Luau to Rust#

A Lua loop calling the bound function 1000 times; per call is the loop divided by 1000.

VariantMean± Std DevPer item
hand-written lua_CFunction35.73 µs1.04 µs35.73 ns
captured Rust context39.97 µs1.57 µs39.97 ns
typed binder (f64, f64) -> f6444.86 µs1.94 µs44.86 ns
Vector3 ingress90.73 µs1.36 µs90.73 ns

The typed binder is within ten nanoseconds of a bare C function.

Typed binder variants#

The same loop over the binder’s argument and result shapes.

VariantMean± Std Dev
(f64) -> ()37.11 µs247.7 ns
(ValueView) -> f6441.26 µs429.0 ns
() -> f6443.59 µs6.45 µs
(f64) -> f6446.18 µs2.39 µs
(&Call) -> f6447.27 µs8.07 µs
(f64, f64) -> f6449.92 µs1.54 µs
(i32, i32) -> i3258.65 µs847.4 ns

Method calls#

obj:get() 1000 times.

VariantMean± Std DevPer item
tagged direct namecall32.25 µs461.6 ns32.25 ns
tagged generated __namecall52.36 µs707.5 ns52.36 ns
untagged generated __namecall72.15 µs4.37 µs72.15 ns

The direct path skips the metamethod dispatch; the generated dispatcher costs one Luau call frame, not two.

Property reads#

obj.value 1000 times.

VariantMean± Std DevPer item
plain table field8.58 µs419.6 ns8.58 ns
tagged direct field12.48 µs170.1 ns12.48 ns
tagged direct index32.56 µs418.2 ns32.56 ns
tagged generated __index58.89 µs1.96 µs58.89 ns
untagged generated __index73.52 µs1.56 µs73.52 ns

A direct field is within four nanoseconds of a table field.

Plan dispatch#

Runtime-resolved DirectPlan dispatch with the cache hit path, 1000 accesses of the last member.

VariantMean± Std DevPer item
cached direct index, 4 members40.83 µs909.1 ns40.83 ns
cached direct index, 32 members42.48 µs2.71 µs42.48 ns
cached direct namecall, 4 members43.35 µs1.32 µs43.35 ns
cached direct namecall, 32 members43.42 µs431.1 ns43.42 ns
cached direct index, 128 members45.45 µs3.59 µs45.45 ns
cached direct namecall, 128 members48.56 µs487.3 ns48.56 ns

The member count barely moves the cost: the plan validates Luau’s per-instruction cache in O(1).

Extension dispatch#

The same operations on a type declared through the planner.

VariantMean± Std Dev
tagged planned direct field13.67 µs568.0 ns
tagged planned direct index50.09 µs709.2 ns
tagged planned cold namecall53.51 µs453.5 ns
tagged planned direct namecall54.52 µs671.5 ns
untagged planned namecall69.78 µs425.4 ns
untagged planned index73.23 µs1.54 µs

Iterators#

A generic for over a 100-element array iterator, 1000 loops.

VariantMean± Std DevPer item
array __iter, 100 elements4.44 ms43.40 µs44.38 ns

Host side#

Host-side operations, per call.

VariantMean± Std Dev
tagged receiver check19.63 ns1.97 ns
untagged receiver check36.58 ns1.66 ns
value pin create and drop57.87 ns20.43 ns
owned table field read67.74 ns2.15 ns
borrowed table field read71.16 ns1.59 ns

A tagged check is one tag read and one TypeId compare; an untagged one compares a per-VM cached metatable identity.

The network bridge#

Both ends of the dream.udp bridge driven from Luau.

VariantMean± Std Dev
idle update + empty pollInto, both ends5.13 µs96.54 ns
client sendEvent + flush58.72 µs800.1 ns
client send, server update + pollInto220.0 µs3.26 µs

Packed quaternions through Luau#

The dream.quat module against an f32 quaternion userdata, 1000 calls.

VariantMean± Std Dev
userdata rotate vector56.72 µs2.33 µs
packed rotate vector76.70 µs1.74 µs
userdata mul (allocates)129.9 µs2.21 µs
packed mul137.9 µs10.68 µs
userdata slerp (allocates)180.3 µs1.75 µs
packed slerp194.0 µs7.69 µs

On the interpreter path the packed form costs the encode and decode and saves the allocation; the native lowering on Built-in extensions is where it pulls ahead.

Packed quaternions in Rust#

The encoding itself, 1000 operations.

VariantMean± Std Dev
f64 mul (reference)4.19 µs368.3 ns
decode smallest-three8.99 µs503.4 ns
encode smallest-three23.18 µs783.3 ns
decode, mul, encode70.08 µs3.12 µs

The encode’s floor is the three float-to-integer conversions.

Bytes#

cargo bench -- bytes_, with every bytes-* feature and jit. Per call from a Luau loop: buffer.readu32 plus bit32.byteswap 116 ns interpreted and 4.3 ns native; the module’s readu32be 65 ns; the receiver’s readu32be 68 ns interpreted and 5.9 ns lowered, readi64be 5.1 ns, readu24be 6.5 ns, writeu32be 5.3 ns; readCString of a 16-byte field 157 ns, readVarint 64 ns. Over one megabyte: find 23 GiB/s, equals 28 GiB/s, crc32 23 GiB/s, xxh3 17 GiB/s, blake3 3.7 GiB/s, sha256 238 MiB/s, inflate 1.6 GiB/s of output, deflate level 1 2.8 GiB/s, LZ4 block decompress 1.1 GiB/s and compress 5.9 GiB/s, decode Windows-1252 1.5 GiB/s.

Instructions and cycles#

Past a few nanoseconds wall time is noise, so the binder’s own cost is tracked in retired instructions and cycles per call, read from the CPU’s counters by cargo bench -- instructions (Linux, perf_event_paranoid at 2 or lower, no perf binary needed). The loop is subtracted, the minimum over seven rounds is reported, and the floors are measured the same way: a hand-written lua_CFunction and a typed direct handler. On Luau 0.740:

Per callInstructionsCycles
hand-written lua_CFunction (f64, f64)27769
bound () -> f6429273
bound (f64, f64) -> f6436989
bound (Vector3) -> f6433285
bound (Packed<Color>) -> f6436489
typed direct namecall, the VM’s leanest method path36898
planned method () -> f64431107
planned getter397101
planned direct field7812
planned sequence [i]547148
planned sequence #563148

A bound call is within about forty instructions of a bare C function; a planned method is within sixty of the typed direct handler. The rest is Luau’s own call and return machinery. A packed argument pays eleven instructions for the kind registry check (one owner load and a type compare) on top of its bit decode.

The jit build adds a second table: packed rotations and colors inside --!native code, where the lowered receivers (quat.math(), raster.math()) skip the call entirely. Each body computes a varying component first and keeps its result live past the loop, so the code generator cannot drop the work; the bare native loop is subtracted.

Per call, nativeInstructionsCycles
four numbers in locals (no packing)92
a table { x, y, z, w }616196
quat.fromXYZW through the binder459196
Q:fromXYZW, lowered9370
Q:rotate of a packed rotation, lowered10477
Q:fromXYZW then Q:rotate, lowered185164
the same rotation as plain vector math on four numbers5960
raster.rgba8 through the binder432129
C:rgba8, lowered2011

A packed rotation built natively costs a sixth of a table and a fifth of a binder call, and allocates nothing. Rotating by four numbers already in locals is still cheaper than unpacking one; the packed form pays for itself where the rotation is stored, keyed, or sent rather than used once.

The same harness reads the L1 data, L1 instruction, last-level cache, data and instruction TLB, and branch-miss counters. Every scenario reports fewer than 0.005 of each per call: the whole path, Luau’s and the binder’s, stays in L1 and predicts. The binder’s side of a call touches four lines of its own, the slot row (rows are 32 bytes, so a row never straddles a line), the member’s context, the thread record, and the shared block’s first line, which holds the slot table, the plan, and the profiler switch (#[repr(C, align(64))]).

Memory per runtime#

After a full collection, a bare VM’s heap is 64 KiB, with dream.quat and dream.raster 84 KiB, with dream.soft_render as well 96 KiB. The binder’s own state is a 600-byte shared block, 32 bytes per plan slot, 64 bytes per plan entry, and the dense (tag, kind, atom) table, sized by the tags and atoms the plan uses (1.4 KB for five tags and 39 atoms).

Where the fast paths came from#

l3i is its author’s second Luau binder. The first is C++, components/luau on a branch of an OpenMW fork. Most of what makes a call cheap here was worked out there first, and the honest way to say so is to point at the lines. Every link below is pinned to one commit of that branch, f69579c8, so the line numbers cannot drift under it. A few of the files live in components/lua on the same branch; the links say which.

Each entry says what the C++ does, what l3i does with the idea, and where l3i goes further. The last one lists what has no C++ ancestor at all.

Borrowed views first, pins second#

The C++ binder sorts its API into three tiers: borrowed stack views for binding hot paths, one registry pin for values that outlive a call, and proxy conveniences that may pin behind your back (README.md 7-29). It also bans the is<T>() then as<T>() habit, which validates the same value twice (README.md 35-37).

l3i keeps the tiers as they are: stack, value, and the cold lookups. One conversion per argument is the binder’s rule here too.

Further: in C++ a view that outlives its slot is a comment asking you not to (stack.hpp 25-26). Here a view borrows the Frame that owns the slot, so the mistake does not compile.

Tagged userdata#

One tag per hot type, the payload allocated inline with the metatable already attached (taggeduserdata.hpp 56-71), and a type check that is a single lua_touserdatatagged (taggeduserdata.hpp 40-54). Registration builds and freezes the metatable first and publishes the destructor and the metatable for the tag last, because those Luau setters cannot report failure (taggeduserdata.hpp 101-123).

userdata::tagged allocates the same way and publishes in the same order.

Further: the C++ tag is a template constant, fixed when the engine is compiled. Here a Rust type carries no tag. The host or the plan assigns one per VM, so the check is a tag read and one TypeId compare against that VM’s tag plan, the 20 ns in the host-side table above, and the same type can be tag 8 in one runtime, 17 in another, and untagged in a third.

Untagged userdata#

The long tail gets no tag: a type is recognised by the exact identity of its one read-only metatable. The C++ check fetches the type’s metatable from the registry, pushes the value’s own, compares the two pointers, and pops (untaggeduserdataaccess.hpp 32-40, untaggeduserdataaccess.hpp 111-129).

l3i keeps the identity rule and drops the stack traffic. The expected pointer is cached per VM in the shared block, keyed by TypeId, and the value’s metatable pointer is read by lua_getmetatablepointer, one of the few C additions in csrc/extra.cpp, which pushes nothing. That is the 37 ns receiver check.

Generated __index and __namecall#

A type with getters gets generated dispatchers. The C++ __namecall thunk looks the method up by Luau atom in an integer-keyed table, falling back to the name for a method without one (binding.cpp 88-111); the table is filled as methods are registered (binding.cpp 541-557) and the three-upvalue closures are installed once the first getter appears (binding.cpp 559-595). __index tries methods, then getters (binding.cpp 63-86).

userdata::dispatch has the same upvalues and the same atom table.

Further: the C++ thunk finds the method and then lua_calls it, a second call frame on top of the dispatcher’s own. l3i stores a bound member as a MemberEntry and runs it on the dispatcher’s stack, so a generated method call costs one Luau call frame, not two.

Direct userdata access#

Luau can call a native callback straight from GETTABLEKS, SETTABLEKS and NAMECALL when the key has an atom. The C++ side registers the callbacks per tag and insists that each has its metamethod (directuserdata.cpp 31-52), wraps the ordinary metamethods so they reach the same handler and keep the original as upvalue 1 (userdatadispatch.hpp 14-54), and installs the wrappers before the metatable freezes and publishes the callbacks after (userdatadispatch.hpp 100-123). Members resolve through a dense (tag, kind, atom) -> slot table built at compile time (directregistry.hpp 51-72, in components/lua), and Luau’s 16-bit per-instruction cache is trusted only after it is shown to name this exact tag, atom and kind (directregistry.hpp 140-158). Direct fields are a separate registration (directuserdata.cpp 54-70).

direct::registry::Registry is that compile-time table as a const fn, with the same cache rule, and the wrapper arrangement is unchanged.

Further: a compile-time table needs every tag and atom to be a constant, which is fine for one engine and useless for crates that do not know what VM they will be loaded into. direct::plan::DirectPlan is the same table built at run time from the tags and atoms this VM assigned. Its cache-hit test is one load and one compare of a packed (tag, kind, atom) key, which is why 128 members cost what 4 do in the plan dispatch table above.

Pointer encoding#

Luau prints and hashes pointers through a keyed permutation. The C++ binder draws four 64-bit words from the platform’s entropy source and redraws while they would normalise to Luau’s identity map (pointerencoding.cpp 14-20, pointerencoding.cpp 28-47), and seeds the state before anything else touches it (pointerencoding.hpp 24-26).

runtime::PointerEncodingKey::random and is_identity are the same two functions, and the key is installed first in the creation order. The entropy comes from the standard library’s RandomState, so it costs no dependency. It is a builder choice, on unless the host turns it off.

Debug names#

Luau borrows the name pointer given to a C closure for the closure’s whole life. The C++ binder interns every name as a Luau string in a VM-private registry table and hands out the interned pointer (debugname.cpp 14-46), after checking that it is a dot-separated identifier path under a known root (cfunction.hpp 25-60).

debug_name does the same interning and the same validation.

Further: the C++ roots are openmw, string and vector, written into the header. Here they are host data, RuntimeBuilder::debug_roots.

Flags and compile options#

Luau’s fast flags are process globals and several change the bytecode the compiler emits. The C++ binder names its whole policy, compiler side and runtime side, and freezes it once (runtimeflags.cpp 48-94, runtimeflags.cpp 96-130, runtimeflags.cpp 133-140). Compile options always travel with the source rather than falling back to Luau’s defaults (compileoptions.hpp 8-26), and loading bytecode is one explicit call (bytecode.cpp 9-13).

flags::LUAU_FLAGS and LUAU_CODEGEN_FLAGS are that policy, and source::CompileOptions has the same defaults: optimisation 2, debug 1, no type information, no coverage.

Further: the flags are set by name through luau_setfflag, and a name the linked Luau does not have is a logic error at the first runtime, so a Luau bump cannot drop a flag quietly. Three flags are l3i’s own: LuauExperimentalIfLocalSyntax, LuauGcTraceUdata and LuauBufferCage. The options own their strings instead of borrowing pointers for the duration of a compile.

Calls into Luau#

Every host call is a lua_pcall that restores the stack (call.hpp 47-66). The variant worth stealing hands the borrowed result to a visitor while the call frame is still alive, so reading a result pins nothing (call.hpp 86-113). Host code that may raise runs as a C closure under pcall, with the C++ callable passed as a light userdata (protectedcall.hpp 15-29).

Function::invoke_with is the visitor call and raw::protect::protected_call is the same light-userdata trick.

Further: a native callback already runs under Luau’s own protection, so it skips the pcall. Only a host-level scope pays for one.

Conversion#

One checked conversion per value, with rules that do not guess: a number becomes an integer by rounding and a range check, a Luau integer by range check alone, and a Rust integer is pushed as a number only when a double can hold it exactly (convert.hpp 201-214, convert.hpp 216-236, convert.hpp 589-626).

convert keeps those rules and the tests hold them against the C++ suite.

Further: the C++ reads a scalar with a type query and then an API call. l3i reads the argument slot itself, through a mirror of Luau’s 16-byte value layout. See the last entry.

Owned references#

A reference owns a registry pin and never the VM, and its owner is the main thread, not the coroutine it happened to be made on (reference.hpp 14-16, reference.hpp 68-75). The rule that every reference must die before the VM is a comment.

value::Value is that pin.

Further: each value carries a weak handle to its VM’s lifetime token. One that outlives its Runtime reads as invalid instead of touching a closed VM.

Native code#

These are in components/lua on the same branch. One code generator per VM with a hard size budget in whole blocks (nativecodegen.cpp 188-194), an annotated mode that compiles only --!native modules (nativecodegen.cpp 24-27), module identities hashed from the bytecode with MurmurHash3 and a fixed seed (nativecodegen.cpp 77-87), a collision reported rather than aliased and a module that ran out of code space never retried (nativecodegen.cpp 229-260). And the first lowering: vector:writef32x3(buffer, offset) as three native f32 stores behind one bounds check (nativevectorbuffer.cpp 71-105).

native_code::NativeCodeGen keeps all of it, the seed included, and native_code::vector_buffer::VectorBufferWriter is that lowering.

Further: the C++ lowering is C++ against Luau’s IrBuilder. l3i puts a C ABI over the builder and writes its hooks in Rust, and a hook asks the VM it is compiling for which tag a type has rather than assuming one. The quaternion, colour, vertex-writer and byte-reader lowerings have no C++ counterpart.

What is l3i’s own#

No C++ ancestor, for better or worse:

  • Runtime plans. Composing a VM from extensions before it exists, assigning tags, atoms, compiler slots and direct slots per VM, and rendering the type definitions for exactly that composition. The C++ binder’s tags and atoms are constants of one engine.
  • Reading the value layout. convert::raw mirrors Luau’s 16-byte TValue, so a scalar argument is a tag compare and a load. csrc/extra.cpp checks every offset when it compiles and each runtime checks the reads against the API when it is created, so a Luau bump that moves a byte fails at once instead of misreading.
  • One call on entry. l3i_native_enter returns the argument count, the thread record and the closure context together, where a bound function would otherwise ask Luau three times. The instruction counts above are what is left: a bound call within about forty instructions of a bare C function.
  • Packed 64-bit scalars and their kind registry: values that never allocate.
  • The shared block’s layout. One cache line holds the slot table, the plan and the profiler switch.
  • The toolchain rule. A C++ binder and Luau are one language, and ordinary link-time optimisation inlines across them. A Rust binder gets that back only with clang, lld and cross-language thin LTO, which is what the campaign below measures.
  • Templates that resolve their imports. The C++ loader thread has empty, unsafe globals, so every import is looked up at run time (luastate.cpp 638-652). l3i’s loader thread has the base environment for globals, and Luau resolves a template’s imports when it loads.
  • source::LoadScope and DebugScope::call_site, for hosts that load modules from inside a bound function and attribute every native call to a script line.

The toolchain campaign#

The rule on Building comes from this measurement, taken on 2026-09-27 with scripts/toolchain_campaign.py (a clean cargo build --release --lib, then cargo bench --no-run, then a five-case subset of the hot paths at 2 s per case) on an i7-10870H in a Fedora 44 toolbox: gcc 16.2.1, clang/lld 22.1.8, mold 2.40.4, rustc 1.98.1 (LLVM 22). Each contender ran three times, interleaved. Nanoseconds per call.

VariantClean release buildBench buildtyped binder (f64, f64) -> f64tagged generated __namecalltagged direct indexRust to Luau scalarraw lua_CFunction
gcc, serial cc68.1 s13.8 s
gcc, parallel cc (3 runs)37.3 to 40.7 s13.7 s53.7 to 54.795.7 to 101.235.2 to 35.476.0 to 82.835.9 to 39.3
gcc plus mold38.0 s13.7 s
clang, no LTO21.1 s13.5 s58.7113.637.882.239.4
clang plus cross-language thin LTO (3 runs)16.3 to 17.8 s18.7 to 20.3 s45.3 to 50.283.0 to 89.329.2 to 31.664.8 to 72.834.1 to 40.3
gcc plus Rust fat LTO, 1 codegen unit41.3 s69.2 s52.999.227.678.640.3
clang LTO plus Rust fat LTO15.9 s54.3 s51.992.827.482.938.6
  • Cross-language LTO is the whole story. The hand-written lua_CFunction does not move between compilers, so Luau’s interpreter is equally fast under gcc and clang. Every binder path gains 8 to 15 percent because the Rust thunk and the Luau API calls it makes (lua_tonumber, lua_pushnumber, the tagged userdata read) inline into one another.
  • Plain clang is slower than gcc at runtime on every binder case. The compiler switch is justified only together with the LTO half, hence the rule enforces both.
  • Rust-only fat LTO buys nothing on the binder paths (the direct-index case improves, the Vector3 case regresses) and quadruples the bench build. Stacked on cross-language LTO it is neutral at best. Not adopted.
  • cc parallel halves the clean build with no runtime effect. Adopted.
  • mold is a no-op here: the link is a small fraction of a build dominated by compiling Luau. The self-contained lld rustc ships is not used with a custom -Clinker, so system lld is the linker.
  • The verified configuration is also the fastest clean build, 16 to 18 s, four times faster than the gcc serial baseline.

The hot-path tables above are regenerated under this configuration, by scripts/gen_benchmarks.py over Criterion’s output; a release tag runs it in CI and attaches the result to the GitHub release.