Skip to content
bizzehdee edited this page Jun 26, 2026 · 6 revisions

JIT

DScript ships an optional, opt-in JIT compiler that turns hot bytecode chunks into compiled delegates. It is off by default: with no compiler registered the VM runs the interpreter exactly as before, with no behavioural change.

Enabling the JIT

Register a compiler once at startup:

using DScript;
using DScript.Vm;
using DScript.Jit;

JitRegistry.Register(new ReflectionEmitJitCompiler());

var engine = new ScriptEngine();
engine.Run(ScriptEngine.Compile(@"
    function poly(a, b) { return a * a + b * b; }
    var s = 0;
    for (var i = 0; i < 1000000; i++) s = poly(i, i + 1);
"));

Disable it again with JitRegistry.Clear(). The JIT and interpreter coexist — you choose which operates, and you can switch at runtime.

Background compilation (opt-in)

By default a hot chunk is compiled synchronously the moment it crosses the threshold. Set JitRegistry.BackgroundCompilation = true to compile hot chunks on a worker thread instead: the chunk keeps running interpreted until the worker publishes its compiled delegate, so the compile never stalls execution. Results are identical either way; only the timing of when compiled code takes over differs.

How it works

A chunk is compiled once it becomes hotJitThresholds.InvocationThreshold (1000) invocations, or JitThresholds.BackEdgeThreshold (10000) loop back-edges. Compiled code is value-identical to the interpreter; the JIT only changes how fast it runs, never what it computes.

The Reflection.Emit back-end compiles in tiers, picking the fastest applicable one:

Tier Applies to What it does
Speculative unboxed int pure, branch-free functions whose arithmetic is profiled integer-only flows raw int through generated code — no per-operation allocation, no dynamic dispatch; boxes only at the return
Speculative unboxed double likewise, for floating-point arithmetic (+ - * /) flows raw double
Conservative (boxed) everything else it can compile (straight-line code, variable/constant reads, arithmetic, property reads, plain calls) mirrors the interpreter's operand semantics exactly

The conservative tier also compiles control flow (if/else, while, for), local variables and assignments, property reads/writes, calls, and nested function declarations / closures (function f(){…} capturing the enclosing scope) — and inlines small pure-parameter leaf helpers at monomorphic call sites (no per-call frame allocation). Functions containing try/catch, generators, async, short-circuit (&&/||/??) or for..of, block-scoped let/const, object/array literals, or tail/method calls are still declined and keep running on the interpreter. (The closure back-end additionally declines all control flow.)

On-stack replacement (OSR)

Tier-up normally happens when a function is entered, so a long-running loop inside a function that is only ever called once (a script's top-level loop, a one-shot worker) would never get compiled — by the time the loop is hot, the single entry has long since passed. On-stack replacement closes that gap: when a loop crosses JitThresholds.OsrBackEdgeThreshold (10000) back-edges within a still-running frame, the VM compiles the chunk and transfers control into the compiled code at the loop header, abandoning the interpreter frame mid-flight. Live state is shared through the environment (locals live there as named bindings), and because a structured loop's operand stack is empty at its back-edge, nothing else needs migrating. The rest of the function — the remaining iterations and everything after the loop — then runs compiled. Resuming inside a block entered before the loop is fine (the live block environment is handed in); OSR only declines a region that would leave a block it did not enter.

When the resumed loop is integer arithmetic, OSR uses an unboxed-long loop tier: scalar variables become CLR long registers (no per-iteration environment lookup or boxing), monomorphic integer-leaf calls are inlined as raw long arithmetic, and values are written back to the environment only at each function exit. An entry guard checks each register is really an integer and otherwise hands off to the boxed conservative entry, so the fast tier is correct even when reused across frames with different types. Integer-valued double constants (e.g. a loop bound 1e7) are admitted as longs. This is what turns a hot counting loop into essentially native long code.

Deoptimization

The speculative tiers assume a type (e.g. "always integer"). If that assumption is violated at runtime — a function compiled for ints is called with a string — the compiled code deoptimizes: it abandons the run and re-executes the chunk on the interpreter, returning the correct result. This is safe because only pure (call-free) functions speculate, so no side effect can have occurred first. After JitThresholds.DeoptThreshold (5) deopts the chunk is recompiled with the conservative tier, which never deopts.

Inline cache

JIT-compiled property reads (obj.prop) use a per-site inline cache keyed by object identity and shape version, skipping the property lookup on a repeat read of the same object. Adding or removing a property bumps the object's shape version and invalidates the entry, so reads always stay correct.

Choosing a back-end

The JIT is pluggable through the IJitCompiler interface, and two back-ends ship:

Back-end Mechanism Performance Portability
ReflectionEmitJitCompiler emits CIL via System.Reflection.Emit (the tiered compiler above) highest all CoreCLR platforms; not NativeAOT
ClosureThreadedJitCompiler composes a tree of C# closures — no reflection, no runtime code generation more modest (values stay boxed) fully portable, NativeAOT-safe
JitRegistry.Register(new ClosureThreadedJitCompiler()); // no-reflection, AOT-safe

Both produce interpreter-identical results. Pick ClosureThreadedJitCompiler when you need NativeAOT or want to avoid runtime code generation; otherwise ReflectionEmitJitCompiler is faster.

Writing your own back-end

Implement IJitCompiler:

public sealed class MyJitCompiler : IJitCompiler
{
    public JitDelegate Compile(Chunk chunk)
    {
        // Return a compiled delegate, or null to decline (the VM keeps interpreting).
    }
}

JitDelegate is ScriptVar (VirtualMachine vm, ScriptVar[] args, Environment env): vm is the runtime handle (for calls, deopt, property reads), and env is the full lexical environment for resolving variables. The shared JitDecoder lowers a chunk's bytecode into a uniform instruction list you can build on.

Clone this wiki locally