python-features Advanced

How For Loops Compile

The loop passes of the Pyvorin 1.0.9 pipeline — complete unrolling, the vectorisation cost model, loop fusion — with the exact thresholds, verified in source.

Published Jun 12, 2026

A for loop over range() is the shape Pyvorin optimises best, and the reason is structural: its trip count is known before the first iteration, its induction variable advances by a fixed step, and its body can be reasoned about in isolation. Pyvorin Native 1.0.9 exploits exactly those properties through three dedicated AST passes — complete unrolling, vectorisation annotation and loop fusion — plus the range-tracking machinery that removes bounds checks. This page gives each pass the exact thresholds taken from the installed source, so you can predict what your own loop will receive before you benchmark it.

The loop passes sit at stage 5 of the pipeline, after type inference and compatibility analysis have run. If you have not read the surrounding stages, Compiler pipeline overview places these passes in context and AST analysis phase covers the gate that decides a loop is compilable at all. Everything below was verified against the package source and by running the CLI on small test functions.

How a counted loop lowers

When the native code generator meets for i in range(a, b, s), it emits a counted loop in LLVM IR: an induction variable initialised to a, a header block that compares against b, and a back-edge that adds s. At the IR level, a dedicated optimiser (opt/llvm_optimizer.py) identifies the induction variable, its bound and its step from the loop header and body, which is what enables later unroll-and-jam style rewrites on the IR itself. Nothing about this is speculative — the loop variable is a machine integer, not a Python object, and the comparison happens in registers.

The compiler only lowers the loop this way when it can prove the shape. A for whose iterable is not a recognisable range() call, or whose target is not a plain name, follows a more conservative path, and certain iterable forms — enumerate or zip used anywhere other than directly as the loop's iterable, for instance — are handled at the compatibility gate rather than the optimiser, as covered in AST analysis phase.

Complete unrolling: the 16-iteration gate

The most aggressive loop pass is CompleteLoopUnroller (opt/complete_unroller.py). It deletes the loop entirely: each iteration is expanded into straight-line code with the induction variable replaced by its constant value, after which ordinary constant folding can evaluate the whole thing at compile time. The compiler constructs this pass with threshold=16 — the class default is 128, but the production pipeline passes 16 explicitly in native_compiler.py, and 16 is the number that governs your code. A loop unrolls only when every one of these holds, all checked against the source:

ConditionVerified in
Iterable is a range(...) call with all-integer-constant argumentsvisit_For, argument scan
Trip count ≤ 16 (start, stop and step may be any constant integers, including negative steps)trip-count computation and threshold check
Loop target is a plain nameisinstance(node.target, ast.Name)
No break, continue, nested loop, or call in the body_is_safe_body
No else clause on the loopnode.orelse check

Two details are easy to miss. A zero-trip loop is not kept as an empty shell — the pass removes it and records complete_unroll: zero-trip. And after expansion the pass appends an assignment of the loop variable's final value, so code after the loop that reads i sees exactly what CPython would have left there. Run against a small illustrative function summing eight squares, explain reports the pass firing:

Optimizations applied:
  ...
  - complete_unroll: 8 iterations
  ...

The payoff is real but narrow. Unrolling removes every bit of loop overhead and hands constant folding a fully explicit computation; it does nothing for a loop whose bound arrives as a function argument, which is the common case in production code. For those, the vectoriser is the pass that matters.

The vectorisation cost model

VectorizationOptimizer (opt/vectorization_optimizer.py) is a cost-modelled AST pass that annotates loops for SIMD lowering; it does not itself emit vector instructions. The compiler instantiates it with its defaults, and the defaults are the whole story:

  • A loop is annotated when its trip count can be estimated above 1,000, or when the body matches an array-reduction pattern — an augmented assignment like total += a[i] * b[i], or an assignment that accumulates through a binary operation over a subscript of the loop variable.
  • The default vector width hint is 4. When the estimated trip count reaches 10,000, the width hint rises to 8, and from 5,000 iterations an unroll-by-2 hint is added.
  • A loop containing break or a nested loop is skipped outright — the pass walks the body and declines, and there is no appeal.

The reduction-pattern rule deserves emphasis because it is more generous than the trip-count rule: an accumulation over a[i] is annotated even when the trip count cannot be estimated at all, such as a bound passed in as an argument. Captured from explain on exactly that shape — a dot product over two lists with n as a parameter:

Optimizations applied:
  - record_aggregate(total)
  - vectorization_hint: width=4, unroll=1, trip≈None

The annotation then rides the AST into LLVM lowering, where the SIMD machinery emits actual vector IR; the width the backend uses ultimately follows your CPU's capabilities, with AVX2-class machines taking 4-wide i64 or 8-wide f32 vectors. One honest caveat: a separate software-pipelining pass exists in the tree, but the pipeline registers it only when explicitly enabled — it is off by default in 1.0.9 — so modulo scheduling is not part of the out-of-box story and this page makes no claims for it.

Fusion and bounds checks

Two neighbouring passes recover the overhead that unrolling and vectorisation leave behind. Loop fusion (opt/loop_fusion.py) merges adjacent for loops whose range() expressions are identical by AST comparison, but only under a strict dependency rule: the first loop must write a disjoint set of arrays from the second and must not write any scalar the second loop reads, and neither body may contain side-effecting calls. Fail any clause and both loops stay separate — the pass is conservative rather than clever. Bounds-check elimination tracks value ranges through the function and proves subscripts safe, deleting checks LLVM cannot see through on its own; where a proof is impossible it can version a loop into fast and slow variants, with the speculative path able to deoptimise if the assumption breaks — the machinery described in Guard and deoptimisation.

What a compiled loop is worth

From the canonical 71-workload benchmark artefact, the measured results for recognisable loop kernels: numerical.trapezoidal_integral at 202.83x, etl.windowed_aggregate at 134.62x, simulation.conway_life at 180.54x, financial.moving_average at 22.53x and core.fibonacci_sum at 21.80x. Read those next to the suite's honest floor: numerical.dot_product measured just 1.03x, because at microsecond scale the workload is too small for any of the machinery above to amortise its own fixed costs. The pass thresholds explain both ends — vectorisation needs a trip count the analyser can see or a reduction it can recognise, and unrolling needs constants, and small or irregular loops offer neither.

When a for loop gets less, and the call to make

break and continue do not block compilation — the compatibility gate accepts them — but they quietly disqualify the loop from every pass on this page: the unroller's safety scan rejects them, and the vectoriser declines any body containing a break. The loop still compiles to a native counted loop; it simply keeps its per-iteration overhead. That is the engineering judgement worth naming: for a hot accumulation loop, restructuring early exits into a bounded range() form with the condition folded into the body is often the single cheapest source of speedup available, but it is a change you make because a measurement asked for it, not a style rule. Run python -m pyvorin explain on your file first. If the loop you care about shows no vectorisation hint and no unroll record, and the surrounding profile says it matters, the restructure has evidence behind it; if the pass list already shows vectorization_hint, leave the code alone and spend the effort somewhere the compiler is not already doing the work.

Where to go next

Last reviewed 12 June 2026 against Pyvorin Native 1.0.9. Thresholds and pass conditions were read directly from the installed source tree (opt/complete_unroller.py, opt/vectorization_optimizer.py, opt/loop_fusion.py and native_compiler.py); pass output was captured by running python -m pyvorin explain on illustrative functions locally.