5  Operation

An MLIR operation is the smallest owned unit of program meaning. It is not merely an instruction. A module, a function declaration, a loop, a branch, a matrix multiplication, an allocation, and a machine instruction can all be operations. This single representation is what lets MLIR describe a program at many abstraction levels without switching to a different IR framework.

The useful mental model is this: an operation is a typed node in a graph, with an optional nested program inside it. Its operands connect it to values already available; its results introduce values other operations may use; its attributes and properties record static facts; its regions hold nested control or bodies; and its verifier states the conditions under which the node is meaningful.

%sum = arith.addi %left, %right : i32

This line creates one arith.addi operation. %left and %right are its operands, %sum is its result, and i32 is the result type. In contrast, this operation owns an entire program fragment:

scf.for %i = %c0 to %n step %c1 {
  %x = memref.load %buffer[%i] : memref<?xi32>
  memref.store %x, %output[%i] : memref<?xi32>
}

scf.for is still one operation. Its loop body is stored in a region owned by that operation. That distinction is central: passes can reason about a loop as a single semantic object while still traversing its body when necessary.

5.1 The Complete Shape Of An Operation

The generic form makes the pieces visible when a custom syntax hides them:

%0 = "arith.addi"(%left, %right) : (i32, i32) -> i32

Conceptually, an operation has the following shape:

operation name
  operands       values consumed from the enclosing scope
  results        new SSA values produced by the operation
  attributes     immutable compile-time data in an attribute dictionary
  properties     structured inherent data, stored separately from attributes
  regions        nested CFGs or structured bodies
  successors     destination blocks for explicit branches
  location       source or generated-code provenance

Not every operation uses every field. arith.addi has operands and one result, but no regions or successors. func.func has attributes and one region. A cf.cond_br has operands and successors but normally no results or regions. An operation must not be judged by how instruction-like its printed form looks.

5.1.1 Names Define Ownership, Not Just Spelling

Operation names conventionally have the form dialect.mnemonic, such as tensor.extract, gpu.launch, or llvm.call. The prefix identifies the dialect that defines the operation’s verification rules, builders, parsing, printing, and semantics. Two operations with similar names from different dialects are not automatically interchangeable: tensor.extract reads a value-like tensor, while memref.load reads mutable storage and therefore has different ordering and aliasing implications.

Operation names are stable IR identifiers, not C++ class names. In C++, an operation is usually represented by a generated wrapper such as arith::AddIOp; generic tooling may instead work with the base Operation class and inspect its OperationName. This is why a generic pass can walk an unknown dialect without hard-coding every operation class.

5.2 Operands And Results Form The Dataflow Graph

An operand is a use of a Value already defined in a valid enclosing scope. A result is a new SSA definition. The operation owns its results, but it does not own its operands: those values belong to the operations or blocks that defined them.

%a = arith.constant 4 : i32
%b = arith.constant 5 : i32
%c = arith.addi %a, %b : i32
%d = arith.muli %c, %b : i32

The graph is %a and %b into addi, then %c and %b into muli. There is no mutable variable named %c; its printed name is just a readable handle for one definition. Replacing all uses of %c means rewriting graph edges, not assigning to a variable.

Results may be zero, fixed-count, variadic, or variadic in groups. A store has zero results because its observable outcome is an effect on memory. An operation can return several values without inventing a tuple:

%quotient, %remainder = arith.divui_extended %x, %y : i32

The operation definition determines result arity and type relationships. A verifier might require the two operands and result to have the same integer type, or it may infer result types from operands. Never infer a semantic rule from punctuation alone; consult the dialect operation definition or generated dialect documentation.

5.3 Static Data: Attributes And Properties

Attributes are immutable compiler-time objects, not values that flow through the program. A symbol name, a comparison predicate, an affine map, a dense constant payload, and a fast-math flag are all examples. They normally print in an operation’s attribute dictionary or in its custom syntax.

%c = arith.constant 42 : i64
%cmp = arith.cmpi sgt, %x, %c : i64

42 is represented by an attribute in the constant operation. sgt is an attribute selecting the comparison predicate. Neither is an SSA operand. If a predicate must be calculated at runtime, it needs to be represented by a value and operations that define its behavior instead.

