Skip to content

Fix soundness holes in the bounds/divisibility dataflow analyses#265

Draft
AntonOresten wants to merge 1 commit into
mainfrom
ao/analysis-soundness
Draft

Fix soundness holes in the bounds/divisibility dataflow analyses#265
AntonOresten wants to merge 1 commit into
mainfrom
ao/analysis-soundness

Conversation

@AntonOresten

Copy link
Copy Markdown
Collaborator

Batch of independent soundness fixes to the forward dataflow framework (analysis/dataflow.jl) and its two integer analyses (analysis/bounds.jl, analysis/divisibility.jl).

Fixes

  1. ForOp IV upper bound wrong for step > 1 (P0). compute_iv_range used upper.hi - step.lo as the last IV, but slt/sle-promoted while loops don't guarantee the trip length divides the step: i = 0; while i < 10; i += 4 visits 0, 4, 8 — the analysis claimed i ≤ 6. Now uses the always-sound exclusive bound upper.hi - 1, keeping the sharp upper - step only when lower/upper/step are exact and the divisibility holds. Loops with an unknown step but known upper bound now get a bound at all (previously none).

  2. Never-visited statements lingered at ⊥ and acted as best-info at joins (P0). ⊥ is the merge identity, so a PiNode-fed loop carry (walk! skipped non-Expr statements) kept its init value's fact — e.g. claiming div-by-16 across iterations that replace the value. PiNodes now pass their operand's lattice element through; other unmodeled statements, unresolvable calls, unmodeled IfOp shapes, and the loop ops' own SSA results record ⊤. The framework-default operand_value likewise now returns ⊤ (not ⊥) for unrecognised literals.

  3. User-written Intrinsics.assume predicates were silently ignored. Pass-constructed assumes embed the AssumePredicate value raw in the operand list, but user-written kernels arrive with it as a QuoteNode (or const-inferred SSAValue), failing the pred isa DivBy / Bounded tests. New constant_operand helper (QuoteNode unwrap + singleton_type chase, mirroring codegen's get_constant) is used by both analyses.

  4. exti ignored its signedness operand; arithmetic ignored wraparound. Zero-extension is only value-preserving for provably non-negative ranges (bounds) and reduces divisors to gcd(d, 2^srcbits) (divisibility) — the comment's claim that zext "doesn't change the mathematical value" is false for negative inputs. Arithmetic interval transfers now clamp to the result element width: the lattice computes over ℤ but the hardware op wraps, so ranges that can escape [typemin(T), typemax(T)] degrade to ⊤ instead of feeding false Bounded predicates or nsw/nuw flags.

  5. ForOp back-edge merges only looked at the direct body terminator. A ContinueOp behind a nested IfOp branch is a back-edge like any other; both ForOp handlers now use reachable_terminators, matching the LoopOp handler, DCE, and IR validation.

Tests

Direct-API tests for each fix in test/analysis/dataflow.jl (PiNode carry, nested-continue merge, QuoteNode DivBy, divisibility zext) and test/codegen/bounds.jl (step-4 IV bound, exti signedness, width clamp, QuoteNode Bounded).

Verified: 68/68 in the directly affected files (analysis/dataflow, codegen/{bounds,assume,no_wrap}), 297/297 regression sweep over codegen/{views,slice,cse,integration,operations} — no output changes from the clamping or the operand_value default flip.

Follow-up (not in this PR)

bitcast pass-through has the same signed↔unsigned reinterpretation issue as exti for same-width casts (e.g. Int32(-1)UInt32 changes the mathematical value); left untouched to keep this batch scoped.

🤖 Generated with Claude Code

Batch of independent fixes to the forward dataflow framework and its
two integer analyses:

- ForOp IV upper bound: `upper - step` is only the last IV when the
  trip length divides the step; slt/sle-promoted while loops with
  step > 1 overshoot it (0:4:<10 visits 8, not 6). Use the always-sound
  exclusive bound `upper - 1`, keeping the sharp bound when lower/
  upper/step are exact and divisibility holds. Unknown-step loops with
  a known upper bound now get a bound at all.

- Never-visited statements no longer linger at ⊥: bottom is the merge
  identity, so a ⊥ operand at a join (loop carry, IfOp yield) let the
  other side's facts survive unrefuted — e.g. a PiNode-fed loop carry
  kept its init value's divisibility. PiNodes pass their operand
  through; anything else unmodeled records ⊤, as do unresolvable
  calls, unmodeled IfOp shapes, and the loop ops' own SSA results. The
  default `operand_value` now treats unrecognised literals as ⊤ too.

- ForOp back-edge merges use `reachable_terminators`, matching the
  LoopOp handler and DCE: a ContinueOp behind a nested IfOp branch is
  a back-edge like any other.

- `Intrinsics.assume` predicates from user-written kernels arrive as a
  QuoteNode (or const-inferred SSAValue), not the raw embedded value a
  pass constructs; resolve through the new `constant_operand` before
  the predicate-kind tests so hand-written assumes refine.

- `exti` consults its signedness operand: zero-extension is only
  value-preserving for provably non-negative ranges (bounds) and
  reduces divisors to gcd(d, 2^srcbits) (divisibility).

- Arithmetic interval transfers clamp to the result element width: the
  lattice computes over ℤ but the hardware op wraps, so ranges that
  can escape [typemin(T), typemax(T)] degrade to ⊤ instead of feeding
  false `Bounded` predicates or nsw/nuw flags.

Co-Authored-By: Claude Fable 5 <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