Skip to content

Copy a loop-body local to the loop head only when a nested loop needs it and it can be moved - #4957

Open
kasimte wants to merge 10 commits into
model-checking:mainfrom
kasimte:fix-4911-loophead-call-move
Open

kasimte wants to merge 10 commits into
model-checking:mainfrom
kasimte:fix-4911-loophead-call-move

Conversation

@kasimte

@kasimte kasimte commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

move_storagelive_call_to_loophead copies a loop-body local's initializer up to the loop head, so CBMC can infer a nested loop's assigns clause (#4179). It was copying in cases that don't need it or can't support it. Two fixes, each only skips a copy it used to make:

Skipping a copy can leave a false failure at the loop head, but never a false VERIFICATION SUCCESSFUL. Known gap: a write through &mut isn't counted as an inner-loop write, so that copy is skipped too. The copied code's own side effects are pre-existing, tracked in #4982.

Resolves #4911. Resolves #4949. Resolves #4959.

Testing: the #4911/#4949 and #4959 reproducers fail on main and verify here. The nested-loop cases that must still be copied still verify (nested_loop_local_var_func_call.rs).

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 and MIT licenses.

@kasimte
kasimte requested review from a team as code owners October 3, 2026 17:49
@github-actions github-actions Bot added Z-EndToEndBenchCI Tag a PR to run benchmark CI Z-CompilerBenchCI Tag a PR to run benchmark CI labels Oct 3, 2026
@kasimte kasimte changed the title Don't copy a loop body call to the loop head when it reads loop locals Don't copy loop-body declaration groups to the loop head when they read loop locals or cannot be spliced Oct 4, 2026
@feliperodri
feliperodri requested a balanced review from Copilot October 4, 2026 19:03
@feliperodri feliperodri self-assigned this Oct 4, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Projected writes are incorrectly treated as definitions of their base locals, causing both unsafe copies and unnecessary skipped copies.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Prevents unsafe loop-body initialization copies that read loop-local state or contain unsupported control flow.

Changes:

  • Adds loop-local dependency and splice-shape checks.
  • Adds positive, negative, and branching regression tests.
File Description
kani-compiler/​src/​kani_middle/​transform/​loop_contracts.rs Adds copy eligibility analysis.
tests/​expected/​loop-contract/​loop_local_var_branching_init.rs Tests loop-local branching initialization.
tests/​expected/​loop-contract/​loop_local_var_branching_init.expected Records successful verification.
tests/​expected/​loop-contract/​loop_local_var_branching_init_external_cond.rs Tests externally controlled branching.
tests/​expected/​loop-contract/​loop_local_var_branching_init_external_cond.expected Records successful verification.
tests/​expected/​loop-contract/​loop_head_call_computed_index.rs Tests computed-index calls.
tests/​expected/​loop-contract/​loop_head_call_computed_index.expected Records successful harness results.
tests/​expected/​loop-contract/​loop_head_call_computed_index_fail.rs Tests genuine bounds failure.
tests/​expected/​loop-contract/​loop_head_call_computed_index_fail.expected Records expected verification failure.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@feliperodri feliperodri left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for splitting this into two independent fixes, and for writing down the known limitations. Both fixes do what the description says. #4911's check_with_closure verifies, both #4959 reproducers verify instead of panicking, and the three tests that should fail on main do. The loop-contract expected suite passes (67/0/6) with this branch merged on current main.

I'm requesting changes because the gate refuses copies that main needs for nested-loop assigns inference (#4179), so programs that verify today start failing Check assigns clause inclusion. 039c19513 fixed the projection-write case from Copilot's thread: my *acc = j reproducer verifies again. Two ways in remain, each with a reproducer inline:

  1. Rvalue::CheckedBinaryOp is not modeled, so any initializer with checked arithmetic, e.g. let mut j = sum_pair(t1 + 1, t2);, is refused. This is the one most users will hit.
  2. Locals the pass has already copied to the same head are still classified as dead there (let a = f(t1); let mut j = g(a);).

These are spurious failures, not unsound results. I also checked the other direction and found no case where refusing a copy hides a real failure. A MirVisitor with visit_local and PlaceContext would fix 1 and remove the arm list, and it would give you the read/write order the question on line 962 is about. Adding a nested-loop test that needs the copy would keep this from regressing again.

Separately, while trying to break the copy, I found that liveness isn't the only problem with it. Because the copy runs once before the loop, a call with side effects, a kani::assume in the callee, or a panic on a path where the body never runs can each make Kani report SUCCESSFUL for a program whose assertion fails natively, and the for-loop machinery computes a nested iterator once from a stale outer value. All of that is on main today and none of it comes from this PR, so I don't want to block on it here. Please open a GitHub issue with the reproducers above (one issue, or two: side effects, assume and panics in the head copy, and the stale nested iterator), and make the comment at line 1055 say that the gate covers liveness only.

What I didn't run: -Z uninit-checks, coverage, and the full regression script (CI covered the last one on 9f9b2274d and was green; it hasn't run on 039c19513 yet).

