The CS 6120 Course Blog

BrilIR: an MLIR dialect for Bril

by Nipat Chenthanakij

Source Repo

Introduction

MLIR is a project within the LLVM ecosystem designed to build reusable and extensible compiler infrastructure through its multi-level intermediate representation. MLIR enables the representation of multiple abstraction levels at the same time through its extensible system of dialects, each of which defines its own operations, types, and semantics. These dialects can coexist within a single IR and are progressively lowered into lower-level dialects, such as LLVM, through MLIR’s dialect conversion framework. Beyond extensibility, MLIR encodes best practices directly into its IR design, reflecting lessons learned by the LLVM team from building multiple prior IRs.

The goal of this project is to create an MLIR dialect for Bril that defines the operations and types supported by Bril, along with a set of tools built around this dialect, namely:

I initially aimed to support the subset of Bril core and floating-point extensions, but ultimately decided to pivot to the core and memory extensions. The main reason for this decision is that floating-point lowering and operation representations are largely similar to integer operations in Bril core, whereas the memory extension is more instructive and requires more deliberate design choices for lowering and semantic representation.

The Implementation

I followed the overall project structure of the MLIR Toy tutorial, which builds a custom dialect for a simple “Toy” language and a lowering pipeline that ultimately produces LLVM IR through the dialect conversion framework.

The implementation can be broken down into four major components:

The Bril Dialect

The Bril MLIR dialect is expressed using MLIR’s Operation Definition Specification (ODS), which is MLIR’s DSL for declaratively defining operations, types, and other dialect-related objects. ODS is the recommended approach for defining MLIR dialects, as it significantly reduces boilerplate C++ code and integrates cleanly with CMake via the TableGen tool, which generates the corresponding C++ sources and headers.

In this project, ODS is used to define Bril operations, types, and passes. I chose to reuse MLIR’s standard integer type to represent Bril’s int and bool types. For the memory extension, I defined a dedicated opaque pointer type to represent Bril pointers.

Each Bril operation corresponds closely to an operation in the dialect. Arithmetic operations are straightforward. More interesting cases include operations such as const, which takes an attribute operand (representing a constant value in MLIR); the function operation func, which contains a region of basic blocks; the call operation, which references an MLIR symbol corresponding to a func; and control-flow operations such as br, which specify successor blocks and pass block arguments (since MLIR models SSA via block arguments).

Finally, I defined two passes: convert-bril-to-std, a dialect conversion pass that lowers Bril into standard MLIR dialects and llvm as a first step toward LLVM IR, and rename-main-function, a somewhat hacky pass used to rename the Bril entry-point function for testing purposes (discussed further below).

Bril to MLIR Converter

Bril’s JSON-based representation makes parsing and conversion relatively straightforward, as I can directly rely on a JSON library to read the input program.

Since MLIR operates in SSA form, the converter expects the input Bril program to already be in SSA form, specifically using the get / set representation. This maps fairly naturally to MLIR’s block-argument-based SSA representation, aside from several corner cases discussed below.

The converter first constructs basic blocks for each function and then performs SSA conversion. To translate get / set into block arguments, I collect all get instructions in a block and treat them as that block’s arguments. In MLIR, block arguments are passed through control-flow operations, so I also collect each set instruction in a block and use those values to populate the operands of control-flow terminators, except for blocks terminated by a return.

Several corner cases must be handled. A block without an explicit terminator requires the insertion of an unconditional branch so that block arguments are properly passed. In addition, some block arguments may not be defined along certain predecessor paths; in such cases, I dynamically generate an undef value and pass it as the corresponding block argument. There is also a verifier constraint requiring the final block in a function to have a terminator; to satisfy this, I insert a dummy return operation with the appropriate type when necessary.

Once these issues are addressed, the converter walks through each instruction in each basic block and emits the corresponding MLIR operation. This step is relatively direct, as the semantics of Bril operations closely align with those in the Bril dialect. During this process, a symbol table mapping Bril variable names to MLIR SSA values is maintained, since MLIR itself does not preserve source-level variable names.

MLIR to Bril Converter

