11  Region

A region is a container of blocks owned by an operation. Regions are MLIR’s answer to a constraint that flat instruction IRs impose too early: many program concepts are naturally nested. Function bodies, loop bodies, conditional arms, GPU kernels, reductions, and transformation programs can retain their nested structure as long as that structure helps analysis and optimization.

%result = scf.if %condition -> (i32) {
  %one = arith.constant 1 : i32
  scf.yield %one : i32
} else {
  %zero = arith.constant 0 : i32
  scf.yield %zero : i32
}

scf.if owns two regions. Each has an entry block; each block contains operations and a required scf.yield. The regions do not merely visually indent code. They delimit scope, define control-flow structure, and establish the contract by which the nested code produces %result.

11.1 Regions Belong To Operations

Regions never float independently in a module. Their owning operation defines how many there are, what kind of control flow they permit, how their entry arguments are supplied, whether they must terminate, and how values yielded from them relate to operation results. The generic IR infrastructure knows that a region contains blocks; it does not assume that every region is a function body or a loop.

This ownership model lets operations represent rich semantic units. A func.func has one body region. An scf.if normally has a then region and an optional else region. A linalg.generic operation owns a region expressing the elementwise scalar computation. A Transform dialect operation can own a region that acts on handles rather than on source-language runtime values.

11.2 Single-Block And CFG Regions

Two broad region forms appear repeatedly:

  • A single-block structured region has one entry block and normally uses a dialect-specific terminator such as scf.yield. It preserves nesting and is easy to reason about recursively.
  • A CFG region may contain multiple blocks joined by explicit successors. Function bodies and low-level control-flow regions commonly use this form.

The difference is semantic, not merely stylistic. A transformation that assumes one block in a loop body must check that the owning operation guarantees one. Conversely, code that walks a function body must be prepared for branches, unreachable blocks, and block arguments. MLIR exposes region traits such as single-block or no-terminator constraints so operation definitions can express these invariants.

11.3 Captures And Lexical Scope

Nested regions can use values from enclosing scopes. This is called a capture:

%scale = arith.constant 2.0 : f32
%result = scf.for %i = %c0 to %n step %c1 iter_args(%acc = %initial) -> (f32) {
  %next = arith.addf %acc, %scale : f32
  scf.yield %next : f32
}

The loop body captures %scale and %initial is supplied as a loop-carried block argument %acc. Capturing can be convenient and keeps structured IR compact. However, it affects transformations: moving a region or outlining it into a new function requires making captures explicit as operands or function arguments. A region cannot reference values that do not dominate the owning operation, and an inner definition cannot be used outside its scope unless the owner returns it through a result.

Some contexts intentionally restrict captures. An isolated-from-above operation, for example, prohibits arbitrary references to values outside its region. This makes the nested IR self-contained and supports parallel parsing, symbol-based references, safe cloning, or separate compilation. The module operation is a familiar isolated scope.

11.4 Region Arguments And Terminators

The entry block’s arguments are the explicit interface into a region. For an scf.for, they include an induction variable and loop-carried values. For a function, they represent parameters. A terminator is often the region’s output interface: func.return returns from a function, scf.yield supplies values to the enclosing structured operation, and linalg.yield supplies scalar results to a structured computation.

%sum = scf.for %i = %c0 to %n step %c1 iter_args(%acc = %zero) -> (i32) {
  %next = arith.addi %acc, %i : i32
  scf.yield %next : i32
}

The types and positions form a four-way contract among the operation’s iter_args, the body block arguments, the yield operands, and the operation results. A correct transformation must preserve all of them. Replacing only the yield or only the result type leaves invalid IR.

11.5 Traversal, Cloning, And Mutation

Walking an operation recursively normally visits its nested regions, but APIs often let a pass choose preorder/postorder traversal and control whether it enters a region. Postorder is useful when erasing nested operations; preorder is useful when an outer operation determines the traversal policy. Be explicit: an analysis that silently descends into regions may accidentally treat a nested function or parallel body as if it were straight-line code.

Cloning an operation with regions must remap values defined within the cloned region. Captures from outside may remain captures or be remapped by an IRMapping, depending on the intended semantics. Moving a region transfers ownership and changes the validity of captures. These are structural compiler operations, not text manipulation; use MLIR cloning and rewriter APIs and verify afterward.

11.6 Why Regions Matter For Optimization

Regions preserve high-level control and computation boundaries. A loop transformation can ask for the induction variable, body, and yield rather than reverse-engineer a loop from arbitrary branches. A conditional simplifier can replace an entire scf.if when its condition is known. A parallelization pass can recognize a region whose operations are independent. Later, lowering can convert the same structured region into basic blocks and branches when the target representation requires it.

The guiding rule is to retain structure while it is useful, then lower it only when a later stage has a reason to trade structure for lower-level detail.