Skip to content

Bytecode VM - #1109

Open
ImTheSquid wants to merge 14 commits into
rhaiscript:mainfrom
ImTheSquid:grain
Open

Bytecode VM#1109
ImTheSquid wants to merge 14 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

Right now, I see that your intended usage scenario is to compile a single AST into bytecodes. Then run it on a VM separately without the source.

We need to address the scenario of loaded modules. I'm not sure how you're handling the import statements and namespace-qualified calls in the VM... I suppose they got delegated back to the engine?

In that case, we'd want to modify the FileModuleResolver with the ability to load stored bytecodes. That way, the VM should probably run those functions directly once they're resolved from the resolver.

Which also means that the standard Module type should now support registering functions that are bytecodes. What that means is extending RhaiFunc with a new variant Grain etc. and the ability to call it directly.

In that case, standard Rhai scripts can also load/call grain functions in a seamless manner.

Also, rhai-run probably should support running grain files by default. We probably should choose a name for the grain output files? Perhaps .rhaigrain or .rhaix or something...

In addition, perhaps a new command line tool rhai-grain to compile individual .rhai files and/or run it.

@ImTheSquid

ImTheSquid commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

The current convention is .rgrn. I agree a new tool would be nice. In terms of module resolution, that can be a later feature once grain gets some usage and we learn more about potential bugs or suggested use cases. Right now it just fails to compile and falls back to the regular engine, so multi-module cross-compilation support is gonna be a more complicated addition that should probably be a separate PR.

I want to get this out in experimental to library users who are interested then add the more complex stuff in the future.

@schungx

schungx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

The current convention is .rgrn

I'd suggest beginning it with .rhai as it would enable users to search for both Rhai scripts and compiled objects in one .rhai*

Alternatively, many scripting systems name their compiled files by attaching a c at the end so .rhaic... looks weird though.

If we settle on a standard extension, we can write a standard header to the file to contain the bytecodes format and/or version (we'd want backwards compatibility). This way, Rhai can load the correct VM based on the header info.

Or we can base64 encode and make it text based, so we can still call it .rhai...

@ImTheSquid

Copy link
Copy Markdown
Collaborator Author

Yeah the header already has versioning information so we could probably just look for the magic string at the beginning of the file. I'm pretty open to whatever since file extensions don't really matter.

@schungx

schungx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Yeah the header already has versioning information so we could probably just look for the magic string at the beginning of the file.

Why don't we use the first 4 bytes in the file as a 4CC to identify the bytecode type... Like !GRN. So grain files are all marked as !GRN.

@ImTheSquid

Copy link
Copy Markdown
Collaborator Author

Way ahead of you lol https://github.com/rhaiscript/rhai/pull/1109/changes#diff-df724370e809f2aaeb7c3d69f65f0585a2514e65309026d49f4d1362fe3aae12R58

@schungx

schungx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Ya I see that RGRN...

Ok, I'll merge this in about 12 hours. Give some time to last minute changes, if any.

When I get back to a desk, I'll send you an invite as an org member and give you direct commit rights. I don't think I can do it on GitHub mobile app...

@ImTheSquid

Copy link
Copy Markdown
Collaborator Author

Awesome, sounds good!

@schungx

schungx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Ok you should now have commit access to rhai.

@ImTheSquid

Copy link
Copy Markdown
Collaborator Author

Yup! I'll let you merge this PR unless you'd like me to do so.

@schungx

schungx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

You can do the honors.:-D

@schungx

schungx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

A few suggestions:

  • Make the API Vm::eval_with_scope and Vm::run_with_scope to mirror Rhai's API. Perhaps also shortcuts without scope.

  • Not sure if the eval_file variants would be useful... If we consider bytecodes advanced usage then probably no need. But if we intend users to be given compiled files and then simply run them then probably yes.

  • Add Vm::call_fn that mirrors Rhai's call_fn API so the user can selectively call a function instead of always having to run the whole script. Potentially also use CallFnOptions where make sense.

  • Can we benchmark it again?

@schungx

schungx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Also, forgot to ask.

How do we handle eval? Or not at all?

I suspect not at all right now... As it requires a parser and variable offsets will be all wracked.

Beware that custom syntax may also modify the scope file so variables offset may be wracked anyway. So the VM needs to keep a flag on this.

@ImTheSquid

Copy link
Copy Markdown
Collaborator Author

Right now eval dies like import does because it would essentially require JIT. Custom syntax is also rejected by the compiler, could also probably be fixed with JIT but that's a later problem. I've added a bench example and the functions you've suggested.

@schungx

schungx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Right now eval dies like import does because it would essentially require JIT. Custom syntax is also rejected by the compiler, could also probably be fixed with JIT but that's a later problem. I've added a bench example and the functions you've suggested.

Yeah, I see it in the code. eval and custom syntax both fails to compile to bytecodes, which should be fine for the majority of users.

However, a custom syntax definition can declare itself to not touch the scope, so perhaps it should be allowed for those cases.

And I see call_fn and call_fn_with_options are all available! Great job!

Pls do the honors of merging!

@schungx

schungx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Actually if we get import working, I have a few applications that I can test the VM. It is a bit of a limitation not to be able to import modules...

As a normal user, I would expect the VM to import modules that must also be compiled, from .rgrn files.

@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.

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