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.

import scair.ir.TypeAttribute
import scair.clair.*
final case class MyType()
  extends DerivedAttribute["mydialect.type"]
  with TypeAttribute
  derives AttrDefs
MyType

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 AttrDefs 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)]:
  override def data = (min, max)
  override def name = "mydialect.range_attr"
RangeAttr

Use data attributes for:

  • constants
  • annotations
  • configuration metadata

Parametrized Attributes

Parametrized attributes are composed of other attributes.

import scair.dialects.builtin.ArrayAttribute
import scair.clair.macros.*
import scair.ir.*
final case class FunctionType(
    inputs: ArrayAttribute[Attribute],
    outputs: ArrayAttribute[Attribute],
) extends ParametrizedAttribute
    with TypeAttribute:

  override def name: String = "builtin.function_type"

  override def parameters: Seq[Attribute] =
    Seq(inputs, outputs)
FunctionType

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"]
  derives OpDefs

This derived companion plays the same role as MLIR’s TableGen-generated boilerplate, but without a separate code-generation step.

A Simple Operation

import scair.ir.*
import scair.dialects.builtin.*
import scair.clair.*
case class Add(
  lhs: Operand[IntegerType],
  rhs: Operand[IntegerType],
  res: Result[IntegerType]
) extends DerivedOperation["mydialect.add"]
  derives OpDefs
Add

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.

import scair.ir.*
import scair.clair.*
import scair.dialects.builtin.*
case class MyIf(
  cond: Operand[IntegerType],
  thenRegion: Region,
  elseRegion: Region
) extends DerivedOperation["mydialect.if"]
  derives OpDefs
MyIf

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:

import scair.ir.*
import scair.clair.*
import scair.dialects.builtin.*
case class PureOp(
  res: Result[IntegerType]
) extends DerivedOperation["mydialect.pure"]
  with NoMemoryEffect
  derives OpDefs
PureOp

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.

Same variadic sizes

An operation holding several variadic operands, written as Seq[Operand[...]] fields, is ambiguous: the flat operand list alone does not say where one group ends and the next begins. By default ScaIR disambiguates it with an operandSegmentSizes property, spelling out the size of each group.

Mixing in SameVariadicOperandSize instead declares that all variadic operands hold the same number of values, so the operand list is split evenly over them and no property is needed. SameVariadicResultSize does the same for results. Both mirror the MLIR traits of the same names; unlike upstream, ScaIR checks the size agreement during verification.

import scair.ir.*
import scair.clair.*
import scair.dialects.builtin.*
case class SameSizeOp(
  lhs: Seq[Operand[IntegerType]],
  rhs: Seq[Operand[IntegerType]],
  res: Result[IntegerType]
) extends DerivedOperation["mydialect.same_size"]
  with SameVariadicOperandSize
  derives OpDefs
SameSizeOp

The trait requires at least two variadic definitions of the construct it applies to, and cannot be combined with an explicit segment sizes property; either is a compile error.

Verification

Operations can define a verify() method to enforce invariants:

import scair.ir.*
import scair.clair.*
import scair.utils.*
case class ExampleOp(lhs: Operand[Attribute], rhs: Operand[Attribute]) extends DerivedOperation["example"] derives OpDefs:
  override def verify() =
    if lhs.typ == rhs.typ then OK(this)
    else Err("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 operations
  • scf — structured control flow

Declare the Dialect

In ScaIR, dialects are declared using summonDialect.

val MyDialect = summonDialect[
  // Attributes
  (MyType, FunctionType, 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

There are two common ways to register a dialect:

  • When using ScaIR as a library: Create a custom Opt class inheriting from OptBase and override dialects to include your dialect.

  • When working within ScaIR itself: Add the dialect directly to the allDialects sequence.

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:

  1. Import the dialect object
  2. Ensure it is linked into the binary
import scair.dialects.mydialect.*

After this, IR containing "mydialect.*" operations can be parsed and printed.