Skip to content

Add exact summaries for pointwise loops behind --functionalize-loops - #852

Open
shaobo-he wants to merge 6 commits into
developfrom
functionalize-loops
Open

shaobo-he wants to merge 6 commits into
developfrom
functionalize-loops

Conversation

@shaobo-he

@shaobo-he shaobo-he commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

This lands the loop-functionalization branch as an opt-in option, --functionalize-loops, after an audit and the fixes it called for. The branch is squashed as the first commit; the six commits on top are what changed to land it.

What the option does

SMACK explores every loop --unroll times, so a bug that needs one more iteration is missed. For loops the recognizer can prove pointwise — one positive affine induction, an exact trip count, affine stores with injective and pairwise-disjoint images, right-hand sides reading only loop-entry memory — the loop's blocks are replaced by an exact summary: a memory-update loop assigns each written map a function of the entry maps; a search loop becomes a quantified predicate plus a failure witness; a loop of __VERIFIER_assert/assume becomes a quantified assumption plus one witness per assertion. A bug at iteration 4 is then found at --unroll=1. Its measured reach on the SV-COMP suites is in the branch's history; the precision limits the audit confirmed are listed at the end.

Lambdas re-encoded so stock Corral takes them

The branch emitted Boogie lambda expressions, which Corral 1.1.8 (Boogie 2.9.1) fails on before its own lambda lifting runs — the feature only worked through a patched Corral. SMACK now lifts each summary map itself, the way /freeVarLambdaLifting would: a map-valued function of the locals and maps the body mentions, defined by one read axiom whose only trigger is a read of the result, carrying {:weight 0} for the same reason the memcpy summaries do (a chain of summaries must not become a Z3 generation cost). Measured on the suite with stock Corral 1.1.8: without {:weight 0} every configuration times out; with it, 468/468 pass.

The flag-off rule

Before the fixes, 0 of 768 flag-off translations were byte-identical to develop: smack.h annotated the verifier primitives for every user (a new llvm.global.annotations global and its strings, and the annotation kept the library body of __VERIFIER_assert alive through DCE), verifier.primitive metadata reached the Boogie output as attributes, and -svcomp was passed for every SV-COMP task. The annotation now exists only under a define the frontend sets with the option, the metadata is filtered, and -svcomp goes only with the option. After: 1290 of 1293 identical across 14 folders × 3 memory models; the remaining 3 are pthread/lockattr.c, whose sea-DSA region numbering is nondeterministic on develop itself.

What the audit found and what changed

Six lenses, 46 findings, each sent to two adversarial verifiers; 27 confirmed, 4 refuted (summarised here). Every confirmed agreement defect had a witness whose verdict flipped between flag-off and flag-on; each now agrees and has a regression:

defect witness fix
i != e trip count is e - start modulo 2^N; under unbounded integers the loop never exits, the summary ran zero iterations and dropped a memory-safety bug ne_wrap_ms.c: flag-off invalid dereference, flag-on no errors equality exits accepted only with a constant count
summary constants printed signed where SmackRep::bop/select print unsigned magnitudes (two representatives per bit pattern) a[i] = i * 2654435761u vs a[j] == j * 2654435761u: spurious error mirror the per-opcode conventions; recurrences rendered from the update instruction, not SCEV's fold
escaping header PHI of a bottom-tested loop assigned start + step*T instead of T-1 do { … last = i; } while (i < n): safe last == n-1 errored, unsafe last == n verified correct value when the exit test follows the body
failure-witness prefix was an untriggered forall never instantiated: an earlier failed assumption could not block a later assertion spurious error read-triggered form added, as the normal path had
recursive firstStop unfolded ≤ ~20 levels (Z3 generation cost), Boogie reported the unknown as a definite error on a safe 30-element scan late_stop_30.c: definite spurious error defined by its own {:weight 0} axiom; verifies in 1 s, symbolic stops to n ≤ 1000
under -svcomp a task-provided primitive was assumed memory-pure required to be (body scan)

Also fixed: the bit-precise fallback path re-queried ScalarEvolution on normalized loops (the crash the branch had worked around); the SV-COMP regression's property path did not resolve from test/, so that test silently dropped out of every run; the folder was not in the CI matrix.

Verification

  • test/c/functionalize-loops: 78 tests × 3 memory models × Boogie + stock Corral 1.1.8: 468/468. Ten new tests discriminate the fixes (each fails on the previous commit) and two decide bugs at iteration 3 with --unroll=1, which only the summary can.
  • Flag off: byte-identical to develop as above.
  • Flag on, injected into every test of 14 folders: 2335 passed, 0 failed, 0 unknown, 8 timeouts — all in the two_arrays family (see below). The same folders pass 100% flag-off. Through the Boogie-3.5.7 Corral fork the functionalize-loops suite is likewise 468/468.
  • Zero compiler warnings from the new code.

Known limits

