✨ feat(mq-lang): add stack-based bytecode VM behind tarn feature - #2267
Open
harehare wants to merge 63 commits into
Open
✨ feat(mq-lang): add stack-based bytecode VM behind tarn feature#2267harehare wants to merge 63 commits into
harehare wants to merge 63 commits into
Conversation
Merging this PR will regress 2 benchmarks
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | eval_string_interpolation |
110.4 µs | 149.8 µs | -26.33% |
| ❌ | eval_select_h |
394.1 µs | 472.5 µs | -16.58% |
| ⚡ | eval_qualified_access_to_csv_module |
26.2 ms | 2.7 ms | ×9.7 |
| ⚡ | eval_while_speed_test |
27.4 ms | 7.3 ms | ×3.7 |
| ⚡ | eval_fibonacci |
88.6 ms | 29.2 ms | ×3 |
| ⚡ | eval_variable_assignment_chain |
1,312.3 µs | 528.4 µs | ×2.5 |
| ⚡ | eval_if_else_branching |
4.9 ms | 2.6 ms | +84.97% |
| ⚡ | eval_object_field_access |
3.3 ms | 1.9 ms | +75.42% |
| ⚡ | eval_array_fold |
2.6 ms | 1.5 ms | +72.33% |
| ⚡ | eval_nested_function_calls |
1,493 µs | 977.3 µs | +52.77% |
| ⚡ | eval_array_filter |
6.5 ms | 4.3 ms | +50.5% |
| ⚡ | eval_array_chained_operations |
5.6 ms | 3.9 ms | +45.99% |
| ⚡ | eval_nested_object_access |
2.3 ms | 1.6 ms | +44.27% |
| ⚡ | eval_foreach |
2.5 ms | 1.7 ms | +43.39% |
| ⚡ | eval_long_pipeline |
3.6 ms | 2.6 ms | +35.03% |
| ⚡ | eval_pipeline_with_conditionals |
2.6 ms | 1.9 ms | +32.71% |
| ⚡ | eval_array_map |
3.6 ms | 2.8 ms | +29.36% |
| ⚡ | eval_yaml_parse |
3.8 ms | 3 ms | +26.1% |
| ⚡ | eval_csv_parse |
3.6 ms | 2.9 ms | +23.04% |
| ⚡ | eval_json_parse |
2.7 ms | 2.3 ms | +20.67% |
| ... | ... | ... | ... | ... |
ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing feat/tarn-bytecode-vm (d1a4b8d) with main (f3ed711)1
Footnotes
harehare
force-pushed
the
feat/tarn-bytecode-vm
branch
from
September 1, 2026 14:21
2ed8456 to
21dde6d
Compare
Introduces "Tarn", a stack-based bytecode VM with static compile-time variable-slot resolution, as an alternative execution backend to the tree-walking Evaluator.
… tarn VM - Replay Engine-loaded include/import modules before VM compilation so their exports stay resolvable, including across a `nodes` split. - Let `break` cross a try/catch boundary via a new FlowBreak opcode instead of rejecting it as a compile error; `continue` still can't. - Track local/upvalue names per chunk and sync them into the legacy dynamic env before get_variable/set_variable, so those builtins keep working against VM-compiled frames. - Carry per-module debug sources through the VM debugger hook so breakpoints in included/imported modules show the right source. - Strip a trailing .mq when checking trackable modules in coverage.
Adds OpCode::FlowContinue and a TryCatch continue_offset so a `continue` inside a nested try/catch chunk now resumes the enclosing while/foreach loop instead of failing to compile. Also extracts bind_params's arity check into parameter_uses_implicit_self and bundles the runtime services it needs into a ParameterContext, and rewrites the arity/continue tests as rstest cases plus a proptest covering arithmetic, closures, and foreach semantics.
Cache module-free CompiledProgram bytecode on the CompiledProgram so repeated eval_compiled calls skip recompilation, and pool local cell Vecs across calls/try-catch frames instead of allocating fresh ones each time. Also add bench-tree/bench-vm just recipes and a compiled fibonacci benchmark for tree-walker vs VM comparison.
…optimize bytecode reuse
…ols and improved local variable management
Continues the tarn VM performance work: profiled the workloads still slower than the tree-walker with sample against debug-symbol release builds, and fixed each root cause found rather than guessing. - call_fixed_closure_from_stack no longer double-clones the caller's self value on every fixed-arity call. - Split several rare/large OpCode arms (try/catch, array spread, type-check, selector matching, misc array ops, string interpolation, external globals) out of run_chunk_inner_impl into their own functions. Besides shrinking the hot dispatch loop for icache locality, this incidentally fixes a real bug: in debug builds the interpreter's per-call native stack frame was large enough that the configured recursion-depth limit had essentially no safety margin before a native stack overflow, crashing instead of raising a catchable RecursionError. selector_op is deliberately not marked cold, since markdown selector matching is mq's primary workload, not a rare path. - Engine::load_builtin_module no longer populates the tree-walker's Env under the tarn feature; the VM compiles its own prelude independently and never reads it. Kept the builtin.mq parse itself (not just the more expensive Env population) so the global string interner's identifier order stays stable, since Ident's Ord (and therefore RuntimeValue::Dict's BTreeMap key order) currently depends on interning history. - get/attr/set_children builtins, and the VM's own ArrayGetLocalAt fast path, no longer take the copy-on-write make_mut path for what are actually read-only operations, which was deep-cloning shared dicts/arrays just to read one entry. - Fixed a real compiler bug: no def-declaring path ever marked its slot immutable, so compile_call's fast CallLocal path (which requires an immutable local) never triggered for calls to top-level functions from outside their own body, silently falling back to the slower dynamic CallValue path (including an O(n) stack shift) for what should be the common case. - Ident::new tries a read-lock-only interner lookup before falling back to the write-locked get_or_intern, avoiding an unnecessary exclusive lock on repeat lookups of an already-interned string. Verified throughout: full nextest suite on both engines and --all-features, just test-mq-vm, workspace check, and clippy/fmt across all three feature combinations.
ExecutionLimits::take_locals_with_initialized_prefix linearly scanned ExecutionPools::local_pool (a single Vec<Vec<Cell>>, capped at 32 mixed-length entries) for one matching local count. Rebucket into a FxHashMap<u16, Vec<Vec<Cell>>> keyed by local count, capped per bucket, turning the lookup into a hashmap access instead of a scan and scaling to programs with many distinct chunk shapes cycling through the pool.
build-bench now passes --features tarn, so the benchmarks CI tracks via CodSpeed measure the VM backend instead of the tree-walker.
Most #[allow(dead_code)] markers in the VM debugger-adapter code carried comments saying they were waiting on Engine wiring in a future milestone, but engine.rs's eval_compiled_vm already calls into compile_and_run_debugged_many, which already drives a real VmDebuggerHook, under the debugger feature. The wiring had landed; the markers were just never cleaned up. Removed the now-inaccurate ones and narrowed the remaining, genuinely feature-dependent ones to precise #[cfg_attr(...)] conditions instead of blanket allows. Removing VmDebuggerHook's blanket allow surfaced a real unused field: module_loader was stored but never read, since eval_expression always builds its own fixed StdModuleResolver-based loader inline by design. Dropped the field and the R: ModuleResolver generic parameter it was the only user of, simplifying VmDebuggerHook::new's signature. Verified clean (no warnings) with clippy -D warnings across --features tarn, --all-features, and default.
Engine::eval_debug_expression replaces switch_env for evaluating an ad-hoc expression against a paused frame: under tarn it compiles the expression with the frame's live bindings predeclared as slots, since the VM never reads a dynamic Env. mq-dap gains an optional tarn feature and its Evaluate-request handler now uses the new API.
… self Adds differential (VM-vs-tree-walker) and property-based cases for 3-level closure nesting, sibling/shadowed let scopes, var mutation visible to a closure, and implicit self combined with variadics. Also documents a real, non-VM-specific finding: foreach's loop variable is not freshly captured per iteration, so closures created in the loop body all share the final value once called afterward - confirmed identical on the tree-walker via a differential test.
Closes the gap on function-call and loop-heavy benchmarks that ran slower on the tarn VM than the tree-walker: - Index the local-frame pool by slot count instead of hashing it. - Memoize Chunk::captures_local_slots instead of rescanning the chunk's code on every call. - Stop round-tripping every statement's value through SetLocal(SELF_SLOT)/GetLocal(SELF_SLOT) in compile_body and compile_top_level; the last statement's value is only needed on the stack. while/until loops re-sync self explicitly since their condition re-reads it next iteration.
…spection Introduces a debug-trace feature flag exposing Tarn VM internals for mq-dbg diagnosis: bytecode dumps (--dump-bytecode) and operand-stack snapshots at debugger stops (--dump-stack, "stack" command).
compile_program_impl walked the whole program to build module_function_roots on every compile, even though it's only read when a module is actually loaded. Most one-shot Engine::eval() calls load none, so this was pure overhead on the VM's compile path that the tree-walker has no equivalent of. Cuts the VM/tree-walker gap on eval_function_call_overhead (33% -> 17%) and eval_large_program (17% -> 11%) benchmarks; eval_nodes moves from a regression to parity.
Adds one-shot and compiled (cached-bytecode) benchmark pairs for the section and table modules, matching the existing csv/json/yaml pattern, so regressions in these real-world-heavy modules show up in the CodSpeed VM benchmark suite going forward. The compiled variants pass a single pre-aggregated array input rather than using `nodes`, since `nodes` currently disqualifies the VM's compiled-bytecode cache.
CachedProgram held one flat program and ran it once per input, which can't express nodes' two-phase model (per-input, then once against the aggregated array) — so any query containing nodes recompiled from scratch on every eval_compiled call, same as a one-shot eval(). CachedProgram now optionally holds a second compiled program for the post-nodes half, reusing the same split_at_nodes/aggregate logic the non-cached path already had. Cuts eval_compiled_nodes from one-shot cost (~60us) to ~25us.
Tracks the nodes compiled-bytecode-cache fix directly, and switches the section/table compiled benchmarks from an artificial pre-aggregated-array input to real `nodes | ...` now that it's cacheable, matching realistic per-node usage.
RuntimeValue::VmClosure embedded VmClosureValue (~64 bytes) inline, so the whole shared enum sized itself to the largest variant: 32 bytes without tarn, 64 with it. Every clone/move in the VM paid for that under tarn. Shared-wrapping it (matching Array/Dict's existing clone-on-write pattern) brings it back to 32 bytes. Turns eval_compiled_function_call_overhead from a regression into a VM win (87us vs the tree-walker's 104us) and roughly doubles eval_compiled_nested_function_calls.
compile_reachable_builtin_prelude rebuilt a name->definition map and re-walked every reachable builtin function's body on each compile, even though builtin.mq never changes within a process. Cache the per-function soft-builtin call graph in a thread-local, keyed by the cached module's identity, so repeated one-shot Engine::eval calls only pay for the graph walk once.
…16 bytes Vec<OpCode> stores every instruction inline, so the enum's size is fixed to its largest variant's — Rust's usual tradeoff for avoiding a separate byte-tag encoding layer. SelectorMatch/SelectorMatchWithArgs's inline Selector (32 bytes) and MakeClosure's inline (u16, Vec<UpvalueSource>) pushed every OpCode to 40 bytes, even for zero-payload instructions like Pop or Dup. Box the heaviest fields (SelectorMatch, SelectorMatchWithArgs, and the new TryCatchInfo for TryCatch) so each variant carries at most a pointer plus a small tag, dropping size_of::<OpCode>() to 16 bytes. Also adds a peephole-optimizer test covering TryCatch's break/continue offsets surviving dead-code removal, and a regression guard on OpCode's size.
import "mod" as m qualified functions (m::helper) but left top-level let/var bindings unqualified and, worse, reachable bare (base) from the importing scope — unlike the tree-walker, which correctly rejects the bare name and requires m::base. compile_import_functions already threaded an alias through for functions; compile_module_vars_binding now does the same for vars, registering them in qualified_bindings and hiding the bare name, for both top-level import and import nested inside an inline `module ... end` block. include is unaffected: its whole point is direct, unqualified access. Also introduces QualifiedName/QualifiedSlot in place of the (Ident, Ident)/(usize, u16) tuples qualified_bindings used, so alias vs. name and depth vs. slot aren't just positional.
try_fold_call folded add(x, 0)/mul(x, 1)/etc. to the non-literal operand whenever the other side was a literal 0/1/"", assuming Number arithmetic identities hold universally. They don't: add/sub/mul/div are polymorphic (array concat/repeat, markdown value append, string coercion, ...), so e.g. add(0, [1, 2]) is [0, 1, 2], not [1, 2], and [1, 2] * 0 is [], not 0 — both previously folded to the wrong value. Only the fully-literal fold, where both operands' concrete types are known, is sound and stays. Replaces the tests asserting the old behavior (scattered across four spots, several duplicates) with two focused ones: one confirming these expressions now stay unfolded, and a regression test reproducing the exact array cases that broke.
Every non-tail pipe stage writes its result to self (or a synthetic slot) via SetLocal, and the next stage immediately reads it back via GetLocal — a store-then-reload the existing peephole pass couldn't touch (it only recognized the opposite GetLocal→SetLocal order). This pattern isn't specific to any one construct: piped selectors, chained builtin calls, and non-tail statements inside function bodies all compile to it whenever the AST optimizer hasn't already merged them. Add OpCode::TeeLocal(slot), which stores the top of stack into slot without popping it (matching what the fused pair already computed — the peephole rewrite mutates the SetLocal in place and drops the GetLocal, same mechanism as the existing dead-code patterns), and wire it into the interpreter the same way Dup peeks the stack.
token()/token_include_spaces() tried up to ~40 nom alternatives in sequence for every token, with the most common case (identifiers) tried last. Route on the input's first character instead, calling only the parsers that can plausibly match, in the same relative priority the old exhaustive alt() used. The old alt() stays as token_slow(), the fallback for anything not in the dispatch table. A differential test suite checks the fast path against token_slow() on every tracked .mq file, curated edge cases, and randomized ASCII input, which caught a real ordering bug during development (number_literal also accepts a leading '+', so `+0` must not lex as Plus).
Cross-referenced every reference doc under docs/books/src/reference against the existing integration/tarn/property-based test suites. Coverage was already extensive, but these documented behaviors had zero cases (verified manually against both the tree-walker and the tarn VM before writing each one): - `!~` (not-regex-match) operator - `?` error-suppression operator - `..` as an operator (not just the `range()` builtin): ascending, descending, single-element, and character ranges - Property selector (`."key"`) mapping over an array of dicts, with non-dict elements producing None - Selector calls with multiple depths (`.h(2, 3)`) and a range argument (`.h(1..3)`) - The `:markdown` type pattern in `match`
'a'..'e' isn't valid mq syntax — there's no single-quoted char literal, only double-quoted strings. Confirmed "a".."e" is what the engine actually accepts and produces the documented ["a", ...] result.
Tarn compiled the two halves of a `nodes` split as independent programs, so a `def`/`import`/`include`/`module` or `let`/`var` declared before `nodes` silently resolved to "not defined" after it (e.g. `def sitemap(...) end | nodes | sitemap(...)`) — the tree-walker's shared env never had this problem. Static declarations are now hoisted into the after-program at compile time; `let`/`var` values are captured from the last per-input run and threaded into the after-program as predeclared bindings, reusing the debugger's existing predeclared-slot machinery. Both paths keep the original zero-overhead code when there's nothing to hoist or capture. Also consolidates the Engine<->Tarn VM dispatch (bytecode caching, module-prelude replay, debugger hookup) behind a new `TarnVm` struct so `Engine::eval_compiled_vm` just builds one and calls `run`, and adds a cookbook-driven regression suite (crates/mq-run/tests/cookbook_tests.rs) that runs docs/books/src/cookbook's examples through both the tree-walker and Tarn.
Add `use crate::{engine, error, tarn}` where each was missing and
shorten the resulting `crate::tarn::X`/`crate::engine::X`/`crate::error::X`
paths to `tarn::X`/`engine::X`/`error::X`.
Also fixes a real bug this surfaced: TarnVm::run's cache-check block
was gated `#[cfg(not(feature = "debugger"))]` instead of
`#[cfg(all(feature = "tarn", not(feature = "debugger")))]`, so a
default build without the `tarn` feature failed to compile
(cargo build -p mq-lang was broken, only ever tested with --features
tarn). Verified default/tarn/tarn+debugger all build, lint, and test
clean.
A query using `import`/`include` couldn't see which builtin.mq functions the imported module's own bodies would need, so any use of one (e.g. `import "table" | table::tables()`, whose module calls `map`/`filter`/`is_empty` etc. internally) tripped the "reachable set incomplete" fallback and compiled the *entire* ~150-function builtin prelude into the program's top-level chunk — rebuilding all of those closures on every execution even though the query only ever calls a handful of them. Retry with just the specific names the failed compile reports missing instead, growing the reachable set by one attempt's worth at a time (bounded, falling back to the full prelude if it doesn't converge). For `nodes | import "table" | table::tables()` this drops the top-level chunk from 197 bound closures to 67, cutting eval_compiled_table_tables's benchmark time by ~53% and eval_section_sections's by ~34%, with no change to queries that don't import a module.
… imports reachable_module_functions bailed out to compiling every function in a module whenever that module had any top-level import/include/var, because a var initializer could call a function that reachability analysis missed. Nested import directives never call back into the module's own functions though, so that half of the guard was needless; scanning the module's own vars for function references closes the real gap instead. table.mq's own `import "csv"` was tripping this guard, forcing all ~31 of its functions to compile on every query. Chunk count for a simple `table::tables()` query drops from 113 to 35, and eval_compiled_table_tables's mean benchmark time drops from ~810us to ~360us.
RuntimeValue::String(String) and RuntimeValue::Bytes(Vec<u8>) held their data inline, so every clone deep-copied the buffer even when the value was just being passed around unchanged. Array/Dict/Markdown/ Function already avoid this via Shared (Rc/Arc); String and Bytes now follow the same pattern, with string_mut/bytes_mut added alongside the existing array_mut/dict_mut/markdown_mut COW helpers for the small number of builtins that mutate in place. Every construction site across the workspace needed updating for the new Shared<String>/Shared<Vec<u8>> payload; most were mechanical, a handful of mutating builtins were switched to the COW helpers, and a few by-value extractions now go through Shared::unwrap_or_clone (the same pattern already used for Array/Dict in to_json_value/to_cbor_value).
RuntimeValue::Function held its params, body, and env as three separate Shared pointers inline in the enum. That 24-byte payload set the size of the whole RuntimeValue enum (32 bytes), so even trivial variants like Number and Boolean paid for moving that much data, and for a branch in the compiler-generated drop glue, on every clone/drop. The three fields now live in one FunctionValue struct behind a single Shared pointer (mirroring VmClosureValue's existing scheme), shrinking RuntimeValue to 24 bytes and cloning a function value to one refcount bump instead of three. eval_compiled_fibonacci's mean benchmark time drops from ~6.76ms to ~4.9ms and eval_while_speed_test's from ~2.24ms to ~1.72ms.
RuntimeValue::Markdown's Option<Selector> was 16 bytes (a plain usize index has no spare bit pattern for Option to reuse as its None niche). Selector::Index now stores index + 1 in a NonZeroUsize, so Option <Selector> fits in 8 bytes via the niche NonZeroUsize provides. RuntimeValue::Module(ModuleEnv) held its Ident + Shared<SharedCell <Env>> inline (16 bytes); it's now Module(Shared<ModuleEnv>), an 8-byte pointer, cloned as a refcount bump like the other Shared-wrapped variants. RuntimeValue is still 24 bytes after this — Markdown's own two fields (Shared<Node> + Option<Selector>) now tie for the largest variant on their own, so this doesn't move the overall size by itself. It's a correct, low-risk step ahead of consolidating Markdown the same way Function was.
Every GetLocal/SetLocal/TeeLocal in the dispatch loop indexed Locals' backing Vec with a bounds check, even though bytecode::verify_chunks already rejects any chunk where one of those opcodes' slots is out of range for chunk.local_count, and Locals is always sized to exactly that count. Added get_unchecked/set_unchecked and used them for these three opcodes plus local_runtime_value's four callers (BinaryLocalLocal, BinaryLocalConst, ArrayLenLocal, ArrayGetLocalAt) — all covered by the same verify_chunks check. TeeLocal was missing from that check; added it. eval_while_speed_test's mean benchmark time drops from ~1.72ms to ~1.53ms.
Locals::get_unchecked/set_unchecked trust verify_chunks to reject any chunk where a local-slot opcode's slot is out of range. Nothing covered that directly — add cases for every opcode carrying a local slot (including param bindings), one per opcode so a future opcode missing from that match fails its own case instead of hiding inside a combined assertion. Confirmed the tee_local case fails without the prior commit's fix by temporarily reverting it and re-running.
Const, GetEnvVar, and BinaryLocalConst all indexed chunk.constants with a bounds check, though verify_chunks already rejects any chunk where one of those opcodes' constant index is out of range. Same treatment as the prior local-slot commit, plus a test confirming verify_chunks still catches an out-of-bounds index for each of the three.
The Function-payload refactor introduced Shared::new in the example but the doctest's use statement didn't pull in the type, breaking `cargo test --doc`.
Same fallout as the previous mq-lang doctest fix: the Function-payload refactor's Shared::new example wasn't matched by an import here either.
harehare
force-pushed
the
feat/tarn-bytecode-vm
branch
from
September 3, 2026 12:34
95e51ad to
d01a992
Compare
Adds a `tarn` fuzz target so the arbitrary-script generator already used by `interpreter` can also be run against the tarn bytecode VM instead of the tree-walking evaluator, gated behind mq-lang's `tarn` feature. Shared script-generation and eval logic moves into fuzz/src/lib.rs so both targets stay in sync. `just test-fuzz-tarn` runs the new target.
`use core::f64;` pulled the `f64` module into scope, so `f64::NAN` / `f64::INFINITY` / `f64::EPSILON` resolved to the deprecated free-standing module constants instead of f64's inherent associated consts.
nom's escaped_transform succeeds with a zero-length match at EOF, so interpolated_string's `while let Ok(...)` loop kept pushing empty segments forever for an unterminated `s"..` instead of erroring, exhausting memory. Found by fuzzing the tarn VM target.
Avoid an always-panics unwrap() by resuming the original panic via std::panic::resume_unwind, and reformat the tarn target's compile_error! call.
harehare
marked this pull request as ready for review
September 3, 2026 15:35
Number::rem always went through f64's % operator, which lowers to a libm fmod call regardless of operand type. Profiling a filter-heavy workload (map/filter/fold over an array with a % check) showed fmod at ~14% of samples. Integer-valued operands, by far the common case for %, now go through i64 remainder instead, which is a single hardware instruction and gives identical truncated-division results.
String interpolation (both engines) formatted every part through Display, even string literal fragments and string-valued expression results, which routes through RuntimeValue's Cow-wrapping fmt impl and the full Formatter machinery for what's already a &str. Push those directly instead. Profiling a string-interpolation-heavy workload showed core::fmt::write at ~14% of samples; after this change it drops to ~6%, with Formatter::pad no longer appearing in the profile at all.
Selector::Index held a NonZeroUsize; Function and Module are already single Shared pointers, so Markdown's two-field payload (Shared<Node> + Option<Selector>) was the sole variant keeping RuntimeValue at 24 bytes. Storing the index in a NonZeroU8 instead shrinks Option <Selector> from 8 bytes to 1, and RuntimeValue from 24 to 16. This caps indexable children at 254 (get/[i] on a markdown node's values, e.g. a huge table row or list item beyond that). Selector:: index now returns None instead of panicking when it doesn't fit, matching how get already treats any other out-of-range index — RuntimeValue::NONE rather than an error or a crash.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Introduces "Tarn", a stack-based bytecode VM with static compile-time variable-slot resolution, as an alternative execution backend to the tree-walking Evaluator.
Type of Change
Checklist
cargo fmtandcargo clippyand addressed any warningsjust test-alland all tests pass/docs, crateREADME.md) if neededAdditional Context
#1146