<!-- Canonical: https://lucioduran.com/es/blog/llvm-mlir-compiler-infrastructure-custom-dialects
Language: es
Content-SHA256: 84591268295b366838f897dc30bbf1a10e532010a0248c57da76153f252e70e7
-->

Compilers

# LLVM MLIR: Construyendo Dialectos de Compilador Custom para Optimización de Dominio

2026-01-2710 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/llvm-mlir-compiler-infrastructure-custom-dialects>)

## El Problema: Por Qué LLVM IR No Alcanzaba

LLVM es brillante para lo que hace: tomar IR moderadamente de bajo nivel y convertirlo en código máquina optimizado para una docena de arquitecturas. Pero tiene una limitación fundamental, hay un solo nivel de IR, y está aproximadamente al nivel de abstracción de C.

Si se construye un compilador para un lenguaje de alto nivel (por ejemplo, un DSL de procesamiento de tensores), es necesario bajar los conceptos de alto nivel a LLVM IR tempranamente. Una vez realizada esa transformación, la información se pierde. El optimizador de LLVM ve loops y aritmética de punteros, no multiplicaciones de matrices y convoluciones. No puede fusionar dos operaciones de matrices que no sabe que son operaciones de matrices.

Este es el "gap de abstracción" que plagó a los compiladores de ML durante años:

```
DSL de alto nivel: matmul(A, B) + transpose(C)
 │
 ┌───────────────┤ (pérdida de información aquí)
 ▼ │
LLVM IR: loops anidados, instrucciones gep, loads/stores
 → LLVM puede optimizar los loops pero no ve el álgebra lineal
```

Cada framework de ML lo resolvió distinto: TensorFlow tenía XLA, PyTorch tenía TorchScript y después Torch-MLIR, TVM tenía su propio IR, Halide tenía su propio IR. Cada uno reinventó sistemas de tipos, construcción de SSA, infraestructura de passes y canonicalización. Duplicación masiva.

## La Idea Central de MLIR: Dialectos Hasta el Fondo

La idea clave de MLIR es que no se necesita un IR, se necesita un framework para construir muchos IRs que puedan coexistir e interoperar. Ese framework es el sistema de **dialectos**.

Un dialecto es un namespace que contiene:

- **Operaciones** (las "instrucciones" de tu IR)
- **Tipos** (el sistema de tipos para tu dominio)
- **Atributos** (constantes de compilación y metadata)
- **Interfaces** (traits que las operaciones pueden implementar para transformaciones genéricas)

La magia es que operaciones de distintos dialectos pueden existir en el mismo IR, y es posible bajar progresivamente de dialectos de alto nivel a los de bajo nivel:

```mlir
// Alto nivel: dialecto linalg (operaciones de álgebra lineal)
%result = linalg.matmul ins(%A, %B : tensor<64x128xf32>, tensor<128x256xf32>)
 outs(%C : tensor<64x256xf32>) -> tensor<64x256xf32>

// Nivel medio: dialecto affine (nidos de loops poliédricos)
affine.for %i = 0 to 64 {
 affine.for %j = 0 to 256 {
 affine.for %k = 0 to 128 {
 %a = affine.load %A[%i, %k] : memref<64x128xf32>
 %b = affine.load %B[%k, %j] : memref<128x256xf32>
 %c = affine.load %C[%i, %j] : memref<64x256xf32>
 %prod = arith.mulf %a, %b : f32
 %sum = arith.addf %c, %prod : f32
 affine.store %sum, %C[%i, %j] : memref<64x256xf32>
 }
 }
}

// Bajo nivel: dialecto llvm (operaciones de LLVM IR)
%ptr = llvm.getelementptr %base[%idx] : (!llvm.ptr, i64) -> !llvm.ptr
%val = llvm.load %ptr : !llvm.ptr -> f32
```

En cada nivel, es posible aplicar optimizaciones específicas del dominio que serían imposibles en niveles más bajos. El nivel ` linalg ` sabe de estrategias de tiling y fusión para operaciones de matrices. El nivel ` affine ` puede aplicar transformaciones poliédricas. El nivel ` llvm ` hace register allocation y selección de instrucciones.

## Construyendo un Dialecto Custom: Un Ejemplo de Juguete

El siguiente ejemplo construye un dialecto simple para un DSL hipotético de procesamiento de imágenes. Se definen operaciones para cargar imágenes, aplicar filtros y compositar.

### Paso 1: Definir el Dialecto en TableGen

```tablegen
// ImageOps.td
include "mlir/IR/OpBase.td"
include "mlir/IR/AttrTypeBase.td"
include "mlir/Interfaces/SideEffectInterfaces.td"

// Definir el dialecto
def ImageDialect : Dialect {
 let name = "image";
 let cppNamespace = "::image";
 let summary = "Un dialecto para operaciones de procesamiento de imágenes";
}

// Definir un tipo custom para imágenes con info de canales
def Image_ImageType : TypeDef<ImageDialect, "Image"> {
 let mnemonic = "img";
 let parameters = (ins
 "int64_t":$width,
 "int64_t":$height,
 "int64_t":$channels // 1=gris, 3=RGB, 4=RGBA
 );
 let assemblyFormat = "`<` $width `,` $height `,` $channels `>`";
}

// Operación de carga
def Image_LoadOp : Op<ImageDialect, "load", [Pure]> {
 let summary = "Cargar una imagen desde un path";
 let arguments = (ins StrAttr:$path);
 let results = (outs Image_ImageType:$result);
 let assemblyFormat = "$path attr-dict `:` type($result)";
}

// Operación de blur
def Image_BlurOp : Op<ImageDialect, "blur", [Pure]> {
 let summary = "Aplicar desenfoque gaussiano";
 let arguments = (ins
 Image_ImageType:$input,
 F32Attr:$sigma
 );
 let results = (outs Image_ImageType:$result);
 let assemblyFormat = "$input `,` $sigma attr-dict `:` type($input) `->` type($result)";

 // Verificar que input y output tengan las mismas dimensiones
 let verifier = [{
 auto inType = getInput().getType().cast<ImageType>();
 auto outType = getResult().getType().cast<ImageType>();
 if (inType.getWidth() != outType.getWidth() ||
 inType.getHeight() != outType.getHeight())
 return emitOpError("las dimensiones de input y output deben coincidir");
 return success();
 }];
}

// Operación de composición
def Image_CompositeOp : Op<ImageDialect, "composite", [Pure]> {
 let summary = "Componer dos imágenes con modo de mezcla";
 let arguments = (ins
 Image_ImageType:$foreground,
 Image_ImageType:$background,
 StrAttr:$blend_mode // "over", "multiply", "screen"
 );
 let results = (outs Image_ImageType:$result);
}
```

### Paso 2: Escribir un Pass de Optimización Específico del Dominio

Ahora se puede escribir un pass que fusiona operaciones de blur consecutivas en un solo blur con sigma combinado:

```cpp
// FuseBlursPass.cpp
#include "mlir/Pass/Pass.h"
#include "ImageOps.h"
#include <cmath>

namespace {
struct FuseBlursPass
 : public mlir::PassWrapper<FuseBlursPass,
 mlir::OperationPass<mlir::func::FuncOp>> {

 void runOnOperation() override {
 getOperation().walk([](image::BlurOp outerBlur) {
 // Chequear si el input de este blur es otro blur
 auto innerBlur = outerBlur.getInput().getDefiningOp<image::BlurOp>();
 if (!innerBlur)
 return;

 // Chequear que el blur interno no tenga otros usuarios
 if (!innerBlur.getResult().hasOneUse())
 return;

 // Fusionar: sigma_combinado = sqrt(sigma1^2 + sigma2^2)
 float sigma1 = innerBlur.getSigma().convertToFloat();
 float sigma2 = outerBlur.getSigma().convertToFloat();
 float fusedSigma = std::sqrt(sigma1 * sigma1 + sigma2 * sigma2);

 // Reemplazar el blur exterior con un solo blur sobre el input del interior
 mlir::OpBuilder builder(outerBlur);
 auto fusedBlur = builder.create<image::BlurOp>(
 outerBlur.getLoc(),
 outerBlur.getType(),
 innerBlur.getInput(),
 builder.getF32FloatAttr(fusedSigma));

 outerBlur.replaceAllUsesWith(fusedBlur.getResult());
 outerBlur.erase();
 if (innerBlur.getResult().use_empty())
 innerBlur.erase();
 });
 }

 mlir::StringRef getArgument() const final { return "image-fuse-blurs"; }
 mlir::StringRef getDescription() const final {
 return "Fusionar operaciones de blur consecutivas";
 }
};
} // namespace
```

## El IR En Acción

Así se ve un pipeline completo en nuestro dialecto de imágenes:

