计算图与中间表示:NPU编译器的“通用语言”
做NPU编译器这几年,我最大的感受就是:没有好的中间表示,编译器就是一盘散沙。你想想看,前端框架五花八门——PyTorch、TensorFlow、MindSpore,每个都有自己的表达方式。后端NPU更是千奇百怪,有的擅长卷积,有的擅长矩阵乘。编译器要做的,就是在这些“方言”之间搭一座桥。
这座桥,就是计算图和中间表示(IR)。今天咱们就来聊聊这个核心话题。
计算图:AI模型的“骨架”
计算图是什么?说白了,就是把神经网络的计算过程画成一张图。每个节点是一个算子(比如卷积、ReLU),每条边是数据流动的方向。
举个例子,一个简单的两层网络:
输入 → Conv2D → ReLU → MaxPool → FC → Softmax → 输出
画成计算图就是:
[输入]
|
[Conv2D]
|
[ReLU]
|
[MaxPool]
|
[FC]
|
[Softmax]
|
[输出]
嗯,看着简单吧?但实际项目中,计算图可能包含上千个节点,还有各种分支、循环、控制流。我记得有一次调试一个BERT模型,计算图展开后有3000多个节点,光看拓扑结构就花了我半天时间。
核心要点:计算图是AI编译器的“中间人”。它把前端框架的模型描述,转换成后端NPU能理解的形式。没有计算图,编译器就是瞎子。
静态图 vs 动态图:两种哲学
这里有个经典问题:静态图和动态图,到底选哪个?
静态图:先定义好整个计算图,再执行。TensorFlow 1.x就是典型代表。
- 优点:编译器可以全局优化,内存分配更高效,适合部署场景
- 缺点:调试困难,控制流不灵活
动态图:边定义边执行,每次前向都重新构建图。PyTorch就是动态图的代表。
- 优点:调试方便,代码更Pythonic,适合研究场景
- 缺点:优化空间有限,重复构建有开销
我个人习惯是:研究阶段用动态图,部署阶段转静态图。你想想看,PyTorch模型训练好了,用TorchScript或者ONNX转成静态图,再交给NPU编译器优化,这是目前最成熟的路线。
避坑指南:我曾经在一个项目中,直接用动态图跑NPU推理,结果发现每次推理都要重新构建计算图,性能惨不忍睹。后来改成静态图,推理速度提升了3倍。嗯,这个教训很深刻。
IR设计原则:好的IR长什么样?
中间表示(IR)是编译器的核心数据结构。设计一个好的IR,需要遵循几个原则:
- 表达能力足够强:能覆盖所有前端算子和后端指令
- 层次清晰:从高层语义到底层指令,逐步降级
- 可扩展:方便添加新的算子、新的硬件特性
- 可优化:IR结构要方便做各种pass(比如常量折叠、算子融合)
我参与过一个自研NPU的项目,刚开始IR设计得太“薄”,只支持基本的加减乘除。结果后面要加一个特殊激活函数,整个IR结构都要改,那叫一个痛苦。后来我们参考了MLIR的设计思路,把IR分成多层,每层负责不同的抽象级别,这才解决了问题。
MLIR的Dialect机制:模块化的IR设计
MLIR(Multi-Level Intermediate Representation)是Google提出的多级IR框架。它的核心思想是:用Dialect(方言)来组织不同层次的IR。
你可以把Dialect理解成一种“语言方言”。比如:
tensordialect:描述张量运算linalgdialect:描述线性代数运算scfdialect:描述结构化控制流gpudialect:描述GPU相关操作
每个Dialect只负责自己那一层的事情。编译器通过pass,把高层的Dialect逐步降级到低层的Dialect,最终生成目标代码。
举个例子,一个卷积操作在MLIR中的表示:
// 高层:tensor dialect
%result = tensor.conv2d %input, %filter : tensor<1x224x224x3>, tensor<3x3x3x64> -> tensor<1x112x112x64>
// 降级后:linalg dialect
%result = linalg.conv2d { strides = [2, 2], dilations = [1, 1] }
ins(%input, %filter : tensor<1x224x224x3>, tensor<3x3x3x64>)
outs(%init : tensor<1x112x112x64>)
// 再降级:scf + arith dialect
scf.for %i = ... {
scf.for %j = ... {
%sum = arith.addf %a, %b
...
}
}
你看,同一个卷积操作,在不同Dialect中有不同的表示。高层关注语义,低层关注实现细节。这种分层设计,让编译器可以灵活地在不同抽象级别上做优化。
个人经验:我在做NPU编译器时,最常用的就是MLIR的Dialect机制。我们为NPU定制了一个叫npu的Dialect,专门描述NPU特有的指令和内存访问模式。这样,通用优化(比如常量折叠)在高层Dialect做,NPU特有的优化(比如内存对齐)在npu Dialect做,互不干扰。
Graph IR vs Linear IR:两种IR的对比
在编译器领域,IR主要分为两类:
| 特性 | Graph IR(图IR) | Linear IR(线性IR) |
|---|---|---|
| 结构 | 有向无环图(DAG) | 指令序列(类似汇编) |
| 数据流 | 显式表示 | 通过虚拟寄存器 |
| 控制流 | 通过分支节点 | 通过跳转指令 |
| 优化能力 | 全局优化强(如算子融合) | 局部优化强(如指令调度) |
| 典型代表 | MLIR、XLA HLO | LLVM IR、TVM TIR |
Graph IR适合做高层优化,比如算子融合、内存规划。Linear IR适合做底层优化,比如指令选择、寄存器分配。
在实际的NPU编译器中,两者都会用到。通常的做法是:
- 前端解析计算图,生成Graph IR
- 在Graph IR上做高层优化(算子融合、数据布局转换)
- 将Graph IR降级为Linear IR
- 在Linear IR上做底层优化(指令调度、内存分配)
- 生成目标代码
注意:不要试图只用一种IR搞定所有事情。Graph IR做底层优化效率低,Linear IR做高层优化表达力不够。分层设计才是正道。
本章小结
计算图和中间表示,是NPU编译器的基石。静态图适合部署,动态图适合研究。IR设计要分层、可扩展。MLIR的Dialect机制是目前最成熟的方案。Graph IR和Linear IR各有优劣,实际项目中需要结合使用。
嗯,这一章的内容就到这里。下一章咱们聊聊算子映射的具体实现,包括如何把框架算子映射到NPU指令上。到时候我会分享一些实际项目中的踩坑经验。