4. 高级中间表示(HIR):HIR的特点、HIR的表示形式(如Graph IR)、HIR的节点与边
好,咱们进入第四讲。前面聊了低级IR,那些东西离硬件很近,寄存器、内存地址满天飞。但说实话,做深度学习编译器,你大部分时间打交道的是——高级中间表示,也就是HIR。
HIR是什么?说白了,它就是计算图的“编译器内表示”。你写一个PyTorch模型,前端解析出来的那个抽象语法树太啰嗦,直接怼到后端又太粗糙。HIR就是中间那个“翻译官”,既要保留高层语义,又要方便做各种优化。
我个人习惯把HIR比作“蓝图”。你盖房子,不会一上来就讨论用几号钢筋、水泥标号多少。你首先得画个平面图,哪里是客厅,哪里是卧室。HIR干的就是这个活——它描述的是“做什么”,而不是“怎么做”。
核心要点:HIR是计算图的语义化表示,它屏蔽了硬件细节,专注于数据流和控制流的逻辑结构。
4.1 HIR的特点
我总结了一下,HIR有四个比较突出的特点。你记一下,面试也常考。
- 高层语义抽象:HIR里一个节点可能代表一个完整的算子,比如Conv2D、BatchNorm、LayerNorm。它不会把这些算子拆成几十条指令。嗯,这里要注意,这种抽象粒度让图级别的优化变得非常直观。
- 平台无关性:HIR不关心你最终跑在NVIDIA还是昇腾上。它只描述“输入A经过卷积得到输出B”。至于卷积是用cuDNN还是TBE算子,那是后端的事。
- 强类型系统:每个节点都带有明确的张量形状、数据类型、内存布局信息。我在项目中遇到过,有人把NHWC和NCHW搞混了,结果在HIR层面没报错,跑到后端才炸了。所以HIR的类型检查一定要做扎实。
- 支持控制流:不像低级IR那样基本是线性的,HIR可以原生支持if-else、循环等控制流结构。这一点对动态模型特别重要。
避坑指南:我曾经在一个项目里,把HIR设计得过于“高级”,节点粒度太粗,结果做算子融合时发现根本没法拆。后来我学乖了——HIR的粒度要适中,太粗优化不了,太细又失去了“高级”的意义。
4.2 HIR的表示形式:Graph IR
HIR最常见的表示形式就是图——Graph IR。你想想看,深度学习模型本质上就是个有向无环图(DAG),节点是算子,边是张量流动。Graph IR就是把这个图画出来,用数据结构存起来。
常见的Graph IR实现有几种:
| 框架/编译器 | Graph IR名称 | 特点 |
|---|---|---|
| TVM | Relay IR | 函数式风格,支持let绑定,类型系统完善 |
| MLIR | Operation + Region | 多级抽象,可自定义Dialect,灵活性极高 |
| XLA | HLO (High Level Optimizer) | 基于静态形状,强优化能力 |
| PyTorch | FX Graph | 纯Python实现,可捕获动态控制流 |
我个人比较喜欢MLIR的Region设计。为什么呢?因为它把控制流也纳入了图结构。一个循环就是一个Region,里面嵌套子图。这种层次化的设计,做循环展开、循环合并时特别顺手。
4.3 HIR的节点与边
好,咱们来拆解一下Graph IR的基本单元——节点和边。
4.3.1 节点(Node)
节点就是算子。但HIR里的节点比你想的要复杂一点。一个典型的HIR节点包含以下信息:
- OpType:算子类型,比如conv2d、relu、add。这是节点的“身份ID”。
- Inputs:输入张量的列表。每个输入都带有张量元信息(形状、类型、布局)。
- Outputs:输出张量的列表。同样带有元信息。
- Attributes:算子的属性参数。比如卷积的stride、padding、dilation。这些是常量,不参与梯度计算。
- Metadata:元数据,比如算子名称、调试信息、设备放置策略等。
举个例子,一个卷积节点在HIR里大概长这样:
Node {
op_type: "conv2d"
inputs: [
{ name: "input", shape: [1, 3, 224, 224], dtype: float32 },
{ name: "weight", shape: [64, 3, 3, 3], dtype: float32 },
{ name: "bias", shape: [64], dtype: float32 }
]
outputs: [
{ name: "output", shape: [1, 64, 112, 112], dtype: float32 }
]
attributes: {
stride: [2, 2],
padding: [1, 1],
dilation: [1, 1],
groups: 1
}
metadata: {
name: "conv1",
device: "/device:GPU:0"
}
}
你可能会问,为什么要把形状信息放在节点里?嗯,这其实是个设计取舍。放在节点里,做形状推断时方便,但图会变得臃肿。我见过有些编译器把形状信息放在边上,各有优劣吧。
4.3.2 边(Edge)
边就是数据依赖关系。在Graph IR里,一条边表示“一个节点的输出是另一个节点的输入”。
边通常包含:
- Source Node:源节点
- Source Output Index:源节点的第几个输出
- Target Node:目标节点
- Target Input Index:目标节点的第几个输入
为什么要有索引?因为一个节点可能有多个输出(比如split算子),也可能有多个输入(比如add算子)。索引就是用来精确定位的。
注意:在HIR层面,边只表示数据依赖,不表示控制依赖。控制依赖(比如“先执行A再执行B”)通常用额外的结构来表示,比如MLIR里的SSA dominance或者TVM里的scope。
4.4 HIR的图形化表示
光说理论太枯燥。咱们画个图看看。下面是一个简单的HIR图,展示了一个卷积+批归一化+ReLU的典型结构。
你看,这个图非常直观。每个节点都带着自己的“身份信息”——形状、类型、参数。边就是一条条箭头,表示数据从哪来到哪去。这就是Graph IR的核心思想:用图来描述计算。
4.5 我在项目中的实践体会
最后聊点实际的。我在做编译器时,HIR的设计往往决定了整个编译器的上限。
举个例子,有一次我们需要支持动态形状——就是输入图片的尺寸在运行时才能确定。低级IR根本没法处理,因为它在编译期就要确定所有内存地址。但HIR可以,因为HIR的节点只记录“形状是变量”,具体值运行时再填。这就是高层抽象的好处。
还有一次,我们在做算子融合。如果HIR的节点粒度太细(比如把卷积拆成了im2col+gemm),那融合逻辑就变得极其复杂。但如果粒度太粗(比如把整个ResNet block作为一个节点),又没法做细粒度的优化。所以HIR的粒度设计,真的是个艺术活。
总结一下:HIR是深度学习编译器的“战略层”。它决定了你能做什么优化、优化到什么程度。设计一个好的HIR,比写一百个pass都重要。