```mlir
// IR de entrada (operaciones de imagen de alto nivel)
func.func @process_photo(%path: !image.img<1920, 1080, 3>) -> !image.img<1920, 1080, 3> {
 %photo = image.load "/photos/input.jpg" : !image.img<1920, 1080, 3>
 %blurred1 = image.blur %photo, 2.0 : !image.img<1920, 1080, 3> -> !image.img<1920, 1080, 3>
 %blurred2 = image.blur %blurred1, 3.0 : !image.img<1920, 1080, 3> -> !image.img<1920, 1080, 3>
 %overlay = image.load "/photos/overlay.png" : !image.img<1920, 1080, 4>
 %result = image.composite %overlay, %blurred2, "over" :
 !image.img<1920, 1080, 4>, !image.img<1920, 1080, 3> -> !image.img<1920, 1080, 3>
 return %result : !image.img<1920, 1080, 3>
}

// Después del pass de fusión de blur (sigma = sqrt(4 + 9) = 3.606):
func.func @process_photo(%path: !image.img<1920, 1080, 3>) -> !image.img<1920, 1080, 3> {
 %photo = image.load "/photos/input.jpg" : !image.img<1920, 1080, 3>
 %blurred = image.blur %photo, 3.606 : !image.img<1920, 1080, 3> -> !image.img<1920, 1080, 3>
 %overlay = image.load "/photos/overlay.png" : !image.img<1920, 1080, 4>
 %result = image.composite %overlay, %blurred, "over" :
 !image.img<1920, 1080, 4>, !image.img<1920, 1080, 3> -> !image.img<1920, 1080, 3>
 return %result : !image.img<1920, 1080, 3>
}
```

El pass de fusión convirtió dos operaciones de blur en una, cortando el trabajo de convolución aproximadamente a la mitad. Esta optimización es trivial de expresar a nivel del dialecto de imagen, pero sería casi imposible de descubrir después de bajar a loops.

## MLIR en el Mundo Real: El Panorama de Compiladores de ML

Los mayores usuarios de MLIR son los compiladores de ML, y con razón. Los workloads de ML tienen una estructura multi-nivel natural:

```
Nivel framework: torch.nn.Linear(256, 128)
 │
 ▼ TorchMLIR
Nivel grafo: linalg.matmul + linalg.add (bias)
 │
 ▼ Tiling + Fusión
Nivel tile: loops scf.for con cuerpos internos vectorizados
 │
 ▼ Lowering a hardware
Nivel hardware: gpu.launch_func / vector.contract / amx.tile_mulf
 │
 ▼ Lowering a LLVM
Código máquina: instrucciones AVX-512 / CUDA PTX / AMX
```

Cada nivel tiene sus propias oportunidades de optimización:

- **Nivel grafo**: fusión de operadores (matmul + bias + relu = un kernel), optimización de layout (NHWC vs NCHW)
- **Nivel tile**: selección de tamaño de tile, scheduling consciente de caché, estrategia de vectorización
- **Nivel hardware**: mapeo a extensiones ISA específicas (AMX para Intel, Tensor Cores para NVIDIA)

### IREE: El Runtime de ML de Google Construido sobre MLIR

IREE (Intermediate Representation Execution Environment) es probablemente el compilador de ML basado en MLIR más completo en producción. Toma modelos de TensorFlow, PyTorch o JAX, los baja a través de dialectos MLIR, y genera código optimizado para CPUs, GPUs y aceleradores.

### Mojo: Un Lenguaje Construido sobre MLIR

El lenguaje de programación Mojo de Modular está construido directamente sobre MLIR. Esto significa que el compilador de Mojo puede expresar abstracciones de alto nivel (sintaxis tipo Python) y operaciones de hardware de bajo nivel (intrínsecos SIMD) en el mismo IR, y el pipeline de optimización puede ver ambos simultáneamente.

```mojo
# Código Mojo que compila a través de MLIR
fn matmul_tiled[M: Int, N: Int, K: Int](
 A: SIMD[DType.float32, M*K],
 B: SIMD[DType.float32, K*N],
) -> SIMD[DType.float32, M*N]:
 var C = SIMD[DType.float32, M*N](0)
 for i in range(M):
 for j in range(N):
 for k in range(K):
 C[i*N + j] += A[i*K + k] * B[k*N + j]
 return C
```

## Empezando: El Camino Práctico

Para experimentar con MLIR, aquí va un camino recomendado:

### 1. Compilar MLIR Desde Source

```bash
git clone https://github.com/llvm/llvm-project.git
cd llvm-project
mkdir build && cd build

cmake -G Ninja ../llvm \
 -DLLVM_ENABLE_PROJECTS=mlir \
 -DLLVM_BUILD_EXAMPLES=ON \
 -DLLVM_TARGETS_TO_BUILD="host" \
 -DCMAKE_BUILD_TYPE=Release \
 -DLLVM_ENABLE_ASSERTIONS=ON

ninja -j$(nproc)
```

### 2. Jugar con mlir-opt

