Repository navigation
Description
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 asfromSigned(lb, ub - 1). Ifub - 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-optimizationsand-arith-unsigned-when-equivalent.
llvm-project/mlir/lib/Analysis/DataFlow/IntegerRangeAnalysis.cpp
Lines 194 to 221 in 018b722
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.canonicalizeMinMaxOpInLoopthen addslb <= iv < ubas 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
0After -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
199Expected 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
1In 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
9223372036854775809All inputs verify and are UB-free:
- the unsigned trip counts are 200, 1 and 4;
LB + n*stepstays 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
llvmorg-github-actions commentedon Nov 12, 2026
@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.IntegerRangeAnalysis. It builds the IV range asfromSigned(lb, ub - 1). Ifub - 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-optimizationsand-arith-unsigned-when-equivalent.llvm-project/mlir/lib/Analysis/DataFlow/IntegerRangeAnalysis.cpp
Lines 194 to 221 in 018b722
scf::matchForLikeLoop. It returns(lb, ub, step)without signedness.canonicalizeMinMaxOpInLoopthen addslb <= iv < ubas signed affine constraints (used by-scf-for-loop-canonicalization).llvm-project/mlir/lib/Dialect/SCF/Utils/AffineCanonicalizationUtils.cpp
Lines 31 to 46 in 018b722
(The
LoopLikeOpInterfacebound getters carry no signedness either. #226761 fixed the analogous problem in thescf.forValueBounds model. The SCF canonicalization patterns have the same problem and are reported separately in #233223.)All examples run as:
(
$LLVM_BUILDis the LLVM/MLIR build directory.)A1. Dynamic upper bound: the IV range is clamped to
[0, 126]dyn_bound.mlir:The loop runs for i = 0..199 (unsigned). Values 128..199 are negative as signed i8, so the expected result is 72.
After
-int-range-optimizations,@<!-- -->count_highis justreturn %c0_i32.A2. Constant bounds
0 to 200 : i8: the IV stays uninitialized, and the iter_arg is folded to its init valueconst_bounds.mlir:Expected 0 + 1 + ... + 199 = 19900. The result 199 means only the last iteration's IV survives: the analysis treats
%accas 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:parallel.mlir:In
parallel.mlir, the single iteration hasiv = INT64_MAX, soiv <s INT64_MAXis false (0).</details>
B.
-scf-for-loop-canonicalizationfoldsaffine.max(iv, 0)toivinside an unsigned looploopcanon.mlir: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.All inputs verify and are UB-free:
LB + n*stepstays representable as an unsigned value;Possible fixes: when the loop is
unsignedCmp, build the IV range from unsigned bounds (ConstantIntRanges::fromUnsigned) and do not take the "meaningless range" shortcut; havematchForLikeLoopreport 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.