news 2026/10/15 10:46:26

从LLVM到MLIR:结构化、可重定目标的张量编译器构造法解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从LLVM到MLIR:结构化、可重定目标的张量编译器构造法解析

搞编译器这么多年,我最怕看见的就是“用一套新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把结构化控制流转成底层CFGlowering阶段
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 bufferizedIR里还有tensor形态的linalg op检查bufferize是否完整,必要时单独跑一遍bufferize再查IR
mlir-translate报unable to convert funcfuncdialect还没转成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献给编译器工程最大的礼物,也恰恰是论文里最值得反复咀嚼的部分。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 10:46:23

dva 内置 fetch 增强:统一封装 GET/POST 请求的工程实践

1. 为什么 dva 项目里还要折腾内置请求dva 内置的fetch是基于 whatwg-fetch 做的一层薄封装&#xff0c;它把fetch挂到了app.fetch上&#xff0c;同时提供了request方法。很多同学第一次用 dva 的时候&#xff0c;看到app.fetch就直接拿来发请求了&#xff0c;结果发现&#xf…

作者头像 李华
网站建设 2026/10/15 10:45:40

2026届论文季:六类AI学术工具四个月实测全解析

2026届的论文季来得比想象中早。我身边的研究生从去年冬天就开始翻各种AI学术工具&#xff0c;朋友圈里全是“综述神器”“降重助手”“数据代码一键生成”的安利。作为一个被毕业论文和期刊投稿双重夹击的人&#xff0c;我没有急着跟风&#xff0c;而是用了将近四个月&#xf…

作者头像 李华
网站建设 2026/10/15 10:45:24

Flutter在OpenHarmony上开发高级闹钟应用实践

1. 项目背景与核心价值在移动应用开发领域&#xff0c;跨平台框架与新兴操作系统生态的结合正成为开发者关注的热点。这次我们要探讨的是如何基于Flutter框架为OpenHarmony系统开发一款具备高级编辑功能的闹钟应用。不同于传统闹钟应用&#xff0c;这个项目需要解决Flutter在Op…

作者头像 李华
网站建设 2026/10/15 10:45:03

Python爬虫入门:从环境配置到反爬策略实战

1. 为什么选择Python作为爬虫入门语言十年前我刚接触网络爬虫时&#xff0c;第一反应是找现成的采集软件。直到被验证码、动态加载和反爬机制反复折磨后&#xff0c;才意识到掌握编程爬虫的重要性。在各种语言中&#xff0c;Python凭借这几个不可替代的优势成为我的首选&#x…

作者头像 李华
网站建设 2026/10/15 10:45:02

世界-动作模型:机器人学习从预测到控制的工程落地

做机器人学习这些年&#xff0c;我越来越认同一句话&#xff1a;机器人学不会“物理直觉”&#xff0c;就跑不远。早几年大家一门心思堆网络层数、刷仿真任务得分&#xff0c;结果模型放到真机上往往一塌糊涂——泛化差、易振荡、对扰动毫无招架之力。直到世界模型&#xff08;…

作者头像 李华
网站建设 2026/10/15 10:44:25

UEFI引导程序从零到内核跳转:最小efi工程构建与避坑指南

简介&#xff1a;《一个UEFI引导程序的实现》是一份PDF电子书&#xff0c;其中UEFI即统一可扩展固件接口&#xff0c;资源面向操作系统底层开发学习者、软件工程师与业余爱好者&#xff0c;系统讲解从零编写引导程序的完整方法。全书首先对比统一可扩展固件接口与传统基本输入输…

作者头像 李华