Backends: Interpreter vs VM​

An engine parses a script once and runs it on one of two backends that are held to identical behavior. The rules:

  • backend_type::vm is the default and the one a shipped host runs: a bytecode VM. backend_type::auto_select names the default and selects it. The engine never switches backends on its own.
  • backend_type::interpreter is the reference: a tree-walking interpreter the VM is checked against. Only a program that links jaiscript_interpreter instead of jaiscript carries it (The interpreter, for checking parity).
  • The choice is made per engine, before its first execute.
  • Same values, same printed output, same error text on both, with one exception: recursion depth.
C++
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 VM

set_backend must happen before the first execute​

Functions and classes capture the backend that defined them, so set_backend throws jai::runtime_error once a script has run. A build without the interpreter refuses it whenever it is asked:

set_backend after execute() is unsupported: defined functions and classes capture the executing backend
set_backend(interpreter) is unsupported: this build has no interpreter (link jaiscript_interpreter)

The backend is built lazily: making an engine or choosing a type costs nothing until a script runs.

The standalone runner takes the backend as a flag and, unlike an engine, starts with no execution budget:

--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)

Checking is strict and the default backend is the VM in both the runner and a host, so a script that behaves differently under jaiscript file.jai than inside your host differs in what the host registers or sets.

The parity contract​

The library's test suite runs on both backends, and a differential fuzzer fails on any difference in output or error text between them. Values, signals and callbacks do not depend on the backend. The one exception is recursion depth:

InterpreterVM
Default call-depth cap (engine::max_call_depth(backend, depth) sets either)256 active calls250,000
Past the capcatchable Maximum recursion depth exceeded - possible infinite recursion
Native stacka headroom check can fire before the cap (about 150 to 200 levels on a 1 MB thread): Native stack exhausted before the call-depth cap: the host thread's stack bounds this recursion—
A bare self tail call (return f(args) outside any try)counts toward the capreuses its frame and runs to any depth; the execution budget stops a runaway one, like while (true)

The VM reference mode​

engine::vm_reference_mode(true) runs the VM on its plain general path with every optimization off. It is a correctness tool: every program gives the optimized VM's values, output and error text, so a disagreement between the two is a bug in an optimized path.

C++
auto reference = jai::engine::make();
reference->vm_reference_mode(true);                    // BEFORE the first execute
reference->execute(script).as_int();                    // 5050 — the optimized VM's answer
  • Compile time: no superinstructions, no typed or dynamic slot rows, no closed regions, no grafted callee, no counted-loop opcodes, no return fusion, no tail call, and no compile-time builtin dispatch index.
  • Run time: no inline caches and no scoped-const folding. The engine switches type_proofs, ir_passes, parameter_alias, local_alias and aggregate_lowering answer off while it is on.
  • Chosen before the first execute, like the backend. Afterwards the call throws vm_reference_mode after execute() is unsupported: defined functions and classes capture the executing backend.
  • Allowed differences: where the specification leaves an outcome open, and recursion. With no tail call, a deep bare self tail call raises the depth error here.
  • An engine without the mode runs exactly the code it would run if the mode did not exist. The runner spells it --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, and the chunk's manifest with every field and 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 guarded cell instruction misses before it runs, as a failed guard would, so the fallback path runs at every such position (the runner's --miss-every=N). 0, the default, is off. engine::miss_injection_available() reports whether the build carries it; without it a nonzero n throws std::logic_error.

Which to run​

Run the VM. Keep the interpreter as a flag of a verification build (the runner's --backend=interp is one), so a suspected behavior difference can be compared against the reference at once. There is no tiered or automatic promotion between the two: the engine's 64-entry source cache and jaibites already make repeated scripts cheap. Performance has the measured cost of each backend.