Skip to content

Add property-directed slicing before Boogie generation - #847

Open
shaobo-he wants to merge 11 commits into
developfrom
property-slicing
Open

Add property-directed slicing before Boogie generation#847
shaobo-he wants to merge 11 commits into
developfrom
property-slicing

Conversation

@shaobo-he

Copy link
Copy Markdown
Contributor

Adds an optional -property-slicing pass that removes program behaviour which cannot influence the assertion property, run after Devirtualize has resolved indirect calls and before RewriteBitwiseOps and SmackModuleGenerator, so nothing removed becomes a Boogie CFG, a memory operation, or a VC.

The slice is backward from the property root and reuses analyses the translator already pays for: the sea-DSA-derived Regions partition that becomes the $M. maps, plus per-function PostDominatorTree and LoopInfo. It introduces no alias analysis and never assumes two Regions are disjoint beyond what Regions::idx already guarantees.

Soundness is one-directional -- Errors(original) is a subset of Errors(sliced) -- so the slice may add executions but never deletes an error-reaching one. Bypassing a property-irrelevant loop can add the execution that skips a nontermination, which is the safe direction for reachability but not for termination; the pass therefore refuses to run under -memory-safety, -integer-overflow, and -fail-on-loop-exit, whose roots this relevance relation does not model.

Three properties of the existing region machinery are guarded rather than assumed. A pointer with no sea-DSA cell receives its own region rather than aliasing everything, because Region::overlaps only unifies two complicated regions and Node::isIncomplete() is never set; such accesses are forced to a TOP sentinel. Region::isDisjoint adds offset + length in plain unsigned, so a UINT_MAX length wraps and reports overlapping intervals disjoint; memory intrinsics with a non-constant length are likewise forced to TOP. And Regions::idx mutates and renumbers the partition on every call, so indices are snapshotted once before any slicing decision and never re-derived.

Flags:
-property-slicing enable (off by default)
-property-slicing-relax-asm adopt SMACK's own Stmt::skip() semantics
for inline asm
-property-slicing-no-loop-bypass drop instructions but keep every loop
-property-slicing-no-regions ignore the region partition (ablation)
-property-slicing-profile FILE machine-readable profile

Adds 28 regression tests covering SSA relevance, region relevance through loads and stores, call and effect rules, and loop bypass, 19 of which must still report an error.

shaobo-he and others added 11 commits August 22, 2026 13:04
Adds an optional `-property-slicing` pass that removes program behaviour
which cannot influence the assertion property, run after Devirtualize has
resolved indirect calls and before RewriteBitwiseOps and
SmackModuleGenerator, so nothing removed becomes a Boogie CFG, a memory
operation, or a VC.

The slice is backward from the property root and reuses analyses the
translator already pays for: the sea-DSA-derived Regions partition that
becomes the $M.<k> maps, plus per-function PostDominatorTree and LoopInfo.
It introduces no alias analysis and never assumes two Regions are disjoint
beyond what Regions::idx already guarantees.

Soundness is one-directional -- Errors(original) is a subset of
Errors(sliced) -- so the slice may add executions but never deletes an
error-reaching one. Bypassing a property-irrelevant loop can add the
execution that skips a nontermination, which is the safe direction for
reachability but not for termination; the pass therefore refuses to run
under -memory-safety, -integer-overflow, and -fail-on-loop-exit, whose
roots this relevance relation does not model.

Three properties of the existing region machinery are guarded rather than
assumed. A pointer with no sea-DSA cell receives its own region rather than
aliasing everything, because Region::overlaps only unifies two *complicated*
regions and Node::isIncomplete() is never set; such accesses are forced to a
TOP sentinel. Region::isDisjoint adds offset + length in plain unsigned, so
a UINT_MAX length wraps and reports overlapping intervals disjoint; memory
intrinsics with a non-constant length are likewise forced to TOP. And
Regions::idx mutates and renumbers the partition on every call, so indices
are snapshotted once before any slicing decision and never re-derived.

Flags:
  -property-slicing                  enable (off by default)
  -property-slicing-relax-asm        adopt SMACK's own Stmt::skip() semantics
                                     for inline asm
  -property-slicing-no-loop-bypass   drop instructions but keep every loop
  -property-slicing-no-regions       ignore the region partition (ablation)
  -property-slicing-profile FILE     machine-readable profile

