Status: Superseded by ADR-306 — its D1-D5 are D6-D10 there, D6 is D11
Date: October 3, 2026
Answers: the owner’s question, on ADR-271 §6’s next step
(overflow checks, by the same proof), after the measurement showed that
step buys nothing on its own: whether the compiler should know bounds on
the values a list holds - what it brings, what it costs, who pays, and how
others do it
Related: ADR-271 D2 (no check is dropped that is not
proved), D4 (the walk and its facts), D5 (a proved index lowers to
proven::read), §6 (overflow checks, x % n, facts across calls);
ADR-269 D4 (a claim the compiler proves has no check emitted);
ADR-270 D5 (a check is dropped on a certificate its checker
accepts); ADR-285 D5 (an overflow stops the program at every
build); ADR-258 (the four weights).
Measurements: docs/solver-workload.md §8.2
After ADR-271 the solver’s watch kernel is at the hand-written Rust’s count
with overflow checks off, and +0.7 % with them on, which is how a Nikaia
crate is built (ADR-285 D5). The plan was to drop overflow checks by ADR-271
D4’s proof. Measured, that buys nothing: the checks the walk can prove
(2 * v + 1 with v < 2000) are the ones LLVM already drops. The one that
costs is
let start = list[k] // list is one of watched
... value[arena[start + at]] ... // start + at: u32, checked
and start is a value read out of a list, of which the walk knows
nothing. The same holds of the two index checks around it, value[blocks[k]]
and value[arena[…]]: ADR-271 D6 lists them as unprovable because the
position is read out of memory.
Yet the program says what is in those lists. watched only ever receives
c * size with c < clauses; arena only … % (2 * variables); blockers
only values of arena. A bound on every value a list holds, known at
each read, proves all three.
Should the compiler know a bound on the values of a list, and if so, does it find it or does the program write it?
Without an answer, remove-overflow-checks stays a name with no measured
gain, and every index or sum over a value read from a table - a solver’s
watch lists, a parser’s tables, a graph’s adjacency lists - keeps its check
in the innermost loop.
| Language / tool | Its answer | Who writes the bound | What it costs there | Source |
|---|---|---|---|---|
| Wuffs | refinement types, also as element types: prefixes : array[4096] base.u16[..= 4095]; every index and overflow is proved at compile time from refinements, earlier ifs and interval arithmetic |
the programmer, in the type | no run-time check at all; a write must be proved to fit, so the author sometimes writes an extra if or a min |
bounds-checking note, std/lzw/decode_lzw.wuffs, std/deflate/decode_deflate.wuffs |
| Ada | range subtypes as component types: array (…) of Lit with subtype Lit is Natural range 0 .. 3999; a value is checked when it is stored, reads are free |
the programmer | a check at each store | from memory, not checked |
| SPARK (GNATprove) | the same types; the absence of run-time errors is proved, part of it by “a simple static analysis of bounds” | the programmer | prover time; contracts and loop invariants where the proof needs them | GNATprove output |
| Pascal | subrange types; with {$R+} stores and indices are checked at run time, usually off in release |
the programmer | a check at each store, or none and no guarantee | range and overflow checks |
| Liquid Haskell | refinements inside type arguments, [{v:Int \| v < n}]; liquid inference fills in what signatures leave out |
signatures by the programmer, the rest inferred | SMT time; the author writes the signatures | ICFP’14 paper |
| Dafny | quantified invariants, forall i \| 0 <= i < \|a\| :: a[i] < n, and subset types as element types |
the programmer | an invariant on every loop that writes the array | Dafny reference, types |
| Astrée | array smashing: a large array is one abstract cell, an interval that every write joins | inferred | analysis time; precision lost for arrays whose cells differ | snippet only, the paper was not reachable |
| Clousot (FunArray, POPL 2011) | an array split into segments with symbolic bounds, one interval per segment | inferred, “fully automatic” | about 1 % analysis overhead, reported | Cousot, Cousot, Logozzo |
LLVM !range |
metadata on a load: the value is in this range; rustc sets it for bool, char, enum tags, NonZero |
the frontend, as an assumption | nothing; a value outside is poison or undefined behaviour | rustc’s builder.rs; the LangRef from a snippet only |
| HotSpot C1, .NET RyuJIT | range-check elimination from induction variables, conditions, lengths and a type’s own range (byte is 0..255); never from what an array holds |
- | - | c1_RangeCheckElimination.cpp, RyuJIT rangecheck.cpp |
| Rust pattern types, Zig ranged integers | u32 is 0..4000 exists only inside the compiler, perma-unstable; Zig’s proposal is open, not accepted |
would be the programmer | - | rust#123646, zig#3806 |
What transfers. Two families answer the question. In one the programmer
writes the bound in the element type (Wuffs, Ada, SPARK, Pascal, Liquid
Haskell, Dafny), and it is checked at each store, or proved there. In the
other the tool infers one interval per array from every write (Astrée,
Clousot), with no annotation. Both put the work at the write, which runs
less often than the read in every kernel measured here. Neither uses a bound
nobody established: LLVM’s !range is the one that does, and it is an
assumption a frontend may emit only after the bound is proved.
What does not transfer. Wuffs proves everything and refuses what it cannot prove. Nikaia checks what is not proved (ADR-271 D2), so a bound that is not found costs speed, never a refusal. Astrée’s and Clousot’s precision comes from a fixpoint over loops. ADR-271 D4’s walk is a single forward pass that forgets at a loop’s head, and the proposal below stays within that.
The checks stay. Record the measurement; the watch kernel keeps its +0.7 % with overflow checks on.
Smashing, in ADR-271 D4’s walk. A local list whose every write the walk can see gets a bound: the join of what each write puts in, each one proved at its write from the facts that hold there. The program writes nothing:
let mut watched: Vec[Vec[u32]] = []
watched.resize((2 * variables) as i64, [])
for c in 0..<clauses {
watched[first].push(c * size) // c * size <= (clauses - 1) * size
}
...
let start = list[k] // list = watched[lit]: start <= 31996
... arena[start + at] ... // start + at <= 31999: no overflow
As Wuffs and Ada do. The write is checked, or proved, and a read knows the bound:
let mut watched: Vec[Vec[u32 in 0..<32000]] = [] // syntax for illustration only
Emit LLVM’s !range or assume from what the program seems to hold, without
a proof. Refused by ADR-271 D2: a wrong guess is undefined behaviour.
| D1 the special case pays, not the common one | D2 the compiler works, not the developer | D3 no cost for the common case | D4 consistent with itself | If it is wrong | |
|---|---|---|---|---|---|
| A | yes | no: the author rewrites the loop, or keeps the cost | yes | yes | slow, never wrong |
| B | yes: only aggressive pays the walk |
yes: nothing is written | yes: off by default, and the walk added nothing measurable over the corpus (§8.2) |
yes: it is ADR-271 D4’s walk with one more kind of fact, under ADR-271 D2 | a wrong proof, caught by the certificate’s checker before a check is dropped; a bound not found costs speed only |
| C | yes | no: the author writes and maintains ranges | yes | no, not yet: Nikaia has no range types, and one for list elements alone would be a type the language has nowhere else | a wrong range stops the program at a write, which is a new place to stop |
| D | yes | yes | yes | no: a build option changes what a program means | memory read past the end, silently |
What B brings, measured by removing from the lowered Rust exactly the checks it would prove (§8.2, fat LTO, overflow checks on, as a Nikaia crate is built):
| watch kernel, n = 2 000 | instructions | against Rust by hand |
|---|---|---|
| Rust by hand | 10.69 M | - |
Nikaia today, aggressive |
10.44 M | −2.3 % |
| B: values’ bounds | 9.68 M | −9.4 % |
| B and a filling loop’s length (D4 below) | 9.41 M | −12.0 % |
Index sites proved: 22 of 40 today, 28 with values’ bounds, 31 with a
filling loop’s length. rows and bignum gain nothing: their remaining checks
need x % n, two lists of equal length, or a product of two unknowns, none
of which is a bound on values.
What B costs, and who pays.
aggressive
only: one query per write into a tracked list, one per read whose value
feeds an index or an arithmetic check. The walk today costs nothing
measurable over the repository’s 46 standalone files (0.65 s → 0.66 s) and
+10 ms on the index-heavy kernels (24 → 34 ms). B’s criterion (D6) bounds
what it may add.bounds.rs: the
bound per list, the walk of writes, the overflow sites, and their tests.
The checker records each + - *’s whole-number type so that the proof and
the lowered operation agree on the type (D5).Why not C now. C reaches what B cannot: a bound across a call, a list behind a parameter, a value the program only knows from outside. But it is a new kind of type, and ADR-258 D2 says the compiler works first. If B’s measurement over real programs shows bounds it cannot find, that is C’s question, asked with that evidence.
B is chosen, together with the overflow option that makes its gain reachable.
remove-overflow-checks:aggressive, a second name for ADR-271 D1’s option.--optimization=remove-overflow-checks:aggressive, or the same word in
[build] optimization, with the levels off (the default) and aggressive.
There is no basic: the one shape a walk without a solver would prove is the
one LLVM already proves. The option is independent of remove-bounds-checks
and part of the cache key like it. ADR-271 D2 holds unchanged: a check that
is not proved stays.
For a list bound in the body being walked (a let, not a parameter), the walk
keeps a lower and an upper bound that hold of every value it holds,
linear terms over names the body never changes. The bound is the join of
what each write puts in: a literal’s items, push, insert, resize’s fill
value, xs[i] = v, and, for a list of lists, the same writes into
xs[i]. Each write’s value is proved within the bound at that write, from
the facts that hold there.
The list has no bound if it is handed to a call that may change it (a
mut parameter, or a callee this program does not declare), captured by a
lambda, a task, an overlap branch or a select arm, or written by any
method not on a closed list. A value read from a list with a bound is a
number of the proof with that bound, wherever ADR-271 D4 reads a number: in
an index, a comparison, an arithmetic check. A binding of such a read (let
start = list[k]) keeps the bound. So does a binding of an inner list
(let list = watched[lit]) for its values.
The bound is found flow-insensitively, before the walk, from the writes of the whole body. That is Astrée’s smashing, and it needs no fixpoint.
The constants a write’s value lies between come from interval propagation over the linear facts that hold there, as Wuffs bounds a value; each end is then confirmed by the solver on a certificate the checker accepts, and an end it does not confirm is open. Two passes find the bounds, each reading the last one’s, and a third keeps only those every write is proved to stay within while assuming all of them - an inductive invariant. A bound that holds only by assuming itself larger is dropped.
x % n is a bound.Where x is not negative and n is a positive term, 0 <= x % n < n; for
an unsigned x, always. This is what bounds arena’s values (ADR-271 §6
names the same rule for the rows kernel’s cols[r]).
for _ in lo..<hi { … xs.push(v) … }, with exactly one push on every path
through the body and no other change to xs, leaves xs.len() larger by
hi - lo where lo <= hi. That is what proves arena[start + at].
The checker records each +, - and * whose two sides are of one
whole-number type, by the operator’s span, with that type. A proved one
lowers to <T>::wrapping_add(a, b) (_sub, _mul). With no overflow, that
is the same number with no check, and no unsafe is needed. Naming T ties
the proof’s type to the emitted one: if they ever disagreed, rustc would
refuse the program instead of computing something else.
Built behind the option, B is accepted only if all three hold:
remove-bounds-checks:aggressive,
remove-overflow-checks:aggressive at or below 9.7 M instructions,
with overflow checks on and fat LTO (the first row of B above, which
assumes D4 is not built);aggressive
within +5 % of off, and the kernels within +20 ms;off stops at
the same one at aggressive, with the language’s message
(tests/bounds_checks.rs, extended to overflow).If the gain holds and the cost does not, the bound is kept for lists whose reads sit in a loop, and the rest are not tracked.
Met (solver-workload.md §8.3): the watch
kernel at 9.41 M (10.44 M at off), with every site D2 to D4 name
proved; lowering the standalone files within noise of off (about +3 %),
the kernels +16 ms (24 to 40 ms); every test program printing the same
at both levels, and tests/value_bounds.rs stopping where off stops.
remove-overflow-checks has a measured reason to exist. Without D2 it has
none on these kernels.rv[i] beside rc[i]):
4 of the 9 index sites left on the kernels. That is a relation, not a bound.xi * y[j]): interval arithmetic
beside Fourier-Motzkin, which would bound it from the operands’ types.a * rv[i]
needs the caller’s a.