Bytecode → JS Function. Same semantics, ~2× the speed.
Penelope's bytecode interpreter (src/vm.ts) is a textbook switch-dispatch loop: read code[ip], unpack the op tuple, switch on the opcode kind, do the work, advance ip. Two overheads dominate:
op[0], op[1] as string, etc.BIN_OP calls applyBinOp(op, l, r) which switches on the operator string.The JIT (src/jit.ts) eliminates both by generating a JS function at compile time. Each opcode becomes a labeled case with op arguments baked in as JS literals; BIN_OP is specialized per operator (l.v + r.v for int add, etc.); constants are inlined.
For the program let x = 1 + 2;, the JIT generates roughly:
function jitRun(state) {
let stack = state.valueStack;
let frames = state.frames;
let ip = state.ip;
outer: while (true) {
switch (ip) {
case 0: // LOAD_CONST 1
stack.push({tag:'int', v:1});
ip = 1; continue outer;
case 1: // LOAD_CONST 2
stack.push({tag:'int', v:2});
ip = 2; continue outer;
case 2: { // BIN_OP +
const r = stack.pop(), l = stack.pop();
stack.push(l.tag==='int'&&r.tag==='int'
? {tag:'int', v: l.v + r.v}
: H.applyBinOp('+', l, r));
ip = 3; continue outer;
}
case 3: // STORE_VAR "x"
frames[frames.length-1].bindings["x"] = stack.pop();
ip = 4; continue outer;
case 4: // HALT
state.ip = 4; return {status:'halted', state};
}
}
}
Pause and effects route through the interpreter's helpers (executeEffect) so snapshot/effect-replay semantics are preserved byte-for-byte. Closures, tail calls, and the block-scope frame stack work identically to the interpreter — only the dispatch overhead is gone.
$ bin/penelope bench examples/09-fib.pen
benchmark: examples/09-fib.pen (3 reps)
VM (-O0) avg 136.18 ms
VM (-O1) avg 161.14 ms
VM (-O2) avg 200.89 ms
JIT compile (-O2) avg 0.06 ms
JIT run (-O2) avg 82.81 ms
On fib(25), the JIT is ~2.4× faster than the -O2 interpreter at steady state. Compile cost (60µs) is negligible — a single rep amortizes it.
Bigger speedups are possible (basic-block scheduling, register allocation, type-stable arithmetic specialization) but require a real CFG pass; this JIT keeps the per-opcode shape and just removes the dispatch tax.
# Run via JIT instead of interpreter:
$ bin/penelope exec --jit my-program.penc
# Compare timings on a hot loop:
$ bin/penelope bench my-program.pen
The JIT's 14 tests (test/jit.test.ts) run the same program through both the interpreter and the JIT and assert byte-identical outcomes: bindings, effect log, printed output, pause state, ip-on-pause. Match expressions (or-patterns, guards, list/dict patterns), template strings, recursive fns, tail-calls, and closures are all covered.
This isn't an LLVM-grade native JIT. It doesn't do: basic-block scheduling, register allocation, escape analysis, monomorphization beyond what the operator switch gives us, deoptimization, native code emission. Those are months of work. Within a session, eliminating dispatch overhead and baking constants is the right thing to ship.