Adds 28 regression tests covering SSA relevance, region relevance through
loads and stores, call and effect rules, and loop bypass, 19 of which must
still report an error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pass requires Regions and DSAWrapper. Adding it to the pipeline
transfers last-usership of DSAWrapper, seadsa::DsaAnalysis and CallGraph
away from the passes that follow, and scheduling it alongside
MemorySafetyChecker crashed the legacy pass manager in
PMTopLevelManager::setLastUser before any pass ran: every
--check=memory-safety, valid-deref and valid-free translation segfaulted,
with or without -property-slicing, on all 31 memory-safety regression
tests that translate fine on develop.

llvm2bpl now consults propertySlicingWillRun() and adds the pass only when
the flag is on and the property is one the relevance relation models. That
also makes the refusals effective: they previously lived in runOnModule,
which the crash meant was never reached.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
UndefValue::get(T) is uniqued per type and Naming::get caches by Value *,
so SmackRep emits every undef of a type as one module-global Boogie const.
A Boogie constant is a single unconstrained but fixed value, so every site
sharing it is forced to agree: on kbfiltr_false a single 'const $u0: i1'
was the condition of 363 branches. That removes execution combinations --
the opposite of the over-approximation the branch nondeterminization and
loop bypass rely on -- and is unsound wherever the relevance relation is
imprecise.

Each site now calls a body-less declaration, which Boogie havocs per call:
329 distinct branch conditions on the same driver. Pointer results keep
undef, since an external call returning a pointer also gets an
assume $isExternal(p) that would constrain the value, and a pointer is
never a branch condition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two irrelevant loops need opposite truth values at their header
branches; with a shared replacement value the assertion is unreachable and
the error is missed. Fails (verified) on the pre-fix pass, errors after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The relevance relation this pass computes is a *sequential* dependence: an
instruction is kept when the property's value depends on it along the
thread's own control flow. Under an interleaved semantics that relation is
wrong in both directions.

  - A store that no later instruction of this thread reads is still read by
    another thread. The slicer drops it, and the protocol it implemented is
    gone with it.
  - A busy-wait loop carries no intra-thread control dependence: its body is
    empty and its exit block post-dominates the header, so nothing after the
    loop is control-dependent on the exit test. bypassIrrelevantLoops
    therefore deletes the spin outright.

Measured on test/c/pthread_extras with --pthread --context-bound=2 (Corral
1.1.8, unroll 4): peterson, dekker and szymanski all go from verified to a
spurious error. In peterson's thr1 three of the four stores -- flag1 = 1,
turn = 1 and flag1 = 0 -- and the whole `while (flag2 == 1 && turn == 1)`
loop are gone, leaving the thread walking straight into its critical
section.

-property-slicing-no-loop-bypass is not a remedy, which is why this is a
refusal rather than a narrower loop rule: with loop bypass off all three
still report a spurious error, because the exit condition is nondeterminized
instead (the spin may leave at any moment) and the protocol stores are
dropped by removeIrrelevantInstructions regardless. Keeping "every loop
whose exit test reads a location another thread may write" would therefore
also have to keep every store to such a location and every load feeding such
a test -- on these programs, everything -- and SMACK's thread edges live
inside `__SMACK_code("async call ...")` string literals in
share/smack/lib/pthread.c, which this pass cannot read.

llvm2bpl has no notion of --pthread, so top.py passes the new
-property-slicing-pthread alongside -property-slicing. The option is
declared in PropertySlicing.cpp only because the slicer is its single
consumer; it describes the program, and belongs in SmackOptions next to
-memory-safety once this pass lands.

With the refusal, --pthread --property-slicing emits a Boogie file
byte-identical to --pthread alone (modulo the "// via <argv>" header), and
the three tests verify again with the same procedure-inlining counts as the
unsliced baseline (55/62/59).

