☰
IREE编译器实战:从PyTorch到10KB级独立执行模块
2026/10/6 6:00:45 网站建设 项目流程

做AI模型部署的人,这几年应该都有一个感受:框架在变大,运行时在变大,依赖在变多。以前一套TensorFlow部署,恨不得捆上几百MB的库文件;后来换ONNX Runtime,稍微轻了一点,但几十MB依然是家常便饭。你要是敢把模型跑在MCU或者FPGA旁边的小处理器上,光是运行时体积就能让你怀疑人生。

IREE这个编译器,圈内这几年一直在提,但我发现真正把它跑通、理解它设计思路的人并没有想象中多。很多人一听“编译器”,第一反应就是“把模型转成某种格式”,比如转成ONNX、转成TFLite。但IREE干的事要狠得多:它把调度(scheduling)和执行(execution)一起编译进一个独立的产物,产物可以小到10KB级别,运行时只保留一个极小的虚拟机内核和硬件抽象层。这意味着你部署的不再是“框架+模型文件”,而是一个几乎自包含的执行模块。

本文我不打算写成IREE的官方文档翻译,而是以一个实际用过它部署模型的人的身份,把这套编译器的核心设计、调度怎么“烧”进产物、10KB的产物到底是怎么憋出来、以及踩过的坑,一条一条讲清楚。适合正在做边缘部署、对模型体积敏感、或者想知道AI编译器内部到底在忙什么的人。

1. 为什么我盯上了 IREE:传统部署链路的三个痛点

1.1 运行时体积失控

先聊一个最扎心的痛点:运行时体积。

传统的模型部署链路大约是“训练框架 → 导出中间格式 → 运行时加载解释执行”。以ONNX Runtime为例,CPU版本的so/dll加起来常见是30~50MB,GPU版本动辄几百MB。TFLite好一些,但Micro版本也要20KB以上,而且功能裁剪很厉害,算子一多就装不下。

你可能会说,运行时大点就大点,反正现在设备存储也不小。但问题在于,很多边缘设备是Flash受限的,比如工业控制板、车载域控制器、消费级摄像头,可用固件空间往往只有几百KB,有的甚至按字节算。在这种场景下,一个几十MB的运行时直接判死刑。

IREE的思路完全不同。它把所有能提前算的都提前算完——算子的内核选型、内存分配、执行顺序、并发调度,全部在编译期定下来。产物就是一个VM module文件(通常是.vmfb),里面装的是执行指令流、调度信息和参数缓冲区描述。运行时只需要一个非常小的VM解释器加一个硬件驱动,整体可以裁剪到几KB级别。

1.2 调度与执行被强行拆开的代价

第二个痛点是调度和执行长期被分开处理。

传统方案里,模型文件(比如ONNX、TFLite)描述的是计算图——谁依赖谁、谁先算谁后算。但真正“谁在哪个设备上跑、几个算子并行跑、内存缓冲区怎么复用”,是运行时调度器干的活。比如你用ONNX Runtime跑多线程,execution provider和线程池在做调度;你用TensorFlow Serving,Batching和inter-op线程池在做调度。

调度器放在运行时,意味着两件事绕不开:

一,调度是通用的,它不认识你这个模型的独特结构。它只能基于运行时拿到的计算图,用启发式规则去决定并行度和执行顺序,这种决策通常不是最优的,甚至可能因为图上一次 reshape 的微小变化,调度行为完全走样。

二,调度器本身是运行时里最庞大、最难裁剪的部分。因为要应对任意计算图,调度器必须保留大量的状态机和策略代码。

IREE选择在编译期做调度。编译的时候,模型结构是已知的,每个算子的延迟特性是可以估计的,内存依赖也是确定的。它直接生成一个静态的执行计划,把并发、等待、信号量同步都编码成指令流。运行时只是“照章办事”,不需要做聪明的决策,所以调度器的代码可以砍掉绝大部分。

1.3 框架生态绑定的死结