Precision items the audit confirmed and this PR leaves as they are: the region-shape gate runs on a not-yet-final Regions partition; a loop-invariant load on the right-hand side rejects the loop; load/store disjointness is checked symmetrically; the ITE order among stores to one map is justified by LLVM-object disjointness, so a program that is already memory-unsafe can see a different value; arrays filled by an initializer list or memset are never summarised.

Known cost

With stock Corral 1.1.8 and z3 5.x (the CI combination), test/c/data/two_arrays*.c go from ~30 s flag-off to a timeout flag-on; the same Boogie file verifies in 27 s with Corral's bundled z3 and in 6 s through the Boogie-3.5.7 Corral fork. /useArrayTheory, qi thresholds, smt.arith.solver=2 and smt.relevancy=1 do not help; {:weight 0} is already load-bearing. Not diagnosed further; the option is opt-in and off by default.

🤖 Generated with Claude Code

shaobo-he and others added 6 commits August 26, 2026 08:39
SMACK is a bounded verifier: every loop is explored --unroll times, and a
bug that needs one more iteration is missed. For a class of loops that
class of bug is avoidable, because the loop's effect has a closed form.
With --functionalize-loops a recognizer (FunctionalLoopSummaryAnalysis)
proves, from ScalarEvolution, alias analysis and MemorySSA, that a loop is
pointwise -- one positive affine induction, an exact trip count, affine
stores whose images are injective and pairwise disjoint, right-hand sides
that read only loop-entry memory -- and the generator replaces its blocks
with an exact summary in the preheader:

  * a memory-update loop assigns each written map a function of the
    loop-entry maps and the loop's invariants, defined pointwise by an
    axiom (see the lifting commit for the encoding);
  * a read-only search loop (return on the first failing element) becomes
    a quantified predicate on the normal path and a failure witness on the
    other;
  * a loop of __VERIFIER_assert/assume actions becomes a quantified
    assumption plus one failure witness per assertion, ordered
    lexicographically so an earlier failed assumption blocks a later
    assertion.

Memory-safety checks inside a summarised loop are preserved as one
demonic branch per check site over a nondeterministic iteration, and a
recursive firstStop function bounds them by the first stopping iteration
of an early-exit loop. The verifier primitives are identified by an
annotation smack.h attaches under the option, or by name under the
SV-COMP frontend, never by name alone.

Everything is opt-in; with the option off the translation is unchanged.
Loop-bound warnings are deferred until the set of summarised loops is
known, so a summarised loop no longer asks for a larger bound.

This is the loop-functionalization branch squashed onto develop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A summarised loop assigned its destination map a Boogie lambda,
`$M := (lambda p: ref :: ...)`, and passed a second lambda over the
iteration space to the recursive firstStop function. Boogie's own lambda
lifting turns each of those into a map-valued function of the lambda's free
variables plus one read axiom -- but the Corral SMACK targets (1.1.8, Boogie
2.9.1) never gets that far: it fails on the expression before lifting, so the
feature only worked through a patched Corral.

Do the lifting in the translator instead, the way /freeVarLambdaLifting
would: while a summary body is built, every procedure local and memory map
it mentions is captured with its Boogie type, and the map becomes

  function $fl.lambda.P.I.K(captured...) returns ([ref] T);
  axiom (forall captured..., p: ref :: {$fl.lambda.P.I.K(captured...)[p]}
         $fl.lambda.P.I.K(captured...)[p] == body);
  $M := $fl.lambda.P.I.K(captured...);

The axiom's only trigger is a read of the result, so a read of the
summarised map expands on demand into reads of the loop-entry maps it
captured, which are proper subterms of the trigger; an instance can only
create reads on strictly earlier memory and the axiom cannot match on its
own output. That is what lets it carry {:weight 0}, for the same reason as
the memcpy summaries: a chain of summaries must not turn into a Z3
generation cost and a spurious `unknown`. Boogie's lifting would have
produced the same function and axiom; this form is simply emitted by SMACK
rather than by the backend, and stock Corral 1.1.8 verifies it.

Bound variables in the axiom keep the names of the locals they stand for,
so the body prints unchanged. Captured names are recorded on use, which is
also the failure mode: a name the capture misses is an undeclared identifier
in the axiom and Boogie rejects the program, rather than verifying something
else.

The lambda AST node has no remaining user and is removed. The one test that
counted `:= (lambda` occurrences now counts the lifted assignments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ized

With --functionalize-loops off, every program still translated differently
from develop: 0 of 768 test translations were byte-identical. Three causes,
all from the machinery that identifies the verifier primitives.

smack.h annotated __VERIFIER_assert and __VERIFIER_assume for every user.
The annotation table llvm.global.annotations and the string constants it
references became Boogie globals, shifting every global address, and the
table's reference to __VERIFIER_assert kept the library body alive through
dead-code elimination, so a procedure appeared that develop never emits.
The annotation now exists only under FUNCTIONALIZE_LOOPS, which the
frontend defines exactly when the option is given, for user code and the
library alike.

