Skip to content

Bytecode VM - #1109

Merged
ImTheSquid merged 33 commits into
rhaiscript:mainfrom
ImTheSquid:grain
Aug 9, 2026
Merged

Bytecode VM#1109
ImTheSquid merged 33 commits into
rhaiscript:mainfrom
ImTheSquid:grain

Conversation

@ImTheSquid

@ImTheSquid ImTheSquid commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

This PR merges my experiment rhaigrain into the main Rhai repo under the grain module 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.

@ImTheSquid
ImTheSquid marked this pull request as ready for review August 8, 2026 00:43
@schungx

schungx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 grain module behind a new grain feature flag, exposing Compiler, Program, and Vm, plus on-the-wire artifact read/write and verification.
  • Add a dedicated grain integration 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_mut to pub(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.

Comment thread fuzz/fuzz_targets/generated.rs
Comment thread src/grain/pos/varint.rs
Comment thread src/grain/format/mod.rs
Comment thread src/module/mod.rs
Comment thread src/grain/bytecode/op.rs
Comment thread Cargo.toml Outdated
Comment thread Cargo.toml Outdated
@schungx

schungx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Final thoughts: Do you suggest creating a new branch for it? Or just merge into main?

Actually merging into main shouldn't be a problem as the changes are extremely localized (other than the fuzzing part). But of course a separate branch would allow for testing new ideas with more extensive changes, such as to compile dynamically-loaded script modules. I suspect for that to happen, there'll be more changes in the core types in Rhai to accommodate bytecode script definitions in modules, or to even load modules which are pre-compiled into bytecodes.

And, as I mentioned in Discord, should we call the feature bytecodes instead of grain? The benefit of using grain is that we can add new VM engines or bytecode formats later on. The downside is the user must be educated on what grain is.

@ImTheSquid

Copy link
Copy Markdown
Collaborator Author

I think pushing to main is fine, it's gated behind a feature flag and doesn't have an impact on the rest of the code. We can always improve it in a branch when we inevitably find ways to do so, and I agree that deeper changes may be needed anyways to get really big features like the ones you mentioned. I think keeping the name as grain makes sense since this is an enclosed feature (bytecodes, vm, compiler) coming from rhaigrain, whereas bytecodes kinda buries the lede on what is actually contained within the module. All it takes is looking at the module docs to know exactly what it is.

@schungx

schungx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Sorry, I just edited my last comment but you might have already read it.

Basically two questions:

  • grain or bytecodes (or vm) as feature name (and src tree root name)

  • new branch or merge into main

@ImTheSquid

ImTheSquid commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

I think grain, also because internally it has mods both named bytecode and vm so it would be a bit clunky with those names. I also think merging to main is fine in this case, nothing changes for users who don't care to use it.

@schungx

schungx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

CI caught a bug in 32-bit integers mode...

@schungx

schungx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

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(...)?;

@ImTheSquid

Copy link
Copy Markdown
Collaborator Author

Example now in the grain module header

Comment thread src/grain/mod.rs Outdated
@schungx

schungx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

OK, one final round of CI...

@ImTheSquid

Copy link
Copy Markdown
Collaborator Author

wait need to make sure it compiles under no_std, i think there are a few lingering items

@schungx

schungx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

@ImTheSquid I just found out that the unchecked and no_position features would conflict with grain. There are a bunch of methods not available when unchecked or no_position is set.

Also, I ran the bench (without unchecked and no_position which I might turn on for benchmarking):

                            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          0

So it seems roughly 2x speedup can be expected!

@schungx

schungx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

I see in the code that Vm::call_fn_with_options is not supported for binding the this pointer.

That would be a pretty severe limitation... I personally use it quite a lot, and it is very common for call_fn to be used in calling event drivers, all of which are written as functions with the context bound to this.

Is there a particular reason why it cannot be supported?

@ImTheSquid

Copy link
Copy Markdown
Collaborator Author

That was just me not wanting to deal with how to deal with this in stack frames, I'll see how easy it could be to do. I'll also try to fix unchecked and no_position, then merge. I think import is a pretty big job so I'm gonna end scope here and do import, custom syntax, and eval in a separate PR.

ImTheSquid and others added 17 commits August 8, 2026 12:31
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>
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>
@ImTheSquid

Copy link
Copy Markdown
Collaborator Author

Lots of stuff here regarding making this work properly, but should be all good now. Also fixed the the feature flag compilation.

@schungx

schungx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

I see the six new opcodes needed to make this work. Is this due to the fact that this = v will failed before evaluating v if this is unbound?

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 v before finding out this does not have a target. That may remove a lot of the complications...

Also, not sure why it is so, but I've tried declaring functions that use this and using it with the VM, which worked perfectly. So the VM must be able to handle function methods before.

@ImTheSquid

Copy link
Copy Markdown
Collaborator Author

That's the reason for the REQUIRE_THIS opcode, the others are just used in the process of dealing with the semantics of this in the context of the stack environment where it kinda needs to be passed around like a hot potato lol. It's only a single opcode and a simple check so it's probably fine, but maybe it is unnecessary. It really depends though because if the function is native/impure then that could change a lot. The VM had some bad behavior that would allow some instances of this through and block others, now it consistently works with all of them.

@schungx

schungx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

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.

@ImTheSquid

Copy link
Copy Markdown
Collaborator Author

Yeah that was just out of caution, it can be optimized later if necessary but before stuff like type_of would fail in very weird ways due to how dispatching works.

@ImTheSquid
ImTheSquid merged commit 72ad275 into rhaiscript:main Aug 9, 2026
53 checks passed
Comment thread src/eval/expr.rs
@schungx

schungx commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Ah, too late to raise the last issue. No worries, we can handle it later.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants