2、Fusion Compiler架构概览:IR中间表示、Pass流水线、后端代码生成、目标硬件抽象层
好,咱们进入正题。第二章,我们来聊聊Fusion Compiler的整体架构。
说实话,我第一次接触Fusion Compiler时,第一反应是:“这玩意儿跟TFLite的转换器长得有点像啊?”但深入用下来才发现,两者设计哲学完全不同。TFLite更像一个“黑盒转换器”,你把模型丢进去,它给你吐出一个flatbuffer文件。而Fusion Compiler,更像一个“乐高工厂”——你可以看到每一块积木是怎么拼起来的,甚至可以自己换积木。
我个人习惯把Fusion Compiler的架构拆成四层来看:IR中间表示、Pass流水线、后端代码生成、目标硬件抽象层。这四层环环相扣,缺一不可。下面我们一层层剥开来看。
2.1 IR中间表示:模型的“通用语言”
IR,全称Intermediate Representation,中间表示。说白了,它就是模型在编译器内部的“通用语言”。
你想想看,我们训练模型时用PyTorch,部署时可能用TFLite,中间还可能经过ONNX。这么多框架,每个都有自己的数据格式。编译器怎么统一处理?答案就是IR。
Fusion Compiler的IR设计,我个人觉得非常优雅。它采用了一种基于静态单赋值(SSA)形式的多级IR。什么意思呢?
- 高层IR(HIR):接近原始模型结构,保留算子语义(比如Conv2D、BatchNorm)。适合做图优化、算子融合。
- 中层IR(MIR):开始做类型推导、形状推断。把动态shape尽量变成静态shape。
- 底层IR(LIR):接近硬件指令。比如把Conv2D拆成im2col+GEMM,或者直接映射到硬件特定的指令。
为什么要分这么多层?我在项目中遇到过一个问题:直接把高层IR映射到硬件指令,结果发现某些硬件不支持动态shape,跑起来直接崩了。后来改成先经过MIR做形状静态化,再往下走,问题就解决了。所以,分层不是炫技,是实战中踩坑踩出来的经验。
核心要点:IR的分层设计,本质上是把“模型表达”和“硬件细节”解耦。每一层只关心自己该关心的事。
来看一个简单的IR示例(高层IR转中层IR的过程):
// 高层IR(HIR):保留原始算子语义
%1 = Conv2D(%input, %weight, stride=1, padding='same')
%2 = BatchNorm(%1, %gamma, %beta)
%3 = ReLU(%2)
// 中层IR(MIR):类型推导后,形状信息已确定
%1: f32[1, 224, 224, 3] = Conv2D(%input: f32[1, 224, 224, 3], %weight: f32[64, 3, 3, 3], stride=1, padding='same')
%2: f32[1, 224, 224, 64] = BatchNorm(%1, %gamma: f32[64], %beta: f32[64])
%3: f32[1, 224, 224, 64] = ReLU(%2)
嗯,这里要注意:MIR阶段会做形状推断。如果某个算子的输出形状无法确定,编译器会报错。我曾经调试过一个模型,死活过不了MIR,最后发现是某个自定义算子的形状推导函数没写对。所以,如果你要接入自定义算子,记得把形状推导函数写好。
2.2 Pass流水线:编译器的“加工车间”
有了IR,接下来就是怎么加工它。Pass,就是加工工序。一条条Pass串起来,就是流水线。
Fusion Compiler的Pass流水线,我把它分为三大类:
- 分析型Pass:只读不写,收集信息。比如“死代码分析”、“依赖分析”。
- 变换型Pass:改写IR。比如“算子融合”、“常量折叠”、“死代码消除”。
- 验证型Pass:检查IR的正确性。比如“类型检查”、“形状一致性检查”。
流水线的顺序非常关键。举个例子:
- 先做常量折叠,把能算的提前算好。
- 再做算子融合,比如把Conv+BN+ReLU合并成一个算子。
- 然后做死代码消除,把没用的节点删掉。
- 最后做内存规划,给每个张量分配内存地址。
顺序错了会怎样?我曾经犯过一个错误:先做了算子融合,再做常量折叠。结果融合后的算子内部包含常量,常量折叠反而没法做了。嗯,从那以后,我每次写Pass流水线,都会画一张依赖图,确保顺序合理。
小技巧:调试Pass时,可以开启“Pass Dump”模式,把每个Pass处理后的IR都打印出来。这样一眼就能看出哪个Pass出了问题。
下面是一个典型的Pass流水线配置(伪代码):
PassManager pm;
pm.addPass(ConstantFoldingPass()); // 常量折叠
pm.addPass(OpFusionPass()); // 算子融合
pm.addPass(DeadCodeEliminationPass()); // 死代码消除
pm.addPass(MemoryPlanningPass()); // 内存规划
pm.addPass(TypeCheckingPass()); // 最终类型检查
pm.run(module);
2.3 后端代码生成:从IR到机器码
IR加工好了,接下来就是生成目标代码。这一步,我称之为“惊险一跃”。
后端代码生成的核心任务:把底层IR(LIR)转换成目标硬件能执行的代码。可能是CPU的汇编,可能是GPU的CUDA代码,也可能是NPU的指令序列。
Fusion Compiler的后端设计,采用了一种“指令选择 + 指令调度 + 寄存器分配”的经典三阶段模式:
| 阶段 | 做什么 | 常见问题 |
|---|---|---|
| 指令选择 | 把LIR节点映射到目标硬件的具体指令 | 指令覆盖率不足,某些算子找不到对应指令 |
| 指令调度 | 重排指令顺序,提高流水线效率 | 调度过度,导致寄存器压力过大 |
| 寄存器分配 | 把虚拟寄存器映射到物理寄存器 | 寄存器溢出,性能骤降 |
我个人觉得,指令选择是最容易出问题的环节。为什么?因为硬件指令集总是在更新,而编译器的指令选择模式往往滞后。我曾经接手过一个项目,新来的NPU支持了“卷积+池化”融合指令,但编译器后端没更新,结果生成的代码还是分开执行的,性能直接打七折。
避坑指南:如果你要适配一个新的硬件后端,一定要先检查指令选择阶段的“指令覆盖率”。覆盖率低于80%的话,建议先补全指令映射,再谈优化。
2.4 目标硬件抽象层:屏蔽硬件差异的“万能适配器”
最后,我们聊聊目标硬件抽象层(Target Hardware Abstraction Layer,简称THAL)。
这个名字听起来很唬人,其实说白了,它就是一层“适配器”。把不同硬件的差异,封装成统一的接口。
举个例子:
- 硬件A的内存分配函数叫
hw_alloc() - 硬件B的内存分配函数叫
device_malloc() - 硬件C甚至没有显式内存分配,全靠隐式管理
如果没有THAL,你的编译器后端代码里会充斥着 #ifdef HW_A、#ifdef HW_B 这样的宏定义,维护起来简直是噩梦。
Fusion Compiler的THAL设计,我总结为三个核心接口:
- 内存管理接口:分配、释放、拷贝、映射
- 执行引擎接口:加载模型、运行推理、同步/异步控制
- 性能调优接口:查询硬件能力、设置缓存策略、调整线程数
有了THAL,编译器后端只需要面向这组接口编程,具体实现由硬件厂商提供。这样,编译器本身不用关心硬件细节,硬件厂商也不用关心编译器内部逻辑。各司其职,互不干扰。
我记得有一次,我们要把一个模型从GPU迁移到NPU。GPU上跑得好好的,NPU上却一直报内存越界。查了两天,最后发现是THAL的内存对齐策略不一样——GPU要求128字节对齐,NPU要求64字节对齐。嗯,这种问题,如果没有THAL层统一处理,排查起来会非常痛苦。
2.5 架构总览图
说了这么多,咱们用一张图来总结Fusion Compiler的整体架构。这张图是我自己画的,把四层的关系和流转路径都标出来了。
从这张图可以看得很清楚:模型从入口进来,经过IR的多级表示,再经过Pass流水线的层层加工,最后通过后端代码生成和THAL适配,输出为目标硬件可执行的代码。每一层都有明确的职责,层与层之间通过标准接口通信。
好了,第二章的内容就到这里。Fusion Compiler的架构,说白了就是“分而治之”四个字。把复杂问题拆成四层,每层只解决一个核心问题。这种设计思想,在我们做嵌入式部署时非常实用——你不需要一次性搞定所有事情,而是可以逐层调试、逐层优化。
本章小结:
- IR中间表示:多级SSA形式,HIR→MIR→LIR,逐层降低抽象度
- Pass流水线:分析→变换→验证,顺序决定优化效果
- 后端代码生成:指令选择→调度→寄存器分配,三阶段缺一不可
- 目标硬件抽象层:统一接口,屏蔽硬件差异,降低适配成本