Question on the semantics of scf.for around integer overflow and why canonicalize changes it

Recently I tried to understand the exact semantics of the scf.for (and affine.for) operation:

scf.for %iv = %A to %B step %S { <body> }

When I first tried to understand it, two “obvious” but non-equivalent interpretations came to my mind:

  1. Loop over all iv in {i | A <= i < B, i = A+j*S for some int j}. Trip count ceil((B-A)/S) (in exact math over infinite precision integers).
  2. It operates like this: iv := A; while (iv < B) { <body>; iv := iv + S }.

These interpretations deviate if iv+S is in danger to overflow (see below for an example). I always believed (1) to be the intended interpretation, mostly based on the rather suggestive syntax (IMO). But yesterday I reconsidered the description of scf.for and tried to extract the answer from this:

The lower and upper bounds specify a half-open range: the iteration is executed iff the comparison of induction variable value is less than the upper bound and bigger or equal to the lower bound.

Unfortunately this is too vague for me and I cannot definitively conclude or exclude any of my interpretations. So I did some experiments. I found two interesting cases. The first one has a loop with an effect:

// overflow_acc.mlir

func.func @main() {
  %lb = arith.constant 126 : i8
  %ub = arith.constant 127 : i8
  %step = arith.constant 2 : i8
  %init = arith.constant 0 : i32
  %one = arith.constant 1 : i32
  %sum = scf.for %iv = %lb to %ub step %step iter_args(%acc = %init) -> (i32) : i8 {
    %next = arith.addi %acc, %one : i32
    scf.yield %next : i32
  }
  vector.print str "sum = "
  vector.print %sum : i32
  return
}

Under interpretation (1) this should print `sum = 1`, under interpretation (2) it should never terminate. (%step = 3 is also interesting because it always terminates but still wraps around and prints sum = 171 under (2)). The other example is a loop without any effect:

// overflow_empty.mlir

func.func @main() {
  %lb = arith.constant 126 : i8
  %ub = arith.constant 127 : i8
  %step = arith.constant 2 : i8
  %init = arith.constant 0 : i32
  scf.for %iv = %lb to %ub step %step : i8 {}
  vector.print str "terminated\n"
  return
}

Under interpretation (1) it should terminate, under (2) it should run forever. I tested it with the following command:

FILE=$1
CANON=""
if [ "$2" = "1" ]; then
  CANON="-canonicalize"
fi

mlir-opt $FILE \
    $CANON -lower-affine \
    -convert-vector-to-llvm -convert-scf-to-cf -convert-cf-to-llvm \
    -convert-func-to-llvm -convert-arith-to-llvm -reconcile-unrealized-casts \
  | mlir-runner -e main --entry-point-result=void \
    --shared-libs=build/dev/lib/libmlir_c_runner_utils.so

The gist of the experiment is that it depends on various factors which interpretation “wins”. Some code actively works in favor of (1) (trip count computation) other code actively works in favor of (2) (lowering to cf).

First of all, with -canonicalize interpretation (1) wins for both example files. The principle reason seems to be that it detects that the trip count is 1 (using infinite precision math) and inlines the loop body. There is a function std::optional<APInt> constantTripCount(...) in StaticValueUtils.cpp which seems to do the mentioned computation. There are users of this function which ask “is the trip count exactly 1, if yes e.g. inline the body into the parent”. I confirmed that this function is crucial by modifying it and seeing that the outcome is different.

Without canonicalize and for overflow_empty.mlir interpretation (1) wins - but by accident. If I remember correctly the loop was identified as trivially dead by the greedy pattern driver (but not sure). In any case, the loop is already eliminated by the seemingly unrelated -convert-vector-to-llvm .

Again without canonicalize and for overflow_acc.mlir interpretation (2) wins. This can be best seen by just applying -convert-scf-to-cf to the file and inspecting the resulting IR.

I have several questions:

  • Is there an intended exact specification of (overflow) behavior of loops?
  • Or do we have to live with semantic ambiguities in corner cases (for reasons)?
  • Should behave “seemingly identical” affine.for and scf.for loops lead to the same observable behavior?
  • Would some of the behavior I documented be considered a bug?

Background info: I got interested in this topic while working on this PR: [mlir][SCFToAffine] Raise scf.for to affine.for by rainij · Pull Request #200851 · llvm/llvm-project · GitHub . It raises scf.for to affine.for and can deal with non-index typed scf.for ops (as long as index is wider than the base type). Under interpretation (1) (the one I believed strongly in until today) there is no issue. But under interpretation (2) there would be corner cases were observable behavior changes similar to the example above. I still think it is OK as is but I would like to have a deeper insight to gain confidence for future contributions. Also: The same examples behave almost the same way for affine.for (but it seems to not have the single-iteration trick built in). I am no expert but I believe that polyhedral optimization probably has to rely on something like (1) to theoretically work. I suppose that some affine optimizations would also not be semantic preserving if something like (2) holds.

I think, in the best traditions of C-style languages, the behavior of loops in case of integer overflow in induction variables is undefined. So both options are “correct”.

Now, more specifically, I suspect the canonicalization pattern inlining a single-iteration loop is wrong in a sense that it doesn’t account for the actual bitwidth of the IV. It was most likely written before we allowed scf loops to operate on specific-width types, and assumed the IV type to be index and/or at least 64 bits wide. index itself is wonky and comes with the idea that “overflows shouldn’t happen”, which I personally interpret as “overflow is UB”.

My personal preference would be for the semantic interpretation 1, largely because I think we shouldn’t define semantics of an operation in terms of lowering (though we quite often do!).

Right: I think we need to be mindful that we don’t introduce an abstraction cost here, that is whether we define it as a valid overflow or make it UB it should map to a straightforward native arithmetic expression (and allow all the possible optimizations / canonicalizations as much as possible)

Overflow being UB makes this possible with interpretation 2 as well I believe.

Thank you both, this helps to clarify a lot. So to summarize an exact specification would probably be like this, interpretation 3:

  • iteration range: iv ranges over {A + j·S | j ∈ ℕ, A + j·S < B}, in order, computed with arbitrary precision integers. Trip count ceil((B-A)/S). Assuming S>0 (otherwise UB).
  • no-overflow condition: the value one past the final iteration, A + n·S where n is the trip count, must be representable in the type of iv. Otherwise UB.

This definition is consistent with my examples above. It would even be somewhat future-proof in case somebody decides to add a flag (e.g. exact_trip_count) to scf.for which forces to drop the no-overflow condition (turning it to interpretation 1). The opposite would be to change the default to interpretation 1 and adding a flag (e.g. no_overflow) to restore interpretation 3 (but I suppose this would introduce more churn than exact_trip_count). Note that I am not suggestion to add such a flag, I just like the option that it is possible in principle.

To me stating something like interpretation 3 in the description of scf.for would really help. Would it make sense if I open a small PR where I suggest a formulation?

What about affine.for? This one allows only index (so far) so one might argue it is “clear” that interpretation 3 should apply. Stating it explicitly might not hurt though.

This is a common assumption in other places of the code, too. I think it would be safe to make it explicit.

Definitely!

With the current “semantics by implementation”, it’s possible affine.for is different in minor cases. But I think your interpretation 3 is general enough that it should apply. And if it doesn’t, then perhaps it’s a bug or a good point of discussion.

Sounds good, opened PR here: [mlir][scf] Tighten description of scf.for by rainij · Pull Request #203713 · llvm/llvm-project · GitHub .