Penelope

Bytecode → JS Function. Same semantics, ~2× the speed.

What "JIT" means here

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:

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.

How it works

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.

Measured speedup

$ 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.

Using it

# 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

Correctness

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.

What's out of scope

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.