IREE 这个项目我盯了两年多,直到最近手头有个嵌入式 AI 推理的需求,才真正把它从头到尾用了一遍。当时我已经被各种动辄几十 MB 的推理引擎搞得够烦了,而 IREE 给我的第一印象非常直接:一个 MNIST 分类模型,编译出来的产物只有不到 10 KB,而且这个产物里不光包含了模型计算的内核代码,连调度逻辑、执行顺序、资源分配策略,全都烧进去了。运行的时候,几乎不需要额外解释器或框架支撑。
我觉得很多人对 IREE 的理解停留在“又一个 AI 编译器”这个层面,但它最特别的设计恰恰是那句“把调度和执行一起烧进产物”。这句话翻译成人话就是:传统的 AI 推理,模型算子怎么排、任务怎么队列化、内核怎么启动,都是运行时的动态决策;而 IREE 把这些全部挪到了编译期,静态确定后写进一个极小的二进制包里。输出的这个文件,本质上是一段可以直接被精简运行时执行的“指令流”加上编译好的内核二进制,没有庞大的调度框架在里面跑。对嵌入式、边缘端、以及任何对体积和延迟敏感的场景,这都是一个方向性的改变。
这篇内容我会从设计思路、编译流水线、调度机制、实操编译到问题排查,完整拆一遍 IREE 是怎么做到“10 KB 跑推理”的。如果你是搞 AI 应用部署的、做嵌入式开发的,或者对编译器后端有兴趣,这篇文章应该能给你省出不少在文档里打转的时间。
1. IREE 凭什么能把调度和执行“烧”进产物
1.1 传统推理引擎里,调度器为什么又大又慢
绝大多数人不觉得调度是个成本。比如你用 TensorFlow Lite 跑一个模型,解释器需要遍历算子图,逐个判断算子依赖是否满足,再决定执行顺序,然后调用对应的 kernel。图里几百个节点时,“调度开销”是可以量化的,每个节点加入待执行队列、检查依赖计数器、触发内核启动,这些操作在桌面 CPU 上可能也就是微秒级,但累积起来非常可观。尤其在小模型、低功耗嵌入式设备上,调度开销可能占整个推理时间的 10% 甚至更高。
更麻烦的是体积。运行时为了“动态调度”,必须带上图结构描述、任务队列、依赖管理、后端设备管理、内存分配器这些基础设施。哪怕你只需要推理一个 200 KB 的模型,运行时也得预埋几百 KB 甚至几 MB 的代码。这种结构在服务器端无所谓,但在 Flash 动不动只有 256 KB 的 MCU 上,属于天然无法接受的方案。
IREE 的思路不是去优化这个运行时,而是直接把运行时砍掉。它假设模型结构和执行计划在编译时已经确定了,那么调度就不需要“运行”了,只需要“跑完”。这就是“把调度烧进产物”的第一个含义:调度结果被固化成静态命令序列。
1.2 IREE 的 MLIR 基础:调度在编译期如何表达
IREE 不是一个从零手写方言的编译器,它构建在 MLIR 之上。如果你接触过 MLIR,会知道它有一套允许自定义方言的机制。IREE 引入了自己的高层方言,比如iree_input、flow、hal,越往下越接近硬件。
“调度在编译期表达”依赖的关键概念是 dispatch。IREE 编译器会把整个计算图切分为很多 dispatch region,每一个 dispatch region 代表一个可以被后端单独编译和执行的计算块。这些 region 之间的依赖关系,由编译期分析器静态推导,并且在输出时转换为一个显式的命令序列。这个序列一旦生成,运行时就只是按照顺序去提交执行,不再做任何依赖推理。
这里有个很实际的好处:你可以把 dispatch region 理解为“可并行单位”和“可调度单位”的结合。传统运行时调度器在动态发现并行性,而 IREE 在编译时已经扫描了整个图,把没有依赖的 dispatch 标记为可并行,并排布到硬件队列上。到了运行时,调度器的核心工作只剩“提交”,逻辑简单到几乎没有分支。
1.3 一个 10 KB 的产物里到底打包了什么
我刚开始接触 IREE 的时候,也好奇 10 KB 到底能干吗。我们用iree-compile编译一个浮点 MNIST 模型,目标后端是 embedded-ELF,最后产物实际上是一个.vmbc文件(IREE 虚拟机字节码格式)。拆开看内容大约包含:
- 一个轻量 VM 模块:里面是一段高度紧凑的字节码,描述了 dispatch 的执行顺序、依赖点和资源句柄。
- 常量数据:权重、偏置以及编译期折叠后的常量缓冲。
- 内核二进制缓冲区:嵌入式 ELF 目标后端编译出来的机器码,用
memcpy等方式加载到目标设备。 - 少量元数据:用于运行时识别版本、布局、入口函数。
没有动态算子注册表,没有图解释器,没有依赖任务队列。整个运行时的代码量可以压到几 KB 到十几 KB 级别,而且这份运行时本身也只是负责“把字节码翻译成硬件操作并执行”,连内核实现都是编译产物内嵌的。这和我之前用解释型推理引擎是完全不同的体验。真正做到了把“调度决策的知识”留在编译期,而不是在设备上重复计算。
2. 编译流水线拆解:模型是怎么一步步变成 10 KB 二进制
2.1 前端接入层:TFLite、ONNX 与 Torch 模型如何进入 IREE
IREE 不直接吃原始模型格式,它需要先将模型转换到 MLIR 的高层方言。目前常见的路径有三条:
- TFLite 模型:通过
iree-import-tflite工具转为tosa方言,再进一步导入。TF 生态下的 MobileNet、EfficientNet 走这条路最顺。 - ONNX 模型:查找
iree-import-onnx或者依赖社区转换器。ONNX 覆盖率已经不错,但个别算子如NonMaxSuppression、动态 Shape 等,还得做额外处理。 - PyTorch 模型:官方推荐
torch-mlir项目,将torch.nn.Module通过 fx 图追踪后生成torch-mlir方言,再往下走。
前端接入层的核心任务其实不只是“格式转换”,还包括把动态 Shape、控制流、复杂算子尽可能静态化。IREE 的编译期调度非常依赖静态信息。如果一个模型的输入 shape 是未知的,那么 dispatch 划分、缓冲区大小分配、内核生成都很难做。所以如果你在焦虑“为什么编译期调度能做得好”,很大程度是因为前端已经把动态性降到了最低。
实操要点:我自己处理过不少 ONNX 动态维度模型,经验是转 IREE 之前先用onnxruntime的优化器做一次 shape 推断和常量折叠,效果会比直接硬转要好很多。特别是 Resize、Transpose 这类算子,shape 信息越完整,后续调度切分越靠谱。
2.2 编译 pipeline 的核心阶段:被忽略的四个关键环节
IREE 的编译流程可以概括为四个阶段:
阶段一:全局优化与算子融合。在这个阶段,IREE 会利用 MLIR 的模式匹配把 linalg 算子做融合。比如conv2d + bias_add + relu往往会被融合进一个内核,减少 dispatch 数量。这不仅仅是性能优化,还是体积优化,因为每个 dispatch 都需要一些元数据和处理逻辑,dispatch 越少产物越小。
阶段二:dispatch 划分。经过全局优化后,计算图变成一个大算子集。IREE 在flow方言层做 dispatch 区域划分。划分策略考虑三个要素:硬件能力、数据依赖、缓冲区复用。例如 GPU 上可以把多个 element-wise 算子组合到一个 dispatch 中,减少 launch 次数。嵌入式 CPU 上则更看重对寄存器和 scratch buffer 的复用。
阶段三:内核代码生成。每个 dispatch region 会被编译为具体后端代码。IREE 支持多种后端,通过目标三元组描述(如embedded-elf-x86_64、vulkan、cuda)。以 embedded-ELF 为例,后端将 linalg 算子 lower 到 LLVM 方言,再交给 LLVM 生成目标机器码。两个内核可以合成到一个二进制块,最终作为字符串常量嵌入到产物中。
阶段四:链接与打包。这一步会生成 VM 模块、常量池、内核缓冲区、资源表。IREE 有很多构建配置可以裁剪信息,比如去掉调试信息、去掉不用的函数导出、压缩常量缓冲。最终产物的大小就是这些部分的合计。
这里可以解释标题中“10 KB”的来由。我实测下来,MNIST 的产物中,常量数据占了大头,大概 4–6 KB,内核代码 2–3 KB,VM 模块代码和元数据加起来 2–4 KB。如果你把权重替换为量化后的 int8,甚至可以把总量压到 5 KB 以下。而同样功能如果用 Android 上常见的推理框架,运行时本身至少多出几百 KB。
2.3 为什么就是能这么小:裁剪、静态信息与“无中间层转发”
IREE 的小不是靠压缩算法,而是靠裁剪信息层级。传统推理引擎运行时要保留多层数据结构:模型层、算子层、执行调度层、硬件抽象层。任何一层都要有代码和元数据。IREE 把层数压到最小,编译完之后运行时能看到的东西简化到三样:
- 指令流(program counter 往后跳)
- 缓冲区列表(内存地址和大小)
- 内核二进制表(函数入口和二进制块)
另外,IREE 支持把常量数据单独外置。针对 MCU 场景,你可以把权重放到 flash 的一个独立分区,产物里只保存元数据偏移量。这样运行时 RAM 占用还能进一步降低。有意思的是,编译期调度直接改变了数据流布局。传统推理里,每层算子执行完结果会写回主存,下一个算子再读出来;IREE 在编译期识别到“两个 dispatch 无需跨设备访问”时,会直接把中间结果留在寄存器或片上缓存,连内存生意都省了。这也是性能和体积双赢的一个重要原因。
3. 调度逻辑与执行内核的融合细节
3.1 “dispatch”的语义:它本质上是一个静态任务列表
很多人问 IREE 里的 dispatch 和传统 GPU Kernel Launch 有什么区别。我的理解是:传统 kernel launch 是一次性的“把这个核函数执行一次”;而 IREE 的 dispatch 是编译期划分出来的“包含计算、依赖信息、设备约束”的综合单元。每个 dispatch 一旦被编译,就有了以下固定属性:
- 输入/输出缓冲区句柄
- 内核函数入口
- 执行顺序编号
- 依赖关系(前置 dispatch 列表)
- 设备队列类型
这些属性在编译时全部确定,运行时只是把这些属性翻译为硬件能懂的描述符。在 GPU 上,它会生成 command buffer;在嵌入式 CPU 上,它会生成一个函数指针调用表。因此我把 IREE 的 dispatch 理解为“静态任务列表”更准确。因为列表中的每一项在编译时已经排好序,且不会有任何条件分支依赖运行时状态。
这种机制带来的直接好处是“可预测性”。我做时序分析时发现一个很舒服的事实:同一硬件上,IREE 产物的每一帧推理耗时波动非常小,很多硬件中断、缓存预取这些不可控因素除外,基本是固定开销。相比之下,带动态调度器的引擎会时不时出现调度器竞争导致的抖动。
3.2 内核代码生成:同一个产物在不同硬件后端上的差异
IREE 的产物不一定只能在 CPU 上跑。它可以通过不同的 HAL(Hardware Abstraction Layer)后端,把 dispatch 编译到 Vulkan、CUDA、Metal、ROCm,甚至一个纯 C 的嵌入式环境。不同后端对“调度和执行”的实现方式差别很大。
以 Vulkan 为例,IREE 使用的是vkDispatchIndirect以及vkCmdDispatch方式。编译产物里保存的命令缓冲录制逻辑,在运行时按字节码中的命令逐条填入 Vulkan command buffer。嵌入式 ELF 后端就简单得多,它就是一个函数表。每个 dispatch 编译为一小段 C 语言语义等价的目标代码,函数调用是底层直接跳转。
后端之间体积差异也很大。同一个 MobileNet 量化模型,Vulkan 后端产物可能有 20–30 KB,因为要嵌入 SPIR-V 格式的内核二进制,并且包含描述符池布局信息;而 embedded-ELF 产物可能只要 15 KB 左右。所以做嵌入式选型时,我一般不建议盲上 Vulkan,MCU 上根本没有 GPU,老老实实走 embedded-ELF 才是正道。
3.3 为什么静态调度能赢:顺序执行与并行性的编译期编码
关于静态调度和动态调度谁更强,业界吵了很久。我的经验是两者不是简单替代,而是“场景导向”。动态调度的优势在于应对运行时变化,如输入 shape 不固定、模型分支触发条件不固定。但绝大多数部署场景里,模型结构是固定且已知的,这时候动态调度纯粹是负担。
IREE 在编译期把“依赖分析”变成了“指令排序”。像矩阵乘之后跟着一个 element-wise ReLU,它们之间如果数据依赖明确,就会按顺序排;如果两条 dispatch 互不干扰,IREE 会标记为可并行,并在运行时把它们提交到不同的队列。这些决策在编译期已经做过一遍,运行时的逻辑就变成:拿到一个 dispatch 就无脑提交,最多做一下事件同步。
这里我还想解释一下“顺序执行”这个概念。很多初学者以为静态调度就是所有 dispatch 严格串行,其实不是。IREE 会保留数据依赖关系并在字节码里使用execute和wait原语来表达并行和同步。比如模型中有两个分支分别计算,最后再合并,IREE 会先在两个不同队列上启动分支,然后等待两个事件都完成后,再启动合并算子。这些依赖等待逻辑也是静态编码好的,不需要运行时动态图遍历。从实现角度看,等价于“自动并行化”,但不引入额外开销。
4. 实操过程:亲手把一个小模型编译成 10KB 级产物
4.1 环境准备:装 IREE 工具链和常见依赖坑
我是在一台 Ubuntu 机器上做的验证,同时也在 Windows 上测试过。安装 IREE 有两个推荐路径:
- 直接用 pip 安装编译好的二进制:
pip install iree-compiler iree-runtime。这个包体积大,但能用。 - 从源码构建,适合对自定义后端和调度策略有改动需求的人。构建依赖 CMake、Ninja、Clang,源码拉下来后配置目标语言选项即可。
Windows 机器上踩坑最深的点是“编译器找不到”和“DLL 缺失”。IREE 工具链本身用了不少 C++ 运行时支持库,如果你系统缺了msvcp140.dll或vcruntime140_1.dll,运行iree-compile就会直接崩溃。这种时候去微软官网装 “Visual C++ Redistributable” 就好。我也遇到过把源码构建版和 pip 版混装导致版本冲突的,表现为iree-run-module加载 VM 模块时报version mismatch。建议每次只保留一条安装路径。
另外,有人把“编译器”和“编辑器”搞混,非要去查“vs code + c编译器 + claudecode”,其实 IREE 的“编译器”是类似 GCC/Clang 的代码生成程序,不是写代码的编辑器。你不需要任何 IDE,只需要命令行工具就能完成全流程。
4.2 使用iree-compile编译最小模型:命令与参数详解
我以 Keras 导出的 MNIST 浮点 TFLite 模型为例,文件是mnist.tflite。核心命令是:
iree-compile mnist.tflite \ --iree-input-type=tosa \ --iree-hal-target-backends=embedded-elf \ --iree-embedded-elf-target=host-x86_64 \ -o mnist.vmfb几个参数背后的意思:
--iree-input-type=tosa:告诉工具输入已经是 TOSA 方言级别,避免重复转换。--iree-hal-target-backends=embedded-elf:选择无操作系统的嵌入式后端,最终产物是一个可被嵌入式运行时加载的字节码,内核以 ELF 机器码形式嵌入。--iree-embedded-elf-target=host-x86_64:我用 x86 模拟验证,如果你要烧到 ARM Cortex-M 上,可以换成cortex-m33或cortex-m4等指定 target。
执行后看mnist.vmfb的大小,通常就是 8–12 KB。如果想进一步压缩,可以加--iree-opt-data-tiling=false或开--iree-opt-const-eval,前者减少数据搬运生成的元数据,后者把常量折叠提前到编译期。实测能再省 10%~20%。
这里必须提醒一个坑:编译目标后端和运行时后端必须匹配。如果你编译时用了 embedded-ELF 后端,运行时就要用对应的 embedded-ELF 驱动,不能用 Vulkan 驱动去加载这个.vmfb。我在入门时因为不理解这个概念,换着驱动加载同一产物,折腾了半天才发现是后端不匹配。
4.3 运行产物并检查实际调度行为
运行时使用iree-run-module命令,加载产物和输入数据。以内置随机输入为例:
iree-run-module --module=mnist.vmfb \ --entry_function=main \ --input="1x1x28x28xf32=0,0.1,..."如果想让产物支持多种输入尺寸,编译时要保留动态 shape 信息,但这会轻微增加产物体积,因为调度层需要额外的 shape 处理指令。而固定 shape 编译时,调度代码更紧凑。我在部署时倾向于固定 shape,尤其对嵌入式场景,换来几 KB 的节省和稳定延迟是值得的。
想直观感受“调度被静态化”,可以加--iree-vm-debug-dump-dispatch之类的调试选项(具体 flag 版本不同会变)。它会在执行时把 dispatch 顺序打印出来。你会发现输出顺序和编译时字节码里的顺序完全一致,没有任何反转或动态重排。这就是调度被烧进产物的直观体验。
4.4 把产物再接回嵌入式平台:ch32v 这类 MCU 上的小验证
前面提到我最终目标是嵌入式平台,所以我实际也拿ch32v这类 RISC-V MCU 做过验证。IREE 生成的嵌入式 ELF 产物,需要配合iree-embedded-elf运行时和iree-embedded-elf-ld链接脚本才能跑在裸机上。
需要留意的是,IREE 的嵌入式运行时不直接支持中断处理之类的外设逻辑。你只能把推理包装成一个普通 C 函数,在应用里把当前传感器数据填进输入缓冲区,然后调用 IREE 生成的入口函数。同步阻塞等待推理结果即可。我在 ch32v 上踩过一个坑:IREE 产物的栈使用量在 MCU 上很容易超过默认 2 KB,导致 hardfault。后来我把链接脚本的栈改成 8 KB 才恢复稳定。编译产物本身没有动态分配内存的话,整个运行时内存模型非常透明,用起来反而舒服。
5. 常见问题与排查技巧实录
5.1 “编译器未包含 main 类型”这类报错的真相
很多初学者第一次启动 IREE 相关工具时,会看到类似“编译器未包含 main 类型”的报错。这其实不是 IREE 的问题,而是你把“编译器”和“可运行程序”搞混了。iree-compile本身是一个命令行工具,有main函数;但如果你链接了错误的库,或者试图用dlopen方式加载编译器动态库然后直接执行,就会触发这种错误。
这种情况的排查步骤一般是:
- 确认你运行的是可执行文件
iree-compile,而不是libIREECompiler.so。 - 检查是否还有旧版本编译器在环境变量
PATH里拦截了命令。 - 用
iree-compile --version验证工具是否正常工作。
我见过有人把 pip 包里的iree.compiler模块误当成可执行文件调用,结果出现各种奇怪报错。正确做法是python -m iree.compiler.tools.compile_tool或直接用命令行入口点,而不是执行库。
5.2 Windows 依赖缺失与编译器路径问题
在 Windows 下最烦的是各种 C++ 运行库缺失。常见报错如“由于找不到 msvcp140.dll,无法继续执行代码”或者“由于找不到 vcruntime140_1.dll,无法继续执行代码”。原因很清晰:IREE 分发的二进制依赖 MSVC 运行库,而 Windows 精简系统或开发环境没安装完整运行库。解决办法是下载并安装对应的 Visual C++ Redistributable,64 位系统两个版本都装上。有一个快速验证方法:执行where iree-compile确认工具路径是否正常,再用dumpbin /dependents查看依赖(如果装了 Visual Studio)。
如果使用 MinGW/GCC 构建 IREE,那么还需要保证libgcc_s_seh-1.dll、libwinpthread-1.dll等运行库在 PATH 中。我自己用 GCC 版在 Windows 上编译出来的工具,换一台干净机器就会缺 DLL。所以部署 IREE 到团队时,优先用官方预编译 MSVC 版本,省心得多。
5.3 产物大小异常、调度顺序异常与固件体积超限
实际项目中,会遇到产物比预期大出好几倍的情况。最常见原因是编译时没有开启针对目标后端的优化。比如你忘了指定--iree-opt=enable-all,IREE 可能不做算子融合和数据流优化,dispatch 数量暴涨,对应的调度字节码也膨胀。调试口诀是“先开全量优化,再查看 dispatch 数量,最后看常量池占比”。
另一个实际问题:模型算子覆盖不全导致编译失败。IREE 目前对 TFLite 和 ONNX 的支持还在逐步补齐,有些算子只能以“遗留”方式运行,而 legacy 算子的运行时路径比编译内核大得多。我建议遇到不支持的算子时,先考虑在模型中替换或移除,再考虑扩展编译器。如果只是少量算子不常见,可以尝试开启--iree-allow-legacy-io=...(具体按版本查找),但要控制数量,否则产物会膨胀。
调度顺序异常这个现象很有意思。IREE 是静态调度,理论上不会出现运行时重排,但当你在 GPU 后端上使用上下文优先级改变或者多个模块并发执行时,不同 VM 实例之间的执行顺序可能交错。那个时段的“调度乱序”其实是多模块并发造成的,不是你产物内的顺序变了。排查方法是用iree-run-module单独跑每一个模块,看是否都稳定。如果都稳定,问题在应用层的多线程协调,而不是 IREE 层面。
5.4 嵌入式上内核链接失败的常见坑
最终链接嵌入式产物时,经常遇到undefined symbol错误。IREE 生成的内核二进制可能依赖一些运行时符号,比如memset、memcpy、浮点运算辅助函数。在裸机环境里,链接脚本必须把这些符号正确解析到你的 C 标准库或编译器跑味库中。我之前用 RISC-V GCC 工具链时,因为没有链接libgcc,出现了__aeabi_memcpy之类缺失。解决方式是确保链接命令里加上-lgcc -lc -lm,并检查启动文件是否正确初始化了.bss段。这一类问题通常不是 IREE 本身的 bug,而是嵌入式构建系统与 IREE 产物的适配问题。
另外,如果你发现编译出的内核二进制包含浮点指令,而目标 MCU 不带 FPU,会出现非法指令错误。处理方式很直接:在iree-compile时指定 target 特性,例如--iree-embedded-elf-target=cortex-m33,并确保工具链为cortex-m33生成了软浮点调用约定,或者在 target 字符串里去掉+fp特性。调这种问题时,盲改链路不如先把 CPU 特性写清楚。
写完这篇之后,我再补一句个人体会
用 IREE 这段时间,我最大的感受是它对“运行时的克制”已经到了偏执的程度。传统框架想尽办法把更多能力放在运行时来应对不可预知的变化,而 IREE 反着来,它把所有能预知的东西全部在编译期固化,剩下一个薄薄的执行层。这种设计在现在的大模型时代看起来有些“背离主流”,因为大模型动不动几个 GB,谁还在意那几 KB 的调度代码,但在嵌入式、移动端、以及低延迟控制系统中,“少即是快、快即是省”永远不会过时。
如果想在项目里快速体验这个流程,建议从一个固定 shape 的小模型开始,先感受.vmfb的尺寸和iree-run-module的稳定输出,再去挑战更复杂的分支模型。用熟了之后再回头看传统解释型推理引擎,你会觉得后者像一个装着整本菜谱的厨师,而 IREE 是一个只认固定菜谱的机器。用户要什么其实很清楚,真正的性能优化往往就藏在“预知”二字里。