@kasimte

kasimte commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Just updated with four commits:

  • 41df310b0: an ordered MirVisitor walk replaces the arm list, so nothing is refused as unanalyzable now.
  • 3993ddddd: track locals an earlier move to the head already made live.
  • 12e1235ba: drop call destinations from the fallback's definition sites.
  • 96ef9934a: scope the comments to live storage, linking #4982.

loop-contract suite 67/0/6. nested_loop_local_var_func_call.rs covers all five shapes (checked arithmetic, a call-initialized local, a reference, a &mut write, the #4179 move). Also resolves #4949, which verifies with and without an explicit loop_modifies.

Fork CI: Format Check and Cargo Deny pass. Kani CI is red only on zerocopy 0.8.60 failing to compile in the cargo-kani regression, not from this PR and hitting any fresh Kani build. The loop-contract suite passes there, and the three failures are all zerocopy-dependent.

@feliperodri feliperodri left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working through round 1 so thoroughly. The MirVisitor walk and the per-head live set fix both regressions I reported. #4982 has the reproducers. Every new harness passes, and the three tests that should fail on main still do. With this branch merged on current main, the loop-contract suite passes (76/0/4), and fmt and both clippy gates are clean.

I'm still requesting changes, for two reasons, each with a reproducer inline:

  1. The gate still refuses copies that #4179 needs. In Kani's MIR a plain let start = i + 1; has no StorageLive, so the fallback counts it as dead, and let mut j = f(start); in front of an inner loop stops being copied. That program verifies on main and fails Check assigns clause inclusion here, at every MIR opt level I tried except 0. Writing id(i + 1) instead makes it verify, which says the rule tracks how a local was computed, not whether it exists at the head. The flip side is that #4911 with let mid = add(base, half); still fails.
  2. When a nested for iterator group can't be retargeted, the copy is skipped but the body's kani_into_iter call is still replaced with a goto. The iterator is then uninitialized: main panics on these programs, and this branch reports an overflow inside KaniIter::len instead.

Both are spurious failures. I checked the other direction (wrong invariants, wrong assertions, loop_modifies violations, loop, while let, closures, generics, on_entry, opt levels 1 and 2), and refusing a copy never hid a real failure.

For 1, I think the gate should ask whether the copy is needed rather than whether its reads are live. The copy exists only so CBMC can infer an inner loop's assigns clause. I prototyped "copy only when an inner loop writes the local" (snippet on line 955). It passes the suite and all of your tests, fixes both cases in 1, and also fixes the single-loop reproducers in #4982. If you see a reason that rule is wrong, I'd like to hear it. Fixing 2 means tracking which kaniiter groups were moved.

The branch also conflicts with main after #4946 (head_live versus decreases_bindings in move_storagelive_assign_to_loophead); keeping both works.

What I didn't run: -Z uninit-checks, coverage, and the full regression script. CI has only run Auto Label on this head.

One more thing, not caused by this PR: could you open an issue for a soundness hole I hit while probing? At -Zmir-opt-level=2 and above, a loop that writes a variable missing from its loop_modifies clause verifies, on main as well as here (I raised the same thing on #4947). Nothing reads the kani_loop_modifies binding, so MIR optimizations remove it and CBMC infers the write set instead. This reproduces it with RUSTFLAGS=-Zmir-opt-level=2 kani file.rs -Z loop-contracts (it fails Check that hidden is assignable at level 1):

#![feature(proc_macro_hygiene)]
#![feature(stmt_expr_attributes)]

#[kani::proof]
fn opt2_modifies_ignored() {
    let mut i: u32 = 0;
    let mut hidden: u32 = 0;
    #[kani::loop_invariant(i <= 5)]
    #[kani::loop_modifies(&i)]
    while i < 5 {
        hidden = i;
        i = i + 1;
    }
    assert!(i == 5);
    std::hint::black_box(hidden);
}

The #[inline(never)] keep-alive that #4946 uses for the decreases binding fixes it in my test.

The loop contract pass copies a `StorageLive(x); ...; x = call(...)` group
from the loop body to the loop head so that CBMC can infer assigns clauses
for inner loops. When the copied code reads a local that is defined inside
the same loop, the copy evaluates it at the head before it has a value: for
`let cmp = f(s.get_unchecked(mid))` with `mid` computed in the body, the head
copy fails get_unchecked's precondition check with "Rust intrinsic assumption
failed" even though the loop invariant proves `mid < len` at the real call
site.

The pass now skips the copy when the group reads a local defined inside the
same loop, excluding the group's own destination and temporaries. A local
counts as loop-defined when its StorageLive is inside the loop, or, for a
local with no StorageLive such as an arithmetic temporary, when every block
that assigns it is inside the loop. The for loop iterator machinery keeps its
moves unconditionally, so the for loop lowering is unchanged, and groups whose
reads are all live at the head are still moved, which keeps the nested loop
inference from model-checking#4179 working.

Resolves model-checking#4911
The loop-contract pass moves a loop-local's StorageLive and initializing
blocks to the loop head to help CBMC infer assigns clauses. The splice
helpers re-target each moved block's terminator, which is only possible
for terminators with a single target (a Call with a return target, a
Goto, an Assert, or a Drop). A collected group spanning any other
terminator, such as the SwitchInt of a branching initializer, made
insert_bb panic with "Kani can only insert instructions after
terminators that have a `target` field". Gate the move on the group's
shape, composed with the existing reads-loop-locals skip: groups that
cannot be spliced stay in the loop body.
`assign_blocks` and the `internal` set in `group_mentions_loop_local`
recorded every assignment's `place.local` as a definition site. Only a
whole-local assignment (an empty projection) brings the local into
existence; a write through a pointer (`*p = v`) or into part of the local
(`x.f = v`, `x[i] = v`) presumes it already exists. Counting those as
definitions can misclassify a head-live `&mut` argument that is written
through inside the loop as loop-declared, and then skip or wrongly copy a
declaration group that mentions it.

`defines_local` now gates both sites on an empty projection. Loop-contract
suite unchanged (67 passed).
The hand-written arm list dropped Rvalue::CheckedBinaryOp (always present
under Kani's overflow checks) and Rvalue::Len, so any initializer with
checked arithmetic was refused and nested-loop assigns inference regressed.
A MirVisitor walk covers every statement, rvalue and terminator, and visits
reads before the same statement's write, so a whole-local write only makes
the local internal once it has happened.
A group was refused when it read a local whose StorageLive sits in the loop
even if the assign hoist or an earlier copied group had already made that
local live at the head: the snapshot maps predate both. Keep a per-head set,
seeded by the assign hoist and extended with each pushed group's destination
and temporaries, and treat its members as live.
A call-initialized local (`let a = sum_pair(t1, 0)`) gets no StorageLive:
rustc omits storage markers for such scalars, so their storage spans the
whole function and the unguarded pass has always evaluated head-copy reads
of them. The no-StorageLive fallback still classified them loop-declared
and refused copies that nested-loop assigns inference needs. Count only
whole-local statement assigns, keeping statement-computed temporaries like
a binary search's mid refused. The extended nested-loop tests pin the
checked-arithmetic, chained-call, hoisted-reference and projection-write
shapes.
The copy of a loop-body initializer to the loop head exists only so CBMC
can infer the assigns clause of an inner loop that writes the local. Gate
on exactly that -- copy iff an inner loop writes the local -- instead of on
whether the initializer's reads are live at the head. The liveness gate
wrongly refused a group whose initializer reads a statement-computed local
(`let start = i + 1;`, which has no StorageLive), regressing nested loops
that model-checking#4179 made verify.

Remove the liveness machinery this replaces: group_reads_dead_local,
assign_blocks, storagelive_blocks, defines_local, GroupLocalUses, and the
per-head live set.

When a for-loop machinery group cannot be retargeted its copy is skipped;
stop also replacing its kani_into_iter call with a goto, which left the
iterator uninitialized. Track only the kaniiter groups actually moved.

Add a statement-computed-initializer regression harness.
@kasimte

kasimte commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

@feliperodri Thanks for looking again and the detailed review.

I rebased and addressed comments. Filed the mir-opt-2 loop_modifies hole as #5053. Re-requesting review.

@kasimte
kasimte requested a review from feliperodri October 23, 2026 03:20
@kasimte kasimte changed the title Don't copy loop-body declaration groups to the loop head when they read loop locals or cannot be spliced Copy a loop-body local to the loop head only when a nested loop needs it and it can be moved Oct 23, 2026
…all-move

# Conflicts:
#	kani-compiler/src/kani_middle/transform/loop_contracts.rs

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Z-CompilerBenchCI Tag a PR to run benchmark CI Z-EndToEndBenchCI Tag a PR to run benchmark CI

Projects

None yet

3 participants