Skip to content

Make Grain function loading lazy, tighten up routines - #1192

Open
ImTheSquid wants to merge 9 commits into
mainfrom
grain-lazy-functions
Open

ImTheSquid wants to merge 9 commits into
mainfrom
grain-lazy-functions

Conversation

@ImTheSquid

Copy link
Copy Markdown
Collaborator

This PR addresses a few problems:

  • Adds support for lazy loading Grain functions from other modules.
  • Verifies only parts of a Grain artifact that need verification (ie jump instructions).
  • Sparsifies the position array to do binary search instead.

@ImTheSquid
ImTheSquid requested a review from schungx October 22, 2026 02:15
@schungx schungx added the vm Issues related to the Rhai Grain bytecodes compiler and VM. label Oct 22, 2026
@schungx

schungx commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator
* Verifies only parts of a Grain artifact that need verification (ie jump instructions).

Not sure what changed other than using a bit vector instead of an array of bool's...

Comment thread src/grain/bytecode/positions.rs
Comment thread src/module/mod.rs Outdated
Comment thread src/module/grain.rs
…ests

Grain functions and plugin manifests are now both sources in one
LazyFunctions list, so a Module has one field for functions looked up
only when called, and the Grain-only module helpers exist only with the
grain feature.
Every statement emits a positioned marker under debugging, so the table
outgrows the code it describes on a loop-heavy script.
Comment thread src/module/mod.rs
@schungx

schungx commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

BTW, have you benchmarked this? How much more memory can it save? This would make Grain really light-weight I predict...

Only Grain calls it, so it was dead code without the feature.
combine and combine_flatten remove the registered functions the incoming
Grain functions take precedence over, so lookups fall through to them;
fill_with skips the incoming functions this module's Grain functions
already hold. Neither registers Grain functions up front any more.
Module::merge_filtered cloned every function across when the target had
none, so AST::clone_functions_only_filtered kept every function.
A function pointer linked to a script function short-circuits through
Module::get_script_fn_by_hash, which saw only registered functions, so a
callback into a Grain closure always took the slow path. It now looks in
the Grain functions too, and returns None for a non-script function
instead of panicking.

Each lookup also hashed every function's name again, so a callback whose
operators miss the Grain module paid for that on every call. The hashes
are now made on the first lookup and shared by a module's library and
its exports: 8 bytes per function.
@ImTheSquid

Copy link
Copy Markdown
Collaborator Author

Benchmarks

Compared against the merge base (aaf90906) on an Apple M5 Max, running both builds one after the other.

cargo +nightly bench

No regressions across the 49 benches. The branch measured 6% faster at the median, ranging from 12% faster to 2.7% slower, but parsing, which this PR doesn't touch, moved by the same amount. I read that as machine drift, not a speedup. This was run at c3dfa813; the only later change on the walker's path is Module::get_script_fn_by_hash, which does the same map lookup.

grain_bench

Lazy loading regressed native callbacks (closures called from map/filter): VM 7.0 → 8.7 ms, consistently over three alternating runs. There were two causes, both fixed in 24a0891:

  1. FnPtrType::get_linked_script looks through Module::get_script_fn_by_hash, which saw only registered functions. A callback into a Grain closure therefore always took the slow path. It now looks in the Grain functions too, and returns None instead of hitting unreachable!() for a non-script entry.
  2. Inside a callback, each operator lookup (x * 2, x % 3) misses global.lib first, and those misses aren't cached between callbacks. Each miss hashed every Grain function name again, including UTF-8 validation of the name; profiling showed calc_fn_hash and from_utf8 at about 4× their baseline samples. The hashes are now made on the first lookup and shared between a module's library and its exports, at 8 bytes per function.

Time per run of the callbacks script on its own, fastest of 10 alternating runs:

µs/run vs base
base 351.5
before the fixes ~402 +13%
fast path + hash table 364.1 +3.6%

With the fast path alone, grain_bench callbacks came down from 8.7 to 7.9 ms; the hash table accounts for the rest.

The remaining gap is the lazy-source check on each of those uncached misses. Final grain_bench runs (noisy, Spotlight was indexing): VM callbacks 7.3–7.7 ms on the baseline, 7.6–7.7 ms on the branch. Every other case is within noise of the baseline. The three cases below their floor (script fn calls, native function calls, primes) are below it on the baseline too.

@ImTheSquid
ImTheSquid requested a review from schungx October 23, 2026 21:09

@schungx schungx left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

So basically we avoid building an entire module hashmap of all Grain functions for callbacks, deferring the building a list of their hashes during the first lookup?

The reason why we need these hashes is because callbacks cannot be cached so this lookup happen every time, basically.

LGTM at this point, but can this feature be opt-in or opt-out? Like a new ..._with_lazy_callbacks instead of replacing the original API.

That's because I'd assume the typical Rhai script won't have that many functions to cause a material saving in runtime memory, so some users may want to just build the module in the first place as lazy loading won't save much.

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

Labels

vm Issues related to the Rhai Grain bytecodes compiler and VM.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants