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的典型结构。

HIR Graph IR 示例:Conv + BN + ReLU Input (1,3,224,224) Conv2D stride=2, padding=1 BatchNorm ReLU Output (1,64,112,112) 图例 数据节点(张量) 计算节点(算子) 数据流边 归一化节点

你看,这个图非常直观。每个节点都带着自己的“身份信息”——形状、类型、参数。边就是一条条箭头,表示数据从哪来到哪去。这就是Graph IR的核心思想:用图来描述计算。

4.5 我在项目中的实践体会

最后聊点实际的。我在做编译器时,HIR的设计往往决定了整个编译器的上限。

举个例子,有一次我们需要支持动态形状——就是输入图片的尺寸在运行时才能确定。低级IR根本没法处理,因为它在编译期就要确定所有内存地址。但HIR可以,因为HIR的节点只记录“形状是变量”,具体值运行时再填。这就是高层抽象的好处。

还有一次,我们在做算子融合。如果HIR的节点粒度太细(比如把卷积拆成了im2col+gemm),那融合逻辑就变得极其复杂。但如果粒度太粗(比如把整个ResNet block作为一个节点),又没法做细粒度的优化。所以HIR的粒度设计,真的是个艺术活。

总结一下:HIR是深度学习编译器的“战略层”。它决定了你能做什么优化、优化到什么程度。设计一个好的HIR,比写一百个pass都重要。


专注资料整理