第三个痛点是框架绑定。PyTorch模型要部署,传统上要么走libtorch(大、重、版本敏感),要么导出ONNX再转一圈。Transformers这种动态图模型,转ONNX经常遇到控制流和动态shape问题。我见过不少团队,模型在PyTorch里跑得好好的,一导ONNX就各种算子不支持,最后只能降级用torchscript,部署体积和性能两头受气。

IREE借助MLIR的多级中间表示,接住了PyTorch(通过torch-mlir)和TensorFlow(通过原生前端),并且用统一的方言体系(dialect)把它们降到同一套中间层。这样一来,模型来源无所谓,最后都会编译成同样的执行模块。对我这种同时维护多个框架模型的人来说,统一链路的价值非常大。

2. 编译期调度:IREE 的架构设计核心

2.1 从MLIR说起:为什么需要多级中间表示

理解IREE,绕不开MLIR。MLIR是LLVM社区孵化的一个编译器基础设施,核心思想是“多级IR”(Multi-Level Intermediate Representation)。它不是一种固定的中间表示,而是提供了一堆可组合的“方言”(dialect),每种方言描述特定抽象层次上的运算。

IREE的编译管线大体是这样的:

PyTorch模型 / TensorFlow模型 │ ▼ 框架专属方言(torch / tf) │ ▼ 张量运算方言(linalg on tensors) │ ▼ IREE流方言(iree-stream) │ ▼ 硬件抽象方言(iree-hal) │ ▼ 虚拟机方言(iree-vm)→ 序列化成 .vmfb

这每一层都不是白设的。框架专属方言负责把PyTorch/TF的算子映射到张量语义;linalg层做算子融合、循环展开、向量化;stream层开始出现调度概念——算子被组织成“流”(streams),流之间有依赖关系;到hal层,一切变成资源、命令缓冲区和设备队列;最后vm层把整个执行计划变成虚拟机指令。

我记得第一次看这个管线,最困惑的就是:为什么要搞这么多层,直接一层IR到底不行吗?后来想明白了——任何一层IR同时承担“描述计算”和“描述调度”两种职责,一定会顾此失彼。linalg层只管计算,它不需要知道“这个算子跑在CPU还是GPU”;hal层只管设备资源,它不需要知道“调度树里的依赖边是哪个算子产生的”。每层各司其职,编译选项才能自由组合。这也是IREE能同时支持CPU、GPU、嵌入式多种后端的原因。

2.2 调度树:编译时就定好的执行蓝图

IREE在iree-stream方言阶段,会构建一个“调度树”(scheduling tree)。这是一个非常重要但文档里讲得含糊的概念,我用自己的话解释一下。

你可以把调度树理解成一张“谁依赖谁”的DAG,但它的每个节点不是单个算子的op,而是一个执行阶段。这个执行阶段内部,若干个内核(kernel)被编排成一个命令序列;不同阶段之间,通过显式的“等待”(wait)和“信号”(signal)来同步。这和CUDA Graph里的图捕获思想很像——把一系列内核启动和事件同步固化成一个可重放的图——只是IREE在编译期就把它定死了,而不是运行期捕获。

调度树的颗粒度是可配置的。你希望多个无依赖的算子并行跑,调度树会为它们分叉成多个流,每个流对应一个执行阶段;你希望内存紧张时严格串行,调度树会退化成一条直线,但缓冲区可以复用,峰值内存最低。

编译期做这个决策,有个天然优势:它可以贪心地做内存规划。因为执行顺序已经定死,编译器知道每个缓冲区的生命周期,可以在同一个内存池上做覆盖(alias)。我就见过一个模型,默认调度下峰值内存是200MB,换成编译期优化后的串行调度,直接降到90MB。这在边缘设备上是质的差别。

2.3 静态流与异步执行的等价变换

IREE的调度编译出来后,运行层有两种典型的执行模式:本地同步执行和任务队列异步执行。

同步模式很好理解,就是一个流一个流地按顺序执行vm指令,执行完一个阶段立刻执行下一个。异步模式则会把流的执行打包成任务,提交给硬件队列(GPU的command queue或者CPU的线程池),主线程不阻塞。

有意思的是,这两种模式用的是同一个.vmfb产物,运行时区别只是一个驱动参数。也就是说,调度计划在编译期已经固定,但“同步跑还是异步跑”是运行时策略。这个设计很巧妙——编译期把DAG的依赖关系编码成指令流,运行期只是选择“沿着指令流一步步走”还是“把整个指令流扔给队列并行加速”。

我在实际部署时,一般先在同步模式下验证正确性,再切到异步模式测吞吐。如果异步模式性能反而下降,通常是因为算子太碎、调度开销大于并行收益,这时候在编译期把调度树调成更粗的颗粒度更有效。这种“调度决策在编译期,执行方式在运行期”的解耦,确实能省掉很多运行时的调度代码。

3. 10KB 产物是怎么憋出来的:模块形态与运行时拆解

3.1 产物不是二进制可执行文件,而是VM模块

很多第一次用IREE的人有个误解:以为编译出来的是一个ELF或者EXE,直接拿去跑。实际上,IREE默认产物是.vmfb——VM module文件,一种紧凑的字节码模块。它需要IREE runtime(一个很小的库)来加载和执行。

这里要解释一个核心逻辑:为什么不用原生二进制,而是用VM字节码?

因为原生二进制绑定架构。ARM Cortex-M的二进制不能在x86上跑,x86的二进制也不能塞进GPU设备代码。如果用AOT生成原生代码,你需要为每个目标平台单独编一个产物。而VM字节码是设备无关的,它可以在x86上解释执行,也可以在MCU上由Micro runtime解释执行。需要极致性能的平台,还可以把.vmfb再编译成原生system library,走C ABI接入。

代价是VM解释器有一点性能损失。但IREE的VM指令集设计得非常精简,大部分算子对应一个密集型内核调用,解释开销平摊到整个内核执行时间里,占比很低。实测一个MobileNet推理,VM解释开销通常在1%以内,完全可以接受。

3.2 产物构成拆解:调度指令、内核入口、常量和参数表

那10KB到底装了什么?我拿一个极简的加法模型(无权重)为例,拆开.vmfb看看。

.vmfb的内部大致包含这几块:

  • 模块元信息:版本号、符号表、导出函数表。
  • VM指令段:整个执行计划的指令流。包括函数入口、帧管理、调用内核、跳转和控制流指令。
  • 内核函数表:每个算子在目标后端(比如llvm-cpu)中对应一个可调用函数,模块里存的是函数ID和调用参数。
  • 常量段:编译期摊平的常量数据。比如某些权重在编译期做了常量折叠,直接嵌进去。
  • 参数缓冲描述:输入输出张量的shape、dtype、内存对齐要求。

一个有几十个算子的轻量模型,指令段可能只占几百字节到几KB,因为每个算子对应的指令就那么几条:设置参数、调用内核、等待事件。10KB是完全合理的规模。权重数据不包含在内的话,调度逻辑本身是很薄的。

真正让产物保持小的,是“不留任何解释器代码在产物里”。运行时VM解释器是独立的,要么静态链接到主程序,要么作为操作系统提供的动态库。产物自身就是纯粹的指令流和数据,不做任何决策。

3.3 runtime最小化:砍到只剩“数学内核 + 驱动”

IREE运行时可以裁剪得很小,因为大部分“智能”都在编译期消耗掉了。运行时只包含三块:

一,VM内核:一个极简的字节码解释器,负责加载vmfb、维护调用栈、分派指令。

二,HAL(硬件抽象层):管理设备、缓冲区、命令队列。对应不同的后端,有不同的driver实现。CPU后端有local-sync和local-task两种driver,GPU后端有Vulkan、CUDA、Metal驱动。

三,内核库:每个后端提供算子库。CPU后端的内核库是编译期生成的,一个模型只带入它用到的那几个内核函数,而不是整个算子大全。

我裁剪过一个CPU runtime,只保留一个加法模型所需的内核函数,最后Flash占用大约16KB。这里面还包括了HAL初始化和内存分配代码。离10KB非常近了。如果你是嵌入式大牛,再手写一个极简的本地驱动,把HAL剥掉,还有进一步压缩空间。

3.4 对比传统运行时:体积差距的本质

传统方案为什么瘦不下来?因为它们的运行时需要“理解”模型文件里所有可能的算子。

ONNX Runtime要能跑任意.onnx模型,它必须内置所有op的实现,以及负责op分派、内存规划、图优化的引擎。这些加起来就是几十MB。TFLite好一点,但Full delegate依然很大。

IREE把“模型特有”的部分和“模型无关”的部分彻底分离。模型无关的只是VM内核和HAL,极小;模型特有的调度、算子、内存计划,全部编码进.vmfb,按需生成。于是总占用 = 小运行时 + 小产物,两条线都小,整体自然就小。

我个人觉得,这种思路对MCU级部署是划时代的。以前MCU上跑AI,要么用CMSIS-NN手写裸代码,要么用TFLite Micro但算子受限严重。IREE的VM模块机制,让你可以用一个统一的编译流程,产出能在极轻量运行时上跑的调度+执行一体模块。

4. 实操:把一个 PyTorch 模型从头编译成独立执行模块

4.1 环境准备与工具链安装

这部分我直接给可落地的步骤。我用的环境是Ubuntu 22.04 + Python 3.10,其他Linux发行版和macOS同样适用,Windows稍麻烦但也能跑通。

首先安装IREE编译器和运行时:

pip install iree-compiler iree-runtime

然后安装torch-mlir。这个包负责把PyTorch模型编译成MLIR模块,是IREE的PyTorch前端:

pip install torch-mlir

装完后,确认几个关键命令存在:

iree-compile --version iree-run-module --version

如果你之前装过老版本,建议升级到最新版并保持一致。IREE的编译器和运行时版本耦合很紧,版本不匹配是最常见的报错源头,我后面在踩坑环节会细说。

4.2 把PyTorch模型转成MLIR

我准备用一个简单的两层MLP演示,输入维度是8,输出维度是4,没有权重初始化的特殊需求:

import torch import torch_mlir class SimpleMLP(torch.nn.Module): def __init__(self): super().__init__() self.fc1 = torch.nn.Linear(8, 16) self.fc2 = torch.nn.Linear(16, 4) self.act = torch.nn.ReLU() def forward(self, x): return self.fc2(self.act(self.fc1(x))) model = SimpleMLP().eval() example_input = torch.randn(1, 8) module = torch_mlir.compile( model, example_input, output_type="torch", use_tracing=True, ) with open("simple_mlp.mlir", "w") as f: f.write(str(module))

注意这里用了use_tracing=True,背后是TorchScript的tracing机制。对于这个模型没问题,但如果你的模型里有数据相关的控制流(比如循环次数取决于输入),不能用tracing,得用torch.jit.script或者走output_type="linalg"之外的路径。第一次做建议从无控制流的模型开始。

生成的simple_mlp.mlir是一个文本格式的MLIR文件,里面主要包含torch方言的算子。你可以打开看一眼,应该能看到类似torch.linear、torch.relu这样的算子。

4.3 用 iree-compile 编译出 vmfb

生成MLIR只是第一步,真正的编译发生在iree-compile。这里我最常用的是llvm-cpu后端,它会用LLVM把内核编译成目标机器的原生代码,同时生成vmfb的调度指令:

iree-compile \ simple_mlp.mlir \ --iree-input-type=torch \ --iree-hal-target-backends=llvm-cpu \ -o simple_mlp.vmfb

看到输出文件生成后,可以检查大小:

ls -lh simple_mlp.vmfb

我的实测结果是:这个两层MLP,包含约100个参数(8x16+16x4=192个权重加偏置),常量数据很小,整个vmfb只有12KB左右。如果我把权重清掉(只编译结构),能压到8KB。这就是“调度+执行一起烧进10KB产物”的真实体现。

有人可能会问:为什么不当场编译成.so?因为IREE把“调度”和“内核实现”分开了。vmfb里,调度是指令流,内核是函数表;真正的LLVM机器码在iree-compile阶段也会生成,但会被放入一个“可执行库”中。默认情况下,iree-compile会把内核库一起嵌入vmfb,也可以选择不嵌入,运行时动态加载。对MCU场景,通常建议嵌入,省掉文件系统查找的麻烦。

4.4 运行验证与多后端切换

编译产物要在本地跑一下验证正确性。用iree-run-module最省事:

iree-run-module \ --module=simple_mlp.vmfb \ --input="1x8xf32=[1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0]"

正常会输出一个1x4的浮点向量。我习惯在编译后跑一个和PyTorch完全相同的输入,对比输出误差。IREE这条链路的数值精度和PyTorch一般差异在1e-6量级,如果出现明显偏差,优先怀疑模型里有未转成稳定算子的自定义autograd函数。

如果要切到GPU后端,编译命令改成:

iree-compile \ simple_mlp.mlir \ --iree-input-type=torch \ --iree-hal-target-backends=cuda \ -o simple_mlp_cuda.vmfb

运行时需要有CUDA驱动。同样一个模型,切GPU后端之后,vmfb里的调度逻辑会包含命令缓冲区提交、事件信号等待这些GPU特有的同步原语,所以产物大小会有变化,这是正常的。

4.5 产物优化:把体积压到极限的几个编译参数

如果对产物体积有硬性要求,下面这几个参数是我实测有效、踩过坑之后确认好用的:

iree-compile \ simple_mlp.mlir \ --iree-input-type=torch \ --iree-hal-target-backends=llvm-cpu \ --iree-llvmcpu-optimization-level=3 \ --iree-opt-const-eval=false \ --iree-scheduling-optimize-bounds=true \ -o simple_mlp_opt.vmfb

逐一解释:

  • --iree-llvmcpu-optimization-level=3:让LLVM做激进优化,生成的内核机器码更小更快。注意是CPU后端参数,GPU后端不适用。
  • --iree-opt-const-eval=false:禁止常量折叠。听起来很奇怪,正常应该允许常量折叠才对。但某些模型权重过大时,常量折叠太多会把大量数据烤进产物里,反而增大体积。如果模型小、常量少,不要开这个选项。
  • --iree-scheduling-optimize-bounds=true:让调度树在计算缓冲区边界时更激进,减少冗余的同步点。

另外还有个细节:vmfb文件里如果嵌入了内核库,可以用strip等二进制工具裁剪符号表。对嵌入式场景,我会把vmfb转成C数组嵌入固件,这一步也能省掉文件系统格式自带的元数据。

5. 踩坑实录:IREE 编译与部署常见问题速查

5.1 版本不匹配:找不到符号 / 加载失败

这是我遇到过最频繁的问题。iree-compile和iree-runtime的版本必须严格对应。你在pip里分别装了这两个包,如果一个是pypi最新版,一个是GitHub nightly版,大概率运行时会报类似“module version mismatch”或者加载失败的错误。

我的排查思路很固定:

  • 用pip show iree-compiler iree-runtime查看版本号,确认一致。
  • 检查iree-compile --version和运行库的版本字符串。
  • 如果用了预编译的runtime(比如自己交叉编译的),必须使用与编译器版本匹配的源码分支。

IREE在这方面的兼容性承诺比较保守——即使小版本之间,IR格式都可能变。别指望“向下兼容”保你平安。

5.2 动态shape导致编译爆炸或运行失败

IREE对动态shape的支持是有的,但不友好。模型输入是None维度的张量时,编译期无法确定缓冲区大小,调度树里的很多优化做不了,产物会变大,部分算子会退化到运行期动态分配。

我的建议是:能固定batch size就固定。部署AI模型时,常见做法是编译一个固定shape的版本,另一个动态shape的版本做兜底。固定shape版本的速度和体积优势,值得你在服务入口加一个resize逻辑。

如果你必须用动态shape,注意iree-run-module传入输入时,shape必须是编译时允许的动态范围,否则会报“input shape mismatch”。我曾经在这里卡了半天,后来发现是--input参数的shape第二个维度写死成了编译时的固定大小,改回动态声明就好了。

5.3 GPU 后端“看不见”设备或性能反而更差

GPU部署的常见问题不是跑不起来,而是跑起来之后比CPU还慢。

原因之一是GPU驱动没加载或者设备被占用。IREE的CUDA后端依赖CUDA runtime,你得确认编译和运行在同一台装好驱动的机器上。另一个原因是算子太小,GPU启动延迟远大于计算时间。两层的MLP上GPU跑,纯粹是找罪受——每个Linear的kernel launch都要几十微秒开销,而本身计算只要几微秒。

如果你的模型在GPU上反而慢,我建议先去iree-benchmark-module跑一下每个阶段的耗时,确认瓶颈在算子上还是调度同步上。通常解决办法是增大batch size,让GPU的并行优势发挥出来,或者干脆切回llvm-cpu后端。

5.4 编译报错:Unsupported operation / failed to legalize

这个报错信息在社区里很常见,意思是MLIR里某个算子没有成功下降到目标方言。大多数情况下是因为这个算子太新、太偏,或者只在特定框架版本里存在,IREE的内核库没覆盖到。

我的做法分三路:

  • 第一路,把算子换成更基础的组合。比如某些自定义激活函数,如果IREE不支持,可以拆成几个基本数学运算。
  • 第二路,检查是不是shape不匹配导致linalg下降失败。偶尔是算子本身支持,但输入是标量或者零维张量,触发BUG。
  • 第三路,升级IREE版本。算子覆盖率每个版本都在提升,有时候一个nightly版本就能解决。

5.5 极端裁剪时的“运行时比产物还大”悖论

最后提醒一个我实际踩过的坑:当你把产物压到10KB级别时,运行时的体积反而成了大头。如果只是验证性部署,随便裁剪一下runtime也有几十KB;如果你追求的是整个固件不超过25KB,那runtime的每个函数都得按字节抠。

这时候最有效的办法是:

  • 使用local-sync驱动而不是local-task,省掉线程池和任务队列的代码。
  • 关掉HAL里的动态内存分配支持,改用编译期静态内存分配。
  • 用MCU运行时(IREE的micro runtime)而不是完整版runtime,体积能再砍一个量级。

我在一个STM32H7的项目里,用micro runtime加静态内存,整个可执行文件+vmfb+常量数据压到了21KB,还能跑极小的人脸关键点检测。虽然和纯手写裸代码还有差距,但开发效率天差地别——不需要手写任何C代码,全是编译出来的。

5.6 排查问题的一套实用命令

给一套我常用的排查组合拳,遇到问题先跑这些,大多数时候能定位到原因:

# 查看MLIR各阶段IR iree-compile --iree-hal-target-backends=llvm-cpu --print-ir-before=iree-stream-convert simple_mlp.mlir # 打印编译过程的所有pass信息 iree-compile --iree-hal-target-backends=llvm-cpu --debug-only=iree-* simple_mlp.mlir # 检查产物内部的导出函数 iree-dump-module simple_mlp.vmfb # benchmark模块,看每个阶段的耗时 iree-benchmark-module --module=simple_mlp.vmfb --input="1x8xf32=...”

iree-dump-module是我最喜欢的一个命令。它能直接展示模块里有哪些导出函数、每个函数的字节码段大小、符号常量。你一眼就能看出“调度代码占了多大”还是“常量数据占了多大”。如果产物偏大,先看这个再决定优化方向。

收个尾

说实话,IREE不是那种“装完就能舒服跑所有模型”的工具箱。它现在的生态成熟度,跟ONNX Runtime比还有差距,尤其是一些新算子、特殊控制流的支持,需要你多一点耐心去查文档、读报错。但它的设计方向,我认为是对的——把调度、执行、内存规划全部放编译器里解决,让运行时回到它本来的位置。

我在实际项目里最大的体会是:用IREE之后,部署一个模型的思考方式变了。以前我会纠结“运行时选了哪个框架”“模型文件大了怎么办”,现在我只关心“目标设备上有没有足够的Flash放runtime”。因为kernel和调度都编进了一个10KB级别的vmfb,运行时一裁剪,剩下的就是纯粹的数学。

如果你手头正好有一个离线部署的压力,建议找个轻量模型,亲手走一遍“PyTorch → MLIR → vmfb → runtime”的链路,用命令看看产物体积,再用iree-dump-module拆开看看里面有什么。看明白那几段调度指令之后,你会对“编译器到底在帮我们做什么”有完全不同的感觉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询