Idioms#

Perl carries decades of performance folklore - habits that paid off on the upstream interpreter and got passed down as rules. Some of that advice is timeless. Some of it is now actively pointless on pperl, because the JIT or the compile-time folder already does the work. And a few new habits matter here that no upstream Perl programmer ever needed.

This chapter triages the classic idioms into three buckets:

  • Still valid - the advice holds; the cost it avoids is real on pperl too.

  • Made moot - pperl’s compiler or JIT already eliminates the cost, so the contortion buys nothing and only hurts readability.

  • New - relevant because of how pperl compiles, with no upstream precedent.

A blanket caveat runs through the whole “made moot” bucket: the JIT only takes a loop when the loop is shaped for it. A loop heavy with subroutine calls, regexp matches, or string building is not compiled and runs on the interpreter - where the classic advice re-applies in full. So “the JIT handles it” is true for loops the JIT handles. Writing fast pperl is the companion chapter on making sure your loop is one of them.

Still valid#

These survive the move to pperl unchanged. The cost they avoid is paid on the interpreter, and most hot code touches the interpreter somewhere.

  • Hoist invariant work out of loops. Computing the same value every iteration is wasted work whether the loop is interpreted or compiled. The JIT does not hoist arbitrary expressions for you; move the invariant out yourself.

  • Avoid needless copies of large structures. Passing a big array by value, or slicing it just to count, copies. Pass a reference, use an aliased loop variable, or work in place. pperl’s value model has the same copy costs Perl always had.

  • Precompile regexps used in a loop with qr. The regex engine and its compile cache work as on upstream; a pattern built fresh each iteration pays to recompile. This is regex territory - see regex performance.

  • Choose the right data structure. A hash lookup beats a linear scan at the same sizes it always did. Algorithmic complexity is unaffected by any runtime; an O(n²) loop is slow even fully JIT’d.

  • Read files in the right-sized chunks. Line-by-line versus slurp-and-split is an I/O question, and I/O is not JIT-compiled. The upstream trade-offs carry over directly.

  • Prefer built-in list operations to hand-rolled loops where the built-in is native. sum, min, max, first from List::Util run at built-in speed with no XS layer (see native modules). A hand-written accumulator loop may JIT to the same speed, but the native built-in gets you there without depending on the loop shape.

Made moot#

These were real wins on upstream Perl. On pperl the compiler or JIT already removes the cost, so applying the idiom by hand buys nothing and usually costs clarity.

Hand-inlining hot arithmetic#

The old habit of unrolling a tight arithmetic loop, or manually inlining a small arithmetic helper to dodge call overhead, targets a cost the JIT removes. A numeric for or while loop over integer or float accumulators is compiled to native machine code, with the loop variables held in CPU registers - see JIT compilation. Hand-unrolling such a loop makes it longer and harder to read without beating the compiler.

# Don't hand-unroll this. The JIT compiles the natural form to
# native code with $i and $sum in registers.
my $sum = 0;
for my $i (1 .. 1_000_000) {
    $sum += $i * $i;
}

The contortion only pays off if the loop does not JIT - and if it doesn’t, the fix is to make it JIT (remove the call or string op that disqualified it), not to unroll it by hand.

DEBUG-constant logging guards#

The idiom of guarding expensive logging behind a compile-time false constant -

use constant DEBUG => 0;
# ...
warn "state: @stuff\n" if DEBUG;
  • costs exactly nothing on pperl, which is the whole point of the idiom, and pperl delivers it completely. When DEBUG is a false constant, the compiler folds the if DEBUG guard at compile time: the disabled statement collapses to a no-op, with no condition test and no branch left in the compiled program. The interpolation "@stuff" is never built. You can leave guarded logging in hot paths with a clear conscience; a false DEBUG constant erases it.

This is the idiom working as designed - keep writing it. What is moot is any further cleverness on top: you do not need to comment out the logging, move it behind a sub, or strip it for production. The constant does it.

Folding constant expressions yourself#

