EuroLLVM 2026 Round Table Summary: Early Exit from MLIR Regions

Thank you for attending the round table on early exit from MLIR regions. We concluded that early exit is generally useful. Stakeholders include Clang IR, Flang, Mojo, CUDA Tile. Use cases include mapping C++ / Python to MLIR and exception handling.

We started the discussion by looking at Mehdi’s prototype (Option 1: Numbers), only focusing on the dialect design (not how it it’s implemented). The per-op opt-in mechanism, i.e., ops must implement PropagateControlFlowBreak / HasBreakingControlFlowOpInterface to signal that they support breaking control flow, seems solid.

We also talked about scf.loop and whether it’s going to be a replacement for scf.while. We concluded that scf.loop is a good first step to develop + land early exit in a non-intrusive way. (Without having to update all transformations on scf.while, etc.)

We then discussed the following 5 options for dialect design. Using SSA values to identify operations/regions seems to be a good middle ground because it allows us to reuse SSA dominance for verification.

Option 1: Numbers

%r = scf.loop -> i32 {
  scf.if %cond {
    scf.break 2, %value
  }
  scf.continue 1
}

Downside: Magic numbers may require renumbering during transformations.

Option 2: Region / Operation Pointer

Like Option 1, but instead of an integer, store an Operation *. (We discussed storing a Region *, but I think it has to be an Operation *.)

Downside: Storage size, requires introduction of a new MLIR “concept” which affects bytecode, in-memory representation, …

Option 3: Labels

%r = scf.loop @my_loop -> i32 {
  scf.if %cond {
    scf.break @my_loop, %value
  }
  scf.continue @my_loop
}

Note: This is how Mojo implements early exit. Checking whether a loop has early exit requires walking the body of the loop op, but experience from Mojo seems to suggest that it’s not a problem in practice.

Option 4: Break Scope with Token