The reverse conversion is considerably simpler, as MLIR already provides a clear hierarchical structure: module -> functions -> blocks.

The converter traverses each function in the module, reconstructing Bril basic blocks and instructions in JSON form. Two mappings must be maintained: one from MLIR SSA values to generated Bril variable names, and another from MLIR blocks to generated Bril block names. Converting back to the get / set SSA representation involves collecting block arguments passed via control-flow operations and emitting a corresponding set instruction for each, while also emitting get instructions for the block arguments at the beginning of each block.

Bril to std Conversion Pass

The conversion pass is responsible for lowering Bril dialect operations into equivalent operations in standard MLIR dialects: arith for arithmetic and boolean operations, cf for control flow, func for function definitions and calls, and llvm for memory operations and printing.

Memory operations are lowered directly to the llvm dialect rather than to memref, as Bril’s pointer semantics are difficult to reconcile with memref’s index-based model. As a result, alloc and free are lowered to calls to malloc and free, respectively. A similar approach is used for print.

Most remaining operations are translated directly into their standard dialect equivalents. A few special cases are worth noting:

Type conversion is required because the custom Bril pointer type must be lowered into the LLVM dialect’s pointer type. MLIR provides a flexible type conversion framework and value adaptors to support this, but I initially overlooked the need to also convert function signatures, block arguments, and control-flow operands, which led to a rather long debugging session due to cryptic error messages from the dialect conversion pass.

Challenges

The project was challenging right from the beginning, starting with project setup. I initially attempted to follow the MLIR Toy example directly, but found it rather unwieldy to embed just my dialect within the entire LLVM monorepo. I later discovered the Standalone MLIR dialect example, which supports out-of-tree dialect development linked against a pre-installed LLVM build with MLIR enabled.

MLIR and LLVM rely heavily on CMake, which I was not really familiar with. The additional complexity introduced by TableGen further increased the learning curve. Eventually, however, I was able to set up a working build with good IDE support via clangd (using CMAKE_EXPORT_COMPILE_COMMANDS), TableGen language support, and faster rebuilds through build caching.

Another major challenge came during development of the converter programs, where crashes would occur without meaningful stack traces. I eventually realized this was due to exceptions being disabled in the LLVM build, causing the JSON library to abort on exceptions. This made debugging difficult and forced me to rely mostly on print debugging. I later realized there is a build option for enabling exceptions for LLVM (which is disabled by default for performance reasons).

Several design decisions also required careful consideration, such as choosing an appropriate lowering target for Bril pointers, deciding between phi-nodes and get / set SSA representations, structuring the conversion process, and handling numerous corner cases.

The most difficult aspect, however, was implementing correct type conversion for the dialect conversion pass. While I knew I needed to lower the custom pointer type, I initially missed the fact that function signatures, block arguments, and control-flow operands must also be converted. The resulting errors were difficult to diagnose due to the limited diagnostic output provided by the conversion framework.

Later during testing, additional issues surfaced, some caused by bugs in my test stub generator and others by problematic Bril benchmarks. For example, the braille benchmark contains a function with no return value, which violates MLIR verifier requirements. Another benchmark, twosum, contains an unreachable jmp following a ret, which breaks my dialect conversion pass.

Correctness Evaluation

Correctness was evaluated using two approaches: round-trip conversion and execution of generated LLVM code.

For round-trip testing, a Bril JSON program is converted to MLIR and then back to Bril JSON. The resulting program is compared against the original using turnt. After minor fixes to problematic benchmarks, all core and memory benchmarks (excluding memory benchmarks that use floating-point extensions) pass this round-trip test.

The second approach involves converting Bril JSON to MLIR, running a pipeline of conversion passes (convert-bril-to-std, rename-main-function, and standard std-to-llvm passes), and then using mlir-translate to produce LLVM IR. This IR is linked with a C++ test stub that calls the Bril entry point with arguments expected by turnt, which motivates the existence of the rename-main-function pass. Test generation is automated via scripts that read Bril sources and expected arguments and produce executables accordingly. All executables generated this way have the same outputs as the original Bril benchmarks (same tests as above), as verified by turnt.

Future Work