Region-based control-flow with early exits
This RFC proposes the additional of a new region-based control-flow paradigm to MLIR, but one that enables early exits via operations like break
or continue
in contrast with SCF.
Background
MLIR supports two control-flow “paradigms”: structured region-based control flow and control flow graphs. Control flow graphs are a general representation of control flow that is very low-level, whereas structured control flow leverages MLIR regions to provide a very high-level abstraction over control flow.
SCF is a powerful representation because the semantics of SCF (single-entry single exit-regions) mean control flow transformations that are complex on CFGs are significantly easier to implement and local, such as loop unrolling, fusion, etc. SCF has a lot of benefits as a control-flow representation, because it is by definition reducible control flow, it’s easy to write flow-sensitive analyses, etc. It is very well-suited for high-level dialects and transformations.
However, SCF lacks the ability to model early conditional exits from functions, loops, and other control-flow structures. For applications like programming language frontends, the full generality of control-flow in the host language cannot be represented with SCF. Compilers rely on CFGs or partially raise CFGs to SCF, which is a complex and costly operation, and often end up with a mixture of SCF and CFGs in their program representation.
In this RFC, I will propose a region-based control flow paradigm to MLIR that allows early exits, providing a middle ground between SCF and CFGs that provides some of the benefits of both, but allowing a pure region-based representation of control-flow.
Early region exits
The proposal is to allow defining terminator operations like break
or continue
that can transfer control flow to any ancestor operation. Consider
func.func @foobar() {
cf_ext.loop {
%0 = call @rand_bool() : () -> i1
cf_ext.if %0 {
cf_ext.break
}
call @do_something() : () -> ()
cf_ext.continue
}
return
}
The execution of cf_ext.break
(placeholder dialect name) operation branches to after the surrounding cf_ext.loop
operation. The consequence of this is that cf_ext.if
is allowed to exit of the middle of a basic block. The primary concern is this is a violation of the MLIR langref, which states that only terminator operations can leave a basic block. I propose that this representation enforce a pure region-based representation, so that the typical notion of a basic block does not apply.
This representation is extraordinarily beneficial for programming language frontends, because programs can be emitted directly into a high-level control flow representation. This representation preserves many of the benefits of SCF, such as being reducible. Data-flow algorithms can guarantee best-case iterations, and passes like mem2reg can be written in a single pass over the IR.
This is the kind of representation we have used in the Mojo compiler and it has been extraordinarily beneficial for efficient compile time and reduced complexity in the stack. I believe such a representation will be very useful for similar projects like Triton, Polygeist, CIR, etc. and by upstreaming some version of it, we can drive standardization and improvements across projects.
Another benefit to region-based control flow not mentioned above is that the scoping of memory allocations is very clear, instead of requiring start and end lifetime markers in a CFG. This representation allows preserving allocation lifetimes structurally in the IR.
Control-flow interfaces
Like RegionBranchOpInterface
and BranchOpInterface
, we have implemented this control-flow paradigm with a pair of interfaces for the region operations and their terminators. With these interfaces, it is possible to write generic algorithms over a variety of operations that implement them. We propose upstreaming the interfaces and the associated verifiers and provide sample implementations in the test dialect. Then, we can upstream some of the analyses and transformations written over the interfaces.
However, because this is an unusual control-flow representation in MLIR, it suffers from some inefficiencies due to the lack of first-class support. This is a similar problem for SCF: MLIR tracks the predecessors and successors of basic blocks for CFGs but provides no such benefit for regions. Due to early exits, this is particularly challenging, because given a cf_ext.loop
operation, one must walk IR to find all possible predecessors. It would be nice to eventually have better first-class support for regions in this manner.
The other issue is the violation of the MLIR langref, which states: “each block represents a compiler basic block where instructions inside the block are executed in order and terminator operations implement control flow branches between basic blocks”. Perhaps we can introduce another region kind besides graph regions and SSACFG regions.
Interactions with CFGs and SCF
Operations that implement this new control-flow paradigm are allowed to coexist with CFGs and SCF. The restriction is that these operations form contiguous sub-trees in the region tree across which control flow is allowed to flow. For example, the following is invalid:
cf_ext.loop {
scf.if %condition {
cf_ext.if {
cf_ext.break // error: no viable parent operation for 'break'
}
}
}
This is because the scf.if
breaks the two cf_ext
ops into separate subtrees, across which control-flow is not allowed to flow. Consequently, cf_ext
operations must have single-block regions.
What about SCF?
I would not advocate making SCF go away. SCF is still a higher-level representation that is suitable for many applications and has stronger guarantees. Many compilers will still prefer to use SESE regions to represent their conditionals and loops. Operations like affine.for
and linalg.generic
are only possible on SESE regions.