%r0 = scf.break_scope -> i32 {
^bb0(%token1: !token) {
  %r1 = scf.loop -> i32 {
    %r2 = scf.break_scope {
    ^bb1(%token2: !token) {
      scf.if %cond {
        scf.break %token1, %value
      }
      scf.break %token2
    }
  }
}

Downside: IR bloat, extra work/op needed for yielding values from break_scope (?)

Option 5: Loop with Token

%r = scf.loop -> i32 {
^bb0(%token: !token)
  scf.if %cond {
    scf.break %token, %value
  }
  scf.continue %token
}

Note: The token must be an entry block argument. In case of multi-region ops, each region has its own entry block token, but those tokens identify the same operation.

Couple of thoughts on this.

Was it considered changing BlockOperands to be PointerUnion<Operation*, Block*>?

The reason I mention this option, is that all control-flow in MLIR is one of:

  1. Terminator → Block via traditional branches
  2. Terminator → Region via region control-flow
  3. Terminator → Operation via region control-flow

2 can be transformed to be Terminator → (Entry Block of region). Therefore all control-flow can be expressed as:

  • Terminator → Block* or Operation*

The above approach for describing cf works quite well as an agnostic model, see aster/include/aster/IR/CFG.h at main · iree-org/aster · GitHub we use that to traverse CF without caring whether it comes from BranchOpInterface or RegionBranchOpInterface.

The biggest impact here would be on textual assembly, as ops and referenced regions would have to know have names. I believe it would also have an impact on the layout of Operation which might be the true issue here.

I didn’t attend, but thanks for summarizing. Some quick thoughts below:

The problems:

  • using an operation pointer raises the question of use-lists. Right now we have a list of OpOperand being the users of an operation through its results. This is a new class of user of an Operation in the IR, and that raises tons of question (for example what if you call replaceAllUsesWith on an Operation? What if you call it on its results?).
  • Slide “Difficulties: Cloning” shows that there is not a lot more bookkeeping for IR manipulation that didn’t require it before. Just cloning a loop nest requires to remap everything. The next slide shows the same issue for just splicing a block, which would require a nested walk of all the operations in the block, with some non trivial remapping involved (depending on what/where you’re splicing).

So after playing with this approach for a while, @River707 convinced me to take a simpler approach to make this more tractable, and I ended up scratching the prototype entirely and reimplemented the current solution.

I don’t remember if this is how the prototype was implemented under the hood, but I don’t think it would change any of the fundamental I encountered.

  • Option 3: labels are introducing a new set of issue in that they need to be uniqued (or at least unambiguous), which has its own lot of fragility. They don’t really offer more expressiveness or flexibility than integers, and offer different tradeoffs during transformations (you can craft cases where you need to update integers and not symbols, and vice-versa). You almost always need a cache side-data structure to manipulate them though.

  • Option 4 and 5: tokens are interesting, however SSA value are dynamic by nature, and threading through a token to model a static relationship seems fragile to me. Basically we introduce here a new class of SSA value that aren’t allowed to be substituted by equivalent SSA value: this may be something that could be useful to do for other cases, but that has large implications…

We did not discuss the implementation details for this option.

Correct, that’s why there’s no IR example for Option 2 in the summary. There is no way to write down such IR with the current syntax.

Agreed. This was the main concern with Option 2. We did not explore this design any further mainly for that reason.

I am not familiar with the details of this design in Mojo, but Billy mentioned that this was implemented in Mojo many years ago and they never looked back since. Maybe all you need is an atomic counter in the MLIRContext, which would allow you to generate unique symbols.

This was discussed to some degree:

  • You could check in the op verifier that the token block argument is used only by scf.break ops, or more generally, region terminator ops.
  • The token value would be an ordinary SSA value. Option 5 is appealing mainly because it is based on existing MLIR concepts. That would make it a less intrusive option than some of the other options.
  • Option 5 is similar to what was proposed for linalg.index.

Re Option 1: I continued the discussion with some folks after the round table. Some folks shared the same concern that I mentioned on the PR for Option 1 and on the RFC: Choosing an implementation approach that requires changes to struct Operation, the core C++ APIs, the bytecode format and the generic op format, when a less intrusive implementation strategy is possible. I asked them to leave a comment on the PR and/or Discourse. If I don’t see any discussion/comments about the implementation approach from folks other than me or Mehdi, I take it that the community doesn’t care how this feature is implemented, and I won’t block Option 1 on that ground any further.

:+1:

Interesting. I had to leave for another talk before you guys reached this point. If someone can summarize this here, it would be really appreciated.

Here’s a summary of the how Option 1 is implemented in the PR today:

  • numBreakingRegions is stored in struct Operation.
  • Some C++ APIs such as Operation::create get an extra parameter.
  • The generic op syntax changes: there’s an additional, optional num-breaking-regions segment.
  • The bytecode format has to be extended because each operation now has an additional numBreakingRegions. (Not part of the PR.)
  • The ODS code generator must be extended because there’s a new num-breaking-regions clause.
  • The disagreement between me and Mehdi is whether an op interface method can should be used instead of storing numBreakingRegions in struct Operation.

I believe it have to be statically mapped: that it the terminator branching on this value should have a direct use of the value from the op it is branching to. Lowering would require being able to statically find the producer of the token.
For example:

  • can you just outline some code and pass this as an argument to the call?
  • can you thread this through an isolated-from-above operation?
  • can you pass it as a block argument?
  • can you perform transformation that would introduce a select between two tokens?

All of these a related to the dynamic nature of SSA value, we never (I hope) require a static mapping through SSA value.

As far as I understand the linalg.index case, we would not have the same structural requirement. We would have a regular SSA value in this case.

I would rather say that it is “whether an op interface would be a better alternative”, there is no disagreement that it “can” be used (I even implemented it), however there are too many downsides to it in my opinion that it warrants my current firm opposition to it.

I think the rewriter consequences should be amongst the last points of concern when it comes to designing this. ie. If we introduce a dedicated method replaceOpControlFlowUses and leave the responsibility to the user of calling the API, or we modify replaceAllUsesWith to handle all cases and make it a more expensive call, is an impl tradeoff that might tilt the balance one way or the other.

To me the main benefit of replacing the BlockOperand with something that can go to other blocks or ops, is that it provides a more unified system, and not control-flow that has 2 tiers, with one that requires special casing (RegionControlFlow). Also, with this approach we get things like predecessors for free.

However, if the impl itself is extremely difficult, then that might be a justification to avoid it.

When you were doing the first impl, did you test the actual impact of the extra bookkeeping in terms of perf? For example, it might make some API bits much more expensive, but if those paths are rarely executed (relative to all other modifications), then does it matter if it’s more expensive?

If any of the above results in a yes, I think that should disqualify it as an option, it would be too big of a foot gun.

It’s not the implementation itself that is difficult, it’s all the consequences I mentioned. It requires us to rethink deeply all the APIs everywhere. Basically high-cost (mental model, code complexity) to pay while most of the operations don’t have to handle this, and most API clients (transformation/analyses) shouldn’t pay the complexity cost of having to handle these cases.

The fact that it is not the common case to have to handle these links is a reason why I stopped with the direct Operation* storage and went with a relative reference one. That said it does not mean we don’t care at all about efficiency here: in general when designing core aspect of the compiler we’re still shooting for as efficient solutions as possible. The requirement of side-data structures is a pain to manage (you need to handle cache invalidation as well). We still haven’t done a good job with symbol tables in the current codebase right now.

The answer to all 4 questions is “no”. The fact that it’s an SSA value means that nothing prevents you from doing that, but the IR verifier would error out.

I think in this case we should aim for the most general future proof option with an emphasis on semantics, even if this causes deep rethinks/changes. We’ve seen that issues keep crawling with the current system, and it’d be better to avoid having this same discussion in a couple of years because we didn’t caught the right semantics.

Which makes me think, besides Billy, was there someone in the roundtable with an MLIR front-end that has been in dev for a while? ie. clang or flang, what do they say it’s missing on their side?

Would this be a per verifier op (ie. we have to go to func.call and explicitly disallow), or do you envision it as a global verification property?

(I proposed the tokens approach on the round table), while tokens are hacky, they are still allow to maintain most of the structural guarantees, unlike integers and string labels and without changes in core infra. On the question “can we lower if token is dynamic” the answer is surprisingly “we probably can if we really want” by treating them as asm labels of the exit block (aka computed gotos), but we probably shouldn’t. For the proper long term way forward IMHO we need to make regions/blocks references to be a first-class citizens ops can reference to. Besides the proper break/continue support it will allow us to implement proper computed gotos too.

The verifier of scf.loop would check that all users of its token block argument are region terminator ops. (The verification can be part of the RegionBranchOpInterface verifier.)

There were folks from Clang IR, Flang and various companies (NVIDIA, AMD, Intel, Modular, Mobileye, NextSilicon, startups etc.). It was a quite large round table. I’m not aware of any front-end dialects with early exit from regions apart from Mojo and CUDA Tile IR. (Understandably, because that requires “hacking” MLIR at the moment.)

I generally have the impression that the larger the change to MLIR, the less support we get from the community. (With one person telling me in private that they think there shouldn’t be early-exit at all.)

also, for some historical anecdote, in numba-mlir I went the path to generating CFG first and then uplifting it to SCF, for the reasons not directly related to early exits. And it worked mostly fine, for the loops without break/continue you can get nice clean scf.fors, for the loops with break/continues you will get a nested mess of scf.while, scf.ifs and boolean flags which is mostly unoptimizable but can still be lowered into functionally correct binary.

func.call can produce any types, so there’s nothing stopping me from creating a token type and pass it to an op. It also seems that the verifiers would have to rely on a lot of non-local behavior (ie. where’s the token coming from, or who is consuming the token), which is something we discourage.

I was referring to control-flow in general, eg. the goto op in CIR llvm-project/clang/include/clang/CIR/Dialect/IR/CIROps.td at main · llvm/llvm-project · GitHub

Flang also supports goto, but from tests it seems it’s materialized as branches. However, seeing Design: Fortran IR — The Flang Compiler makes me think they also need better support.

Correct, you would eventually end up with a lowering error.

Also correct.

I don’t claim that this is a perfect solution. At this point I’m just looking for the option with the least resistance.

“goto” was briefly mentioned at the beginning of the discussion. We concluded that it’s out of scope for this discussion, because it’s unstructured control flow that can already be done with cf.br.

This is something interesting to keep in mind, the early-exit support here and the scf.loop introduced in my PR can always be converted to the existing scf.while.

So why use early-exit if there is an equivalent construct, I’ll bring up two points that I made in the PR:

  1. Convenience: expressing code that is expressed with early-exit into a form that fits scf.while is not totally trivial. You need to carry over quite a significant amount of state and guards to emulate the same code-paths.

  2. More importantly: analyses become less precise in general when you emulate this with scf.while style control-flow.

I added a test to illustrate this: llvm-project/mlir/test/Transforms/sccp-early-exit.mlir at 98db83bf57bc906399930abca36292307164dc42 · joker-eph/llvm-project · GitHub

I simplified as much as possible a test case I saw somewhere (I don’t remember which DSL), here is the pseudo code:

a = 0
while (true) {
  if (cond) {
    a = -5;
    return; // terminate the function
  } 
  a++;
  if (other_cond) {
    break;    
  }
}
if (a < 0) {
  do_something(); // actually dead code.
} 

The question is how can the compiler realize that the do_something is dead code.
In MLIR SCF right now the code above would have to be something like (pseudo-code but using some boolean state to convey the “early exit” through the regions):

a = 0
should_break = false
a, return_flag = while {
  a_inner, return_flag_inner, should_break = if (cond) {
    yield -5, true, true
  }  else {
    yield a+1, false, true
  }
  scf.condition(should_break) iter_args(a_inner, return_flag_inner)
}
if (!return_flag) {
  if (a < 0) {
    do_something(); // actually dead code.
  }
}

Now the problem here is that something like integer range analysis would kick in on the first example (with early-exit) and allow to eliminate the if (a < 0), but without the early exit we end-up approximating the return path as a live path and that forces the dataflow analysis to be conservative.

I don’t know if we can recover this in other ways though (some code transformations exposing the original program structure?).

Note that flat CFGs don’t have this problem they have a natural “early exit” anytime.

The contract between an op and the region terminator in regions nested under it isn’t part of what we discourage though, the verifier mentioned by Matthias would be fine to me. The solution though is still relying on structural aspects tied to the SSA value which I think would require much more thoughts: this is not something common, and while you can do this in specific cases (your own dialect with your own pipeline), it can interact badly with generic tooling and thus can’t just be adopted for a generic feature like early-exit.

We also discussed the scf.while construct and how we could make it simpler, but there was no easy solution, so I’m supportive of the PR with whatever method we agree here of marking the breaking region.

I also mentioned on the round table that when languages start to lower to MLIR (clang, flang, rust?), we’ll need more complex control flow, in which case we’ll likely discuss adding early-exit to the other loop constructs, which we’ll probably have to do one by one.

If they have to use scf.loop for everything with early-exit, the IR will get increasingly complex and probably miss a lot of optimization opportunities (for example, if-conversion, masking, etc) that can retain the structured nature even with branches (we do that in LLVM).

The uniquing aspect could possibly be solved using distinct attributes?

I see that both labels and integers may require updating after transformations. At first glance, integers seem a bit more fragile though. Let’s say a region operation implements a canonicalization that inlines a nested region. Would such a canonicalization need to walk the inlined region and update the relevant integers? This seems like it would turn a local transformation into a non-local one.

Does the label approach also break for such local rewrites?