Skip to content

[mlir][dataflow][scf] IntegerRangeAnalysis and scf-for-loop-canonicalization treat scf.for unsigned / scf.parallel unsigned bounds as signed (miscompile) #241712

Open

Description

@wildoranges

scf.for / scf.parallel document that bounds and step "are interpreted as signed integers by default, or as unsigned integers if the unsignedCmp unit attribute is present". Two analysis-based transformations derive induction-variable facts from these bounds and silently assume they are signed. When an unsigned loop's iteration space crosses the signed boundary, the derived facts are wrong and the code is miscompiled.

  • A. IntegerRangeAnalysis. It builds the IV range as fromSigned(lb, ub - 1). If ub - 1 <s lb, it leaves the IV lattice uninitialized ("the resulting range is meaningless"). That shortcut is only sound when it implies a zero-trip loop, which is true for signed loops only. Users that go wrong: -int-range-optimizations and -arith-unsigned-when-equivalent.
    for (auto [iv, lowerBound, upperBound, step] :
    llvm::zip_equal(*maybeIvs, lowerBounds, upperBounds, steps)) {
    Block *block = iv.getParentBlock();
    APInt min = getLoopBoundFromFold(lowerBound, iv.getType(), block,
    /*getUpper=*/false);
    APInt max = getLoopBoundFromFold(upperBound, iv.getType(), block,
    /*getUpper=*/true);
    // Assume positivity for uniscoverable steps by way of getUpper = true.
    APInt stepVal =
    getLoopBoundFromFold(step, iv.getType(), block, /*getUpper=*/true);
    if (stepVal.isNegative()) {
    std::swap(min, max);
    } else {
    // Correct the upper bound by subtracting 1 so that it becomes a <=
    // bound, because loops do not generally include their upper bound.
    max -= 1;
    }
    // If we infer the lower bound to be larger than the upper bound, the
    // resulting range is meaningless and should not be used in further
    // inferences.
    if (max.sge(min)) {
    IntegerValueRangeLattice *ivEntry = getLatticeElement(iv);
    auto ivRange = ConstantIntRanges::fromSigned(min, max);
    propagateIfChanged(ivEntry, ivEntry->join(IntegerValueRange{ivRange}));
    }
    }
  • B. scf::matchForLikeLoop. It returns (lb, ub, step) without signedness. canonicalizeMinMaxOpInLoop then adds lb <= iv < ub as signed affine constraints (used by -scf-for-loop-canonicalization).
    LogicalResult scf::matchForLikeLoop(Value iv, OpFoldResult &lb,
    OpFoldResult &ub, OpFoldResult &step) {
    if (scf::ForOp forOp = scf::getForInductionVarOwner(iv)) {
    lb = forOp.getLowerBound();
    ub = forOp.getUpperBound();
    step = forOp.getStep();
    return success();
    }
    if (scf::ParallelOp parOp = scf::getParallelForInductionVarOwner(iv)) {
    for (unsigned idx = 0; idx < parOp.getNumLoops(); ++idx) {
    if (parOp.getInductionVars()[idx] == iv) {
    lb = parOp.getLowerBound()[idx];
    ub = parOp.getUpperBound()[idx];
    step = parOp.getStep()[idx];
    return success();
    }

(The LoopLikeOpInterface bound getters carry no signedness either. #226761 fixed the analogous problem in the scf.for ValueBounds model. The SCF canonicalization patterns have the same problem and are reported separately in #233223.)

All examples run as:

$ LOWER="-convert-vector-to-scf -convert-scf-to-cf -convert-vector-to-llvm -convert-arith-to-llvm -convert-index-to-llvm -convert-cf-to-llvm -convert-func-to-llvm -finalize-memref-to-llvm -convert-ub-to-llvm -reconcile-unrealized-casts"
$ RUN="mlir-runner -e main -entry-point-result=void -shared-libs=$LLVM_BUILD/lib/libmlir_runner_utils.so,$LLVM_BUILD/lib/libmlir_c_runner_utils.so"

($LLVM_BUILD is the LLVM/MLIR build directory.)

A1. Dynamic upper bound: the IV range is clamped to [0, 126]

dyn_bound.mlir:

func.func @count_high(%ub: i8) -> i32 {
  %c0 = arith.constant 0 : i8
  %c1 = arith.constant 1 : i8
  %z = arith.constant 0 : i32
  %r = scf.for unsigned %i = %c0 to %ub step %c1 iter_args(%acc = %z) -> (i32) : i8 {
    %neg = arith.cmpi slt, %i, %c0 : i8
    %e = arith.extui %neg : i1 to i32
    %n = arith.addi %acc, %e : i32
    scf.yield %n : i32
  }
  return %r : i32
}

func.func @main() {
  %ub = arith.constant 200 : i8
  %r = func.call @count_high(%ub) : (i8) -> i32
  vector.print %r : i32
  return
}

The loop runs for i = 0..199 (unsigned). Values 128..199 are negative as signed i8, so the expected result is 72.

$ mlir-opt dyn_bound.mlir | mlir-opt $LOWER | $RUN
72
$ mlir-opt dyn_bound.mlir -int-range-optimizations | mlir-opt $LOWER | $RUN
0

After -int-range-optimizations, @count_high is just return %c0_i32.

A2. Constant bounds 0 to 200 : i8: the IV stays uninitialized, and the iter_arg is folded to its init value

const_bounds.mlir:

func.func @sum_unsigned() -> i32 {
  %lb = arith.constant 0 : i8
  %ub = arith.constant 200 : i8
  %st = arith.constant 1 : i8
  %z = arith.constant 0 : i32
  %r = scf.for unsigned %i = %lb to %ub step %st iter_args(%acc = %z) -> (i32) : i8 {
    %e = arith.extui %i : i8 to i32
    %n = arith.addi %acc, %e : i32
    scf.yield %n : i32
  }
  return %r : i32
}

func.func @main() {
  %r = func.call @sum_unsigned() : () -> i32
  vector.print %r : i32
  return
}
$ mlir-opt const_bounds.mlir | mlir-opt $LOWER | $RUN
19900
$ mlir-opt const_bounds.mlir -int-range-optimizations | mlir-opt $LOWER | $RUN
199

Expected 0 + 1 + ... + 199 = 19900. The result 199 means only the last iteration's IV survives: the analysis treats %acc as the constant 0.

A3. -arith-unsigned-when-equivalent rewrites divsi to divui; A4. the same for scf.parallel unsigned

unsigned_when_equiv.mlir:

func.func @sum_div(%ub: i8) -> i32 {
  %c0 = arith.constant 0 : i8
  %c1 = arith.constant 1 : i8
  %c3 = arith.constant 3 : i8
  %z = arith.constant 0 : i32
  %r = scf.for unsigned %i = %c0 to %ub step %c1 iter_args(%acc = %z) -> (i32) : i8 {
    %d = arith.divsi %i, %c3 : i8
    %e = arith.extsi %d : i8 to i32
    %n = arith.addi %acc, %e : i32
    scf.yield %n : i32
  }
  return %r : i32
}

func.func @main() {
  %ub = arith.constant 200 : i8
  %r = func.call @sum_div(%ub) : (i8) -> i32
  vector.print %r : i32
  return
}

parallel.mlir:

func.func @par(%lb: index, %ub: index, %out: memref<1xi1>) {
  %c0 = arith.constant 0 : index
  %c1 = arith.constant 1 : index
  %cmax = arith.constant 9223372036854775807 : index
  scf.parallel unsigned (%i) = (%lb) to (%ub) step (%c1) {
    %e = arith.cmpi slt, %i, %cmax : index
    memref.store %e, %out[%c0] : memref<1xi1>
    scf.reduce
  }
  return
}

func.func @main() {
  %c0 = arith.constant 0 : index
  %lb = arith.constant 9223372036854775807 : index
  %ub = arith.constant -9223372036854775808 : index
  %false = arith.constant false
  %m = memref.alloca() : memref<1xi1>
  memref.store %false, %m[%c0] : memref<1xi1>
  func.call @par(%lb, %ub, %m) : (index, index, memref<1xi1>) -> ()
  %v = memref.load %m[%c0] : memref<1xi1>
  vector.print %v : i1
  return
}
$ mlir-opt unsigned_when_equiv.mlir | mlir-opt $LOWER | $RUN
471
$ mlir-opt unsigned_when_equiv.mlir -arith-unsigned-when-equivalent | mlir-opt $LOWER | $RUN
6567
$ mlir-opt parallel.mlir | mlir-opt $LOWER | $RUN
0
$ mlir-opt parallel.mlir -int-range-optimizations | mlir-opt $LOWER | $RUN
1

In parallel.mlir, the single iteration has iv = INT64_MAX, so iv <s INT64_MAX is false (0).

B. -scf-for-loop-canonicalization folds affine.max(iv, 0) to iv inside an unsigned loop

loopcanon.mlir:

func.func @f(%ub: index, %out: memref<4xindex>) {
  %lb = arith.constant 9223372036854775806 : index
  %c1 = arith.constant 1 : index
  scf.for unsigned %i = %lb to %ub step %c1 {
    %m = affine.max affine_map<(d0) -> (d0, 0)>(%i)
    %k = arith.subi %i, %lb : index
    memref.store %m, %out[%k] : memref<4xindex>
  }
  return
}
func.func @main() {
  %ub = arith.constant -9223372036854775806 : index
  %m = memref.alloca() : memref<4xindex>
  func.call @f(%ub, %m) : (index, memref<4xindex>) -> ()
  %c0 = arith.constant 0 : index
  %c1 = arith.constant 1 : index
  %c2 = arith.constant 2 : index
  %c3 = arith.constant 3 : index
  %v0 = memref.load %m[%c0] : memref<4xindex>
  %v1 = memref.load %m[%c1] : memref<4xindex>
  %v2 = memref.load %m[%c2] : memref<4xindex>
  %v3 = memref.load %m[%c3] : memref<4xindex>
  vector.print %v0 : index
  vector.print %v1 : index
  vector.print %v2 : index
  vector.print %v3 : index
  return
}

The loop runs 4 times, for i = 2^63-2 .. 2^63+1 (unsigned). The last two IVs are negative as signed values, so affine.max(iv, 0) must give 0 for them.

$ mlir-opt loopcanon.mlir -lower-affine | mlir-opt $LOWER | $RUN
9223372036854775806
9223372036854775807
0
0
$ mlir-opt loopcanon.mlir -scf-for-loop-canonicalization -lower-affine | mlir-opt $LOWER | $RUN
9223372036854775806
9223372036854775807
9223372036854775808
9223372036854775809

All inputs verify and are UB-free:

  • the unsigned trip counts are 200, 1 and 4;
  • LB + n*step stays representable as an unsigned value;
  • the remaining arithmetic has no overflow flags;
  • all memory accesses are in bounds.

Possible fixes: when the loop is unsignedCmp, build the IV range from unsigned bounds (ConstantIntRanges::fromUnsigned) and do not take the "meaningless range" shortcut; have matchForLikeLoop report signedness, or reject unsigned loops.

Version: llvm-project main @ 018b722 (2026-10-17), Release + assertions, x86_64 Linux. Also reproduced on main @ a60677d (2026-11-12).

Disclosure: I wrote this report with the help of Claude Code. I reproduced and reviewed everything in this report myself.

Activity

  1. llvmorg-github-actions commented on Nov 12, 2026

    @llvmorg-github-actions

    @llvm/issue-subscribers-mlir

    Author: Jinbao Chen (wildoranges)

    Details `scf.for` / `scf.parallel` document that bounds and step "are interpreted as signed integers by default, or as unsigned integers if the `unsignedCmp` unit attribute is present". Two analysis-based transformations derive induction-variable facts from these bounds and silently assume they are **signed**. When an unsigned loop's iteration space crosses the signed boundary, the derived facts are wrong and the code is miscompiled.
    • A. IntegerRangeAnalysis. It builds the IV range as fromSigned(lb, ub - 1). If ub - 1 &lt;s lb, it leaves the IV lattice uninitialized ("the resulting range is meaningless"). That shortcut is only sound when it implies a zero-trip loop, which is true for signed loops only. Users that go wrong: -int-range-optimizations and -arith-unsigned-when-equivalent.
      for (auto [iv, lowerBound, upperBound, step] :
      llvm::zip_equal(*maybeIvs, lowerBounds, upperBounds, steps)) {
      Block *block = iv.getParentBlock();
      APInt min = getLoopBoundFromFold(lowerBound, iv.getType(), block,
      /*getUpper=*/false);
      APInt max = getLoopBoundFromFold(upperBound, iv.getType(), block,
      /*getUpper=*/true);
      // Assume positivity for uniscoverable steps by way of getUpper = true.
      APInt stepVal =
      getLoopBoundFromFold(step, iv.getType(), block, /*getUpper=*/true);
      if (stepVal.isNegative()) {
      std::swap(min, max);
      } else {
      // Correct the upper bound by subtracting 1 so that it becomes a <=
      // bound, because loops do not generally include their upper bound.
      max -= 1;
      }
      // If we infer the lower bound to be larger than the upper bound, the
      // resulting range is meaningless and should not be used in further
      // inferences.
      if (max.sge(min)) {
      IntegerValueRangeLattice *ivEntry = getLatticeElement(iv);
      auto ivRange = ConstantIntRanges::fromSigned(min, max);
      propagateIfChanged(ivEntry, ivEntry->join(IntegerValueRange{ivRange}));
      }
      }
    • B. scf::matchForLikeLoop. It returns (lb, ub, step) without signedness. canonicalizeMinMaxOpInLoop then adds lb &lt;= iv &lt; ub as signed affine constraints (used by -scf-for-loop-canonicalization).
      LogicalResult scf::matchForLikeLoop(Value iv, OpFoldResult &lb,
      OpFoldResult &ub, OpFoldResult &step) {
      if (scf::ForOp forOp = scf::getForInductionVarOwner(iv)) {
      lb = forOp.getLowerBound();
      ub = forOp.getUpperBound();
      step = forOp.getStep();
      return success();
      }
      if (scf::ParallelOp parOp = scf::getParallelForInductionVarOwner(iv)) {
      for (unsigned idx = 0; idx < parOp.getNumLoops(); ++idx) {
      if (parOp.getInductionVars()[idx] == iv) {
      lb = parOp.getLowerBound()[idx];
      ub = parOp.getUpperBound()[idx];
      step = parOp.getStep()[idx];
      return success();
      }

    (The LoopLikeOpInterface bound getters carry no signedness either. #226761 fixed the analogous problem in the scf.for ValueBounds model. The SCF canonicalization patterns have the same problem and are reported separately in #233223.)

    All examples run as:

    $ LOWER="-convert-vector-to-scf -convert-scf-to-cf -convert-vector-to-llvm -convert-arith-to-llvm -convert-index-to-llvm -convert-cf-to-llvm -convert-func-to-llvm -finalize-memref-to-llvm -convert-ub-to-llvm -reconcile-unrealized-casts"
    $ RUN="mlir-runner -e main -entry-point-result=void -shared-libs=$LLVM_BUILD/lib/libmlir_runner_utils.so,$LLVM_BUILD/lib/libmlir_c_runner_utils.so"

    ($LLVM_BUILD is the LLVM/MLIR build directory.)

    A1. Dynamic upper bound: the IV range is clamped to [0, 126]

    dyn_bound.mlir:

    func.func @<!-- -->count_high(%ub: i8) -&gt; i32 {
      %c0 = arith.constant 0 : i8
      %c1 = arith.constant 1 : i8
      %z = arith.constant 0 : i32
      %r = scf.for unsigned %i = %c0 to %ub step %c1 iter_args(%acc = %z) -&gt; (i32) : i8 {
        %neg = arith.cmpi slt, %i, %c0 : i8
        %e = arith.extui %neg : i1 to i32
        %n = arith.addi %acc, %e : i32
        scf.yield %n : i32
      }
      return %r : i32
    }
    
    func.func @<!-- -->main() {
      %ub = arith.constant 200 : i8
      %r = func.call @<!-- -->count_high(%ub) : (i8) -&gt; i32
      vector.print %r : i32
      return
    }

    The loop runs for i = 0..199 (unsigned). Values 128..199 are negative as signed i8, so the expected result is 72.

    $ mlir-opt dyn_bound.mlir | mlir-opt $LOWER | $RUN
    72
    $ mlir-opt dyn_bound.mlir -int-range-optimizations | mlir-opt $LOWER | $RUN
    0

    After -int-range-optimizations, @<!-- -->count_high is just return %c0_i32.

    A2. Constant bounds 0 to 200 : i8: the IV stays uninitialized, and the iter_arg is folded to its init value

    const_bounds.mlir:

    func.func @<!-- -->sum_unsigned() -&gt; i32 {
      %lb = arith.constant 0 : i8
      %ub = arith.constant 200 : i8
      %st = arith.constant 1 : i8
      %z = arith.constant 0 : i32
      %r = scf.for unsigned %i = %lb to %ub step %st iter_args(%acc = %z) -&gt; (i32) : i8 {
        %e = arith.extui %i : i8 to i32
        %n = arith.addi %acc, %e : i32
        scf.yield %n : i32
      }
      return %r : i32
    }
    
    func.func @<!-- -->main() {
      %r = func.call @<!-- -->sum_unsigned() : () -&gt; i32
      vector.print %r : i32
      return
    }
    $ mlir-opt const_bounds.mlir | mlir-opt $LOWER | $RUN
    19900
    $ mlir-opt const_bounds.mlir -int-range-optimizations | mlir-opt $LOWER | $RUN
    199

    Expected 0 + 1 + ... + 199 = 19900. The result 199 means only the last iteration's IV survives: the analysis treats %acc as the constant 0.

    <details><summary>A3. -arith-unsigned-when-equivalent rewrites divsi to divui; A4. the same for scf.parallel unsigned</summary>

    unsigned_when_equiv.mlir:

    func.func @<!-- -->sum_div(%ub: i8) -&gt; i32 {
      %c0 = arith.constant 0 : i8
      %c1 = arith.constant 1 : i8
      %c3 = arith.constant 3 : i8
      %z = arith.constant 0 : i32
      %r = scf.for unsigned %i = %c0 to %ub step %c1 iter_args(%acc = %z) -&gt; (i32) : i8 {
        %d = arith.divsi %i, %c3 : i8
        %e = arith.extsi %d : i8 to i32
        %n = arith.addi %acc, %e : i32
        scf.yield %n : i32
      }
      return %r : i32
    }
    
    func.func @<!-- -->main() {
      %ub = arith.constant 200 : i8
      %r = func.call @<!-- -->sum_div(%ub) : (i8) -&gt; i32
      vector.print %r : i32
      return
    }

    parallel.mlir:

    func.func @<!-- -->par(%lb: index, %ub: index, %out: memref&lt;1xi1&gt;) {
      %c0 = arith.constant 0 : index
      %c1 = arith.constant 1 : index
      %cmax = arith.constant 9223372036854775807 : index
      scf.parallel unsigned (%i) = (%lb) to (%ub) step (%c1) {
        %e = arith.cmpi slt, %i, %cmax : index
        memref.store %e, %out[%c0] : memref&lt;1xi1&gt;
        scf.reduce
      }
      return
    }
    
    func.func @<!-- -->main() {
      %c0 = arith.constant 0 : index
      %lb = arith.constant 9223372036854775807 : index
      %ub = arith.constant -9223372036854775808 : index
      %false = arith.constant false
      %m = memref.alloca() : memref&lt;1xi1&gt;
      memref.store %false, %m[%c0] : memref&lt;1xi1&gt;
      func.call @<!-- -->par(%lb, %ub, %m) : (index, index, memref&lt;1xi1&gt;) -&gt; ()
      %v = memref.load %m[%c0] : memref&lt;1xi1&gt;
      vector.print %v : i1
      return
    }
    $ mlir-opt unsigned_when_equiv.mlir | mlir-opt $LOWER | $RUN
    471
    $ mlir-opt unsigned_when_equiv.mlir -arith-unsigned-when-equivalent | mlir-opt $LOWER | $RUN
    6567
    $ mlir-opt parallel.mlir | mlir-opt $LOWER | $RUN
    0
    $ mlir-opt parallel.mlir -int-range-optimizations | mlir-opt $LOWER | $RUN
    1

    In parallel.mlir, the single iteration has iv = INT64_MAX, so iv &lt;s INT64_MAX is false (0).
    </details>

    B. -scf-for-loop-canonicalization folds affine.max(iv, 0) to iv inside an unsigned loop

    loopcanon.mlir:

    func.func @<!-- -->f(%ub: index, %out: memref&lt;4xindex&gt;) {
      %lb = arith.constant 9223372036854775806 : index
      %c1 = arith.constant 1 : index
      scf.for unsigned %i = %lb to %ub step %c1 {
        %m = affine.max affine_map&lt;(d0) -&gt; (d0, 0)&gt;(%i)
        %k = arith.subi %i, %lb : index
        memref.store %m, %out[%k] : memref&lt;4xindex&gt;
      }
      return
    }
    func.func @<!-- -->main() {
      %ub = arith.constant -9223372036854775806 : index
      %m = memref.alloca() : memref&lt;4xindex&gt;
      func.call @<!-- -->f(%ub, %m) : (index, memref&lt;4xindex&gt;) -&gt; ()
      %c0 = arith.constant 0 : index
      %c1 = arith.constant 1 : index
      %c2 = arith.constant 2 : index
      %c3 = arith.constant 3 : index
      %v0 = memref.load %m[%c0] : memref&lt;4xindex&gt;
      %v1 = memref.load %m[%c1] : memref&lt;4xindex&gt;
      %v2 = memref.load %m[%c2] : memref&lt;4xindex&gt;
      %v3 = memref.load %m[%c3] : memref&lt;4xindex&gt;
      vector.print %v0 : index
      vector.print %v1 : index
      vector.print %v2 : index
      vector.print %v3 : index
      return
    }

    The loop runs 4 times, for i = 2^63-2 .. 2^63+1 (unsigned). The last two IVs are negative as signed values, so affine.max(iv, 0) must give 0 for them.

    $ mlir-opt loopcanon.mlir -lower-affine | mlir-opt $LOWER | $RUN
    9223372036854775806
    9223372036854775807
    0
    0
    $ mlir-opt loopcanon.mlir -scf-for-loop-canonicalization -lower-affine | mlir-opt $LOWER | $RUN
    9223372036854775806
    9223372036854775807
    9223372036854775808
    9223372036854775809

    All inputs verify and are UB-free:

    • the unsigned trip counts are 200, 1 and 4;
    • LB + n*step stays representable as an unsigned value;
    • the remaining arithmetic has no overflow flags;
    • all memory accesses are in bounds.

    Possible fixes: when the loop is unsignedCmp, build the IV range from unsigned bounds (ConstantIntRanges::fromUnsigned) and do not take the "meaningless range" shortcut; have matchForLikeLoop report signedness, or reject unsigned loops.

    Version: llvm-project main @ 018b722 (2026-10-17), Release + assertions, x86_64 Linux. Also reproduced on main @ a60677d (2026-11-12).

    Disclosure: I wrote this report with the help of Claude Code. I reproduced and reviewed everything in this report myself.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions