1. 课程导入
编译器技术在过去二十年经历了从“手工优化”到“分层抽象”的深刻变革。LLVM的出现统一了中后端优化与代码生成的接口,但面对AI加速器、FPGA、专用DSP以及异构计算架构的爆发式增长,传统编译器的“前端-中端-后端”三层模型逐渐暴露出抽象层次单一、扩展成本高昂的问题。MLIR(Multi-Level Intermediate Representation,多级中间表示)正是在这一背景下由Google于2019年发起并贡献给LLVM基金会。它并非要取代LLVM IR,而是要在LLVM IR之上构建一套可复用的、支持多抽象级别的中间表示基础设施。本章作为课程的第一讲,将系统梳理MLIR的起源动机、核心设计目标、与LLVM的协作关系,并深入剖析Dialect与Pass这两个贯穿MLIR开发始终的核心概念。
1.1 MLIR的起源:从硬件碎片化到编译器基础设施重构
核心原理:为什么LLVM IR不够用了?
LLVM IR的设计初衷是作为编译器中端的“通用语言”——它假设所有前端(C/C++/Rust等)经过一系列降级后,都能统一到一组相对底层的操作上(load/store、算术运算、控制流、函数调用等)。这一假设在CPU领域基本成立,因为x86、ARM、RISC-V等架构的指令集抽象层次相近。然而,当编译器需要面向AI加速器(如TPU、NPU)、GPU、FPGA、DSP等异构硬件时,LLVM IR暴露出三个根本性缺陷:
- 抽象层次错位:AI加速器上的“矩阵乘”是一个原子操作,但在LLVM IR中会被拆解为成千上万条标量指令,优化器无法恢复其语义,更无法针对硬件特性(如脉动阵列、片上缓存)做优化。
- 类型系统不匹配:LLVM IR只支持整数、浮点、向量、指针等基础类型,无法表达张量的形状、布局(NCHW vs NHWC)、量化参数(int8/uint8/对称/非对称)等高层信息。
- 扩展成本极高:若要在LLVM IR中新增一个“矩阵乘”指令,需要修改LLVM核心的指令定义、Verifier、文档、所有后端,这对任何团队都是不可接受的。
在MLIR出现之前,业界已经出现了大量“各自为政”的编译器栈:Google的XLA(面向TPU)、TVM(面向GPU/FPGA)、Intel的nGraph、Facebook的Glow等。它们都在LLVM之上或之外构建了私有的IR和优化管道,导致代码无法复用、社区分裂。MLIR的提出正是为了终结这种“重复造轮子”的局面。
判断方法:何时应该使用MLIR?
判断一个编译器项目是否适合引入MLIR,可以从以下三个维度评估:
| 评估维度 | 适合使用MLIR | 不适合使用MLIR |
|---|---|---|
| 目标硬件 | 异构加速器、自定义指令集、FPGA、多后端支持 | 仅面向x86/ARM等成熟CPU,且无扩展计划 |
| 输入语言 | DSL、Python框架、多前端融合、需要高层语义保留 | 单一传统语言(如仅C),且无需自定义优化 |
| 优化需求 | 需要硬件特定优化(布局转换、算子融合、量化) | 仅需标准优化(内联、循环展开、常量传播) |
一个典型的反例是:如果只是为某个C语言子集编写一个简单的静态编译器,直接使用LLVM前端(Clang)+ LLVM IR即可,引入MLIR反而会增加不必要的抽象层和开发成本。
案例拆解:TVM与MLIR的对比
TVM是一个面向深度学习模型的编译器栈,它设计了Relay IR(高层图IR)和TIR(张量IR)。TVM团队在开发过程中发现,Relay和TIR之间的边界模糊,且无法复用LLVM社区的优化Pass。当MLIR发布后,TVM社区开始探索将TIR迁移到MLIR的affine dialect和linalg dialect上。这一迁移的核心收益在于:
- MLIR提供了现成的循环变换基础设施(affine dialect),无需TVM自己维护一套循环优化框架。
- MLIR的Dialect间转换机制(如linalg→affine→scf→llvm)使得TVM可以聚焦于高层图优化,而将底层代码生成交给MLIR+LLVM。
- 代价是TVM需要学习MLIR的API和Pass基础设施,迁移成本约3-6人月。
这个案例说明,MLIR并非银弹,但它确实为“高层IR与底层IR之间需要多级过渡”的场景提供了标准化的解决方案。
常见误区与总结
误区2:认为MLIR只能用于AI编译器。MLIR的Dialect机制是通用的,它同样适用于HPC、信号处理、FPGA综合等场景。
误区3:认为MLIR开箱即用。MLIR是一个“编译器开发工具包”,你需要为自己的领域定义Dialect和Pass,这需要一定的学习曲线。
1.2 MLIR的设计目标:多抽象级别与渐进式Lowering
核心原理:从“单一IR”到“IR光谱”
传统编译器的IR是“一维”的——从AST到LLVM IR再到机器码,中间只有一次大的抽象跳跃。MLIR的核心设计哲学是:编译器应该由多个抽象级别的IR组成,每个IR(即Dialect)服务于特定的优化目标,且相邻IR之间的转换(Lowering)应该是渐进、可验证的。例如,一个深度学习模型的编译过程可以经历以下Dialect层次:
- Tensor Dialect:表示张量运算,保留形状、数据类型、布局等高层信息,适合做算子融合和布局优化。
- Linalg Dialect:将张量运算结构化地映射到循环结构,支持循环分块、向量化等变换。
- Affine Dialect:显式表达循环嵌套和内存访问模式,适合做循环重排、并行化。
- SCF Dialect(Structured Control Flow):将Affine循环降级为通用的结构化控制流(for/while/if)。
- LLVM Dialect:将SCF和算术操作映射到LLVM IR的语义。
这种“IR光谱”设计使得每一层优化都只关注本层的信息,不需要关心底层细节。例如,Tensor Dialect的算子融合Pass不需要知道最终是x86还是GPU,它只负责将两个tensor操作合并为一个。
操作流程:如何设计一个自定义Dialect
在实际项目中,开发者经常需要定义自己的Dialect。以下是标准流程:
// 1. 定义Dialect类(继承mlir::Dialect)
class MyDialect : public mlir::Dialect {
public:
explicit MyDialect(mlir::MLIRContext *ctx);
// 注册类型、属性、操作
};
// 2. 定义Operation(继承mlir::Op)
class MyOp : public mlir::Op<MyOp, ...> {
public:
using Op::Op;
// 定义操作数、结果、属性、Verifier
static void build(mlir::OpBuilder &builder, ...);
mlir::LogicalResult verify();
};
// 3. 在Dialect构造函数中注册
MyDialect::MyDialect(mlir::MLIRContext *ctx)
: mlir::Dialect(getDialectNamespace(), ctx) {
addOperations<MyOp>();
addTypes<MyType>();
}
定义完成后,还需要编写Canonicalization Patterns(简化模式)和Fold Hooks(常量折叠),这些是MLIR优化Pass的基础。
实战场景:Tensor Dialect的算子融合
假设我们有一个模型包含“卷积→ReLU→池化”三个操作。在Tensor Dialect层面,这三个操作是独立的节点。通过一个融合Pass,我们可以将它们合并为一个“ConvReLUPool”操作。这个Pass的输入是Tensor Dialect的IR,输出仍然是Tensor Dialect的IR,但操作数量从3个减少到1个。融合后的IR在后续lowering到Linalg时,可以直接生成一个高效的循环嵌套,避免中间结果的写回内存。
关键参数:融合Pass需要检查每个操作的输入输出形状是否匹配、数据类型是否一致、是否有其他用户引用中间结果。如果ReLU的输出被另一个分支使用,则不能融合。
常见误区与总结
1.3 与LLVM的关系:协作而非替代
核心原理:MLIR在LLVM生态中的定位
MLIR是LLVM项目的一个子项目,它位于LLVM IR的上层。MLIR的llvm dialect提供了对LLVM IR指令、类型和属性的直接映射,因此任何MLIR IR最终都可以通过--convert-to-llvm Pass转换为LLVM IR。这意味着:
- MLIR可以复用LLVM的所有中后端优化(如指令选择、寄存器分配、指令调度)。
- MLIR的Pass可以调用LLVM的分析和转换接口(如LoopInfo、ScalarEvolution)。
- MLIR的代码生成可以无缝对接LLVM的Target后端(x86、ARM、NVPTX等)。
从工程角度看,MLIR与LLVM的关系类似于“前端框架”与“后端引擎”——MLIR负责处理高层语义和硬件特定优化,LLVM负责底层的通用代码生成。
判断方法:何时lowering到LLVM IR?
并非所有MLIR IR都需要lowering到LLVM IR。以下决策树可以帮助判断:
| 场景 | 推荐路径 | 原因 |
|---|---|---|
| 目标为CPU(x86/ARM) | MLIR → LLVM IR → 机器码 | LLVM的CPU后端成熟稳定 |
| 目标为GPU(CUDA/ROCm) | MLIR → GPU Dialect → LLVM NVPTX/AMDGPU | 需要GPU特定的内存模型和并行语义 |
| 目标为FPGA | MLIR → 自定义硬件Dialect → HLS/Verilog | LLVM IR无法表达硬件时序和资源约束 |
| 目标为自研AI加速器 | MLIR → 自定义加速器Dialect → 专用代码生成 | 需要完全控制指令级并行和数据流 |
案例拆解:一个端到端的MLIR编译流程
以Google的TPU编译器为例,其编译流程如下:
- 前端将TensorFlow计算图转换为MLIR的mhlo dialect(HLO:High-Level Operations)。
- 执行mhlo层面的优化:算子融合、常量折叠、形状推断。
- 通过
--convert-mhlo-to-linalg将mhlo转换为linalg dialect。 - 在linalg层面执行循环变换(分块、向量化、并行化)。
- 通过
--convert-linalg-to-tpu将linalg转换为TPU自定义dialect。 - TPU dialect的Pass生成TPU微码(类似汇编)。
整个流程中,LLVM IR并未出现——因为TPU不需要LLVM的指令选择。但如果是面向CPU的推理引擎,则第5步会替换为--convert-linalg-to-loops和--convert-scf-to-cf,最终lowering到LLVM IR。
常见误区与总结
1.4 Dialect与Pass:MLIR的两大支柱
核心原理:Dialect是词汇表,Pass是语法规则
如果把MLIR比作一门语言,那么Dialect就是词汇表——它定义了有哪些“单词”(操作)和“词性”(类型、属性);Pass就是语法规则——它定义了如何组织这些单词形成有意义的“句子”(优化后的IR)。Dialect和Pass是正交的:一个Dialect可以被多个Pass操作,一个Pass也可以跨多个Dialect工作。
Dialect的核心组件包括:
- Operation:IR的基本单元,类似于LLVM的Instruction,但更灵活(可以包含任意数量的操作数和区域)。
- Type:定义操作数的类型系统,如TensorType、MemRefType、VectorType。
- Attribute:编译期常量信息,如张量形状、量化参数、循环步长。
- Interface:为操作提供统一的行为抽象,如LoopLikeInterface、ViewLikeInterface。
Pass的核心机制包括:
- Pattern Rewriter:基于模式匹配的IR重写引擎,是大多数优化Pass的基础。
- Analysis:对IR进行只读分析(如依赖分析、活跃变量分析),结果可被多个Pass复用。
- Pass Pipeline:将多个Pass按顺序编排,支持嵌套和复用。
操作流程:编写一个简单的Pass
// 定义一个转换Pass:将MyOpA替换为MyOpB
struct ConvertMyOpPass : public mlir::PassWrapper<ConvertMyOpPass,
mlir::OperationPass<mlir::func::FuncOp>> {
void runOnOperation() override {
mlir::func::FuncOp func = getOperation();
func.walk([&](MyOpA op) {
mlir::OpBuilder builder(op);
builder.setInsertionPoint(op);
auto newOp = builder.create<MyOpB>(op.getLoc(), op.getOperand());
op.replaceAllUsesWith(newOp.getResult());
op.erase();
});
}
};
// 注册Pass
void registerMyPasses() {
mlir::PassRegistration<ConvertMyOpPass>("convert-myop-a-to-b",
"Convert MyOpA to MyOpB");
}
这段代码展示了Pass的基本骨架:遍历IR、匹配目标操作、创建新操作、替换使用点、删除旧操作。实际项目中,更推荐使用mlir::RewritePattern来编写声明式的转换规则,因为Pattern Rewriter会自动处理操作数替换和IR合法性维护。
实战场景:Dialect间Lowering的典型模式
假设我们需要将自定义的matmul操作lowering到linalg dialect。标准做法是编写一个ConversionPattern:
class MatmulLowering : public mlir::OpConversionPattern<MatmulOp> {
public:
using OpConversionPattern::OpConversionPattern;
mlir::LogicalResult matchAndRewrite(
MatmulOp op, OpAdaptor adaptor,
mlir::ConversionPatternRewriter &rewriter) const override {
// 将MatmulOp转换为linalg.matmul
rewriter.replaceOpWithNewOp<mlir::linalg::MatmulOp>(
op, op.getResult().getType(),
adaptor.getOperands());
return mlir::success();
}
};
这个Pattern在--convert-my-dialect-to-linalg Pass中被注册。当Pass运行时,它会遍历所有MatmulOp,并尝试将其替换为linalg.matmul。如果替换成功,原操作被删除;如果失败(例如类型不匹配),则保留原操作并报告错误。
常见误区与总结
mlir::OpBuilder和mlir::ConversionPatternRewriter来修改IR,而不是直接操作操作数的内部指针。MLIR的IR是SSA形式的,直接修改可能导致use-def链不一致。
1.5 本章总结与学习路径建议
本章从MLIR的起源出发,梳理了其设计目标、与LLVM的关系,并深入剖析了Dialect和Pass这两个核心概念。需要强调的是,MLIR不是一门“语言”,而是一个编译器开发框架——它的价值在于让你能够以标准化的方式构建自定义的IR和优化管道。在后续章节中,我们将逐一深入:第2章讲解MLIR的IR结构(Operation、Value、Region、Block);第3章讲解Dialect开发实战;第4章讲解Pass基础设施与Pipeline编排;第5章开始进入具体的Dialect(affine、linalg、scf等)和转换模式。建议读者在学习过程中,始终带着“我的编译器需要哪些抽象层次?每个层次需要什么信息?”这两个问题去理解MLIR的设计选择。
- MLIR的诞生源于硬件多样性和LLVM IR抽象层次单一之间的矛盾。
- MLIR的核心设计目标是“多抽象级别表示”和“渐进式Lowering”。
- MLIR与LLVM是协作关系:MLIR负责高层优化,LLVM负责底层代码生成。
- Dialect定义IR的“词汇表”,Pass定义IR的“语法规则”,两者正交组合。
- 开发MLIR项目时,应从最小可行Dialect开始,避免过度设计。