-
-
Notifications
You must be signed in to change notification settings - Fork 5
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.
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.
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.
A chunk is compiled once it becomes hot — JitThresholds.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 (see Choosing a back-end below) compiles the same control
flow, plain calls, method calls (with monomorphic method-body inlining), and
nested function declarations, and carries its own unboxed long-register loop tier
(described next) — so on NativeAOT, where the Reflection.Emit tiers are unavailable,
hot integer loops still run as raw long code.
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
(present in both back-ends — it is the only unboxing path the closure back-end
has, and the main reason a NativeAOT build stays fast on hot loops): 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.
The inlined leaf may be a free/global helper (re-checked by identity at entry) or a
function declared inside the looping function itself — the latter is resolved from
its compiled body at compile time, since each call creates a fresh closure instance
but the pure body is identical across them. Integer-valued double constants (e.g. a
loop bound 1e7) are admitted as longs, and block scopes introduced by let/const
are transparent to the register frame (their locals are flat positional slots). This
is what turns a hot counting-or-calling loop — including the common
for (let i = 0; i < 1e7; i++) s += f(i) shape — into essentially native long code.
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.
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.
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 in general (values stay boxed), but hot integer loops reach the unboxed long-register tier above | fully portable, NativeAOT-safe |
JitRegistry.Register(new ClosureThreadedJitCompiler()); // no-reflection, AOT-safeBoth produce interpreter-identical results. Pick ClosureThreadedJitCompiler when
you need NativeAOT or want to avoid runtime code generation; otherwise
ReflectionEmitJitCompiler is faster.
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.