Optimisations — preconditions and checks
Status: normative from M9c (2026-10-25); §3 from M9d; §4 from M9e. Master prompt §16: an optimisation needs safety preconditions, tests, correctness checks, debugging support and measurements, against a correct baseline. This document states each optimisation's preconditions, which must hold for every program the checker accepts, and how the toolchain checks that the optimisation changes nothing a program can observe. Results are in milestone-9.md.
1. Rules for every optimisation
- It must not change what a program does: its output, exit status, panics (which one, and where), and its allocations as counted by the runtime. A removed check must be one that cannot fail. The one exception is placement (§4), which removes allocations of a placement-transparent allocator by design.
- It has a switch that turns it off (
--no-…), so that the unoptimised build stays available as an oracle and for measuring the gain. - It is decided from facts the compiler already proves (types, loans, checked arithmetic), never from heuristics about what a program "probably" does, and never by weakening a check of
ownership.md. - The interpreter checks it on every program it runs, where it can (below), and the run corpus and the fuzzer run with it both on and off.
- Its output is deterministic, like the rest of the compiler.
2. Bounds-check elimination (M9c)
2.1 What is removed
A BoundsCheck(i < L) (intermediate-representation.md; the index check of type-system.md §16.3) whose condition holds every time the statement is reached. The C backend then emits no nv_check_index for it. Nothing else is removed: range checks (a[lo..hi]), length checks (copyFrom), overflow checks and every other panic stay. Because a removed check never fails, which panic a program reaches, and where, does not change.
The analysis is Analysis/CheckElimination.cs, which also decides overflow checks (§3). It runs on each function's final NIR (after drop elaboration), and its result is the set of checks it proved. The C backend and the interpreter consult that set. The NIR itself is not changed.
2.2 Terms and facts
A term is an integer the analysis can name at a program point:
- the current value of a tracked local (§2.3);
- an integer constant;
- the length of a tracked slice, string view, vector or string local.
A fact relates two terms: a = b, a ≤ b, a < b. Terms are mathematical integers and facts are integer relations. That is sound because Nova's arithmetic is checked: +, -, * panic rather than wrap (§6), and as is lossless (§7). A definition x = a op b records how a local was computed, for op among +, -, %, & and >> on unsigned integers. A condition c = a cmp b records the comparison that produced a bool local.
2.3 Tracked locals
A fact may mention only a tracked local:
- an integer,
bool, slice orStringViewlocal, for its value (and a slice's or view's length); - a
VectororStringlocal, for its length.
A tracked local changes only through an assignment to it, which the analysis sees, or through a mutable loan of its own storage: borrow mut i, or push, pop, clear and append, which take borrow mut of a vector. While such a loan is live, every other use of the local is rejected (B1–B2), so no new fact about it can be made. After the loan, nothing may write the local through a pointer made from it (U2, ADR 0010). A shared loan cannot change it. So facts about a local end at each mutable Ref or Slice of a place rooted at it without a dereference, and a value or length read after that is a new fact.
A tracked local's facts end at:
- an assignment to a place rooted at the local without a dereference;
- a mutable borrow of such a place, as above;
- being a target of
Unpack; - its
StorageDead.
A local that is moved out is uninitialised until it is assigned again, and that assignment ends its facts.
A fact that ends with a local but also holds of a term known to be equal to it is kept, rewritten to that term. So t = length(s); m = t - 1; StorageDead(t) still knows m < len(s).
2.4 Where facts come from
- Copies:
x = yandx = y as T(lossless) givex = y.x = length(s)givesx = len(s).x = cgivesx = c, andx = c op dfor constants and+,-,*givesxits exact value when that is inx's range (M9d).x = e as Twhereeis not a term (a field, an element) givesmin(E) ≤ x ≤ max(E)fore's typeE(M9d). - Definitions:
x = a op bwith unsigned operands, unlessxis itself one ofa,b(as ini = i + 1, where the old value is gone). If a definitiony = a op bof the same terms already holds,x = yholds after the assignment, even whenaorbisx's old value:i = i + 1after a test ofi + 1 < ngivesi < n. - Comparisons: a
boollocalc = a cmp bcomparing integers (<,<=,>,>=) gives two implications: ifcis true, the relation holds; if false, its negation (a < bfalse isb ≤ a). A copy ofcor!ccarries them, and aBranchoncadds the relation of the edge it takes. Aboollocal assigned a constant is known to hold it. - Passed checks: after
BoundsCheck(i < L),i < L; afterRangeCheck(lo, hi, L),lo ≤ hiandhi ≤ L; afterLengthCheck(a, b),a = b.
Derived facts:
- a < b implies a ≤ b;
- ≤ and < are transitive (and a ≤ b < c gives a < c);
- constants compare as integers;
- equal definitions (the same operation on equal terms) have equal results;
- x = a − b gives x ≤ a and b ≤ a (the subtraction did not panic), and x < a when b is a constant ≥ 1;
- t = L − o with a constant c ≥ 1 and c ≤ t gives o < L (some length remains after o);
- x = a % n gives x < n;
- x = a & m gives x ≤ m and x ≤ a;
- x = a >> k gives x ≤ a;
- x = a + b gives a ≤ x and b ≤ x, and a < x when b is a constant ≥ 1;
- x = o + i, with i < t (or i ≤ t) and t = L − o for the same
o, gives x < L (or x ≤ L): an offset plus an index below the remaining length stays below the length. - x = a + m and y = b + m (the same
m) with a ≤ b give x ≤ y, and with a < b, x < y.
A check BoundsCheck(i < L) is removed when i < L is derived from the facts that hold before it.
2.5 The analysis
Forward dataflow over the control-flow graph. The facts at a block's entry are those holding on every incoming edge; a block not yet reached holds everything. At a join of two paths the analysis keeps:
- the facts of both paths;
- an implication "if
cisw, then F" of one path, if the other path knowscis notw(it holds vacuously there) or knows F itself; - a comparison or definition F of one path as "if
cis notw, then F", if the other path knowscisw.
a && b and a || b lower to joins of a path that computed b and a path that assigned a constant, so while (i < s.length && s[i] != 0) and if (k == 0 || n - k < 4) { return; } keep what their tests showed.
Every rule keeps only facts that hold on both paths, so each fact in a block's entry holds on every execution that reaches it. Loops are iterated until no entry changes. The join is not a plain intersection, so the iteration is bounded (64 visits per block on average). A function that reaches the bound has nothing removed. Implications use a length rather than a compiler temporary known to equal it, since the temporary may be gone where the implication is used. Derived facts are computed by saturation, which does not depend on the order facts were found, so the result is deterministic.
2.6 Unsafe code
The rule of ADR 0010 is the only assumption about unsafe code. The unsafe code in this repository (lib/core, integrations/cosmoos/**, tests/corpus/**, programs/, benchmarks/) was audited for writes through a pointer made from a reference after its target was used directly (2026-10-25): none, as recorded in milestone-9.md (9c).
2.7 Switch and diagnostics
- On by default.
novac build --no-bceandnovac run --no-bcekeep every check (CEmitOptions.BoundsCheckElimination,InterpreterOptions.BoundsCheckElimination). novac build --explain-bounds-checksprints each index check of the package, in source order, withremovedorkept.
2.8 How it is checked
- The interpreter evaluates every bounds check, removed or not. If a removed one would fail, it stops with an interpreter fault (exit status 70, "an eliminated bounds check fails"): a compiler bug. Every interpreter run checks this: the corpus agreement tests, the fault-injection sweep, the property tests and the fuzzer's oracle.
- The run corpus builds every program with the analysis (every configuration) and without it (
clang -O2 no-elim), so output and panics are compared both ways. So does the fuzzer's oracle. CheckEliminationTestsandtests/corpus/bounds-checks/*.nova: snapshots of--explain-bounds-checks(.bce) for loops that must be proved, and for near misses that must keep their check (a reassigned index, a mutated vector, a borrowed index,<=bounds, a loop bounded by another slice's length, a stable vector, whosepushtakes onlyborrow this).
Stable vectors (type-system.md §24.2) are not tracked: their length grows through a shared borrow.
2.9 Character boundaries in ASCII text (after M13)
A slice of a StringView (v[lo..hi], type-system §22.2) checks the range and that lo and hi are character boundaries. In text whose bytes are all ASCII (each below 0x80), every index is a boundary, so the boundary check cannot fail. When the text is a local every assignment of which is a string literal of ASCII bytes, or a copy of such a local, the C backend checks the range only (nv_check_range for nv_check_str_range). The range check stays.
- The analysis is per function and flow-insensitive, like §5.1's; it is part of
CheckEliminationand runs under the same switch (--no-bcekeeps the boundary check too).--explain-bounds-checkslists each slice of a view withboundary removedorboundary kept. - The interpreter checks boundaries on every slice; if one whose check was removed is not a boundary, it stops with an interpreter fault ("an eliminated boundary check fails"), as for §2.8.
- The run corpus's and the fuzzer's
clang -O2 no-elimconfiguration builds without it.
3. Overflow-check elimination (M9d)
3.1 What is removed
The overflow check of an integer +, - or * (type-system.md §6) or of a negation, when the exact result is in the range of its type every time the operation runs. The C backend then writes the operation without a check ((T)(a + b), with no undefined behaviour, since the exact result fits). Nothing else is removed: division and remainder keep their zero-divisor and MIN / -1 checks, shifts their amount checks, and conversions their range checks. A removed check never fails, so which panic a program reaches, and where, does not change.
It is part of the same analysis as §2 (Analysis/CheckElimination.cs), with the same facts at the same program points, and it adds no new kind of fact. §2's facts treat checked arithmetic as exact; that stays true, since an operation whose check is removed was proved not to overflow.
3.2 Bounds of a term
Every term has a range that holds whatever the facts say: a local's is its type's (i32: −2³¹ to 2³¹ − 1), a length's is that of usize, and a constant's is itself. From the facts before the operation, a term a has:
- an upper bound, the least of
max(u)over the termsuwitha ≤ uderived (§2.4), and ofmax(u) − 1over those witha < uderived; - a lower bound, the greatest of
min(l)over the termslwithl ≤ a, and ofmin(l) + 1over those withl < a.
An operand that is not a term (an element read from memory, a field) has its type's range.
3.3 When an operation cannot overflow
With the operands' bounds [lo(a), hi(a)] and [lo(b), hi(b)] and the result type's range [MIN, MAX], the check is removed when one of these holds:
- by interval: every combination of the bounds stays in
[MIN, MAX]: fora + b,lo(a) + lo(b) ≥ MINandhi(a) + hi(b) ≤ MAX; fora - b,lo(a) − hi(b) ≥ MINandhi(a) − lo(b) ≤ MAX; fora * b, the four products of the bounds; for-a,−hi(a) ≥ MINand−lo(a) ≤ MAX; - an unsigned subtraction
a - bwithb ≤ aderived; - an unsigned addition
a + bwhose sums, added to the facts as the definitions = a + b(exact: §2.4's rules for+hold of the mathematical sum), has an upper bound of at mostMAX. That covers an offset plus an index below the remaining length (to + i < L, §2.4), which the interval rule does not.
An operation whose operands are all constants is decided by its exact result alone, whatever the facts (M11, found by the G8c fuzz campaign). Under a condition that is never true, such as if (1u16 < 0u16), the facts contradict and every rule above would hold vacuously. The check would then be removed from code that never runs, which is sound, but the C would hold an overflowing constant expression such as 1 + 2147483647, which C compilers reject under -Werror.
The bound rule is what makes a counter safe: in for (i in 0..n) the increment i + 1 runs after i < n, so hi(i) ≤ max(n) − 1 and i + 1 does not overflow. A loop over a bound held in a constant (for (s in 0..100)) is the same.
3.4 Switch and diagnostics
- On by default.
novac build --no-oceandnovac run --no-ocekeep every overflow check (CEmitOptions.OverflowCheckElimination,InterpreterOptions.OverflowCheckElimination).--no-bceand--no-oceare independent. novac build --explain-overflow-checksprints each checked+,-,*and negation of the package, in source order, withremovedorkept.
3.5 How it is checked
- The interpreter evaluates every operation exactly, removed or not. If a removed one would overflow, it stops with an interpreter fault (exit status 70, "an eliminated overflow check fails"). As for §2, every interpreter run checks this.
- The run corpus and the fuzzer build every program without either elimination (
clang -O2 no-elim) as well as with both, and compare. CheckEliminationTestsandtests/corpus/overflow-checks/*.nova: snapshots of--explain-overflow-checks(.oce) for operations that must be proved (counters, constant bounds,b ≤ asubtractions, offsets below a length, widened operands) and near misses that must keep their check (a counter bounded by<=the type's maximum, a sum of two unbounded values, a subtraction tested the wrong way, a signed negation that may beMIN).
4. Placement (M5; on by default from M9e)
4.1 What changes
A Box.create site that PlacementAnalysis places (memory-model.md §5) keeps its value in a slot of the function's frame instead of the heap. The rules and preconditions are memory-model.md §5 and §5.1; they are not repeated here. Since M9e a site in a loop may be placed when its box ends within the iteration (§5.1).
4.2 What a program can observe
Output, exit status, panics and drop timing are unchanged. Allocation counts are not. A placed box is no allocation attempt, so the allocation report (NOVA_ALLOC_REPORT) shows fewer attempts, and fault injection (NOVA_FAIL_ALLOC_AT) cannot make a placed box fail. That is allowed only for an allocator whose contract says allocation "may spuriously succeed": SystemHeap in userland (memory-model.md §5, rule 1d). FailingAllocator, KernelHeap and user-defined allocators are never placed, so counting and failure injection stay exact for them. A test that needs every SystemHeap attempt counted builds with --no-placement.
4.3 Switch and diagnostics
- On by default (M9e).
novac build --no-placementandnovac run --no-placementkeep every box on the heap (CEmitOptions.Placement,InterpreterOptions.Placement).--placementis still accepted. novac build --explain-allocationsprints every allocation site with its decision and reason.
4.4 How it is checked
- The interpreter applies the same decisions. A placed box lives in its own allocation, which ends at the box's drop, so a use after it is a fault. A site whose previous box is still alive when it runs again is a fault ("a stack-placed box's slot is reused while its previous box is alive"), since natively the site has one slot.
- The run corpus and the fuzzer build every program with placement (every configuration) and without it (
clang -O2 no-placement), and compare. PlacementTests: the same output, panics and drop trace with and without placement, never more allocation attempts, and native code that agrees with the interpreter on everything, the allocation report included.tests/corpus/placement/*.allocare reviewed snapshots of--explain-allocations.
5. Closure-call specialisation (OD-9a; ADR 0019)
5.1 What changes
A call through a closure (CallClosure, type-system §36.2) whose closure is known becomes a direct call to the closure's body function, which the C compiler may inline. A call that passes a known closure to a function's closure-typed parameter goes to a variant of that function: a copy in which that parameter is bound to the closure's body, so the calls through it inside are direct too.
- Known. In a function, a local holds a known closure with body
Kwhen every assignment to it is one of: a reference to field 0 of an environment local every assignment of which is an aggregate whose field 0 isK's address (a closure literal, or anfnvalue's adapter, made in this function); a copy, cast or reborrow of a local holding a known closure with bodyK; or, in a variant, the parameter bound toK. Any other assignment makes the local unknown. The analysis is per function, flow-insensitive, and iterated to a fixed point. - Variants. One per (function, bindings), where the bindings map each bound parameter index to a body. They are made from a worklist in the order the program's functions and their calls are visited, so the result is deterministic. A recursive call with the same known argument goes to the same variant. Generic functions are specialised as their instances (§20.4); a function with no NIR (an
extern) is never specialised. - Not specialised: a closure read from a field, an element or a
dyn fn(§37), aBox<dyn fn…>, a closure whose value depends on a branch, and aThread.bothargument (called through the runtime, c-backend §2). - A variant is the same NIR with call targets fixed: the same statements, checks and frame. Borrow and effect checking run before the pass and are not repeated.
5.2 What a program can observe
Nothing: output, exit status, panics and allocations are unchanged. Only the C function a call goes to changes, and the generated C has one more function per variant (nvXS<n>_<name>, c-backend §2.1).
5.3 Switch and diagnostics
- On by default.
novac build --no-closure-specialisationandnovac run --no-closure-specialisationkeep every closure call indirect (CEmitOptions.ClosureSpecialisation,InterpreterOptions.ClosureSpecialisation). novac build --explain-closure-callsprints each call through a closure with its function and line, and whether it became direct (and to which body) or why not, then each variant made.
5.4 How it is checked
- The interpreter runs the specialised NIR when the pass is on, so the corpus's comparison of interpreter and native output covers it.
- The run corpus and the fuzzer build every program with the pass (every configuration) and without it (
clang -O2 no-closure-specialisation), and compare. ClosureSpecialisationTests: the C ofbenchmarks/sorthas no indirect comparator call left, and the run corpus gives the same output with and without the pass.tests/corpus/closure-calls/*.callsare reviewed snapshots of--explain-closure-calls.