Loop guards and container access
How Prismio proves a container's representation and index range once per loop, versions the loop on that proof, and emits unchecked access inside it.
Last verified
A list_get in Prismio is not one machine instruction. It is a representation
test, a bounds test, and two loads from the list header, and a loop pays all of
them on every iteration. Loop guards move that work out of the loop: one
predicate in the preheader proves what every access in the body needs, LLVM
versions the loop on it, and the fast version accesses memory directly.
This page describes the mechanism, the analysis that decides which loops qualify, the soundness argument for each thing it removes, and the measurements. It is an implementation deep dive for compiler and runtime contributors; nothing here is observable from the source language except speed.
What application developers need to know
- Container access has the same meaning whether or not a loop is guarded.
list_getpast the end still returns the element type's zero, andlist_setpast the end is still a no-op. - You do not opt in. A
whileloop over aVec<Int>is guarded when the compiler can prove it, and takes the ordinary path when it cannot. - Two source patterns decide whether the loop can also vectorize: write the
update as an unconditional store, and prefer
std.math'smax/minover a hand-written branch. A conditional store cannot be widened by any compiler. - A call inside a hot loop is no longer automatically a performance cliff, but a call that can grow a list still is. See What may appear in a guarded loop.
The cost being removed
RtList is a 40-byte header holding, among other fields, a data pointer, a
length, and an elem_size stamp that records whether elements are stored inline
or as pointers. A scalar read compiles to list_get_inline_scalar, whose body is:
unsigned long long list_get_inline_scalar(void* lp, int index, int elem_size) {
RtList* l = (RtList*)lp;
if ((unsigned)index >= (unsigned)l->len) return 0; // bounds
if (l->elem_size != elem_size) { // representation
return (unsigned long long)(uintptr_t)l->data[index];
}
...
}Both tests are load-bearing. The representation test exists because
list_get_inline returns two different kinds of thing — a slot's address for
an inline list, a slot's contents for a boxed one — and only a scalar element can
tell those apart. The bounds test exists because an index out of range stops the
program (list_index_fail), and the inline paths emit that test themselves
(list_bounds_or_fail): a cold call that never returns, so the in-range path is a
guard with no join. Until 2026-10-16 list_get was total, answering zero, and the
inline paths implemented that a second time.
Inlined into a DP loop, one iteration of a 1-D knapsack emitted three
representation tests, two bounds tests, and six or seven reloads of the header.
The equivalent C++ inner loop is two loads, a compare, a csel, and a store.
The bounds check is why the header reloads
This is the non-obvious part, and it is what makes the guard worth building rather than hand-optimising the runtime.
l->data is loaded only on the in-range path. A conditional load is one LICM
will not hoist, because it cannot prove the address dereferenceable on the path
that skips it. So the bounds check is not a cost sitting beside the header
reload — it is the cause of it. Remove the condition and LLVM hoists the
pointer into a register by itself.
Measured on a model of the same loop over the real RtList layout:
| variant | min ns | vs raw C array |
|---|---|---|
| representation test per access | 505,000 | 2.89x |
| representation proved per loop | 309,000 | 1.77x |
(base, len) hoisted into the preheader by hand | 310,000 | 1.77x |
| one range precondition per loop, then unchecked | 176,000 | 1.01x |
| no bounds checks at all (unsound; the ceiling) | 173,000 | 0.99x |
The third row is the one worth pausing on. Hoisting the base pointer yourself — the obvious fix, and the one an earlier design note recommended — is worth nothing. Removing the check gets the hoist for free, and adding the hoist on top of that changes nothing again.
The guard
One i1 per loop, computed in the preheader, is the conjunction of everything
the body needs. It carries two kinds of fact.
Representation
For every guarded access, elem_size == stride, where stride is the static
inline width of the element type. One conjunction rather than one test per site:
LLVM will not clone a loop for 2^n independent invariant conditions, and a
fused loop over five lists measured worse with per-site tests than with none.
Range
For every guarded access, 0 <= lowest index and highest index < len,
evaluated in i64 so the arithmetic cannot wrap a 32-bit Int.
The range is recovered from the loop itself. For a while (V op B) loop whose
body updates V exactly once, as its last statement, and every one of whose
guarded accesses indexes V, V + E or V - E for a loop-invariant E, the
values V takes in the body are exactly [B, V_init] going down or
[V_init, B] going up — and V_init needs no symbolic reasoning, because the
preheader still holds it.
Why the restriction is not cosmetic: the update must be the body's last statement, and an earlier version of this analysis shows why the restriction is not cosmetic. Widening the range by one step to allow the update anywhere makes the lower bound of
at - weightequalweight - 1 - weight, which is-1, so the conjunct>= 0is false at run time always. The guard never fired, the loop still paid to be versioned, and the result was 1.29x slower — with correct output, correct-looking IR, and a passing test suite. Only the benchmark distinguished a guard that fires from one that cannot.
Loop versioning, and why totality survives
The guard is a loop-invariant i1, so LLVM versions the loop on it: two copies,
one entered when it holds and one when it does not.
The false version is byte-for-byte what the compiler emitted before guards existed. That is the whole soundness story for the language's semantics. Nothing in this mechanism can make an out-of-range access do something new; it can only decide which of two loops runs, and a loop whose indices are not provably in range runs the one that checks them.
This is HotSpot's loop predication with a better failure mode. HotSpot must deoptimise when a hoisted check fails; Prismio keeps both loops and picks one.
What may appear in a guarded loop
A call in the body can invalidate the guard, so the compiler needs an answer for each one. The question is narrower than "does this touch a list", and the difference is most of the language.
The guard proves the element representation and the index range. A read cannot
change either. Nor can a write — list_set and its inline siblings store into
a slot that already exists. Only moving the element block can: growth reallocates
it, and release frees it. The element width itself cannot change at all — a list
receives it in its constructor and keeps it for life.
So the compiler classifies every function in the module by whether it can reach one of those primitives:
irPrimitiveMovesBlock list_push and family, list_inline_grow,
list_release,
rc_release, cyc_release
irPrimitiveIsSettled list_get, list_set, list_len, rc_retain, ...
A module pass then runs a greatest fixed point over the call graph. Every
function with a body starts assumed settled; each sweep takes that away from any
function reaching a block-moving primitive, an unknown extern fn, a spawn or
join, or a trait-object call (DYN_CALL), which runs whichever impl the vtable
holds. The loop analyses refuse those three node kinds directly too
(irIsUnjudgedCall): until they did, a dyn method that pushed to the list a
loop was reading left the guarded copy reading a freed element block.
Sweeps repeat until nothing changes, which terminates because a verdict only ever
moves one way and the module is finite.
Starting optimistic is what admits recursion: a function that calls only itself moves no block, and a pessimistic start would wait on itself forever.
Each function is judged under its own symbol, and a call reads the verdict of
the function sema resolved it to (CALL_EXPR.s2, the name codegen calls). The
table also keeps a verdict per source name, safe only when every function
carrying the name is, and a call falls back to it when it has no resolved symbol.
Keying by name alone made one unsafe overload poison every call spelled the same
way: Buffer.eq calls an extern, so in any program importing std.mem every
Map probe -- whose eq is an Int comparison -- lost its guard, and one closure
that pushes took the guard from every closure call.
Only functions the program can reach are judged at all: the standard-library
functions it cannot call are dropped after type checking (src/driver/prune.psm),
so a dead overload can no longer decide how a live call compiles.
Why not inlining
The obvious explanation for a call being expensive is that it was not inlined,
and it is wrong here. A two-line maxInt(a, b) helper called from a DP loop
measured 1.79x slower than the branch it replaced — and the disassembly
contains no call to it. LLVM inlined it.
It was slower because the guard analysis runs in Prismio's codegen, before LLVM sees the module at all. The loop lost its guards at that point, and inlining afterwards could not give them back. Inlining was never the missing piece; looking through the call was.
Emission
Inside the true version, an access skips what the guard already proved:
| representation test | bounds test | header loads | |
|---|---|---|---|
| unguarded | per access | per access | per access |
| representation guard only | preheader | per access | per access |
| representation and range | preheader | preheader | hoisted by LICM |
The read is ir_list_flat_scalar_elem with check_bounds cleared; the write is
ir_list_flat_scalar_set. Both branch on the guard and fall back to the ordinary
runtime entry point on the false arm.
Implementation note: Every access this backend emits carries a TBAA leaf, and the store above was briefly written without one. An untagged store may alias anything — including the
datapointer loaded from the header two instructions earlier — so LICM could not hoist that load, the base was re-read every iteration, and cross-iteration dependence analysis was impossible. The loop stayed scalar with nothing in the assembly to say why. Onetag_scalarcall fixed it, and it is what lets a guarded loop vectorize at all.
Vectorization, and the one thing source code controls
With the guard in place the inner loop is straight-line memory access, which is the precondition for LLVM to widen it. Whether it actually does is decided by one property of the source: is the store unconditional?
// Conditional store. Correct, and cannot be vectorized — by any compiler.
if (candidate > list_get(best, at)) { list_set(best, at, candidate) }
// Unconditional store of a select. Widens to four elements per iteration.
list_set(best, at, max(list_get(best, at), candidate))This is not a Prismio limitation. Modelled on a raw C array, the same loop is
220,000ns with if and 188,000ns with max, 7 NEON operations against 22. LLVM
declines to speculate a store it cannot prove safe to execute unconditionally,
and so does every other production compiler.
std.math's max, min and abs exist for this reason. They lower to
llvm.smax.i32, llvm.smin.i32 and llvm.abs.i32, which LLVM lists as
trivially vectorizable — a branch written by hand is not. std.math's Float
functions are marked guard-safe the same way: every __builtin_f64_* name is in the
table before the sweep, so a loop calling x.sqrt() or a.max(b) keeps its guard.
Index arithmetic in 64 bits, where it is proved
Int is 32 bits and wraps, and an index is sign-extended to 64 bits before it
becomes an address. A plain sub i32 there hides the index's shape from LLVM.
In knapsack's best[at] = max(best[at], best[at - weight] + value) the
vectoriser cannot express the distance between best[at] and
best[at - weight], so it guards its vector loop with a run-time test that the
two ranges do not overlap. They always do, so the vector loop never runs.
C++ has the same problem with an int index, and the same loop with an
int64_t index is 4.8x faster.
The proved copy already knows more than LLVM does: the range guard holds
0 <= at - weight < len. So rangeMarkIndex (src/ir/ranges.psm) marks the
index's + and - with the loop's proof, and the proved copy emits them nsw.
With that, LLVM computes the index in 64 bits, sees a distance of weight, and
drops both the wrap check and the overlap check.
nsw is a promise, and a broken one is poison, so the rule is strict:
- An op is marked only when its result and both operands are inside
Int. The guard's i64 interval is the loop's value only modulo 2^32, and(i + k) - kis in range whilei + kwraps. A value insideIntdoes equal its interval's value, though. So operands insideIntare what the loop computed, and their exact sum is the result. - What is known inside
Intcomes from facts the guard already holds: the index itself, which is in[0, len), and the operand of/or%, which the guard keeps insideInt. A literal offset carries the bound down:c - 1in[0, len)putscin[1, len], which is why every op of edit_distance'scurrent + c - 1is marked.c - 2is not, becauseccould then beINT_MAX + 1. - No fact is ever added to earn a mark. A fact that failed at run time would send the loop to its checked copy, so a mark could cost more than it saves.
- Multiplies are never marked, and neither is anything outside a proved
index, so trip counts keep their wrapping arithmetic. §9 of
the
Intwidth decision (see Performance decisions and rejected experiments) measured what marking a trip-count multiply costs:dead_code_eliminationlost a closed-form fold.
The difference-bound tier already did this for the indices it proves: each
term it interprets folds its own no-wrap facts and is marked by relCommit.
Loops over sized arrays
A fixed Array<T, N> index is checked against N (generateArrayIndexCheck in
src/ir/expr.psm, lowered by ir_array_index_check to a compare and a branch to the cold,
noreturn array_index_fail; a constant index out of range is refused in sema). An access the
proved copy covers skips the check. Its loops are versioned when the guard holds something LLVM
cannot work out for itself. A loop with no list guard runs the same analysis over its sized-array accesses
(arrays in src/ir/ranges.psm), with the type's N as the length. The guard then holds the
facts that keep every access in [0, N) over the counters' whole range, and the proved copy
marks the arithmetic those facts make safe.
The freestanding knapsack is the case it exists for:
let mut c = 1500
while (c >= w) {
best[c] = max(best[c], best[c - w] + v)
c = c - 1
}c runs over [w, 1500], so best[c - w] in [0, 1501) puts w >= 0 in the guard. From
that one fact LLVM proves the loop cannot wrap and adds the nsw itself. It also sees that every
read trails the store, and interleaves four iterations at a time: with NEON off, as the suite
builds, that is ldp/stp pairs rather than a vector loop. Instructions executed, aarch64
freestanding:
| knapsack | instructions | vs C |
|---|---|---|
| before | 5,690,512 | 1.11× |
guard on w >= 0 | 3,139,600 | 0.61× |
C, with if (w < 0) __builtin_unreachable(); added | 3,139,120 |
C's undefined overflow never supplied this fact: C reaches 3.14M only when the fact is written
into its source, with or without -fwrapv. The guard the obvious argument suggests is
w != Int.MIN, which only keeps c - 1 from wrapping. It measured 6,259,472, worse than no
guard: c widened to 64 bits, with c - w still computed in 32, costs a truncation and an
extension per access.
Two rules keep the duplicated code small:
- Innermost loops only. Versioning an outer loop copies every loop inside it, for facts about the outer loop's own accesses. Knapsack's item loop got a second copy of the capacity loop and nothing for it.
- Only when the proof marks arithmetic.
for i in 0..<n { a[i] }would gain a second copy for the factn <= Nalone, and LLVM already makes that one fact out of the check: one compare ofnagainstNbefore the loop, which still vectorises (measured 2026-10-16).
A [T] parameter has no length the preheader can read, so it is left to the facts below.
Facts an unchecked access implies
An element of a [T] view is addressed without a check, and the language requires every index
to be in range (Bounds and storage in the specification's undefined behaviour page); an element
of a fixed array is checked, which stops the program on any other index. Either way an access that
ran says its index was in [0, len). Every length fits
Int, so the index was at most Int.MAX - 1. The fact holds until the name is assigned or
bound again: a scalar cannot be passed inout and closures capture by value, so only an
assignment in the same function changes a local Int.
The subscripts that give a fact are a name, name + k and name - k, for a literal k. A
name + k that wrapped is negative, so an access that ran rules the wrap out. A name - k that
wrapped is in [Int.MAX - k + 1, Int.MAX], so its fact is taken only when the last index plus
k stays at or below Int.MAX. That holds for any fixed array of a practical length. A view's
indices reach Int.MAX - 1, so a view's a[i - 2] could have run with i at Int.MIN + 1, and
it gives no fact on i. viewBelow in test_270_array_nowrap.psm checks that.
src/ir/nowrap.psm walks each function that has a loop or subscripts a fixed array, before its
body is generated. It marks two kinds of +/- nsw, in every copy of every loop, and proves
indices in range (below):
- Bounded. The facts bound every operand, and the exact result is inside
Int. Nothing is assumed. Quicksort's scanwhile (a[i] < p) { i = i + 1 }adds 1 to anithe access left at mostInt.MAX - 1. That scan has no bound a guard could use, because it stops on the data. - A subscript with a non-negative left operand. In
best[c - w]afterbest[c],c - wwithc >= 0can only wrap upwards, to a negative value. A wrapped result is therefore out of range, and the access that is its only use is undefined anyway. Only the subscript is marked this way, never its operands.
Facts flow forward in evaluation order:
- Code that may not run keeps its facts. Facts from the right side of
and,orand??stay inside it. - Comparisons narrow.
a < b(and<=,>,>=,==) narrows the locals it names on the taken branch,abelowb's largest value andbabovea's smallest, and its negation narrows them on theelsebranch and after a loop that has nobreak.!=trims an interval whose edge it names. An operand with no fact is anything its type holds. - Branches meet. An
ifjoins the facts of its two branches. - Loop heads keep what is inductive. A loop head keeps the facts on names the loop never assigns, and on names it does assign when one is an invariant (below).
- A
Vecgives no facts yet. Its out-of-range read now stops the program, so a read that returned implies its index was in range, as aSliceaccess does; using that is not done.
An update is marked only when its loop's subscripts are. The same widening that made
w != Int.MIN a loss applies here. nsw on c = c - 1 with c - w unmarked measured 6.26M,
against 5.69M with neither. So the pass walks twice. The first walk records every counter that
some subscript in a loop combines without a mark. The second walk marks, and leaves those
counters' updates in those loops plain.
| freestanding, instructions | before | after | C |
|---|---|---|---|
quicksort | 11,304,368 | 9,991,296 | 10,096,384 |
knapsack, these marks alone | 5,690,512 | 5,122,720 | 5,122,624 |
Counted loops
A while whose condition bounds a counter, and whose body ends by stepping it, bounds the counter
in the body too. That takes three things:
- A bound. One
andconjunct of the condition isi < e,i <= e,i > eori >= e, whereeis a literal or a name the loop does not assign. - A step. The body's last statement is
i = i + cori = i - cfor a literalc, towards the bound. - Nothing else. No other statement in the loop assigns or binds
i.
Every check of the condition that gets past the bounding conjunct has i on the bound's side, and
nothing moves i before the last statement. So the step cannot wrap, as long as the bound plus c
fits Int, and i stays between where it entered and the bound. The fact holds from the bounding
conjunct on. A conjunct ahead of it also runs on the last check, when i is already past the bound:
fn note(inout v: [Int], x: Int) -> Bool {
v[0] = x
return true
}
fn last(start: Int) -> Int {
let mut seen: Array<Int, 1>
let limit = 999999999 + 999999999 + 147483649
let mut i = start
while (note(seen, 1 + i) and i < limit) {
i = i + 1
}
return seen[0]
}
fn main() -> Int {
if (last(2147483645) == 0 - 2147483647 - 1) { return 0 }
return 1
}i is in [start, Int.MAX - 1] in the body, but 1 + i in the first conjunct runs once more with
i at Int.MAX, and wraps; it is not marked.
The walk also counts the loop. With a fact on i where the loop starts, it runs at most
(bound - start) / c + 1 times, unless the body holds a continue, which skips the step. The
count is what bounds the contents of an array, below.
Loop invariants
Binary search keeps lo and hi in [0, 4096], but only as a pair: hi = mid and
lo = mid + 1 stay inside it because lo < hi held where mid was computed. No single assignment
shows it, so a loop head that forgot every name the loop assigns lost both, and table[mid] kept
its check. nowrapInvariants finds such facts by trying them:
- Candidates. Each local of a fact type the loop assigns that had a fact where the loop started.
- A probe. One walk of the condition and body from the head's facts with the guesses added, in
a mode that marks nothing and records nothing (
NOWRAP_PROBE). Its marks go to a pass of their own, and the walk's loop and block counters are put back afterwards. - Widening with thresholds (Blanchet et al., PLDI 2003). A guess grows to the hull of itself and what the probe leaves at the end of the body. From the third round a bound that still moves jumps to the next of the name's thresholds, a literal the loop compares it with, one either side of that, or its entry bound, else to its type's edge. From the sixth a guess that still moves is dropped, so it does not hold the others up. When a probe moves nothing, the guesses are inductive.
- Exits. A
continueanywhere in the body (a labelled one in a nested loop included) is a way back the walk does not follow, so the loop gets no invariants. Abreakleaves from inside the body, so the invariants hold in the body but are not the facts after the loop.
A function spends at most 400 probes (NOWRAP_PROBE_BUDGET); past that a loop keeps what it kept
before. Compiling the compiler took the same time within noise.
Narrow integers and proved indices
The facts are I64 intervals over every integer type of at most 32 bits: Int, U32, U16,
U8, I16, I8. Each operation is checked against its own type's range and succeeds only if its
exact result fits, so a fact is never a wrapped value: +, -, *, >> and & by a literal,
an unsigned % by a literal or by a divisor bounded above zero, and as between integers when the
value fits the target (truncation, extension and reinterpretation agree there). for i in a..<b
bounds i between its ends in either direction. Only a signed Int +/- is marked nsw.
An access to a fixed array whose subscript's interval lies inside [0, N) is marked on the
INDEX_EXPR (nowrapMarkInRange), and generateArrayIndexCheck emits no check for it
(nowrapIndexInRange). That is what keeps binary_search, pointer_chase and
bytecode_interpreter free of checks in their hot loops:
| freestanding, instructions | checked | proved | C |
|---|---|---|---|
binary_search | 13,438,400 | 11,036,304 | 11,036,304 |
pointer_chase | 14,448,592 | 9,629,904 | 9,629,904 |
bytecode_interpreter | 10,400,032 | 9,650,048 | 9,850,032 |
test_311_proved_indices.psm holds the proved shapes and twelve that look provable and are not
(an off-by-one <=, a U32 below zero, an exit the end of the body does not see, a large U32
cast, a mask one too wide, a store nothing bounds, a let bound twice), each of which must still
stop at its index; proved_indices in test_runner.py reads the IR for which keep a check.
What a local array holds
Edit distance keeps two rows of costs, and each new cost is one more than a neighbour or equal to one:
cur[j] = __builtin_min(__builtin_min(prev[j] + 1, cur[j - 1] + 1), prev[j - 1] + cost)C's compiler turns min(a + 1, b + 1) into min(a, b) + 1, one add fewer per cell, because C's
overflow is undefined. For Prismio it may only if neither add can wrap, which depends on what the
rows hold, not on any index. src/ir/contents.psm keeps a bound on the elements of a local array
of any fact type:
- Which arrays. Nothing but a subscript names the array, in its function and outside any
closure, and its name is bound once there. A view, an
inoutargument, a copy or afor x incould write it where the walk does not look. - One lifetime. The bound holds from the declaration, which zero-fills or initialises the array each time it runs, to the end of its block. Arrays declared in one block are a family and share the bound, because a store into one reads the others.
- A form per store. A stored value is a concrete interval from literals and facts, or relative
to the family's bound
[L, M]through the elements it reads:prev[j] + 1is at mostM + 1. Sums,minandmaxcombine forms; a program's ownminormaxcounts when its body is one comparison of its two parameters and areturnof each (nowrapSelection), since a freestanding program cannot import std.math. A value read through an immutableletbound once in the function (let t = order[i]; ... order[j] = t) is a read of that array. Anything else, or a sum of two elements, leaves the family without a bound. - Types. The bound must lie inside each member's element type. A member too narrow for it, a
U8row beside anIntone, fails alone, and the rest settle again without it. - Growth.
Lholds if no store can go below it.Mgrows by at most the relative part of each store, so over one lifetime it stays at or below the largest initial or concrete value, plus that part times the store's executions. The counted loops between the declaration and the store bound the executions.
The benchmark's rows start at zero and receive 600 x 600 stores of at most M + 1 per lifetime, so
the elements stay in [0, 360600] and all three adds are marked. An array that fails, because a
store's value has no form, takes with it only the arrays whose stores read it.
| freestanding, instructions | before | after | C |
|---|---|---|---|
edit_distance | 5,900,624 | 5,540,624 | 5,596,832 |
Wrapping integers and tight loops has the investigation: the shapes behind the gap, every alternative measured and the prior art.
Remainders by a literal
A checksum loop ends every iteration with a remainder that depends on the last one:
for i in 0..<n {
let value = i % 997
checksum = (checksum + value) % 1000000007
}LLVM emits each % as a multiply by a magic constant — smull; asr; add; msub, about eleven
cycles — and the chain through checksum serialises them. It cannot do better, because it does not
track the range of a value carried around a loop. The access-facts walk can, and
src/ir/residues.psm adds what it needs:
- A remainder's range.
x % mfor a literalm >= 1lies in[-(m - 1), m - 1]with the dividend's sign, whateverxwas, and no wider than a bounded dividend allows. - What a loop keeps. A name every assignment in a loop sets to a remainder by a literal, which entered the loop inside that range, is inside it at every head: no assignment can leave it, so no induction over the body is needed. Other names assigned in the loop are still forgotten.
- One step. On a dividend in
(-2m, 2m)the signed remainder isx - mfrommup,x + mfrom-mdown, andxin between. The walk records which of four forms the dividend's interval allows — the dividend itself in(-m, m), the subtract in(-m, 2m), the add in(-2m, m), both in(-2m, 2m)— andgenerateBinaryemits compares and selects instead ofsrem. Neither the add nor the subtract isnsw; the arm a select discards may wrap.
A range runs either way at run time (0..<n counts down when n is negative), so value above is
only known to lie in (-997, 997), and checksum + value needs both steps. That is still the loop
in 4.06 ms rather than 9.67 (vector_growth, 4M iterations); a dividend known non-negative takes
one step, 3.04 ms in the C model of the same loop. A divisor up to Int.MAX is read, but a dividend
whose range does not fit Int keeps its remainder, so % 2147483647 on a sum stays srem.
PRISMIO_NOWRAP=0 turns the forms off with the nsw marks: they come from the same facts.
Square bounds and stride tiling
A sieve's outer loop is bounded by a square, while (p * p <= n). The range guard folds the facts
that keep the square exact, so the conjunct bounds p: that proves prime[p] and makes
p = p + 1 nsw, which is what lets LLVM count and interleave the inner loop. A nest that stores
one value at a stride is also run one cache-sized window at a time, as a hand-written segmented
sieve is. Both, with why reordering the stores is legal and what was measured, are on
Stride tiling and square bounds.
Measurements
knapsack, a 1-D DP over Vec<Int> with 576,000 inner iterations, against C++
-O3 and Rust opt-level=3 on the same machine, 31 alternating samples,
checksums equal:
| stage | vs C++ | vs Rust |
|---|---|---|
| before loop guards | 3.49x | 1.17x |
list_set stops vetoing the guard | 2.55x | 0.85x |
| range guard, unchecked reads | 1.91x | 0.64x |
| unchecked writes | 1.42x | 0.50x |
| TBAA on the store | 1.37x | 0.47x |
source written as an unconditional max | 1.03x | 0.35x |
proved index arithmetic nsw (2026-09-25) | 0.25x |
The last row is the vector loop running at all, not Prismio outpacing clang's
code: the C++ arm indexes with int and never enters its vector loop, and the
same C++ with an int64_t index gets the same factor.
Rust is scalar on this workload and pays bounds checks Prismio no longer does, which is why Prismio is roughly three times faster there while still level with C++.
The mechanism is not specific to one benchmark. Across the 34-program suite it
changed 13 of 53 functions, and tokenization — a string workload with no DP
loop at all — is 0.56x of C++.
Measurement warning: This suite is layout-sensitive well past its recorded noise floor.
gcd_lcmread 0.868x reproducibly across 31 alternating samples on byte-identical code; the symbol had merely moved 132 bytes, changing its 64-byte alignment. Before believing any number here, diff the per-symbol mnemonic sequences of the two binaries and measure only the functions that actually changed.
What is declined
The analysis is deliberately narrow, and each of these is a separate widening with its own soundness argument:
loopforms.whileand rangeforloops carry the guard.- An induction variable updated anywhere but the body's last statement, more than once, or by a non-literal step.
- An index that is not
V,V + EorV - Efor a loop-invariantE. - A receiver reached through a field (
w.items). A stale length is an out-of-bounds store, and an assignment through a field is not what the module pass inspects. - Any loop calling a function that can reach a block-moving primitive, an
unknown
extern fn, or aspawn. - Struct elements in the write path, whose setter can run a user releaser.
- A sized array in a loop that also has a list guard. The guard serves the lists, and a fact about an array that failed at run time would cost the lists their unchecked copy.
- Access facts from a
Vecand from aSlice, whose checks both trap and so would supply them. Neither is done yet. - Heapsort's
while (root * 2 + 1 <= end).root = pickis no counter, and proving the multiply needsroot >= 0carried through the loop plus a guard onstartandend. An emulation measured 3.2% on a row already inside the parity band; seedocs/KNOWN_ISSUES.md. - Counted
forandloopforms, and a counter stepped anywhere but the body's last statement. - Array contents through a sum of two elements (
t[i] + t[i]doubles, and no form describes it), a cast, a call other than__builtin_minand__builtin_max, or a store that can lower the bound.
Implementation map
| Concern | Location |
|---|---|
| Representation guard | generateLoopFlatGuard in src/ir/expr.psm |
| Range proofs, per access | src/ir/ranges.psm (delta tier), src/ir/relations.psm and src/ir/dbm.psm (difference bounds) |
nsw on proved index arithmetic | rangeMarkIndex in src/ir/ranges.psm, relCommit in src/ir/relations.psm; emitted by irAccessProved in src/ir/expr.psm |
| Sized-array guards | servesAccess, rangeServesArrays and the arrays field in src/ir/ranges.psm; requested by generateWhile and generateForDirection in src/ir/stmt.psm |
| Access facts | src/ir/nowrap.psm, run from generateFunction in src/ir/module.psm; marks in ir_nowrap_mark (runtime/ir_symbols.c); emitted by nowrapImplied in generateBinary |
| Counted loops | src/ir/counters.psm; applied from the bounding conjunct on by nowrapCondition in src/ir/nowrap.psm |
| Array contents | src/ir/contents.psm; the forms are evaluated by nowrapForm in src/ir/nowrap.psm |
| Loop invariants | nowrapInvariants, nowrapProbe and NowrapThresholds in src/ir/nowrap.psm, applied by nowrapLoop |
| Proved indices | nowrapMarkInRange and nowrapIndexInRange in src/ir/nowrap.psm, read by generateArrayIndexCheck in src/ir/expr.psm; the walk runs for a loop-free function that subscripts a fixed array (moduleTreeIndexesArray, src/ir/module.psm) |
| Remainders by a literal | src/ir/residues.psm; ranges in nowrapRemainder, loop heads in nowrapKeepResidues, forms marked by nowrapMarkRemainder (ir_rem_form_mark in runtime/ir_symbols.c) and emitted by reducedRemainder in src/ir/expr.psm |
| Square bounds, stride tiling | Stride tiling and square bounds |
| Which calls decline a loop | irFlatGuardCount in src/ir/expr.psm |
| Effect analysis and fixed point | irBodyMovesNoBlock and the sweep in src/ir/module.psm |
| Guard-safe function table | ir_mark_guard_safe_fn, ir_is_guard_safe_fn in runtime/ir_symbols.c |
| Unchecked read and write | ir_list_flat_scalar_elem, ir_list_flat_scalar_set in runtime/llvm-api-backend.c |
| min/max/abs intrinsics | src/sema/builtins.psm, src/ir/expr.psm, std/math.psm |
| Float math builtins | src/common/float_builtins.psm (the table), marked safe in src/ir/module.psm |
Failure builtins (panic, assert, …) | marked safe in src/ir/module.psm; irFlatGuardCount counts only an assert's condition, since the rest runs on the way out of the process |
| Tests | tests/test_119_loop_range_guard.psm, test_120_min_max_abs.psm, test_121_guard_effect_analysis.psm, test_169_loop_range_proofs.psm, test_179_proved_index_nsw.psm (with proved_index_nsw in test_runner.py), test_270_array_nowrap.psm (with array_nowrap), test_271_array_contents.psm (with array_contents), test_311_proved_indices.psm (with proved_indices), test_286_stride_tiling.psm (with stride_tiling), test_288_remainder_steps.psm (with remainder_steps_codegen) |
Debugging a guard change
Ask whether the guard fired before asking whether it helped. A guard that is
emitted and always false looks identical in the test suite to one that works.
Count cmp against the stride in the disassembly: once, in the preheader, means
it fired; once per access means it did not.
Then check the fast version, not the first loop you find. A versioned loop has two bodies, and the checked one is often the one your eye lands on first.
Write the adversarial test before the optimistic one. A helper that pushes to
the list under test must decline the guard; so must one that reaches a pusher
through another call. Those two cases are the entire soundness claim of the
effect analysis, and they fail loudly — a stale length past a list_push is an
out-of-bounds access, not a wrong answer.