Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0daf56b
initial code compiles
ImTheSquid Aug 7, 2026
6be7e2f
all tests pass
ImTheSquid Aug 8, 2026
4ec3b7e
fix MSRV errors
ImTheSquid Aug 8, 2026
6d7359d
fix review issues
ImTheSquid Aug 8, 2026
3430495
cargo fmt
ImTheSquid Aug 8, 2026
14be485
fix semi-unused import, add tests to CI, fix test naming
ImTheSquid Aug 8, 2026
a95baff
cleanup, fix CI, add examples
ImTheSquid Aug 8, 2026
c2644b6
fmt
ImTheSquid Aug 8, 2026
658f43f
remove std references
ImTheSquid Aug 8, 2026
ad4b3a5
remove MORE std references
ImTheSquid Aug 8, 2026
a7747b3
typo
ImTheSquid Aug 8, 2026
4c6a79b
ensure no_std feature works, add CI
ImTheSquid Aug 8, 2026
80529a4
fail on import
ImTheSquid Aug 8, 2026
b933a11
more edge cases, function names
ImTheSquid Aug 8, 2026
b8cfd74
install a program's environment for the whole of call_fn
ImTheSquid Aug 8, 2026
f9b2b05
silence must_use on the main chunk's discarded value
ImTheSquid Aug 8, 2026
1d138b9
cargo fmt
ImTheSquid Aug 8, 2026
cd3c7da
keep a chain's mutation when the walk raises after it
ImTheSquid Aug 8, 2026
eb0a06b
add the instruction surface for a bound `this`
ImTheSquid Aug 8, 2026
d16c61f
bind a receiver to a compiled frame
ImTheSquid Aug 8, 2026
3c59459
walk a chain rooted at `this`
ImTheSquid Aug 8, 2026
2903a13
call with `this` as the first argument
ImTheSquid Aug 8, 2026
f795520
hand the frame's receiver to a residual fragment
ImTheSquid Aug 8, 2026
6cda7b7
resolve a method call against the compiled table
ImTheSquid Aug 8, 2026
6092b07
lower a function body that uses `this`
ImTheSquid Aug 8, 2026
a9d8340
check a bound handler end to end
ImTheSquid Aug 8, 2026
866568e
bind `obj.call(f)`'s receiver by reference
ImTheSquid Aug 8, 2026
643949d
leave a `this`-taking chunk reachable by rhai
ImTheSquid Aug 8, 2026
cd1855d
compile and pass under unchecked and no_position
ImTheSquid Aug 8, 2026
a88ba84
resolve a bare function name to a pointer regardless of always_search…
ImTheSquid Aug 8, 2026
d005daf
answer type_of and is_shared when called by name
ImTheSquid Aug 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
- uses: actions-rs/cargo@v1
with:
command: check
args: "--features decimal,metadata,serde,debugging"
args: "--features decimal,metadata,serde,debugging,grain"