The new tests are a spin-wait handshake and its failing twin: with the
refusal both pass; with the PR as it stood, pthread_spin_wait.c reports a
spurious error. The folder's memory model is pinned because Corral rejects
the prelude the other two emit for a concurrent program ("Ensures has a
shared global") -- test/c/pthread pins it for the same reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The refusal list is only useful if a reader can tell what is missing from it
on purpose. Every other mode SMACK's front end offers -- the remaining
--check properties, and the translate-group flags --modular, --rust-panics,
--float, --llvm-assumes and --bit-precise -- was checked against the
relevance relation and measured; none needs a refusal, so the reasoning goes
in a comment rather than in code.

The one that came closest is -llvm-assumes=check, where
SmackInstGenerator.cpp:930 turns llvm.assume into an *assert* -- a property
root isPropertyRoot() does not name. It survives anyway, and not by
accident: llvm.assume is inaccessiblememonly, so computeEffects() puts it in
unsafeToDrop and propagate() keeps the call along with the condition it
names. Measured with a probe whose assumed value is irrelevant to the
assertion (`int x = nondet(); __builtin_assume(x > 0); assert(watched ==
1);`): the violation is reported with slicing on and off alike.

Also measured: test/c/contracts under --modular (9/9), test/rust/panic under
--check=rust-panics (5/5, four of them error tests), and the 28 tests of
test/c/property-slicing re-run under -bit-precise, -float and
-llvm-assumes=check (28/28 in each).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every rule in the property slicer that reasoned about a call was written
over CallInst, which is not the class of "a call" in LLVM -- CallBase is.
An `invoke` is a call that happens to also be a terminator, and SMACK
translates it through the very same SmackRep::call a CallInst goes
through (SmackInstGenerator::visitInvokeInst, SmackRep.cpp:1105-1128,
which tells the two apart only to count operands, then adds a goto on
$exn).  So the slicer saw, at an invoke: no callee, no call-graph edge,
no written-region summary, no argument binding and no returned value.

The pass was not visibly unsound today only because hasUnmodelledEffect
listed InvokeInst outright, which made every function containing one
un-droppable and, through the callee rule of computeEffects, every
caller of it too.  That is an accident, and it is not even the accident
it looks like: an invoke's unwind destination must begin with an EH pad
in the same function (LangRef), and the EH pad is unmodelled as well, so
removing InvokeInst from that predicate costs nothing in unsafeToDrop
while letting every rule below finally see the call.

What was actually broken is the rule "a relevant call result makes the
callee's returned values relevant".  Written over CallInst it does not
fire at an invoke, so nothing marks the callee's returned load relevant,
the region behind it never becomes relevant, and the store that feeds it
-- along with the call that performs the store and the global's static
initialiser -- is sliced away.  test/c/property-slicing/invoke_call_result.ll
is that program: verified with the flag off, and reported as an error by
the slicer before this commit because $M.0 was left unconstrained.

Converted to CallBase: calleeOf, isPropertyRoot, hasVerificationEffect,
the hasUnmodelledEffect inline-asm/unresolved-callee test,
computeMayReachError's root and edge detection, computeEffects' unsafe
classification and callee edges, explainRelevance, seedRoots, the
propagate keep rule, the per-argument rule, the callee-return rule and
the loop-blocker classification.

Deliberately left as CallInst, each with a comment saying why: the
callsRemoved/callsRetained statistics (an invoke is a terminator and is
never a removal candidate, so counting it in one and not the other would
make the two disagree), and the switch-condition guard in
removeIrrelevantInstructions (it recognises the nondet call freshNondet
itself emits, which is always a CallInst).

Two further adjustments the conversion requires:

- An invoke can never be *dropped* the way a call can.  Deleting one
  would mean rewriting it into a br to the normal destination and
  repairing the unwind destination's PHIs, inside a rewriter that
  otherwise only erases instructions; and it buys nothing, because
  removeIrrelevantInstructions already skips every terminator, so no
  invoke was ever being deleted.  isTerminatorCall makes that explicit
  and seedRoots retains such call sites unconditionally.  Only their
  effects take part in the analysis.

- hasUnmodelledEffect now tests I.isEHPad() rather than
  isa<LandingPadInst>, and adds catchret/cleanupret.  The Windows
  funclet pads used to be caught only via the blanket InvokeInst case
  that this commit removes; naming them directly keeps that coverage
  instead of inheriting it by accident.

MEASURED, install-b4 vs a build of the parent commit: the C corpus never
produces an invoke at all (share/smack/frontend.py:88-121 passes no
-fexceptions, so clang marks every C function nounwind), hence the four
hand-written .ll tests; test/c/basic and test/c/property-slicing
translate byte-identically with the flag on and off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The loop-droppability scan in bypassIrrelevantLoops skips every
terminator inside the loop and instead checks the block's terminator
afterwards -- but that later check exempts the latch, because the latch's
back-edge branch is exactly what a bypass is entitled to delete.  For an
ordinary loop that is right.  It stops being right once a terminator can
also be a call: an invoke may terminate the latch, and then neither test
looks at it, the loop is declared droppable, and EliminateUnreachableBlocks
takes the call site away with the rest of the body.  Nothing else in the
rewriter can delete an invoke, so this was the one path that could.

The scan now examines a terminator that is also a call as if it were an
ordinary instruction, which puts it back under both the
hasUnmodelledEffect test and the keep test -- and seedRoots always keeps
such a call, so any loop containing one is retained.

HYPOTHESIS, not measured: I could not build a program that actually
reaches this.  An invoke whose unwind destination lies outside the loop
gives the loop a second exit block, so getExitBlock() returns null and
the bypass is refused as MULTIPLE_EXITS; one whose unwind destination
lies inside the loop brings an EH pad in with it, which the scan does
see and which hasUnmodelledEffect retains.  The guard is therefore cheap
insurance against those two accidents ever being weakened, not a fix for
an observed failure -- but it costs one predicate call per instruction
and the alternative is a silently deleted call site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Control dependence here came from LLVM's PostDominatorTree, which treats
every loop as if it always terminated. For

    while (1) { if (bad) { __VERIFIER_assert(0); return; } step(); }

the error block is the function's only exit, so it post-dominates the
branch that decides to enter it and comes out control-dependent on
nothing: `bad` is irrelevant, the branch is replaced by a nondeterministic
one, the loop body then holds no kept instruction, and bypassIrrelevantLoops
redirects the preheader onto the loop's unique exit block -- which is the
error block. A program that never reaches the assertion reports a bug.

Both halves of that are the same missing fact: everything after a loop
the program may never leave happens only because the loop was left.

  - mustTerminate(): ScalarEvolution bounds the backedge count. Nothing
    else in the pass can distinguish `for (i = 0; i < 1000; i++)` from
    `while (1)`, and the difference is exactly what decides whether
    skipping the loop adds an execution the original does not have.
  - addLoopExitDependence(): for a loop that is not known to terminate,
    every block its exit can reach is control-dependent on the loop's
    exiting branches. The guard survives; so does the loop that computes
    it.
  - bypassIrrelevantLoops(): only a loop with a bounded backedge count is
    bypassed. This is stronger than refusing loops from which the property
    root is reachable, and simpler: it also covers the interprocedural
    case, where the loop's own function contains no root but returns into
    a caller that reaches one. The comment claiming the bypass "can add
    the execution that skips a nonterminating loop -- sound for
    reachability" was the false premise; an added execution cannot hide a
    bug but can report one that is not there.

Measured on test/c/property-slicing with -property-slicing-profile: the
28 pre-existing tests keep byte-identical instruction counts, and both
irrelevant_loop.c (constant trip count) and irrelevant_unknown_bound_loop.c
(`i < n`) are still BYPASSED -- ScalarEvolution bounds both. The new
ps_nonterm_error_loop.c goes from BYPASSED (verdict: error) to kept
(verdict: verified); its _fail twin still finds the real error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A call was kept only when the callee may reach the error, is unsafe to
drop, or writes a relevant region. The LDV drivers' stop idiom --

    void ldv_stop(void) { L: goto L; }

-- satisfies none of the three: it reaches no property root, has no
unmodelled effect, and writes nothing. So `if (c) ldv_stop();` lost its
call and the slice carried on past a point the original execution never
leaves, making everything after it -- assertions included -- reachable
that was not. That is a false alarm on precisely the driver family this
pass was written for.

The attribute is not the test: ldv_stop is an ordinary function that
clang marks with nothing at all, and only its shape says it never gives
control back. neverReturns() asks whether any `ret` is reachable from the
entry block, and honours `noreturn` as well for the body-less
declarations (exit, abort) where shape says nothing.

The result feeds unsafeToDrop, which is what "the body may not be elided
at a call site" already means and which the existing greatest-fixpoint
already propagates along call edges -- so a function that calls a
function that may not return inherits the property, as it should.

The companion to this is the non-termination rule in the previous commit:
keeping the call would buy nothing if the callee's own infinite loop were
then bypassed and made to fall through.

Measured: ps_noreturn_call.c reports `error` with -property-slicing on
the prior build and `verified` with this one, matching the flag-off
verdict; its _fail twin reports the real error in both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The folder was added with the pass but never entered the matrix, so none of
its tests -- including the four that discriminate the fixes for the
non-termination, noreturn, pthread and invoke defects -- ran on a pull
request. The .ll tests need their own entry because --languages rejects a
comma-separated list despite its help text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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