VerifierCodeMetadata marked the calls with `verifier.primitive` metadata,
and SmackInstGenerator::annotate emits a Boogie attribute for every
`verifier.*` metadata, so `assume {:verifier.primitive "assert"} true;`
appeared in the default output. The pass now collects primitives only under
the option, and the metadata is filtered from the annotations like the
memory-check provenance is; nothing in Boogie consumes it. Once consumed,
the annotation table and its strings are erased, so they stay out of the
functionalized program's globals too.

top.py passed -svcomp for every SV-COMP task. Only the loop summaries use
the identity it establishes, so it is passed only with them.

After this, 766 of the same 768 translations are byte-identical to develop;
the remaining two are pthread/lockattr.c, whose sea-DSA region numbering is
nondeterministic on develop itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
They ran only through Boogie, which lifts lambdas itself and so could not
tell whether the summaries work with the verifier SMACK actually ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The folder was never in the matrix, so nothing in CI exercised the feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An audit of the branch (six lenses, each finding checked by two adversarial
verifiers) found five ways a summary could disagree with the unrolled
translation of the same loop, one spurious-error mechanism in the backend
encoding, and one unchecked assumption. Each has a regression here that
fails on the previous commit and passes on this one.

Trip counts that hold only modulo 2^N. For `i != e` ScalarEvolution's exit
count is `e - start` modulo 2^N -- right for LLVM, where the induction
wraps around to reach e, but SMACK's default integers never wrap, so that
loop never terminates and `e - start` is negative. The summary then ran
zero iterations, dropping every write and check the flag-off translation
performs: a memory-safety bug found flag-off vanished, and an assertion
after the loop flipped. getIterationCount now accepts an equality exit
only with a constant count (ne_exit_symbolic_kept, ne_exit_constant).

Constants with the wrong representative. Under the unbounded encoding a bit
pattern has two integers, and SmackRep::bop picks one per opcode -- a
non-nsw `add`/`mul` operand, and every `sub`/`udiv`/`urem` operand, prints
as an unsigned magnitude; SmackRep::select prints its arms that way too.
The summary printed every constant signed, so `a[i] = i * 2654435761u`
disagreed with the assertion `a[j] == j * 2654435761u` by 2^32*j, and a
`?: -1 : 0` fill disagreed with the same select after the loop.
functionalValue now applies the same rules (hash_fill, select_sign_fill).

Recurrences folded by ScalarEvolution. `x + (-1)` without nsw and `x - 1`
are the same recurrence to SCEV but different values to SMACK. A
loop-carried scalar is now taken from its PHI's update instruction and its
step is rendered exactly as that instruction's operand would be
(unsigned_decrement_recurrence); the induction itself must be updated by
an `add` of its step, since its closed form prints the step positive.

The escaping induction of a bottom-tested loop. With the exit test after
the body the count is the backedge count plus one, and a direct use of the
header PHI after the loop sees the value of the last iteration, one step
short of the incremented value the exit PHIs carry. The summary assigned
the incremented value: a safe `last == n - 1` became an error and an unsafe
`last == n` was verified (rotated_escaping_induction{,_fail}).

The failure witness's prefix. The witness for a failing assertion assumed
`forall prev < witness :: every action holds at prev` with no trigger, and
its variable occurs only inside map indices, so E-matching never
instantiated it: an assumption that fails at an earlier iteration could not
be seen to block a later assertion, and the summary reported an error the
unrolled program does not. The prefix now also comes in the read-triggered
form the normal path already used (verifier_assumption_blocks_later_assertion).

The recursive firstStop. Its Boogie body became a definitional axiom of
weight one; Z3 charges a generation per unfolding and stops near twenty,
and Boogie reports the resulting `unknown` as a definite error -- on a safe
scan that stops at index 29 of a 30-element array. The function is now
declared without a body and defined by its own axiom carrying {:weight 0},
which the encoding of the summary maps already relies on; the same scan
verifies in a second, and symbolic stops verify to n <= 1000. A first-order
"least k" definition was tried and is exact but is never instantiated at
the right k.

Task-provided primitives. Under the SV-COMP frontend __VERIFIER_assert is
the task's own function and the summary evaluates it on loop-entry memory,
which is only right if that body writes nothing; it is now required to be
memory-pure (no stores or intrinsics, only declared or pure callees).

Also: the bit-precise/wrapped fallback path used ScalarEvolution on the
normalized loops instead of the counts recorded before normalization, the
very query that crashed LLVM 14 on large drivers; and two regressions that
only the summary can decide (a bug at iteration 3 with --unroll=1) join the
suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@shaobo-he
shaobo-he force-pushed the functionalize-loops branch 2 times, most recently from c1f7d0f to 1f72580 Compare August 27, 2026 21:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant