Writing fast pperl#
The other chapters in this guide are about removing cost. This one is about earning the speedups pperl offers that no other Perl has: the JIT that compiles hot loops to native code, and the parallelizer that spreads a qualifying loop across CPU cores. Both are automatic - you do not call them, annotate for them, or configure them. But they only fire on code shaped the right way, and a single misplaced operation can shut either one off silently.
This chapter is the positive program: write the loop like this, and the compiler takes it. The JIT and parallel execution chapters explain the machinery; this one tells you what to type.
The shape the JIT wants#
The JIT targets loops - for and while - whose bodies are arithmetic and comparison over numeric variables. When it recognises that shape, it compiles the body to native machine code and holds the loop variables in CPU registers instead of on the interpreter stack. That is where the order-of-magnitude speedups come from.
A loop the JIT compiles cleanly:
my $sum = 0;
for my $i (1 .. 1_000_000) {
$sum += $i * $i;
}
Integer and floating-point arithmetic (+, -, *, /), comparisons (<, >, <=, >=, ==), and control flow (if, last) inside the loop all compile. Nested loops compile too, several levels deep, each level connected to the next. The natural, idiomatic form is the fast form - you do not unroll, hoist into temporaries, or otherwise distort it.
What pushes a loop back onto the interpreter#
The JIT declines a loop whose body does work it cannot prove it can reproduce exactly. When it declines, the loop runs on the interpreter - at interpreter speed, which is still fast for non-numeric work, but without the native-code multiplier. The coverage is broad (arithmetic, strings, array and hash elements, push/pop, map/grep, numeric sort, join/split, regex matches and constant substitutions, calls to small pure subs, and the common Scalar::Util/List::Util operations - blessed, reftype, weaken, sum, min, max and friends), so the practical disqualifiers are fewer than you might expect:
Calls to subs with side effects. A call compiles when the sub’s body is a
my (...) = @_;prelude plus one pure numeric expression - including recursion. A sub that touches globals, does I/O, usesreturn, or takes references to@_declines the loop. Keep hot helpers small and pure and the call costs almost nothing; factoring work OUT of a sub is no longer the advice.I/O. A
print, a file read, a system call - I/O-bound work does not benefit from native compilation and is not compiled.Regex beyond the compiled forms. Scalar matches and
s/pat/constant/(including/g) compile;s///e, scalarm//g, and patterns with embedded code do not. See regex performance for tuning the engine’s own work.Magic in any form. Tied variables, overloaded objects,
local, taint mode.
None of these is an error; the loop simply runs the interpreted way. But if a loop you expected to be fast is not, run it --no-jit versus default (Measuring): if the times match, the JIT was already declining it. Set PPERL_JIT_LOG=/tmp/jit.log and read the compile skip reason=... lines to see exactly which operation is responsible (JIT compilation).
Keep strings out of numeric loops#
The JIT does handle one string operation - .= concat-assign, and clearing a string with $x = "" - through calls back into the runtime. So a string-building loop still compiles. But this is the one case where the shape of the loop changes which optimisers apply, and it is the highest-value rule in this chapter.
When the JIT sees a string variable in a loop, the loop compiles as a sequential native loop - but parallel dispatch is gated off. The parallelizer needs the loop body free of the shared-mutable-state that string building requires, so the moment a string variable appears, the loop is no longer spread across threads. It still runs as compiled native code; it just runs on one core.
The practical consequence: a numeric reduction that would parallelize loses that parallelism if you build a string in the same loop.
# Parallelizable: pure numeric reduction.
my $total = 0;
for my $i (0 .. $n) {
$total += $i * $i;
}
# Same arithmetic, but the string build demotes it to a
# single-core compiled loop - the parallel speedup is forfeited.
my $report = "";
my $total = 0;
for my $i (0 .. $n) {
$total += $i * $i;
$report .= "$i ";
}
If you need both, split the work into two loops. The numeric loop parallelizes; the string loop runs as a sequential compiled loop. Mixing them costs the parallel speedup on the numeric half and gains nothing on the string half.
The shape the parallelizer wants#
When parallelization is enabled - it is on by default; --no-parallel turns it off - a JIT-compiled loop is dispatched across threads if the analysis can prove it safe. Two conditions carry the most weight:
A reduction shaped as accumulate-never-reset. The parallelizer recognises a reduction variable by seeing it accumulated and never reset inside the loop. Declare the accumulator before the loop, use the plain += form, and never re-initialise it inside the body:
my $sum = 0; # declared outside the loop
for my $x (@data) {
$sum += score($x); # accumulate only → recognised as a reduction
}
Re-initialising $sum inside the loop, or interleaving a reset, defeats the recognition (the analysis subtracts reset patterns from accumulation patterns) and the loop runs sequentially. Full detail in reduction detection.
A loop body free of observable side effects. The analyzer is deliberately conservative. Any of these disqualifies a loop from parallel dispatch:
I/O (
print,open, file reads)Writes to package/global variables
Subroutine calls not proven pure
Side-effecting regex operations (
s///)
A missed parallelization is safe - the loop just runs sequentially. An incorrect one would be a bug, so the analysis errs toward declining. That conservatism is why keeping the loop body small, local, and free of globals is what gets it parallelized.
map and grep compile too#
map and grep with pure expression blocks compile to native loops:
my @out = map { $_ * $k + 1 } @data;
my @filtered = grep { $_ % 7 } @data;
The block must be a side-effect-free expression over $_, loop variables, and constants; mutating $_ (which writes through to the source array) or producing a boolean as the map VALUE declines. Like all array work, compiled map/grep runs sequentially - array state gates parallel dispatch off - but the native-loop speedup over the interpreted op machinery is itself several-fold.
When the work is not a loop#
Not every program is a numeric loop, and the JIT and parallelizer have nothing to offer code that is call-bound, I/O-bound, or string-shaped. For that code the rest of this guide applies in full:
Pick native built-ins over hand-rolled loops where they exist -
List::Utilruns at built-in speed.Apply the classic still-valid idioms from Idioms: hoist invariants, avoid copies, precompile regexps.
Transform expensive sorts (Sorting).
Call C through the FFI only when the call frequency is low - the per-call marshalling cost makes it the wrong tool inside a hot inner loop.
A checklist for a hot loop#
Is the body expressible work - arithmetic, strings, element access, compiled regex forms, pure sub calls? If yes, the JIT compiles it. If not,
PPERL_JIT_LOGnames the disqualifying operation - address that first.Is there a string operation in the loop? If so, and the loop is otherwise a numeric reduction, split the string work out so the numeric loop can parallelize.
Is the accumulator declared outside the loop and only accumulated, never reset? That is what makes it a recognised reduction.
Is the body free of I/O, global writes, and impure calls? That is what lets the loop parallelize.
Measure default versus
--no-jitversus--no-parallel(Measuring) to confirm which optimiser is engaging.
See also#
JIT compilation - the compilation pipeline, the variable-type model, the caching.
Parallel execution - the reduction analysis, the side-effect gate, the thread controls.
Measuring - how to confirm the JIT and parallelizer are actually engaging on your code.
Idioms - which classic optimisations still matter and which the compiler already handles.
Sorting - the one common hot path the JIT does not reach.