--- name: writing fast pperl code --- # 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](../pperl-architecture/jit) and [parallel execution](../concurrent-execution/parallel) 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: ```perl 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 express in native code. 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 disqualifiers: - **Subroutine calls.** The JIT compiles loop bodies, not call frames. A loop that calls a sub each iteration runs interpreted - the call overhead is the very thing the JIT cannot remove. If the per-iteration work is small and hot, inline it into the loop body rather than factoring it into a sub. - **Regex operations.** The regex engine has its own optimiser and its own path; a match or substitution in the loop body is not JIT-compiled. See [regex performance](../regular-expression/performance) for tuning that work on its own terms. - **I/O.** A `print`, a file read, a system call - I/O-bound work does not benefit from native compilation and is not compiled. - **Complex data-structure access.** Hash and array operations with dynamic keys fall outside the JIT's numeric model. 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](measuring)): if the times match, the JIT was already declining it, and one of the above is why. ## 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. ```perl # 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: ```perl 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](../concurrent-execution/parallel). **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. ## Built-ins that parallelize [`map`](../../p5/core/perlfunc/map) and [`grep`](../../p5/core/perlfunc/grep) with pure callbacks over a large-enough collection can run in parallel, with output order preserved: ```perl my @out = map { expensive($_) } @big; my @filtered = grep { costly_test($_) } @big; ``` The same gates apply: the callback must be free of side effects and shared mutable state, and the collection must clear the parallelization threshold (tunable with `--parallel-threshold`). A pure transform over a large array is the easiest parallel win pperl offers - no loop to shape, just a clean callback. ## 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::Util`](../pperl-architecture/native-modules) runs at built-in speed. - Apply the classic still-valid idioms from [Idioms](idioms): hoist invariants, avoid copies, precompile regexps. - Transform expensive sorts ([Sorting](sorting)). - Call C through the [FFI](../pperl-architecture/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 1. Is the body arithmetic and comparison over numeric variables? If yes, the JIT compiles it. If not, the disqualifier is a call, a regex, I/O, or dynamic data access - address that first. 2. 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. 3. Is the accumulator declared outside the loop and only accumulated, never reset? That is what makes it a recognised reduction. 4. Is the body free of I/O, global writes, and impure calls? That is what lets the loop parallelize. 5. Measure default versus `--no-jit` versus `--no-parallel` ([Measuring](measuring)) to confirm which optimiser is engaging. ## See also - [JIT compilation](../pperl-architecture/jit) - the compilation pipeline, the variable-type model, the caching. - [Parallel execution](../concurrent-execution/parallel) - the reduction analysis, the side-effect gate, the thread controls. - [Measuring](measuring) - how to confirm the JIT and parallelizer are actually engaging on your code. - [Idioms](idioms) - which classic optimisations still matter and which the compiler already handles. - [Sorting](sorting) - the one common hot path the JIT does not reach.