```bash
# Escribir un programa MLIR simple
cat > test.mlir << 'EOF'
func.func @add(%arg0: f32, %arg1: f32) -> f32 {
 %0 = arith.addf %arg0, %arg1 : f32
 return %0 : f32
}
EOF

# Correr canonicalización
./bin/mlir-opt --canonicalize test.mlir

# Bajar al dialecto LLVM
./bin/mlir-opt --convert-func-to-llvm --convert-arith-to-llvm test.mlir

# Ver todos los passes disponibles
./bin/mlir-opt --help | grep "Passes:"
```

### 3. Estudiar el Tutorial Toy

El repo de MLIR incluye un tutorial completo que construye un compilador para un lenguaje de juguete desde cero. Está en ` mlir/docs/Tutorials/Toy/ ` y cubre todo desde lexing hasta codegen LLVM a través de MLIR.

## Por Qué Esto Importa Más Allá de los Compiladores

El sistema de dialectos de MLIR es genuinamente útil fuera del trabajo tradicional de compiladores:

- **Diseño de hardware**: CIRCT usa dialectos MLIR para representar lenguajes de descripción de hardware, reemplazando toolchains de Verilog/VHDL.
- **Computación cuántica**: El dialecto ` quake ` representa circuitos cuánticos para optimización antes de mapear al hardware cuántico.
- **Motores de base de datos**: Los planificadores de queries pueden usar dialectos custom para representar álgebra relacional y optimizar planes de ejecución.
- **Simulación numérica**: El dialecto ` sparse_tensor ` maneja operaciones de matrices sparse con selección automática de formato.

El patrón es siempre el mismo: teners un dominio con operaciones específicas y reglas de optimización que son invisibles a un nivel de abstracción más bajo. MLIR te da la infraestructura para expresar ese dominio sin construir todo desde cero.

MLIR no es solo un LLVM IR mejor. Es un framework para construir compiladores a cualquier nivel de abstracción, con infraestructura que tardó décadas en desarrollarse en LLVM, disponible out of the box. Si se está construyendo algo que transforma representaciones estructuradas, ya sean modelos de ML, descripciones de hardware, o queries de bases de datos, MLIR vale tu tiempo.

## Preguntas frecuentes

### ¿Qué es MLIR?

MLIR (Multi-Level Intermediate Representation) es una infraestructura de compiladores dentro del proyecto LLVM que provee un framework flexible y extensible para construir compiladores de dominio específico usando 'dialectos' componibles, colecciones de tipos, operaciones y reglas de transformación.

### ¿En qué se diferencia MLIR del LLVM IR?

LLVM IR es una representación de un solo nivel fijo, diseñada para generación de código de CPU de propósito general. MLIR soporta múltiples niveles de abstracción simultáneamente, permitiendo representar conceptos de alto nivel del dominio (operaciones tensoriales, intrínsecos de hardware) junto con código de bajo nivel.

### ¿Qué son los dialectos de MLIR?

Los dialectos son colecciones modulares de operaciones, tipos y atributos en MLIR. Cada dialecto define su propio vocabulario, por ejemplo, el dialecto 'linalg' tiene operaciones para álgebra lineal, mientras que el dialecto 'gpu' tiene operaciones para kernels de GPU. Puede mezclar dialectos libremente en el mismo IR.

### ¿Quién usa MLIR en producción?

Google (TensorFlow/XLA, IREE), AMD (stack de compilador ROCm), Intel (oneAPI), Modular (lenguaje Mojo), y varias startups de hardware ML usan MLIR como backbone de sus compiladores.

## Tips

1. **Tip:** Cuando se define ops custom en MLIR, utilizar siempre TableGen (archivos ` .td `) en vez de escribir clases C++ de ops a mano. TableGen auto-genera verificadores, builders, parsers y printers. Escribir ops a mano es una pesadilla de debugging.
2. **Tip:** El flag ` -mlir-print-ir-after-all ` de MLIR vuelca el IR después de cada pass. Pasarlo por ` diff ` para ver exactamente qué hace cada transformación. Esta es la técnica de depuración más útil para desarrollo de passes.
3. **Tip:** Iniciar con los dialectos ` func ` y ` arith ` al aprender MLIR. Son lo suficientemente simples para comprender en una tarde pero demuestran todos los conceptos centrales, regions, blocks, valores SSA y sistemas de tipos.
4. **Tip:** Utilizar ` mlir-opt --canonicalize ` como primer pass de optimización. Los patrones de canonicalización (constant folding, dead code elimination, identity removal) se registran por operación y se ejecutan hasta fixpoint. Captura el 80% de las redundancias obvias.

- llvm
- mlir
- compiladores
- dialectos
- optimización
- machine-learning
- dsl
- representación-intermedia

[Volver al Blog](<https://lucioduran.com/es/blog>)
