Bytecode VM - #1109
Conversation
|
There are some failed CI tests... Can you look into them? All in all, surprisingly few global impact for such a large feature! The changes are all localized and we'll packaged. |
There was a problem hiding this comment.
Pull request overview
This PR introduces an experimental, feature-gated (“grain”) bytecode VM into Rhai, including artifact (de)serialization, verification, and extensive differential/fuzz-style test harnesses. It also updates the crate to Rust 2021 edition to enable newer borrow-checker capabilities needed by the implementation.
Changes:
- Add
grainmodule behind a newgrainfeature flag, exposingCompiler,Program, andVm, plus on-the-wire artifact read/write and verification. - Add a dedicated
grainintegration test harness and fixtures, plus fuzz targets for hostile-bytecode loading and compile→write→read→run roundtrips. - Bump Rust edition to 2021 and add supporting internal API exposure (
Engine::get_indexed_muttopub(crate)).
Reviewed changes
Copilot reviewed 42 out of 46 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| .gitignore | Ignore .zed/ editor directory. |
| Cargo.toml | Switch to Rust 2021, add grain feature, wire grain-only tests, extend fuzz feature to include grain. |
| fuzz/Cargo.toml | Enable grain feature for fuzz crate; add new fuzz bins; strengthen release profile checks. |
| fuzz/fuzz_targets/generated.rs | Grammar-directed fuzz target comparing VM vs walker. |
| fuzz/fuzz_targets/load.rs | Fuzz target for hostile artifact bytes into Program::read and VM execution. |
| fuzz/fuzz_targets/roundtrip.rs | Fuzz target for script compile→write→read→run parity vs walker. |
| src/eval/chaining.rs | Make Engine::get_indexed_mut pub(crate) for grain VM use. |
| src/lib.rs | Export pub mod grain behind #[cfg(feature = "grain")]. |
| src/module/mod.rs | Whitespace cleanup in docs for with_params_info. |
| src/grain/mod.rs | New grain module root; forbids unsafe_code; exports Compiler, Program, Vm. |
| src/grain/bytecode/mod.rs | Bytecode module surface (ops, pools, verifier, assembly/disassembly). |
| src/grain/bytecode/chain.rs | Chain representation (Root/Step/Tail) for lvalue/rvalue chaining semantics. |
| src/grain/bytecode/chunk.rs | Chunk metadata (entry/end/max_stack) and ops iterator. |
| src/grain/bytecode/code.rs | Bytecode encoding/decoding, assembly/disassembly, switch target resolution. |
| src/grain/bytecode/op.rs | Opcode model and operand decoding helpers. |
| src/grain/bytecode/positions.rs | Position table representation and compact encoding/decoding integration. |
| src/grain/bytecode/strings.rs | String table representation borrowing names from artifact bytes. |
| src/grain/bytecode/switch.rs | Switch dispatch using Rhai’s hashed case semantics with seed probing. |
| src/grain/bytecode/verify.rs | Verifier ensuring structural safety before VM executes borrowed code bytes. |
| src/grain/compile/mod.rs | AST→bytecode lowering pipeline (compiler). |
| src/grain/compile/cases.rs | Range-arm splitting for switch dispatch table generation. |
| src/grain/compile/poolable.rs | Constant-pool eligibility rules for cross-process artifact safety. |
| src/grain/compile/slots.rs | Local slot assignment model aligned with Scope behavior. |
| src/grain/format/mod.rs | Artifact container format (header/sections), Cursor, varint helpers. |
| src/grain/format/abi.rs | ABI fingerprinting (widths + restriction feature flags) and mismatch reporting. |
| src/grain/format/read.rs | Artifact parsing into a Program, including safety checks + verification. |
| src/grain/format/write.rs | Artifact writer with explicit refusal modes for unsupported constructs. |
| src/grain/pos/mod.rs | No-std-friendly address→(line,col) resolver for stripped diagnostics. |
| src/grain/pos/varint.rs | LEB128/zigzag varint codec for position tables. |
| src/grain/program.rs | Program container for pools/chunks/code/positions plus helpers used across VM/compiler/format. |
| src/grain/vm/mod.rs | VM execution engine over verified bytecode, reusing host Engine for dispatch. |
| src/grain/vm/callback.rs | Runtime wrapper module enabling Rhai-native callbacks into compiled grain chunks. |
| tests/mod.rs | Grain integration test harness entry point (feature-gated via Cargo test config). |
| tests/grain/allocation.rs | Allocation/peak tracking harness (separate binary due to global allocator). |
| tests/grain/callback.rs | Tests for native-to-compiled callback behavior and known divergences. |
| tests/grain/corpus/mod.rs | Differential corpus definitions/utilities. |
| tests/grain/corpus/generate.rs | Deterministic script generator used by tests + fuzz targets. |
| tests/grain/differential.rs | Corpus-based differential testing between walker and VM. |
| tests/grain/fixtures/follow.rhai | Realistic fixture script for AST sizing/projection/allocation tests. |
| tests/grain/fixtures/golden.rgrn | Golden artifact fixture for format stability tests. |
| tests/grain/fixtures/golden.rhai | Source for the golden artifact fixture. |
| tests/grain/format.rs | Artifact format and robustness tests. |
| tests/grain/fuzz.rs | Seeded mutation/structural fuzz tests for loader+VM safety and parity checks. |
| tests/grain/limits.rs | Tests ensuring compiled code respects operation limits and progress interrupts. |
| tests/grain/projection.rs | AST-node pricing projection against planned stack encoding (internals-only). |
| tests/grain/scope.rs | Tests covering scope semantics and closure pointer differences. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Final thoughts: Do you suggest creating a new branch for it? Or just merge into Actually merging into And, as I mentioned in Discord, should we call the feature |
|
I think pushing to |
|
Sorry, I just edited my last comment but you might have already read it. Basically two questions:
|
|
I think |
|
CI caught a bug in 32-bit integers mode... |
|
A question: right now, how does the user use this thing? Something like the following? let code = engine.compile_into_grain(&ast)?;
let result = engine.eval_grain_with_scope(...)?; |
|
Example now in the |
|
OK, one final round of CI... |
|
wait need to make sure it compiles under |
|
@ImTheSquid I just found out that the Also, I ran the bench (without walker vm speedup floor spread walker-slow fragments
tight integer loop 106.0ms 54.4ms 1.95x 1.30x 29% 194.0ms 0
float arithmetic 537.8ms 272.4ms 1.97x 1.10x 83% 405.9ms 0
script fn calls 90.8ms 51.2ms 1.77x 1.55x 40% 106.0ms 0
switch, 4 arms 145.4ms 83.7ms 1.74x 1.40x 22% 261.3ms 0
switch, 16 arms 136.3ms 78.1ms 1.75x 1.35x 13% 283.3ms 0
branch heavy 197.8ms 107.9ms 1.83x 1.45x 12% 414.1ms 0
native callbacks 13.5ms 62.3ms 0.22x 0.25x 9% 19.8ms 0So it seems roughly 2x speedup can be expected! |
|
I see in the code that That would be a pretty severe limitation... I personally use it quite a lot, and it is very common for Is there a particular reason why it cannot be supported? |
|
That was just me not wanting to deal with how to deal with |
A program's library, source and module resolver were pushed onto `global` around the main chunk only, so a function reached through `call_fn` ran without them. Anything the compiler could not lower stays an AST in the library, and rhai finds it only in `global.lib` — so a lowered function calling such a helper reported `ErrorFunctionNotFound`, or, when the name was also a rhai built-in, silently answered the built-in instead. Hoist the push and restore into `with_environment` and wrap both halves of `call_fn_with_options` in it. `run_with` splits into that plus `run_main`, which is the same body it always had. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The chain root is walked as a copy and written back afterwards, but the write-back was gated on the walk succeeding. Rhai reaches the entry through a live reference, so a step that mutates and then raises has already written: `let w = widget(1); try { w.bump_then_fail(); } catch(e) {} w.level` is 2 under the walker and was 1 here.
Only that conjunct goes. A shared root is walked through its guard and still needs no write-back, and a read-only one still refused the mutation rather than making it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four opcodes, a chain root and a call receiver, none of which the compiler emits yet. `this` is not a scope entry and no slot addresses it, so it gets a register of its own and these are the only things that reach it. LoadThis flattens and LoadThisShared does not, the split LoadLocal/LoadShared already makes: rhai reads `this` unflattened but flattens at nearly every consumer. RequireThis exists because `this = v` checks boundness before evaluating v, unlike the variable arm. AssignThis carries no name because rhai's failure here has none either. Root::This groups with Local and Named rather than Temporary — a temporary would walk a copy and drop a mutation. root_tag is append-only, so no format version bump; the two runtime sites that need the register itself refuse for now rather than misread. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the register the four opcodes reach, and makes call_fn_with_options honour options.this_ptr instead of ignoring it. The register is owned, not borrowed: the value a binder hands over always lives in the operand stack or a caller's Scope, and neither can lend a &mut across the &mut self call that runs the callee. So the binder moves it in and call_compiled_with_this hands it back. Two properties fall out with no conditional anywhere — a receiver is never inherited, because every ordinary call installs None; and a mutation survives an error, because the handback runs on both paths. Verified against hand-built chunks, since the compiler still refuses to emit any of this: the leak test fails if call_compiled passes the caller's receiver down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Root::This resolves to the frame's register and writes back into it under the same rule a scope entry does — which is why it is not Root::Temporary: `this.push(1)` has to reach the caller's value rather than a copy. ErrorUnboundThis is reported against the `this`, not the `.` after it, which is what the position on the variant is for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Receiver::This takes a path of its own rather than a third Site, because the register is not a scope entry. Rhai's rewrite fires for a receiver that is not shared, and read-only is deliberately not part of that test — unlike the variable arm, which copies a constant before deciding. The dispatch arm for CALL_THIS_REF was missing until a test found it: that site is one of the silent ones, where a new tag falls through to the wildcard and reports a malformed chunk rather than failing to compile. All six new tags are now covered at widths, decode and dispatch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A body that uses `this` can still hold a fragment the compiler could not lower — `this?.x`, or an `import` inside one — and the walker was being handed None, so a `this` the surrounding instructions read perfectly well came back as ErrorUnboundThis. It goes by reference, so a write from the fragment lands in the same register. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Step::Method always went to rhai's dispatch, which finds a script function only in global.lib — so a `this` user that leaves the library would become unreachable the moment the compiler stops skipping it. It now tries our own table first, binding the receiver as the callee's `this`. Consulting the table first is safe: import pushes onto global.modules, not global.lib (eval/stmt.rs:947), and across the crate global.lib is only ever pushed where an AST is being run. That was the one ordering question the plan refused to guess at. Function carries this_type, and Program::method reproduces rhai's typed-hash-first, untyped-fallback selection without hashing. A typed method stays invisible to function-call style, as it is in rhai. The field sits inside a positional record, so VERSION moves 6 -> 7 and the golden fixture is regenerated — two bytes wider, one varint per script function. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The refusal at compile/mod.rs:673 goes, and with it the reason a program with an event handler in it could not be written as an artifact at all — one such function kept rhai's whole function library alive, and format/write.rs refuses to write a program that still has one. Six arms: a bare read, the unflattened read the three cell-observing readers need, `this = v` ahead of the two variable arms as rhai's parser puts it, Root::This, Receiver::This, and LoadThis emitted before the other arguments rather than after. `this.call(f)` and `this.curry(f)` come for free through the existing arm. 22 corpus cases, which the differential harness compares against the walker on value, error variant, error position and leftover scope, and which the residual census requires to lower whole. uses_this is deleted: nothing is refused for it any more. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shape the work exists for: call_fn_with_options with bind_this_ptr on a handler that reads and writes this, compared against the same call through the walker on both the value and the caller's Dynamic afterwards. Including the write that precedes a throw, which rhai keeps. Two tests had gone hollow: their example of something the compiler cannot lower was a body using this, which is no longer one. They now use a bare eval statement, and assert that premise rather than assuming it — eval in expression position leaves a fragment instead and would have left the function compiled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rhai binds obj as the closure's this by reference (func/call.rs:862), so a closure that writes to this writes to obj. The operand stack only ever held a copy, so Op::CallFnPtr now carries where the receiver came from and the write is carried back there. A temporary has nowhere to go, which is what rhai does too. The receiver is pushed unflattened: one that is a shared cell arrives as the cell, so the write lands where every holder can see it and no write-back is needed — run_chain's rule for a chain root. This also repairs a regression from lowering `this` bodies. A closure using `this` became a chunk, invisible to rhai's dispatch, while call_fn_ptr refused the compiled path whenever a receiver was present — so `v.call(|| this * 2)` failed outright with ErrorFunctionNotFound. Nothing in the suite covered it; six corpus cases do now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A native calling a pointer back binds the element as the closure's this and appends what it likes beside it — map an index, reduce the running result — so the arity such a call arrives with is the native's business, not the callee's. A registered wrapper has exactly one arity and cannot cover that; rhai's own pointer carries the body and sizes the call from it. So wrappers skip these, and the library is kept when a program both makes pointers and has one. Repairs the second regression from lowering this bodies: [1,2,3].map(|| this * 2) failed with ErrorFunctionNotFound(anon (i64, i64)) — rhai asking for two arguments where the wrapper offered none. Four corpus cases. takes_this is derived from the chunk rather than encoded, as makes_fn_pointers is, so a loaded program and a compiled one cannot disagree about it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both were already broken at the branch point — 10 errors and 2 — so neither is new, but neither built. no_position: reach for crate::types::Span rather than crate::types::position::Span, which the feature swaps out. unchecked: guard the limit checks the VM makes, since api/limits.rs is cfg'd out whole. On the test side both features change what a script *means*, so the affected cases are skipped rather than papered over: no_position has no table to strip and no two positions to tell apart, and unchecked turns 1/0 into a panic inside rhai's own built-in rather than an error. The corpus filter grew an unchecked arm, and format.rs's writable() now applies that filter — it was iterating every case regardless of build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…_scope
A bare script-function name is a function pointer, not a variable read, and the check for one sat behind always_search_scope in search_scope_only. That flag means "do not trust the parse-time variable indices" — a name resolving to a function has no index to distrust — so gating the check on it made the name stop resolving for the rest of the run as soon as anything set the flag.
Stock rhai has this too, with nothing else involved: fn dbl(x) { x * 2 } eval("let m = 2;"); let f = dbl; f.call(4) reported dbl as an unknown variable while the same script without the eval returned 8. grain merely hits it constantly, because it sets the flag for any program still holding an AST fragment — and a bare function name is one.
The check still runs only for a variable with no cached index, so a variable of the same name keeps winning over the function. Verified against rhai's own suite: 368 passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both are implemented by rhai rather than registered anywhere, and the implementation sat behind `hashes.is_native_only()` inside exec_fn_call. But a call by name decides native_only from the name being *reserved* — and every function answered this way is reserved — so both _call_fn_raw implementations returned early into exec_native_fn_call, straight past the only code that could answer them. type_of then found nothing registered and reported itself missing.
Extracted as Engine::exec_syntactic_fn_call and consulted from all three callers, rather than routing the native-only branch through exec_fn_call: that would also have added ensure_no_data_race and a second global.level increment, neither of which belongs in this change.
In stock rhai a native calling context.call_fn_raw("type_of", ..) got ErrorFunctionNotFound while the script spelling worked. In grain every call goes that way, so type_of was broken for every non-constant value — `let x = 1; type_of(x)` failed while `type_of(1)` passed only because the optimizer folds it. Five corpus cases and one native-API test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Lots of stuff here regarding making |
|
I see the six new opcodes needed to make Rhai did it this way to fail early and save some cycles. However we can consider this an implementation detail and Rhai can probably be changed to make it easier on the VM. This, the VM can evaluate Also, not sure why it is so, but I've tried declaring functions that use |
|
That's the reason for the |
|
One more thing. I see you lifted the syntactic fn names out as an independent function and call that in each of the entry points. Beware that some of these entry points cross call each other, so we may be checking for syntactic function names more than once in a single call. That probably would not affect performance but it would still be wise to avoid it as function calls are done a lot in Rhai... All operators are function calls. |
|
Yeah that was just out of caution, it can be optimized later if necessary but before stuff like |
|
Ah, too late to raise the last issue. No worries, we can handle it later. |
This PR merges my experiment
rhaigraininto the main Rhai repo under thegrainmodule and feature flag, requested as an alternative to #1108. It will be marked as experimental. This PR also updates the Rust edition to 2021 from 2018 to support some operations that were previously forbidden by the borrow checker (such as split mutable borrows). The MSRV was not changed.