Defining a Dialect
This tutorial explains how to define new attributes and operations in ScaIR and how to package these into a dialect.
Defining Attributes
Attributes represent compile-time information in the IR. They are immutable and may appear:
- as SSA types
- as constant values
- as metadata attached to operations
In ScaIR, all attributes extend the base Attribute hierarchy.
Attributes vs Types
In MLIR (and conceptually in ScaIR), types are a specialized kind of attribute. Attributes represent general compile-time information. Type attributes are used to describe the types of SSA values.
In MLIR every SSA value has exactly one type. In ScaIR the distinction between Attribute and TypeAttribute is primarily maintained for MLIR IR compatibility (e.g., printing # vs ! for MLIR dialects). SSA values in ScaIR do not strictly require a TypeAttribute.
This distinction is reflected in the IR syntax:
#dialect.attr<...>— general attributes!dialect.type<...>— type attributes
Although both are implemented as attributes internally, only type attributes should appear in SSA value type positions.
In ScaIR, this distinction is expressed explicitly in Scala: type attributes extend TypeAttribute, while other attributes do not.
Type Attributes
TypeAttribute describes the types of SSA values. While MLIR requires every SSA value to have exactly one type attribute, ScaIR allows SSA values to be typed using regular attributes as well.
final case class MyType()
extends DerivedAttribute["mydialect.type", MyType]
with TypeAttribute
derives DerivedAttributeCompanion
Type attributes are printed in the IR type position:
%0 : !mydialect.type
DerivedAttribute is the typed base for attributes whose IR name and parameters are provided by a derived companion.
derives DerivedAttributeCompanion generates the glue code needed for printing/parsing and parameter handling.
Data Attributes
Data attributes store constant compile-time data, such as numbers or structured constants.
ScaIR provides many built-in examples (e.g. IntData, FloatData). You can define your own:
import scair.dialects.builtin.IntData
import scair.ir.DataAttribute
case class RangeAttr(min: IntData, max: IntData)
extends DataAttribute[(IntData, IntData)]("mydialect.range", (min, max))
Use data attributes for:
- constants
- annotations
- configuration metadata
Parametrized Attributes
Parametrized attributes are composed of other attributes.
import scair.clair.macros.*
import scair.ir.*
final case class FunctionType(
inputs: Seq[Attribute],
outputs: Seq[Attribute],
) extends ParametrizedAttribute
with TypeAttribute:
override def name: String = "builtin.function_type"
override def parameters: Seq[Attribute | Seq[Attribute]] =
Seq(inputs, outputs)
These are ideal for:
- function types
- container types
- composite metadata
Defining Operations
Operations represent units of computation in the IR.
Every Operation has:
- a name
- results
- operands
- successors
- regions
- properties
- attributes
Typed Operations and the DerivedOperationCompanion
As with attributes, ScaIR defines operations using a typed definition plus a derived companion. Operations are defined as strongly typed Scala case classes.
Each Operation definition consists of two parts:
DerivedOperation: defines the typed shape of the Operation (its name, operands, results, regions, and verification logic).DerivedOperationCompanion: connects the typed Scala definition to the generic IR, providing construction, parsing, and printing support.
Together, these two parts bridge the typed Scala API and the generic IR representation used by parsers, printers, and transformation passes.
In most cases, the companion is derived automatically using macros:
case class Add(...)
extends DerivedOperation["mydialect.add", Add]
derives DerivedOperationCompanion
This derived companion plays the same role as MLIR’s TableGen-generated boilerplate, but without a separate code-generation step.
A Simple Operation
case class Add(
lhs: Operand[IntegerType],
rhs: Operand[IntegerType],
res: Result[IntegerType]
) extends DerivedOperation["mydialect.add", Add]
derives DerivedOperationCompanion
This defines an operation printed as:
%r = "mydialect.add"(%a, %b) : (i32, i32) -> i32
Operations with Regions
Operations may contain regions, which define nested scopes.
case class MyIf(
cond: Operand[IntegerType],
thenRegion: Region,
elseRegion: Region
) extends DerivedOperation["mydialect.if", MyIf]
derives DerivedOperationCompanion
Regions are commonly used for control flow and loops.
Traits
Traits in ScaIR are simply Scala traits. Most operation traits extend Operation directly. When an operation mixes in such a trait, the operation itself becomes an instance of that trait. This allows trait implementations to directly access operation properties such as operands, results, and the containing block via this.
Traits are used to attach semantics or constraints (including structural properties and shared behavior) to operations and may optionally participate in operation verification.
Common examples:
case class PureOp(
res: Result[IntegerType]
) extends DerivedOperation["mydialect.pure", PureOp]
with NoMemoryEffect
derives DerivedOperationCompanion
Example trait Implementation:
import scair.ir.Operation
import scair.utils.*
trait IsTerminator extends Operation:
override def traitVerify(): OK[Operation] =
val verified =
this.containerBlock match
case Some(b) =>
if this ne b.operations.last then
Err(
s"Operation '$name' marked as a terminator, but is not the last operation within its container block"
)
else OK(this)
case None =>
Err(
s"Operation '$name' marked as a terminator, but is not contained in any block."
)
verified.flatMap(_ => super.traitVerify())
Traits are commonly used by transformations and verification passes.
Verification
Operations can define a verify() method to enforce invariants:
override def verify() =
if lhs.typ == rhs.typ then Right(this)
else Left("type mismatch")
Verification is run automatically during parsing and transformation passes. Verification combines generic IR checks with operation- and trait-specific constraints.
What is a Dialect?
A dialect is a namespace that groups:
- operations
- attributes
- types
Dialects represent a coherent abstraction level in the IR.
Examples:
arith— arithmetic operationsscf— structured control flow
Declare the Dialect
In ScaIR, dialects are declared using summonDialect.
val MyDialect = summonDialect[
// Attributes
(MyType, VectorType, RangeAttr),
// Operations
(Add, PureOp)
]
Calling summonDialect constructs a dialect definition, describing its attributes, operations, and associated parsing and printing logic. By itself, however, this does not make the dialect available to any tool or pass.
Register a Dialect
ScaIR tools typically inherit from ScairOptBase, which defines the set of available dialects via the dialects field:
import scair.tools.opt.*
import scair.tools.ScairToolBase
trait ScairOptBase extends ScairToolBase[ScairOptArgs]:
override def dialects = scair.dialects.allDialects
which defaults to:
val allDialects: Seq[Dialect] =
Seq(
BuiltinDialect,
...,
MyDialect
)
A dialect becomes usable once it is included in the sequence returned by dialects.
There are two common ways to register a dialect:
-
When using ScaIR as a library: Create a custom
Optclass inheriting fromScairOptBaseand overridedialectsto include your dialect. -
When working within ScaIR itself: Add the dialect directly to the
allDialectssequence.
Once a dialect is registered with a tool, the IR parser and printer can recognize:
- attribute names
- operation names
- dialect-specific parsing and printing logic
How to Connect a Dialect
To make a dialect available:
- Import the dialect object
- Ensure it is linked into the binary
import scair.dialects.mydialect.*
After this, IR containing "mydialect.*" operations can be parsed and printed.