Backends: Interpreter vs VM
Every engine runs your script through one parser and one AST, then hands it to one of two interchangeable execution backends (include/jaiscript/core/execution_backend.hpp), a choice made once per engine, and A/B-able the moment behavior or speed raises an eyebrow:
backend_type::vm: the bytecode VM (include/jaiscript/vm/): compiles the AST to chunks, slot frames windowed onto the value stack, fused superinstructions, counted-loop opcodes, fiber-based coroutines. The default.backend_type::interpreter: the tree-walking interpreter, the reference implementation the VM is checked against. Only the programs that check the backends against each other compile it: they linkjaiscript_interpreterinstead ofjaiscript(The interpreter, for checking parity).
std::string script = "int s = 0; for (int i = 1; i <= 100; ++i) { s += i; } s";
auto oracle = jai::engine::make();
oracle->set_backend(jai::backend_type::interpreter); // BEFORE the first execute
oracle->execute(script).as_int(); // 5050 — same answer as the VMset_backend must happen before the first execute
Once any script has run, defined functions and classes have captured the executing backend, so switching is refused with a thrown jai::runtime_error:
set_backend after execute() is unsupported: defined functions and classes capture the executing backendA host built without the interpreter (one that links jaiscript) refuses it the same way, whenever it is asked:
set_backend(interpreter) is unsupported: this build has no interpreter (link jaiscript_interpreter)backend_type::auto_select names the default and selects the VM. The engine never switches backends implicitly.
From the command line, the backend is a flag. The standalone runner starts on the VM, as an engine does:
--backend=vm|interp|vm-reference execution backend (default vm; interp = tree-walking interpreter; vm-reference =
the vm with every optimization off, engine::vm_reference_mode)
--budget=N execution budget in seconds per execute (default 0 = unlimited)Static checking is strict in both places (see Static Checking) and the backend is the VM in both, so a script that behaves one way under jaiscript file.jai and another inside your host differs in what the host registers or sets; --backend=interp runs it on the interpreter to compare against the reference. The runner also lifts the engine's 5 second execution budget to unlimited; pass --budget=N to put one back.
Backend construction is lazy. Creating an engine or setting the type costs nothing until a script actually runs.
The parity contract
The two backends are held to byte-for-byte behavioral parity, including error text. This is not aspirational; it is machinery:
- The full test suite (more than 4,700 tests) runs against both (
jaiscript_testsandjaiscript_tests --backend=vm), and both must stay green on every commit. - A differential fuzzer (
source/tests/fuzz/) generates random programs and fails on any output or error-text divergence between the backends; a divergence it finds is fixed and pinned by a regression test. - Many individual suites additionally run their assertions in an explicit both-backends matrix on top of the global flag.
- The VM is also held to its own reference mode: the suite runs in it (
jaiscript_tests --backend=vm-reference) and the fuzzer runs every program on the optimized VM and on the reference VM (jaiscript_fuzz --vm-reference), so an optimized lane that drifts from the VM's general path fails on the first program that reaches it.
The contract has exactly one documented, deliberate exception, with two faces: recursion depth. The call-depth caps differ: 256 active calls on the interpreter and 250,000 on the VM by default (engine::max_call_depth(backend, depth) sets either), and on the interpreter a native-stack headroom check can fire before its cap (about 150 to 200 levels on a 1 MB thread such as the standalone runner's). Recursion deeper than the interpreter allows still runs on the VM; past its cap each backend raises the same catchable text, Maximum recursion depth exceeded - possible infinite recursion, and the interpreter's stack guard raises Native stack exhausted before the call-depth cap: the host thread's stack bounds this recursion. And a bare self tail call (return f(args) outside any try) reuses its frame on the VM. Tail recursion runs to any depth there, while the interpreter keeps the depth cap for every shape (a runaway tail loop is stopped by the execution budget on the VM, exactly like while (true); see Safety Rails). Everything else, including error text, is byte-identical.
Script callables execute through execution_backend::execute_callable, and thunks capture engine* + payload (never a backend pointer), so values, signals and callbacks work identically under both backends.
The VM reference mode
engine::vm_reference_mode(true) runs the VM on its plain general path. The compiler emits no superinstruction (no fused binary, compound store, compare-and-jump, subscript, push or member store), no typed or dynamic slot row, no closed region, no graft of a called function, no counted-loop opcode, no return fusion and no tail call, and records no compile-time builtin dispatch index; at run time the VM fills no inline cache (member reads, call sites, method dispatch, the environment lookup's fast heads) and folds no scoped const. The engine switches the optimized VM consults (type_proofs, ir_passes, parameter_alias, local_alias, aggregate_lowering) answer off while it is on, whatever they were set to. Like the backend, it is chosen before the first execute; afterwards the call throws vm_reference_mode after execute() is unsupported: defined functions and classes capture the executing backend.
auto reference = jai::engine::make();
reference->vm_reference_mode(true); // BEFORE the first execute
reference->execute(script).as_int(); // 5050 — the optimized VM's answerIt is a correctness tool, and its speed is beside the point. Every program gives the optimized VM's values, printed output and error text, so a disagreement between the two is a bug of whichever optimized lane the program reached, which makes the reference mode the VM's everyday differential partner, with the interpreter as the language's validator behind both. The two may differ only where the specification leaves the outcome open and in the recursion exception above: the reference mode makes no tail call, so a deep bare self tail call raises the depth error there where the optimized VM reuses its frame. Every choice the mode makes is taken when a chunk compiles or the backend is built, so an engine without it runs exactly the code it would run if the mode did not exist. From the command line it is --backend=vm-reference.
Seeing what the VM compiled
engine::disassembly_sink(fn) hands every chunk the VM compiles from then on (a program, a function at its definition, a standalone expression or declaration) to fn(listing, manifest) in compile order: the readable listing with each region's canonical operations, and the chunk's semantic manifest (every field, exact constant bits). The bytecode is the same with or without a sink. The runner's --disasm=FILE and --manifest=FILE write the two.
engine::cells_miss_every(n) is a testing lever for a build configured with JAISCRIPT_MISS_INJECTION: every nth cell instruction whose own miss re-runs its original instruction misses before it runs, as a failed guard would, so the flush and the continuation run at every such position against the interpreter (jaiscript_fuzz --miss-every n, the runner's --miss-every=N). 0, the default, is off. engine::miss_injection_available() says whether the build carries the lever; a build without it throws std::logic_error on a nonzero n, so a gate never runs without the misses it asked for.
What the choice actually costs
The shape of it: engine creation and cold one-shots are effectively identical on both backends. The moment a script repeats (a cached string, a jaibite, anything per-frame), the VM pulls decisively ahead, because its one-time compile is small (about a third of the parse both backends already pay) and cached, so it amortizes within the first few executions. The interpreter's only durable win is genuinely unique, never-repeated small strings on a warm engine, and even that vanishes as scripts grow. Class-heavy scripts run close to even warm: per-execute class redefinition/hot-reload machinery costs the same on both. The full regime-by-regime table (creation, cold and warm dispatch, jaibite rows, crossover points) is quantified in Performance.
Recommendation
- Run the VM. It is the default: at parity or ahead on cold one-shots, well ahead on warm repeated scripts, and cumulatively ahead within the first few executions everywhere scripts repeat, which is what game scripts do.
- Keep the interpreter as the oracle backend. Select it with
set_backend(jai::backend_type::interpreter)in a program that linksjaiscript_interpreter, as the test suite, the fuzzer, the runner andjai_spiderdo; the host you ship linksjaiscriptand carries none of it. Its value is not speed: reference-semantics simplicity, and being the second implementation that keeps the VM honest. It is also the marginal winner if your workload is truly unique-string one-offs. - There is deliberately no tiered/auto-promotion mode. The compile premium is a fraction of the parse both modes already pay, so promotion bookkeeping would buy nothing and cost a third semantic path; the 64-entry source LRU + jaibite already capture "hot script" with zero policy machinery.
Practical pattern: keep the backend a flag of a verification build (the runner's --backend=interp is one) so any behavioral suspicion can be A/B'd against the other backend instantly.