Properties are a newer representation for operation-specific structured data. They have much the same conceptual role as inherent attributes, but MLIR can store and access them through generated C++ property structures rather than through the generic attribute dictionary. This is an implementation and API improvement, not a new runtime data channel. When learning IR, treat a property as static operation configuration; when writing dialect code, follow the local dialect’s ODS pattern rather than mixing properties and attributes casually.

5.4 Regions And Successors Describe Control Structure

Regions provide lexical nesting. A region contains blocks; blocks contain operations. Function bodies, loop bodies, conditional branches, GPU kernels, and transform programs are all commonly modeled as regions. An operand used in a nested region must be visible from an enclosing scope or be introduced as a block argument.

%answer = scf.if %predicate -> (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 region has a terminating scf.yield; the yielded values become the results of the enclosing operation. This is not an implicit convention: the operation verifier enforces the relationship between the result types and the yielded values.

Successors instead model explicit control-flow edges between blocks. A cf.cond_br owns two successor references and may pass values to block arguments at each destination. Structured control-flow operations often use regions and dedicated terminators rather than raw successors; lower-level control-flow dialects expose successors directly. Both forms are operations, but they preserve different amounts of structure for later passes.

5.5 Traits, Interfaces, And Effects

Traits and interfaces let generic infrastructure ask useful questions without knowing a dialect’s exact operation names. A trait is generally a declarative, static fact attached to an operation definition: IsTerminator, SameOperandsAndResultType, or Symbol are typical examples. Traits can also contribute verification or helper behavior.

An interface is a C++ contract that an operation can implement. For example, the memory-effects interface lets analysis ask whether an operation reads, writes, allocates, or frees a resource. Call and region interfaces similarly give generic infrastructure a way to reason about behavior. Interfaces are important because a pass should usually ask “does this operation write memory?” rather than maintain a fragile list of every store-like operation across every dialect.

Effects are semantic facts, not decoration. An arith.addi is normally pure; two identical adds can often be merged. A memref.store writes memory; moving or deleting it may change the program. A call may have unknown effects until an interface, summary, or interprocedural analysis proves otherwise. The difference drives dead-code elimination, code motion, common-subexpression elimination, and many transformation legality decisions.

5.6 Construction, Verification, And Simplification

In dialect source, an operation is normally declared in ODS/TableGen. The declaration specifies operands, results, attributes, regions, traits, interfaces, assembly format, and generated accessors. It may also declare:

  • builders, which construct valid-looking operation instances from C++;
  • a verifier, which checks constraints requiring more than local type matching;
  • a folder, which can replace an operation with constants or existing values during local simplification;
  • canonicalization patterns, which express semantics-preserving rewrites.

Builders are convenience APIs, not a substitute for verification. A builder can make a malformed operation if its inputs are invalid or if a hand-written builder omits a required invariant. Verifiers are the boundary that protects the rest of the compiler from malformed IR. In debug builds and tools, call or enable verification after constructing nontrivial IR; a transformation that leaves invalid IR behind may fail much later in an unrelated pass.

For example, a binary arithmetic operation may verify element-type and shape compatibility, fold a constant expression, and canonicalize an algebraic identity. These are separate mechanisms. Folding is local and often constant-driven; canonicalization is pattern-based and can replace a larger expression. Neither gives permission to apply a mathematically appealing rewrite that changes overflow, NaN, rounding, aliasing, or side-effect semantics.

5.7 Reading And Writing Operations

Every operation has a generic textual representation. Dialects may also use a declarative assembly format or custom parser/printer methods to make the common form concise. The generic form is invaluable when learning a new operation because it exposes the exact operand list, result types, attributes, successors, and regions without relying on a friendly syntax.

%0 = "arith.addi"(%left, %right) : (i32, i32) -> i32

When writing a pass, prefer generated accessors such as getLhs() and getRhs() for known operation classes, and use generic operand/result APIs when the pass intentionally works across dialects. Do not parse printed MLIR text to learn an operation’s fields; the in-memory IR API and interfaces are the authoritative representation.

5.8 A Practical Reading Routine

When encountering an unfamiliar operation, answer these questions in order:

  1. What dialect owns it, and what abstraction does that dialect preserve?
  2. Which inputs are SSA operands and which facts are static attributes or properties?
  3. What values does it define, and what types or shape relationships must hold?
  4. Does it own regions or name CFG successors?
  5. What effects, traits, and interfaces constrain transformations around it?
  6. What does its verifier guarantee and what does its terminator require?

That routine turns a line of opaque IR into a semantic object a compiler pass can safely analyze or transform.