Jaehun Baek

Qwerty: Building a Custom MLIR Dialect (Part 2)

September 12, 2025

In the first part of this series, I detailed the initial phase of my internship: modernizing the Qwerty quantum programming language compiler. The project's central goal was to replace an external C++ library, Tweedledum, with a modern, integrated compiler pipeline built using MLIR. After successfully upgrading the project's LLVM dependency and learning the fundamentals of compiler architecture, the next phase of the project began: designing and implementing a brand-new MLIR dialect from the ground up.

This article documents that process—the creation of CCirc, a custom MLIR dialect designed specifically to represent classical logic circuits within the Qwerty compiler.


Designing the CCirc Dialect

The first step was to define the language of our new dialect. An MLIR dialect is composed of a set of custom types and operations (ops) that represent the concepts specific to its domain. For CCirc, the domain was classical boolean logic. The design was captured in a formal specification document.

Custom Types

To ensure that classical circuit components could not be accidentally mixed with components from other dialects (like quantum operations or standard arithmetic), we defined a set of strongly-typed constructs.

  • WireType: This represents a single, one-bit classical wire. It is the most fundamental type in the dialect. We deliberately avoided using MLIR's built-in i1 (one-bit integer) type to enforce domain separation and prevent logical errors.
  • WireBundleType: This represents a collection of WireTypes, analogous to a bus in digital logic. It contains a parameter, dim, to specify the number of wires in the bundle.
  • CircuitType: This type is associated with a complete circuit operation. It defines the circuit's interface by specifying the dimensions of its input and output bundles (in_dim and out_dim).

Custom Operations

With the types defined, we specified the operations that could be performed on them. These ops are the building blocks of any circuit constructed with CCirc.

  • Logical Ops: These are the fundamental boolean gates. Each was defined to operate on WireType inputs and produce a WireType output.
    • AndOp: Bitwise AND.
    • OrOp: Bitwise OR.
    • XorOp: Bitwise XOR.
    • NotOp: Bitwise NOT.
  • Structural Ops: These operations manage the structure of wires and circuits.
    • WireBundlePackOp: Takes a variable number of individual WireTypes and groups them into a single WireBundleType.
    • WireBundleUnpackOp: Performs the reverse operation, breaking a WireBundleType back into its constituent WireTypes.
    • CircuitOp: A top-level operation that defines a complete, reusable circuit with a well-defined entry and exit point, containing a region of other CCirc ops.

Implementation: From Specification to Code

Defining a dialect in MLIR is streamlined by a tool called TableGen. Instead of writing large amounts of repetitive C++ boilerplate code, developers use TableGen to provide a high-level, declarative description of the dialect's components. MLIR uses these descriptions to auto-generate the necessary C++ classes.

For example, this is the TableGen definition for the NotOp:

def CCirc_NotOp : CCirc_Op<"not", [Pure]> {
    let summary = "Logical NOT operation";
    let description = [{
        Bitwise NOT on a single wire.
    }];
    let arguments = (ins CCirc_Wire:$operand);
    let results = (outs CCirc_Wire:$result);
    let assemblyFormat = "`(` $operand `)` attr-dict `:` functional-type(operands, results)";
}

An analysis of this definition reveals how it works:

  • def CCirc_NotOp: Defines a C++ class named CCirc_NotOp.
  • CCirc_Op<"not", [Pure]>: Specifies that this is a CCirc op. Its name in the textual IR will be "ccirc.not". The [Pure] trait indicates that the operation has no side effects, which helps the optimizer.
  • arguments and results: These lines define the operation's signature. It takes one input ($operand) of type CCirc_Wire and produces one output ($result) of the same type.
  • assemblyFormat: This defines the human-readable syntax for the operation when it's printed in a .mlir file, enhancing readability and debugging.

Adding Intelligence: The Canonicalizer Pass

A key part of building a robust dialect is adding optimization patterns. The first such optimization I implemented was a canonicalization pattern for NotOp to remove double negations. Canonicalization is a process that simplifies IR into a standard, or "canonical," form. In this case, any instance of not(not(x)) should be simplified directly to x.

The implementation involved three steps:

  1. Enabling the Pattern: In the TableGen file for NotOp, the hasCanonicalizer = 1; flag was set, signaling to MLIR that this operation has simplification patterns associated with it.
  2. Implementing the Rewrite Logic: A C++ struct was created that inherits from mlir::OpRewritePattern. This struct contains the core logic: it attempts to match a NotOp whose input is also a NotOp. If this pattern is found, it tells the rewriter to replace the entire double-negation operation with the original input of the inner NotOp.
  3. Registering the Pattern: A method named getCanonicalizationPatterns() was implemented for the NotOp class. This method registers the C++ rewrite pattern with the MLIR framework, making it available to the canonicalizer pass.

To validate this optimization, a FileCheck test was created. This test contained an IR snippet with a double negation and checked that after running the canonicalizer pass, the output IR was correctly simplified. This test-driven approach ensures that optimizations are both correct and effective.

Next Steps

With the CCirc dialect designed, implemented, and equipped with its first optimization, Milestone 2 of the project was complete. The compiler now had a native language for representing classical circuits.

The final and most critical step remains: connecting Qwerty's frontend AST to this new dialect. The third and final part of this series will cover the implementation of the "lowering" pass, which translates the classical components of a Qwerty program into the CCirc dialect, completing the replacement of the old backend and fulfilling the project's primary objective.