-
-
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.
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 |
Functions containing control flow (loops, branches), assignments, try/catch,
generators, async, tail/method calls, or other unsupported constructs are simply
declined and keep running on the interpreter.
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 (values stay boxed) | 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.