# typical build with various feature combinations
build:
Expand All @@ -46,6 +46,7 @@ jobs:
os: [ubuntu-latest]
flags:
- ""
- "--features grain"
- "--features testing-environ,debugging"
- "--features testing-environ,metadata"
- "--features testing-environ,serde"
Expand All @@ -62,6 +63,7 @@ jobs:
- "--features testing-environ,f32_float,serde,metadata,internals,debugging"
- "--features testing-environ,no_custom_syntax,serde,metadata,internals,debugging"
- "--tests --features testing-environ,only_i32,serde,metadata,internals,debugging"
- "--tests --features testing-environ,only_i32,serde,metadata,internals,debugging,grain"
- "--features testing-environ,only_i64,serde,metadata,internals,debugging"
- "--features testing-environ,no_index,serde,metadata,internals,debugging"
- "--features testing-environ,no_object,serde,metadata,internals,debugging"
Expand Down Expand Up @@ -107,6 +109,7 @@ jobs:
- {os: ubuntu-latest, flags: "--profile unix", experimental: false}
- {os: windows-latest, flags: "--profile windows", experimental: true}
- {os: macos-latest, flags: "--profile macos", experimental: false}
- {os: ubuntu-latest, flags: "--profile unix --features rhai/grain", experimental: false}
steps:
- name: Checkout
uses: actions/checkout@v4
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ doc/rhai.json
.idea
.idea/*
src/eval/chaining.rs
.zed/
27 changes: 25 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ members = [".", "codegen", "codegen/tests/custom_root"]
name = "rhai"
version = "1.25.1"
rust-version = "1.66.0"
edition = "2018"
edition = "2021"
resolver = "2"
authors = ["Jonathan Turner", "Lukáš Hozda", "Stephen Chung", "jhwgh1968"]
description = "Embedded scripting for Rust"
Expand Down Expand Up @@ -70,7 +70,9 @@ debugging = ["internals"]
## Features and dependencies required by `bin` tools: `decimal`, `metadata`, `serde`, `debugging` and [`rustyline`](https://crates.io/crates/rustyline).
bin-features = ["decimal", "metadata", "serde", "debugging", "rustyline"]
## Enable fuzzing via the [`arbitrary`](https://crates.io/crates/arbitrary) crate.
fuzz = ["arbitrary", "rust_decimal?/rust-fuzz", "serde"]
fuzz = ["arbitrary", "rust_decimal?/rust-fuzz", "serde", "grain"]
## Enable the experimental `grain` bytecode VM
grain = []

#! ### System Configuration Features

Expand Down Expand Up @@ -147,6 +149,27 @@ required-features = ["serde"]
name = "definitions"
required-features = ["metadata", "internals"]

# VM-versus-walker timings. Run with `--release`; `-- --check` exits non-zero
# on a case that has fallen below the floor recorded beside it.
[[example]]
name = "grain_bench"
required-features = ["grain"]

# The grain harnesses, as one binary. `tests/mod.rs` wraps them in an inline
# `mod grain`, which is what puts their module paths inside `tests/grain/`.
[[test]]
name = "grain"
path = "tests/mod.rs"
required-features = ["grain"]

# Its own binary, and not negotiable: it installs a counting global allocator
# and reads process-global counters as deltas around each call, so any test
# allocating on another thread corrupts every number it reports.
[[test]]
name = "grain_allocation_efficiency"
path = "tests/grain/allocation.rs"
required-features = ["grain"]

[profile.release]
lto = "fat"
codegen-units = 1
Expand Down
261 changes: 261 additions & 0 deletions examples/grain_bench.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
//! Rough VM-versus-walker timings, on the same AST.
//!
//! Indicative, not criterion: repeated runs, reporting the fastest of each and
//! the spread around it so a single scheduling hiccup does not read as a
//! result. Run with `--release`; a debug build measures bounds checks more than
//! anything else.
//!
//! # Catching a regression
//!
//! `cargo run --release --example bench -- --check` exits non-zero if any case
//! has fallen below the floor recorded beside it.
//!
//! What is compared is the **ratio**, not the time. Absolute milliseconds say
//! as much about the machine as about the code, and there is no useful way to
//! commit one; the walker and the VM run back to back on the same machine in
//! the same process, so their ratio mostly divides the machine out. Mostly, not
//! entirely — cache size and core count still move it — so a floor sits about
//! 15% under the observed figure. That is wide enough not to cry wolf on a
//! slower runner and tight enough that losing a fast path shows up.

use std::time::{Duration, Instant};

use rhai::grain::{Compiler, Program, Vm};
use rhai::{Dynamic, Engine, Scope};

const RUNS: usize = 9;

struct Case {
name: &'static str,
source: &'static str,
iterations: usize,
/// Whether the run needs the callback wrappers installed, which costs an
/// owned program and a module built per run.
callbacks: bool,
/// The speedup this case must not drop below.
///
/// Beside the source rather than in a table of its own, so changing one
/// without the other is visible in the diff.
floor: f64,
}

/// The fastest sample, and how much slower the middle one was.
///
/// Noise on a timing is one-sided — nothing makes a run finish sooner than it
/// can — so the fastest sample is the least contaminated estimate, and the
/// median is here only to say how contaminated the rest were. A wide spread
/// means the number below it should not be read closely.
struct Timing {
fastest: Duration,
median: Duration,
}

impl Timing {
fn secs(&self) -> f64 {
self.fastest.as_secs_f64()
}

/// How far the median sits above the fastest, as a fraction.
fn spread(&self) -> f64 {
self.median.as_secs_f64() / self.fastest.as_secs_f64() - 1.0
}
}

const CASES: &[Case] = &[
Case {
name: "tight integer loop",
source: "let s = 0; let i = 0; while i < 20000 { s += i; i += 1; } s",
iterations: 20,
callbacks: false,
floor: 1.30,
},
Case {
name: "float arithmetic",
source: "let x = 0.0; let i = 0; while i < 20000 { x += (i.to_float() * 1.5) / 2.5; i += 1; } x",
iterations: 20,
callbacks: false,
floor: 1.10,
},
Case {
name: "script fn calls",
source: "fn add(a, b) { a + b } let s = 0; let i = 0; while i < 5000 { s = add(s, i); i += 1; } s",
iterations: 20,
callbacks: false,
floor: 1.55,
},
// The VM scans its case hashes; rhai probes a hash map. Two sizes,
// because which of those wins is a question about how many arms there
// are, and a `switch` nobody would write is the only place the scan can
// lose.
Case {
name: "switch, 4 arms",
source: "let s = 0; let i = 0; while i < 20000 { \
switch i % 4 { 0 => s += 1, 1 => s += 2, 2 => s += 3, _ => s += 4 } \
i += 1; } s",
iterations: 20,
callbacks: false,
floor: 1.40,
},
Case {
name: "switch, 16 arms",
source: "let s = 0; let i = 0; while i < 20000 { \
switch i % 16 { \
0 => s += 1, 1 => s += 2, 2 => s += 3, 3 => s += 4, \
4 => s += 5, 5 => s += 6, 6 => s += 7, 7 => s += 8, \
8 => s += 9, 9 => s += 10, 10 => s += 11, 11 => s += 12, \
12 => s += 13, 13 => s += 14, 14 => s += 15, _ => s += 16 } \
i += 1; } s",
iterations: 20,
callbacks: false,
floor: 1.35,
},
Case {
name: "branch heavy",
source: "let s = 0; let i = 0; while i < 20000 { if i % 3 == 0 { s += 1; } else if i % 3 == 1 { s += 2; } else { s -= 1; } i += 1; } s",
iterations: 20,
callbacks: false,
floor: 1.45,
},
// The one case the VM is expected to lose. Every element is a boundary out
// of the VM, through rhai's dispatch and back into a second `Vm` with an
// empty resolution cache — where the walker stays inside itself and reaches
// the closure body directly. 1000 crossings per iteration.
Case {
name: "native callbacks",
source: "let a = []; let i = 0; while i < 500 { a.push(i); i += 1; } \
let b = a.map(|x| x * 2); b.filter(|x| x % 3 == 0).len()",
iterations: 20,
callbacks: true,
floor: 0.25,
},
];

fn time(mut run: impl FnMut()) -> Timing {
let mut samples: Vec<Duration> = (0..RUNS)
.map(|_| {
let start = Instant::now();
run();
start.elapsed()
})
.collect();
samples.sort_unstable();
Timing {
fastest: samples[0],
median: samples[RUNS / 2],
}
}

fn main() {
let check = std::env::args().any(|arg| arg == "--check");
let engine = Engine::new();

// Rhai's default options include FAST_OPS, which makes the walker
// short-circuit binary operators and op-assignments straight to builtin
// function pointers — no hash, no resolution cache
// (`func/call.rs:1775-1799`, `eval/stmt.rs:131-148`). Turning it off
// measures how much of the walker's speed comes from that, and therefore
// how much of the VM's planned "typed fast opcodes" win is already taken.
let mut slow_engine = Engine::new();
slow_engine.set_fast_operators(false);

println!(
"{:<22} {:>11} {:>11} {:>9} {:>7} {:>8} {:>11} {:>10}",
"", "walker", "vm", "speedup", "floor", "spread", "walker-slow", "fragments"
);

let mut below_floor = Vec::new();

for case in CASES {
let ast = engine.compile(case.source).expect("must compile");
let program: Program = Compiler::new().compile(&ast);
// Owned and shared only where a pointer can escape to a native, so the
// ordinary cases keep measuring the ordinary path.
let shared = case
.callbacks
.then(|| Compiler::new().compile(&ast).into_shared());
let run_vm = || match &shared {
Some(shared) => Vm::new(&engine).eval_with_callbacks(&mut Scope::new(), shared),
None => Vm::new(&engine).eval_with_scope(&mut Scope::new(), &program),
};

// Same result, or the comparison is meaningless.
let expected = engine
.eval_ast_with_scope::<Dynamic>(&mut Scope::new(), &ast)
.expect("walker must succeed");
let actual = run_vm().expect("vm must succeed");
assert_eq!(
format!("{expected:?}"),
format!("{actual:?}"),
"{} disagreed, so its timing means nothing",
case.name,
);

let walker = time(|| {
for _ in 0..case.iterations {
let _ = engine
.eval_ast_with_scope::<Dynamic>(&mut Scope::new(), &ast)
.unwrap();
}
});

let vm = time(|| {
for _ in 0..case.iterations {
let _ = run_vm().unwrap();
}
});

let slow_ast = slow_engine.compile(case.source).expect("must compile");
let walker_slow = time(|| {
for _ in 0..case.iterations {
let _ = slow_engine
.eval_ast_with_scope::<Dynamic>(&mut Scope::new(), &slow_ast)
.unwrap();
}
});

// The spread reported is the VM's, because that is the number the
// floor is about. A walker sample knocked sideways shows up in the
// speedup anyway.
let speedup = walker.secs() / vm.secs();
println!(
"{:<22} {:>9.1}ms {:>9.1}ms {:>8.2}x {:>6.2}x {:>7.0}% {:>9.1}ms {:>10}",
case.name,
walker.secs() * 1000.0,
vm.secs() * 1000.0,
speedup,
case.floor,
vm.spread() * 100.0,
walker_slow.secs() * 1000.0,
program.residual_nodes(),
);

if speedup < case.floor {
below_floor.push(format!(
"\n {}: {speedup:.2}x, floor {:.2}x (VM samples spread {:.0}%)",
case.name,
case.floor,
vm.spread() * 100.0,
));
}
}

if below_floor.is_empty() {
return;
}

// Printed whether or not this is a gated run: a regression is worth seeing
// even when nobody asked for an exit code.
eprintln!(
"\n{} case(s) below their floor:{}",
below_floor.len(),
below_floor.join(""),
);
eprintln!(
"\nA wide spread means the machine was busy — rerun before believing it. \
If the loss is real, either find it or move the floor in the same commit \
that causes it.",
);
if check {
std::process::exit(1);
}
}
27 changes: 25 additions & 2 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@ cargo-fuzz = true
anyhow = "1.0.78"
arbitrary = { version = "1.3.2", features = ["derive"] }
libfuzzer-sys = "0.4"
rhai = { path = "..", features = ["fuzz", "decimal", "metadata", "debugging"] }
rhai = { path = "..", features = ["fuzz", "decimal", "metadata", "debugging", "grain"] }
serde = { version = "1.0.194", features = ["derive"] }

# Prevent this from interfering with workspaces
[workspace]
members = ["."]

[profile.release]
debug = 1
debug = true
debug-assertions = true
overflow-checks = true

[[bin]]
name = "scripting"
Expand All @@ -38,3 +40,24 @@ name = "fuzz_serde"
path = "fuzz_targets/fuzz_serde.rs"
test = false
doc = false

[[bin]]
name = "load"
path = "fuzz_targets/load.rs"
test = false
doc = false
bench = false

[[bin]]
name = "roundtrip"
path = "fuzz_targets/roundtrip.rs"
test = false
doc = false
bench = false

[[bin]]
name = "generated"
path = "fuzz_targets/generated.rs"
test = false
doc = false
bench = false
Loading
Loading