Skip to content

Repository files navigation

rhaigrain

A bytecode compiler and VM for Rhai.

Rhai's evaluator walks an AST, which the parser allocates a node at a time — so what a script costs to hold scales with how much program there is. rhaigrain compiles a rhai::AST to a flat artifact the VM reads in place, so the parser, the optimizer and the tree all stay on the machine that did the compiling. See Performance for what that saves.

Building

./scripts/vendor-rhai.sh    # reconstruct vendor/rhai, which is gitignored
cargo test

What is checked

tests/differential.rs 171 corpus scripts, all of which must lower with nothing left over
tests/fuzz.rs 4000 generated scripts per run, seeded so a failure reproduces by index
tests/format.rs round trip, every single-bit corruption, and a checked-in artifact from an older build
tests/scope.rs the host's side: caller variables, resolvers, published modules
cargo fuzz run roundtrip | generated | load the same claims, for hours rather than seconds
cargo run --release --example bench speed against the walker, with floors that fail the run

load is the one that matters for safety rather than parity: an artifact arrives over a wire, so Program::read must be total over arbitrary bytes, and a loaded one is verified before it runs. Verification is not termination, though — a flipped jump target that still lands inside the chunk is a structurally valid infinite loop, which is why every back edge is metered and why a host running untrusted bytecode must set max_operations, exactly as it must for untrusted source.

Performance

Two different claims, and the second is the one the project is for.

cargo run --release --example bench — the fastest of repeated samples, since timing noise is one-sided, with the spread beside it saying how much to trust the number. The walker is measured at its best: rhai's default fast_operators short-circuits binary operators straight to builtin function pointers, and leaving it on is what makes the comparison fair rather than flattering.

walker VM speedup floor
tight integer loop 34.4ms 22.8ms 1.51x 1.30x
float arithmetic 94.0ms 72.2ms 1.30x 1.10x
script fn calls 31.5ms 17.9ms 1.76x 1.55x
switch, 4 arms 59.2ms 36.1ms 1.64x 1.40x
switch, 16 arms 57.9ms 37.2ms 1.56x 1.35x
branch heavy 80.9ms 48.5ms 1.67x 1.45x
native callbacks 5.2ms 15.1ms 0.34x 0.25x

Callbacks are the one case the VM loses, and structurally: crossing back into a native means rhai resolves the wrapper by name and type per element from a cache it rebuilds each time, where its own pointer skips resolution entirely. The floors are ratios rather than times, so they survive a noisy machine, and --check fails the run if any is breached.

Memory is the point, though. cargo test --test allocation measures it with a tracking allocator, against follow.rhai — 8.2KB of real script:

retained allocations peak
engine.compile (the tree) 137,864 B 1,283 153,340 B
loaded artifact 9,604 B 64 none

14.4x less held, and no parser peak at all — the peak matters as much as the retained figure, because it is what has to fit. What crosses the wire is 7,269 bytes, with a further 1,720 of position table held back on the host, which can resolve an error the device reports by address.

And it does not scale with the script. The same program repeated 1, 4 and 16 times:

source artifact tree loaded allocations
215 B 301 B 4,256 B 472 B 4
860 B 868 B 15,456 B 472 B 4
3,440 B 3,136 B 60,576 B 472 B 4

A load retains the same four allocations however long the program is, because the code is borrowed out of the artifact rather than copied. The test asserts the ratio keeps climbing rather than pinning any of these numbers — one that stopped would mean something in the loader had started to scale.

Every figure here is from one host machine and will drift; the commands above reprint them.

Known divergences

Two, both deliberate, both pinned by a test that fails if they stop being true.

  • A closure pointer is late-bound (a_closure_pointer_is_late_bound). Rhai's parser builds a pointer embedding the closure's ScriptFuncDef — an AST body, and precisely what an artifact cannot carry — so rhaigrain emits a name-only pointer to the chunk compiled from that same body. Redefining the function afterwards is visible here and not in the walker.
  • A callback crossing costs more call levels (a_callback_costs_more_call_levels), so callbacks nest less deeply before max_call_levels. Stricter, never laxer.

Every number here is a host number; the no_std split is not done.

Layout

src/compile/     rhai::AST -> instructions; anything it cannot lower stays an
                 AST fragment and is handed back to the walker
src/bytecode/    the instruction set, the chain records, and the verifier that
                 an untrusted artifact has to pass before it runs
src/vm/          the dispatch loop
src/format/      the artifact: header, ABI fingerprint, pools, position table
crates/rhaigrain-pos/  the position table on its own, with no dependencies

About

Stack-based VM for Rhai

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages