
我第一次看到 IREE 编译出来的产物时第一反应是反复确认文件大小。一个包含完整推理调度逻辑和执行逻辑的产物加到一起才 10 KB 出头。IREE 这套基于 MLIR 的编译器最被低估的地方不是它能把模型编译得多快而是它把“调度”和“执行”这两件事的职责边界重新划了一遍凡是编译器能提前决定的调度绝不留到运行时凡是能写死进产物的执行逻辑绝不依赖外部臃肿运行时。这篇文章就从 IREE 编译器入手拆解它到底怎么把调度和执行一起烧进 10KB 产物适合正在做端侧推理、嵌入式部署或者对 AI 编译器架构感兴趣的同学参考。1. 调度权争夺战为什么 IREE 要把运行时的工作抢回编译期1.1 从 MLIR 说起IREE 不是简单的模型转换工具很多人第一次接触 IREE以为它只是又一个“把模型转换成别家格式”的工具比如把 TFLite 转成 ONNX或者反过来。实际完全不是一回事。IREE 基于 MLIR 构建是一整套真正的编译器基础设施它的上游接收各种前端产物下游输出面向具体硬件的可执行代码。IREE 的典型处理流程大致是这样先用导入器把 TFLite、ONNX 或 Torch 模型统一成 MLIR 方言表示比如 TOSA 或 MHLO然后经过方言转换、bufferization、dispatch 划分、stream 编排、代码生成等多个阶段最终得到可被目标端加载执行的产物文件常见的是 .vmfb 格式也可以展开成 C 模块。所以它解决的问题不是“格式互转”而是“如何把一张静态计算图变成一个高效的、自包含的部署单元”。这就决定了它和传统推理引擎在架构层面有着本质分歧传统引擎倾向于在运行时保留解释器、调度器而 IREE 倾向于把尽可能多的事情在编译期做完。1.2 传统推理引擎的运行时调度开销到底有多大作为对照可以看看传统推理引擎的做法。TFLite 解释器、ONNX Runtime 这类框架通常会保留一个完整的运行时调度器每次推理时运行时都要遍历图节点检查算子依赖决定执行顺序分配内存再逐个调用 kernel。有的框架还带线程池、设备管理、异构调度指令流是运行期动态生成的。这套机制的优点是灵活。模型变更、分支结构、动态 shape 都能应付。但代价也很实在每次推理都有固定的调度开销哪怕模型里只有一个算子也要先走一遍运行时初始化。调度逻辑本身的体积不小。一个最小可用的解释器运行时加上线程派生、依赖分析、内存管理通常就能到几百 KB 甚至上 MB。多核异构场景更夸张运行时还要处理同步、事件、工作队列复杂度直线上升。对手机端还能接受可一旦目标是 MCU、DSP、嵌入式裸机环境这套东西就非常不划算。很多时候用户根本不需要动态调度能力模型结构是固定的输入 shape 也基本不变运行时却依然携带了处理各种未知情况的能力这是巨大的浪费。1.3 决策点前移把“厨师”变成“菜谱”IREE 的核心理念可以概括成一句话调度决策尽可能前移到编译期。部署场景里模型结构是确定的输入规模是可预期的运行环境也相对固定。那编译器完全有资格在编译时就把“哪个算子先跑、哪个算子可以并行、内存块怎么复用、kernel 按什么顺序启动”全部定下来。我平时给别人解释时喜欢用一个类比。传统运行时就像一个大厨接到订单后才开始想先切菜还是先烧水配菜顺序临时决定而 IREE 的编译产物更像一份详细到“先开火再倒油放葱姜蒜计时 15 秒”的菜谱执行者不需要思考只需要照做。这样做出来的菜可能不是米其林级别但在工业部署里确定性和低开销比创造性重要得多。所以与其给运行时配一个调度器不如把调度结果直接写进产物。这就是“调度被烧进 10KB 产物”的含义。2. 静态调度的两个关键切片dispatch 划分与 stream 编排2.1 dispatch 区域划分先找到可调度的最小单元想把调度静态化第一步是决定“调度什么”。IREE 在编译中期会把计算图切分成一个个 dispatch 区域每个区域将来对应一次 kernel 启动。这里的关键不是简单按算子切而是基于 tile and fuse 策略做整体规划。具体来说编译器会在 linalg-on-tensors 层面做循环分块和算子融合。一个很大的 matmul比如 512x512编译器可能把它切成多个 128x128 的 tile每个 tile 是一个可独立执行的 dispatch而多个可以融合的算子比如“卷积 ReLU 池化”组合如果硬件支持就可能被融合成一个大的 dispatch减少中间结果的写回和重新读取。dispatch 区域的粒度直接决定产物体积和效率。切得太细dispatch 数量爆炸调度指令和 kernel 启动开销都上来切得太粗又可能因为中间结果太大、缓存装不下导致执行效率下降。编译器在这里做的是平衡在可接受的数据搬运成本内选出尽量少且尽量大的 dispatch 区域。2.2 stream 编排让顺序、并行、设备分配在编译期定案dispatch 划分完成后下一步是把这些 dispatch 按正确的执行顺序编排起来。这一层在 IREE 里对应 stream dialect可以理解为设备级调度模型。stream 层做的事包括分析 dispatch 之间的依赖关系确定哪些必须串行、哪些可以并行把同一设备上的执行序列整理成有序指令必要时插入同步和事件等待。比如一个模型里有两个相互独立的卷积分支编译器会在 stream 层标记它们可以并行执行生成“同时启动两组 kernel”的指令序列而某个算子的输出需要喂给下一个算子时stream 层会保证前面的 kernel 执行完并完成同步才会触发后续 dispatch。这一层最关键的地方在于所有编排结果最终都固化成了产物里的指令序列而不是一套运行时依赖分析逻辑。产物只保留“先做 A再并行做 B 和 C等 B、C 都完成再做 D”这种静态展开结果。结果就是执行器变得非常简单它不再需要维护一个图、计算依赖关系、做拓扑排序只需要顺序取指令、发给硬件、等结果回来然后取下一条。整个调度器的代码量因此可以压缩到非常小。2.3 静态调度换来的三个实打实收益很多人担心静态调度会牺牲灵活性但从工程角度看部署侧获得的收益远大于损失。第一个收益是确定性。同样的模型、同样的输入调度顺序永远一样性能波动和现场问题都好排查。动态调度器有时会因为线程调度、内存分配时机产生毫秒级抖动在嵌入式环境里很难复现。第二个收益是零决策开销。运行时不依赖图分析不需要队列管理没有动态资源分配执行流程接近线性。对延迟敏感或功耗敏感的场景省下的每个周期都是实际收益。第三个收益是产物大幅变小。编译器已经把调度逻辑和算子实现一起打包进产物运行时不再需要携带一个通用调度器整体体积自然就下来了。所以后面看到的 10KB 量级产物不是在编译器上做了多少魔法而是架构层面削掉了大量运行时职责后的自然结果。3. 让执行层退化成“薄壳”VM、HAL 与产物内部构成3.1 执行引擎的三种形态按需选择IREE 的执行层并不是只有一种形态根据部署场景可以有三种选择。最常用的是 VM 字节码形态编译结果保存在 .vmfb 文件里由内嵌 VM 解释执行。这种形态好处是加载灵活模型文件可以独立更新host 应用不用重新编译。追求极致体积时会选择全静态 C 模块形态。编译器把调度指令和 kernel 实现直接展开成 C 代码再和你现有工程一起编译最终产物里几乎没有任何解释器逻辑。这种形态适合 MCU、裸机场景。还有一种是组合模式host 应用通过 IREE 的 C API 嵌入 VM适合跑在 Linux、RTOS 这类有操作系统的环境下。我自己的经验是先按 VM 形态把链路跑通再根据体积和启动要求决定要不要换成 C 静态模式不要一上来就跳进最难的路。3.2 HAL 抽象层产物不绑死具体硬件驱动执行层之上还有一个容易被忽略的设计就是 HALHardware Abstraction Layer。它的作用是把“编译后的程序”和“具体硬件驱动”解耦。产物里不会直接写死某个厂商的驱动调用而是通过 HAL 接口进入设备侧执行。举个例子同一个编译产物在 CPU 上执行时 HAL 调用的是多线程任务执行函数在支持 Vulkan 的 GPU 上执行时HAL 调用的是 Vulkan 队列提交逻辑在自研 NPU 上则可能映射到自定义驱动接口。产物本身只携带轻量的设备描述和调用表真正的驱动由宿主环境注入。这意味着编译产物可以保持很小不需要把操作系统级的完整驱动塞进来。对嵌入式项目来说这等于把硬件相关的部分留给了平台层编译器生成的核心产物可以高度平台无关。真正要到裸机部署阶段HAL 层甚至可以退化成几个函数指针几十行代码就能实现。3.3 10KB 产物里到底放了什么以我实测过的某个极简单模型为例最终产物 10KB 出头的量级内部大致可以分成这么几块产物区块主要包含内容量级参考输入输出描述表张量名称、形状、元素类型1-2 KBdispatch 指令序列静态编排的调度指令3-5 KB内存布局与复用表buffer 分配信息、别名关系1 KB 左右执行函数与 kernel 入口针对目标后端的代码入口2-3 KBHAL 薄层与设备抽象设备调用表、同步信息1-2 KB这个构成能解释一个关键问题为什么产物可以这么小。因为它没有通用调度器没有图结构描述没有动态内存分配逻辑没有异常分支处理。产物只是把一个特定模型在特定设备上的最优执行方案以最精简的方式记录下来了。代价也很明显如果模型变了、输入 shape 边界变了产物可能就不再适用需要重新编译。这是静态化方案必须接受的取舍。4. 从 TFLite 到 10KB 产物的完整编译链路4.1 环境准备先把工具链版本对齐动手前最容易被坑的是版本匹配。iree-compiler 和 iree-runtime 的版本必须对应否则编译出来的 .vmfb 可能加载失败。我自己吃过这个亏后来养成了一个习惯固定一套版本组合用同一份 Docker 镜像或者虚拟环境做编译部署时也带锁版本。最简单的安装方式是用 pippip install iree-compiler iree-runtime如果你需要改编译逻辑或者调试后端那就得从源码构建 iree构建时间会长一些但灵活性足够。第一次接触的话我建议先用 pip 装好的版本把链路跑通再考虑源码方案。4.2 导入模型从 TFLite 到中间表示我用一个 TFLite 格式的极简模型来演示。先导入成 MLIR 表示iree-import-tflite --tf-import-typetflite model.tflite -o model.mlir这一步生成的 model.mlir 是把 TFLite 算子提升到 TOSA 或 MHLO 之后的中间表示。如果你对编译过程感兴趣可以打开这个文件看看算子长什么样。这时候的 IR 还停留在张量层面没有涉及任何设备相关内容真正与硬件相关的处理在下一步 compile 里完成。4.3 iree-compile 关键参数控制产物大小的几个开关真正决定产物形态和体积的是 iree-compile。一个典型命令长这样iree-compile model.mlir \ --iree-input-typetosa \ --iree-hal-target-backendsllvm-cpu \ --iree-llvmcpu-target-cpu-featureshost \ --iree-llvmcpu-debug-symbolsfalse \ -o model.vmfb逐个说下这几个参数的作用。--iree-input-typetosa 告诉编译器输入 IR 是 TOSA 方言--iree-hal-target-backendsllvm-cpu 表示目标后端是 LLVM CPU--iree-llvmcpu-target-cpu-featureshost 允许编译器利用当前 CPU 的向量指令比如 AVX2、AVX-512代价是产物只能在支持这些指令的机器上跑--iree-llvmcpu-debug-symbolsfalse 极其重要这是把产物体积压下来的关键开关之一开着调试符号时产物可能直接膨胀好几倍。编译完成后看看产物大小ls -lh model.vmfb如果你的模型足够简单比如一个 MNIST 级别的分类器产物大小会非常接近 10KB 这个量级。4.4 把产物跑起来加载与执行验证产物编译出来后可以用 Python API 快速验证import iree.runtime as rt config rt.Config(local-task) vm_module rt.load_vm_module( rt.VmModule.from_flatbuffer(open(model.vmfb, rb).read()), configconfig, ) result vm_module[module.main](input_tensor) print(result)这里的 local-task 表示使用本地任务系统执行适合 CPU 后端验证。需要注意的是 IREE 的 Python API 在不同版本里改动较多如果函数名对不上以当前版本文档为准。跑通这一步之后你就拥有了一条完整的“模型文件 - 10KB 级产物 - 可执行推理”链路。5. 产物变大的三种方式动态形状、调试符号与冗余执行逻辑5.1 所谓的“10KB”是怎么达成的并不是所有模型都能压到 10KB。这个量级对应的是极简模型输入输出张量固定、算子数量个位数、无动态分支、无复杂控制流。在这种前提下调度指令和 kernel 入口都少产物自然就小。我有一次把同一个简单模型分别用默认参数和裁剪参数编译大小差异非常明显。默认编译可能 40KB 到 60KB裁剪调试符号、固定 CPU 特性后能压到 10KB 上下。这说明很多体积是非必要开销而不是模型本身需要那么大。所以如果你想复现“10KB 产物”我建议先固定输入 shape关闭调试符号选择最简单的 CPU 后端不要一开始就上多设备支持。这几个条件满足后体积会明显降下来。5.2 动态形状是静态调度的天敌静态调度最怕的就是动态 shape。一旦输入宽高或 batch 可变编译器就没办法提前确定内存布局、dispatch 次数和执行顺序很多决策只能后移回运行时。后果是一连串的产物会额外携带 shape 检查逻辑、动态内存分配逻辑可能还要加上多套执行路径因为编译器无法只生成一份代码搞定所有 shape。体积变大只是一方面更麻烦的是性能不确定性大幅增加。我个人的建议是如果部署场景的输入 shape 基本稳定尽可能把 batch、宽高写死固定 shape 给编译器。这会直接反映在产物体积和执行效率上。5.3 不要凭感觉优化先看体积花在哪里想压缩产物第一步不是到处找 flag而是搞清楚体积到底花在哪个区块。Unix 工具在这里够用file model.vmfb ls -lh model.vmfb strings model.vmfb | lessstrings 输出能快速看到产物里残留了哪些可读符号和调试信息如果出现大量函数名、路径名说明调试符号和符号表还没关干净。针对 ELF 形态的产物也可以用 llvm-size 查看各段大小。面对体积膨胀问题时我的排查顺序是先关调试符号再检查是否引入了多余后端再看输入 shape 是否被推断成了动态最后才考虑换更激进的编译选项。这个顺序能帮我把大部分“莫名膨胀”的问题快速定位掉。6. 落地部署的真实代价VM 与 C 静态模式的取舍6.1 VM 字节码和全静态 C 模式别一上来就选错如果是裸机 MCU 项目我不建议直接塞 VM 形态。VM 解释器再小也要占几 KB 到十几 KB 的代码空间而且还需要一点 RAM 作为执行上下文。C 静态模式把编译产物展开成普通 C 函数可以直接和你的驱动代码一起编译最终二进制里没有解释器只有最纯粹的调度指令展开和执行函数。反过来如果你跑的是带 Linux 或 RTOS 的处理器VM 形态往往更合适。模型更新可以单独发文件不用升级整个应用固件调试也更方便。我这几年做端侧部署的体会是不要被“最小体积”这个目标绑架VM 和 C 静态模式各有一席之地关键是看你的 RAM、Flash 和更新频率约束。6.2 调试符号和未使用代码是最常见的“隐性膨胀”很多从 IREE 入门的人会发现产物比预期大很多最后查出来就是调试符号没关。编译器默认生成的产物可能保留大量便于 host 侧调试的符号信息这些信息对推理本身毫无帮助在发布产物里纯属浪费空间。对应开关就是前面提到的 --iree-llvmcpu-debug-symbolsfalse。如果是嵌入式 C 静态模式还需要注意链接阶段的 garbage collection确保没被调用的 kernel 和初始化函数被直接丢弃。使用 GCC 或 Clang 的链接器脚本时记得检查最终固件里有没有残留无用函数有时裁剪掉这些冗余代码比调编译 flag 更立竿见影。6.3 工具链版本不一致会让旧产物突然“打不开”IREE 的 VM 字节码和运行时之间有版本兼容问题。我之前遇到过升级 iree-runtime 后老的 .vmfb 直接加载失败报的错又不太直观排查半天才发现是 VM 版本差异。解决方法是体系化地管理版本。我现在会固定一套编译环境并把 iree-compiler 和 iree-runtime 的版本写进构建脚本里任何一次升级都是显式的、经过验证的。如果你同时维护多个设备端工程建议为每个平台锁定独立的环境快照不要频繁漂移。6.4 什么时候不该执着于把小产物最后说点反共识的话。10KB 是个让人兴奋的数字但它不是所有场景的解药。如果模型本身结构复杂、输入多变、需求迭代快强行静态化反而会拖累开发效率。每次模型略微变化都要重新走一遍编译、裁剪、验证流程这种成本有时候比产物省下来的那几百 KB 更值得计较。我现在的习惯是新项目先跑通默认编译链路不做任何激进裁剪确保正确性和迭代速度到部署阶段再根据 Flash 和 RAM 的硬约束逐步把 shape 固定、关调试符号、裁剪冗余执行逻辑。IREE 最迷人的地方从来不在于“能把产物做多小”而在于它提供了一条从灵活到极致的连续路径你可以按需停在任何位置。