搞编译器这么多年,我最怕看见的就是“用一套新IR解决所有问题”这种话。听起来格局很大,真上手两个月就发现,IR里什么抽象都有,等于什么抽象都没有——因为每一层优化都拖着无关的信息在跑。MLIR能在这个圈子站稳,恰恰在于它没有假装自己是一个IR,而是一套多层IR、多方言的框架。
这篇论文精读的,就是围绕MLIR建立起来的“结构化、可重定目标的张量编译器构造法”。它讲的核心不是某个新算子,也不是某个花哨的调度技巧,而是一种组织代码生成的方式:把循环结构、数据布局、后端指令选择拆成可组合的模块,让编译器在多个抽象层级上逐步降低,最终落到不同硬件。标题里的三个关键词——结构化、可重定目标、张量编译器——基本就是整个MLIR代码生成流派的地基。
这篇文章适合两类人。一类是正在折腾AI芯片工具链、NPU编译器、或者SIMD/向量化底层优化的人,你们大概率已经遇到LLVM IR表达能力不够、pass写起来牵一发动全身的问题;另一类是刚入手MLIR、被各种方言和pass名字绕晕的新手,我后面会直接给出MLIR 15版本下能跑通的全流程命令和完整代码,按着敲就行。我会把我复现时踩过的坑、试错得出的结论、以及从论文里提炼出的工程思维全部摊开讲,尽量让你少走弯路。
1. 张量编译器的底层困境:为什么LLVM还不够
1.1 从“循环”到“语义”:LLVM IR的结构性缺失
先聊一个很多初学者容易忽略的事实:LLVM IR是一个极其RISC化的底层IR。基本块、phi节点、load/store指令、以及一堆二元运算,它为了做寄存器分配和指令选择,把所有高层结构都拍平了。在LLVM IR里,一个循环不过是一组基本块和跳转边,一个矩阵乘也不过是成片的load、mul、add。这对通用编译器是好事,但对张量编译器来说就很麻烦。
麻烦在哪?你做循环tiling,得先把循环结构重新识别出来;你做向量化,得先做依赖分析,把那些在高层本来就很清晰的信息重新挖掘一遍;你想把一段计算映射到NPU上的脉动阵列,LLVM IR连“这有一段规整的矩阵乘”都看不出来,只能靠模式匹配去猜。我经常用一句话类比:LLVM是一把好用的改锥,但你拿着改锥去盖房子,每一步都要自己重新画图纸。张量编译器的本质工作是“有图纸地施工”,这个图纸在LLVM IR里是缺失的。
所以MLIR的切入点非常朴素:既然一层IR不够,那就做很多层,每一层保留自己该保留的结构。Tensor语义就用tensor方言表达,循环和分块就用Linalg和SCF方言表达,内存布局就用memref方言表达,底层指令就交给LLVM方言。层与层之间通过pass逐步降低,这就是“结构化”在编译器层面的真正含义——不是指代码风格整洁,而是指每个抽象层级都结构完整、信息不丢失。
1.2 三个词翻开论文的核心命题
标题里的“结构化、可重定目标、张量编译器构造法”,这三个词其实对应三个不同维度的问题。
“结构化”回答的是:算子如何表达,才能让编译器在高层做调度时不用猜?传统做法是用affine循环或者嵌套循环直接写,循环变量、边界、步长都是显式的,但计算语义散落在语句里。Linalg方言的做法是把计算语义和循环结构分开,用structural ops描述“对两块数据做某种规约操作”,循环只是后续降级时的实现选择。这样一来,tiling、fusion、vectorization这些变换就可以在“语义”层面安全地做,而不是在一大堆循环语句里做模式匹配。
“可重定目标”回答的是:同一份IR,如何优雅地生成CPU、GPU、以及自定义加速器的代码?MLIR的思路是靠方言边界来隔离后端差异。前端的张量计算描述保持后端无关,中间层根据目标选择不同的降级路径——要CPU就往SCF+向量化走,要GPU就往GPU方言走,要NPU就对接自定义方言。这套设计把“可重定目标”从口号变成了工程现实,不用每个硬件从头写一套编译器。
“张量编译器构造法”则是把这些理念串成一个方法论:构造一个张量编译器,不是从零写一个巨大的单体pass,而是把代码生成拆成若干个可组合、可替换的小pass,按需排列组合成pipeline。这篇论文最有价值的地方,我认为不是某个具体技巧,而是它示范了如何用模块化的方式构造编译器本身。
2. 论文核心架构精读:结构化抽象与组合式转换
2.1 linalg:把矩阵乘写成“语义”而不是“循环”
论文里最核心的结构化抽象是Linalg方言。它用一组named ops和generic op表达科学计算和张量计算中的常见模式。我直接用一个矩阵乘的MLIR片段来说明,这是全流程复现时会用到的输入:
func.func @matmul(%A: tensor<64x64xf32>, %B: tensor<64x64xf32>) -> tensor<64x64xf32> { %c0 = arith.constant 0.0 : f32 %init = linalg.fill ins(%c0 : f32) outs(%A : tensor<64x64xf32>) -> tensor<64x64xf32> %C = linalg.matmul ins(%A, %B : tensor<64x64xf32>, tensor<64x64xf32>) outs(%init : tensor<64x64xf32>) -> tensor<64x64xf32> func.return %C : tensor<64x64xf32> }注意这里linalg.matmul并没有像C代码一样显式写出三重循环,它只告诉编译器:这是一次矩阵乘,输入是A和B,输出累加到init上。那三重循环去哪了?被表达了。MLIR中的linalg.matmul内部隐含了“i、j、k”三个维度,对应的indexing_maps和iterator_types描述了数据如何被访问、哪些维度是并行的、哪些维度是规约的。
论文里把这种设计叫作“lift the level of abstraction”——把计算层级从循环结构提高到了算子语义。这么做有个立竿见影的好处:编译器在做调度变换时,不需要做循环依赖分析来确认“这个乘加能不能交换顺序”,因为算子语义已经告诉它,这就是一个可交换的规约。类似地,linalg.generic允许你用indexing_maps描述任意张量到张量的映射关系,广播、转置、点积都能被统一表达。结构化,实质上就是给编译器一份“不撒谎”的语义说明书。
2.2 pass编排:tiling、vectorize、bufferize、lower
有了结构化算子,接下来就是怎么把它变成可执行代码。论文通篇在强调一件事:变换是可组合的,关键是选好pass、排好顺序。我整理了一张表,把最常见的几个变换和它们在pipeline中的职责列出来:
| pass/变换 | 作用 | 典型位置 |
|---|---|---|
linalg-generalize-named-ops | 把linalg.matmul等具名op展开为linalg.generic,便于统一处理 | 调度前 |
linalg-tile | 按指定tile size切分计算,生成scf.for循环 | bufferize前 |
linalg-fuse | 融合相邻的linalg op,减少中间张量 | bufferize前 |
one-shot-bufferize | 把tensor值语义转成memref缓冲区语义 | 调度后 |
convert-linalg-to-loops | 把linalg op显式展开成scf.for循环 | bufferize后 |
convert-scf-to-cf | 把结构化控制流转成底层CFG | lowering阶段 |
convert-*to-llvm | 各dialect转成llvm方言 | 最终阶段 |
这张表的顺序不是随意写的。tiling和fusion必须发生在bufferize之前,原因是tensor值语义下做变换非常干净,数据是用SSA值传递的,没有副作用,编译器想怎么切分都不会破坏内存安全性。一旦bufferize成memref,你就有了实际的内存读写和副作用,变换的合法性和复杂度都会上升。这就像画设计稿时怎么改都容易,一旦开工砌墙了再调整户型就要砸墙。
论文里对pass编排的核心观点是:每个pass只做一件事,但pass之间通过IR类型和方言边界隐式耦合。tiling之后产生scf.for和tensor.extract_slice,bufferize时识别这些结构并转换为memref形式,lowering时再一步转成llvm方言。你把中间任何一环拿掉,pipeline就跑不通;你加一个不必要的pass,IR就可能被过度复杂化,后续优化反而更难做。这个“少而精”的组合思想,是我从论文里学到的最重要的工程原则。
2.3 可重定目标:从linalg到不同后端
论文标题里的“可重定目标”,是很多做硬件工具链的人最关心的点。Linalg之所以强大,是因为它的结构化描述可以分流到多条降级路径:
- 走CPU路径:
linalg -> scf.for -> affine/vector -> llvm,生成多线程或SIMD代码; - 走GPU路径:
linalg -> gpu.launch + gpu.blocks/gpu.threads -> NVVM/ROCm,生成CUDA或HIP kernel; - 走自定义加速器路径:
linalg -> 自定义后端方言 -> LLVM方言或机器码。
这背后的关键机制就是方言。MLIR允许你同时持有多个不同抽象层级的方言,方言之间通过“转换pass”而不是“指令选择”来联动。传统的可重定目标编译器,比如GCC那样按target描述文件做指令选择,面向的是一个指令集;MLIR的可重定目标面向的是一系列抽象层级,你可以只替换最后两三个pass,或者追加一个新的方言来描述硬件的特殊原语,前面庞大的优化基础设施全部复用。
我见过不少团队做AI芯片编译器,最初想着从TVM或LLVM改起,最后都被迫回到MLIR,就是因为MLIR允许你在IR里同时看到“张量级的计算结构”和“底层指令选择的结果”,并且这个跨度可以由你自定义的方言平稳填平。论文中的构造法本质上是在说:不要把“可重定目标”寄托在某个万能后端上,而是做好抽象分层,让每一层都有可能被重新定向。
3. MLIR 15全流程复现:手把手跑通一个matmul
3.1 环境准备:LLVM/MLIR 15的构建
我复现用的版本是LLVM/MLIR 15,对应llvm-project的release/15.x分支。如果你用的是更新版本,部分pass名字会有变化,但整体流程完全一致。
首先拉代码和构建。源码构建是官方推荐方式,configure时一定要开release和MLIR_ENABLE_BINDINGS_PYTHON=OFF,否则等你想真正跑一个pipeline时,构建出的二进制可能慢到让你怀疑人生:
git clone -b release/15.x --depth 1 https://github.com/llvm/llvm-project.git cd llvm-project mkdir build && cd build cmake -G Ninja ../llvm \ -DLLVM_ENABLE_PROJECTS=mlir \ -DLLVM_TARGETS_TO_BUILD="X86;AArch64" \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_ASSERTIONS=ON ninja mlir-opt mlir-translate mlir-cpu-runner构建时间取决于机器,8核以上的机器大概20到40分钟。这里我建议只构建你需要的几个工具,别整个ninja一把梭,能省特别多时间。构建完成后,把build/bin加入PATH,检查版本:
mlir-opt --version输出里能看到MLIR对应的LLVM版本号。15版本里mlir-opt依赖的库比早期版本更大,所以二进制体积大是正常现象。
3.2 从tensor到linalg:一个最小输入
我把前面的matmul代码保存为matmul.mlir。注意,这个文件已经是tensor级别的linalg表达,没有经过前端框架。真实项目里你可以从TOSA、TensorFlow、PyTorch等前端导入,但在复现论文思路时直接用linalg是最清晰的方式,可以跳过前端降级步骤,直奔代码生成主题。
接下来执行一段完整的pipeline,把ta从tensor级别一路降到LLVM方言:
mlir-opt matmul.mlir \ --linalg-generalize-named-ops \ --linalg-tile="tile-sizes=32,32" \ --one-shot-bufferize \ --convert-linalg-to-loops \ --convert-scf-to-cf \ --convert-cf-to-llvm \ --convert-arith-to-llvm \ --convert-memref-to-llvm \ --convert-func-to-llvm \ -o matmul-llvm.mlir执行过程中不需要额外参数,如果命令没报错,说明整条pipeline是合法的。最后生成的matmul-llvm.mlir是LLVM方言的IR,里面全是llvm.add、llvm.load、llvm.store这类底层操作。你可以直接把文件打开看看最终形态,虽然阅读性很差,但你能直观感受到“前面的结构化语义已经被全部拍平了”。
想看中间结果,就把pipeline拆开执行。比如只跑前两步,输出tiling后的IR:
mlir-opt matmul.mlir \ --linalg-generalize-named-ops \ --linalg-tile="tile-sizes=32,32" \ -o matmul-tiled.mlir这个时候的IR里能看到scf.for嵌套循环包裹着小块的linalg.generic,每个小块处理32x32的矩阵分片。这个中间形态就是论文里所说的“structured schedule”——计算仍然以结构化算子存在,但已经带上了分块信息。
3.3 关键的pass参数与顺序:为什么这个顺序不能乱
很多新手第一次跑MLIR,最常问的问题是:“这些pass到底按什么顺序排?为什么我随便加一个就报错或生成错误代码?”我的经验是:盯住数据表示的变化。
--linalg-generalize-named-ops是第一步,因为具名的linalg.matmul虽然语义清晰,但它内部的iterator结构和通用generic形式还不完全一致。想做统一的tiling,最好先全部展开成linalg.generic,这样后续pass只需要处理一种op形态。这就像把统一格式的数据先规整到一张大表里,再做各种统计分析,分析逻辑才不用写多份。
接着是tiling。tile size的选择直接影响性能。对这个64x64的matmul,我分别试过32,32、16,16和8,8,实测下来32,32在这个尺寸上最平滑,既不会因为分块太小导致循环开销过大,也不会因为分块太大失去缓存局部性。当然这只是小例子,真实场景里tile size需要针对硬件cache大小、向量寄存器宽度反复调参。
然后是--one-shot-bufferize。这一步之所以放在tiling后,是因为tensor域的变换是纯函数式的,你可以自由地重写计算图、改变数据流,不用担心内存副作用。一旦bufferize,所有tensor都变成了memref引用,数据流关系变成了内存读写关系,变换空间会被严重压缩。论文里把这称为“structured control flow上的bufferization”,它比传统编译器的内存分配更灵活,因为它能识别tensor的use-def链来复用缓冲区。
最后才是loops和lowering。--convert-linalg-to-loops把结构化op展开成显式循环,--convert-scf-to-cf去掉结构化控制流,再逐级转成LLVM方言。这里有一点要注意:--convert-linalg-to-loops要求linalg op都已经是memref形式,如果你的IR里还有tensor,它会报错。所以pipeline顺序绝对不能搞反,bufferize必须在你做任何lowering之前。我见过太多人把--convert-linalg-to-loops放在--one-shot-bufferize前面,然后被一团错误输出折磨半天。
3.4 用mlir-translate生成LLVM IR并执行
mlir-opt输出的LLVM方言文件还只是MLIR的dialect形态,要继续走LLVM后端,需要mlir-translate把它翻译成标准LLVM IR:
mlir-translate --mlir-to-llvmir matmul-llvm.mlir -o matmul.ll llc matmul.ll -o matmul.s -mcpu=native如果你想直接执行验证正确性,可以给这个文件包一层main函数,或者用mlir-cpu-runner配合JIT。我测试时习惯在matmul外面加一个简单的func.func @main(),初始化两个输入张量、调用@matmul并打印结果。不过更省事的是直接用mlir-opt在IR里插入一个临时call加上打印函数,然后用mlir-cpu-runner -e main -entry-point-result=void跑。对于只想验证生成代码是否正确的人,我推荐先跑一个全1的输入,手算一眼就能验证结果。
这一步跑通之后,整个“结构化、可重定目标的张量编译器构造法”的闭环就完整了:你从tensor级别的linalg结构化表示出发,经过tiling、bufferize、loops、llvm逐级降低,最终获得了可执行代码。这个流程本身,就是论文所倡导的模块化代码生成范式。
4. 复现避坑、性能观察与论文读法
4.1 我踩过的坑:一张速查表
复现过程中我踩了不少坑,整理成速查表,按“现象-原因-解决”分组:
| 现象 | 原因 | 解决 |
|---|---|---|
--linalg-tile报错expects operation to have a tensor or memref type | 输入不是linalg op | 先跑--linalg-generalize-named-ops再tile |
--one-shot-bufferize后仍有tensor类型残留 | 某些op没被支持或pipeline顺序不对 | 打开--one-shot-bufferize="allow-unknown-ops=false"检查;确认tiling在bufferize前 |
--convert-linalg-to-loops报linalg op is not bufferized | IR里还有tensor形态的linalg op | 检查bufferize是否完整,必要时单独跑一遍bufferize再查IR |
mlir-translate报unable to convert func | funcdialect还没转成llvm.func | 确保执行了--convert-func-to-llvm |
| 生成的LLVM IR跑起来结果全0 | 初始化/输出tensor没有正确传入 | 检查extern函数调用和memref传参方式,用固定值初始化输入验证 |
第一个坑尤其隐蔽。linalg.matmul虽然看起来可以直接tile,但不同版本的MLIR对具名op的tiling支持程度不一样,最稳妥的办法永远是先generalize再tile。我在MLIR 15上就遇到过linalg.matmul直接在tile时报“does not have a tiling interface”的情况, generalize之后就一切正常。
第三个坑也值得多说一句。bufferize之后,IR里的linalg op已经变成memref形式,但如果你在同一个IR文件里混用tensor和memref——比如某个tensor.empty没被bufferize——convert-linalg-to-loops就会卡住。解决办法是执行完bufferize后立刻mlir-opt输出检查一遍,用grep搜tensor.,只要没有非法的tensor op残留再继续降级。
4.2 结构化代码生成的性能初探
论文里反复强调结构化不是为了“好看”,而是为了性能。我用自己的机器简单对照过几种生成的代码。测试是一个64x64的f32矩阵乘,分别走三条路径:第一条是直接--convert-linalg-to-loops不tiling;第二条是tile size=32再转loops;第三条是tile size=16。编译器后端都用llc -mcpu=native,开O3。
观察到的趋势是:不tiling的版本执行时间最长,因为大循环体在寄存器复用和缓存局部性上都不理想;tile size=32的版本相对不tiling版本大约有1.3倍左右的提升;tile size=16则略逊于32。这个数据不够严谨,但反映了结构化调度确实是有效的——它能让你在不写手写汇编的情况下,通过调整一个pass参数显著改变性能特征。
有个更直观的体会:当你把tiling后的IR和手工写的tiled C代码生成的LLVM IR对比时,会发现两者形状高度相似。这说明MLIR提供的结构化构造法,本质上是在编译器层面把专家手工优化的套路变成了可编程、可组合的转换流程。而且传统C代码一旦写死循环结构,想再改变tile size就得改源码重新编译;MLIR里你只需要改一行tile-sizes参数,后面的优化流水线完全不变。这个“把性能工程变成参数配置”的能力,对需要快速适配多款芯片的团队来说价值巨大。
4.3 把“构造法论文”读成工程方法
最后说一点读这类论文的心得。很多人读编译器论文喜欢先看算法细节,比如tiling的公式推导、bufferization的证明,结果读了两天还在前几页打转,然后放弃。我的经验是,对于一个偏“构造法”的论文,先用半个小时建立主干:作者想把编译器组织成几层,每层用什么方言/IR表达,层与层之间通过哪些pass衔接。记住这个骨架,你才不会被细节淹没。
这篇论文(以及MLIR代码生成这一系列工作)真正让我收益的,不是某个具体pass,而是“可组合性”优先的设计态度:每个变换只做一件事,事件之间通过IR形态隐式耦合,而不是互相调用、互相依赖。我在自己项目里设计代码生成流水线时,也学着把“调度变换”和“后端降级”彻底拆开。这个思路让团队里的不同成员可以各自迭代自己的模块,而不至于天天合并冲突。
复现过程中的另一个体会是:MLIR是一个变化速度很快的项目,不同版本之间pass接口经常微调。所以学的时候,要把重点放在“为什么这个pass要在另一个pass前面”这类不变的逻辑上,而不是死记命令行的写法。理解了抽象层级和数据表示的变化规律,换版本、换后端对你来说都只是查文档的问题,而不是重学一遍的问题。
这套可组合、模块化的构造法,我个人认为是MLIR献给编译器工程最大的礼物,也恰恰是论文里最值得反复咀嚼的部分。