Precomputing a constant arithmetic result, or a constant call to a pure built-in, and storing it in a variable to “avoid recomputing it” buys nothing - the compiler folds constant expressions and constant-argument pure built-ins (sin, cos, sqrt, chr, ord, and the like) at compile time, as long as the call is in a valid domain. my $two_pi = 2 * 3.14159265358979; and my $root = sqrt(2); are computed once, at compile time, whether or not you “help” by hoisting them.

The one nuance: folding is skipped for arguments outside a function’s domain (sqrt of a negative, log of zero), so those fall through to run time and croak there exactly as they should. You never need to fold a constant by hand to gain speed.

Stripping dead constant-condition loops#

A while (0) { ... } or a block guarded by a constant-false condition is eliminated at compile time - the body is removed, not merely skipped at run time. (A C-style for (; 0; ...) is handled slightly differently: the body is never entered, though the loop scaffolding op may remain.) Conditionally-compiled-out code behind a false constant carries no run-time weight; you do not need to physically delete it to make the program faster.

New#

These have no upstream analogue. They matter because of how pperl compiles and parallelizes, and getting them wrong silently forfeits the largest speedups pperl offers.

Keep numeric hot loops numeric#

The single most valuable new habit. The JIT compiles a numeric loop and, when parallelization is enabled (the default) and the loop is a clean reduction over a known range, dispatches it across threads - the difference between a 76× and a 431× speedup on the Mandelbrot benchmark (see parallel execution).

A string operation inside that loop forfeits the parallel half. When the JIT sees a string variable in the loop - a .= concat target, a string accumulator - it demotes the loop to a sequential compiled loop: still native code, but no longer spread across threads.

# Numeric reduction - JIT-compiled and eligible for parallel dispatch.
my $total = 0;
for my $i (0 .. $n) {
    $total += compute($i);     # (only if compute() itself stays numeric/inlinable)
}

# A string built in the same loop demotes it to sequential JIT -
# the parallel dispatch is gated off the moment a string var appears.
my $log = "";
for my $i (0 .. $n) {
    $total += $i * $i;
    $log .= "$i ";             # forfeits parallel dispatch for this loop
}

If you need both the numeric reduction and the string accumulation, split them into two loops: the numeric one parallelizes, the string one runs as a sequential compiled loop. Mixing them costs you the parallel speedup on the numeric work for no gain on the string work.

Shape reductions as accumulate-never-reset#

The parallelizer recognises a reduction variable by seeing it accumulated and never reset inside the loop body (the analysis subtracts reset patterns from accumulation patterns; a variable that is both is not treated as a reduction). Writing your accumulator in the plain $sum += ... form, declared before the loop and never re-initialised inside it, is what lets the loop be claimed for parallel execution.

my $sum = 0;                   # declared outside - a reduction
for my $x (@data) {
    $sum += weight($x);        # accumulate, never reset → parallelizable
}

Re-initialising the accumulator inside the loop, or interleaving a reset, defeats the recognition and the loop runs sequentially. This is covered in full under reduction detection.

Let the loop JIT - don’t hide it in a sub#

The JIT compiles loops, not subroutine bodies. A hot numeric loop written at file scope or inside a larger routine compiles fine; the same arithmetic refactored so each iteration is a subroutine call does not - the call frame is interpreter territory, and the per-call overhead is exactly what the JIT cannot remove. When a loop is hot and numeric, keep its body inline rather than factoring each iteration into a sub for tidiness. Factor for clarity elsewhere; keep the inner loop flat.

See also#

  • Writing fast pperl - the positive program: how to shape a loop so the JIT and parallelizer take it.

  • Measuring - confirm an idiom actually moved the number before and after applying it.

  • JIT compilation - what compiles, why hand-inlining arithmetic is redundant.

  • Parallel execution - the reduction analysis and the side-effect gate behind the “new” idioms.

  • Sorting - the sort-specific idioms (Schwartzian, Guttman-Rosler) get their own chapter.