JIT Compilation#
pperl includes a JIT compiler built on Cranelift, the code generator used by Wasmtime. It compiles hot loops and hot recursive subs to native machine code, and hands qualifying loops to a parallelizer that spreads them across CPU cores. Both are automatic: no annotations, no pragmas, no configuration. Individual compiled shapes run 20x to 400x faster than perl5; across the whole project benchmark suite, including everything the JIT does not touch, pperl comes out about twice as fast.
Two properties define the design, and everything else follows from them:
Exactness. Compiled code produces byte-identical output to the interpreter, always. Not “close enough”: the test harness compares pperl’s output byte-for-byte against perl5 on every test, with the JIT and parallelizer on.
Decline, never guess. Anything the compiler cannot prove it can reproduce exactly, it declines. A declined loop runs on the interpreter at normal speed. There is no mode in which compiled code is “probably right”.
What compiles#
The compiler recognises whole constructs, not individual opcodes. As of mid-2026 the covered surface is:
Numeric loops:
forover integer ranges,foreachover one array,while/untilincludingwhile (@queue)drain loops; nested loops;if/else, ternaries,last; integer and float arithmetic, comparisons, bitwise operations;++/--in both void and value positions.Strings: interpolation and concatenation,
sprintf(%dforms),length,indexwith a constant needle,uc/lc,chomp,reverse, thexrepeat,substr(read forms), string comparisons.Arrays and hashes:
$a[$i]reads and writes,push/pop, list assignment,scalar(@a),$h{key}reads and writes with constant or variable keys.List functions:
mapandgrepwith pure expression blocks, numericsort { $a <=> $b }(and descending),joinwith a constant separator,spliton a literal pattern.Named sub calls: calls to subs whose body is a
my (...) = @_;prelude plus one pure numeric expression, including self-recursion. These compile as real native functions with register argument passing; a recursive call tree runs entirely in native code. Recursion compiles both inside loops and for a single top-level call likefib(30).Regex: scalar
m//matches (stored, branched, and capture-list forms) ands/pat/replacement/with a constant replacement, including/g. These run the real regex engine from compiled code, so$&,$1,@-and friends behave exactly as interpreted; what disappears is the per-operation interpreter dispatch around the engine.Native-module calls: the
Scalar::Utiloperations that Perl compiles to single ops (blessed,reftype,refaddr,weaken,unweaken,isweak) compile directly; a curated set of pure XSUBs (List::Utilsum/sum0/min/max/product,Scalar::Utillooks_like_numberandreadonly) runs from compiled loops as call-outs through the realentersub, with the callee re-verified at every loop entry.
The exactness machinery#
The compiler attaches through Perl’s own extension points: the peephole-optimizer hook discovers candidate loops after Perl’s optimizer has finished, and the per-op function pointers of loop-entry and sub-call ops are redirected to trampolines. Everything below is driven from those trampolines.
Guards. At every loop entry the trampoline re-checks the world: variable types, magic and tie-ness, readonly-ness, aliasing between variables, whether a sub was redefined, whether $/ still holds its default. Any mismatch means the interpreter runs this entry. After repeated failures the trampoline uninstalls itself and the loop costs nothing extra forever after.
Scratch state and abort/replay. Compiled code never writes anything observable until it completes. Scalars live in a private buffer; strings and arrays operate on scratch copies; hash writes are buffered. If anything leaves the provable domain mid-run (integer overflow where Perl would promote, a substr outside the string, a missing hash key, a pending signal, recursion depth at a cap), the run aborts: the scratch is discarded and the interpreter replays the whole loop from the start. Since nothing observable happened, the replay is exact, including any warnings Perl would have printed. Completion, by contrast, writes results back through Perl’s own setter functions.
Two numeric modes. Loops compile in integer mode (checked 64-bit arithmetic; overflow aborts precisely where Perl promotes to a double) or float mode (IEEE doubles evaluated in Perl’s own expression order, which is bit-exact with Perl’s NV arithmetic). Values that might be integers in Perl carry exactness checks at the 2^53 boundary. Which mode runs is decided per entry from the actual types, and both variants can coexist for one loop.
Semantics that survive by construction. Perl’s boolean false is the dual-natured "", which no numeric slot can spell - so stored booleans (comparison results, predicate returns) live in write-only slots that are written back as copies of Perl’s own true/false values, exactly what the interpreter’s assignment would store. The “Deep recursion” warning is reproduced by capping compiled recursion exactly where the warning would fire and letting the replay warn. Match variables after a loop show what the interpreter would show, because Perl itself restores match state at block exit.
Parallel execution#
Loops with the right shape additionally parallelize: an integer range whose per-iteration work is independent except for sum-style reductions is chunked across a work-stealing thread pool. The gate is strict bit-exactness: integer reductions always qualify; float reductions only when reassociation provably cannot change the result. A loop that touches strings, arrays, hashes, or calls subs runs compiled but sequential. Trip-count thresholds keep small loops off the pool.
--threads=N sizes the pool; --parallel-threshold=N adjusts the minimum trip count; --no-parallel disables dispatch entirely.
Observing the JIT#
--jit-statsprints counters at exit: candidates found, loops compiled, entries run compiled, guard failures, deopts.--no-jitdisables compilation. Comparing a run against--no-jittells you whether the JIT was engaging at all.PPERL_JIT_LOG=/path/fileappends one line per compiler event:candidate ok,compile ok,compile skip reason=body-op:NAME(the loop contained op NAME the compiler does not handle),deopt reason=...(a guard failed or a run aborted). Thereason=vocabulary is the fastest way to learn why a specific loop is not compiling.
For test authors: the test harness understands a # harness: jit-engage=compile pragma that fails a test unless the JIT actually compiled something during it, preventing vacuous passes.
Limitations#
Perl-visible line numbers and
caller-style introspection inside a compiled region reflect the loop entry, not the current statement. Debuggers and profilers see a compiled loop as one opaque unit.Native-module calls outside the compiled set (anything not in the pure allowlist, and any block-taking form like
first { }) decline the loop.s///e, scalarm//g, patterns with embedded code blocks, ties, overloading, and tainting all decline.The first compiled entry of a loop costs roughly a millisecond of compilation; loops below the trip thresholds stay interpreted for that reason.
Numbers#
Measured mid-2026 on the project benchmark suite (speedups are perl5 time divided by pperl time):
Shape | Speedup vs perl5 |
|---|---|
Mandelbrot 1000x1000, parallel | ~400x |
Sub calls (empty through 3-arg, and recursion) | 20x to 60x |
| ~30x |
Whole 97-benchmark suite, geometric mean | ~2x |
The whole-suite number includes benchmarks the JIT does not touch; the per-shape numbers are what compiled code delivers on its own ground.