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.
./scripts/vendor-rhai.sh # reconstruct vendor/rhai, which is gitignored
cargo testtests/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.
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.
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'sScriptFuncDef— 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 beforemax_call_levels. Stricter, never laxer.
Every number here is a host number; the no_std split is not done.
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