3. 编译器前端设计:模型解析与中间表示(IR)、TensorFlow/PyTorch模型导入、ONNX格式解析

好,咱们今天聊聊编译器前端。说白了,就是怎么把训练好的模型“吃进去”,变成编译器能理解的东西。

我刚开始做AI芯片编译器那会儿,最头疼的就是模型解析。你想想看,TensorFlow、PyTorch、ONNX,各家有各家的脾气。模型文件一进来,怎么把它拆开、读懂、再组装成统一的中间表示?这就是前端要干的事。

3.1 中间表示(IR)——编译器的“通用语言”

IR是什么?就是中间表示。它像一座桥,连接着前端和后端。

前端解析完模型,生成IR。后端基于IR做优化,最后生成目标代码。没有IR,编译器就是一盘散沙。

我个人习惯把IR分成几个层次:

  • 图级别IR:描述计算图的结构,节点是算子,边是数据依赖。比如TensorFlow的GraphDef、PyTorch的TorchScript。
  • 算子级别IR:每个算子内部的计算逻辑。比如卷积的stride、padding这些参数。
  • 指令级别IR:接近硬件的指令序列。比如LLVM IR、自家芯片的微码。

嗯,这里要注意。图级别IR和算子级别IR,是编译器前端最关心的。指令级别IR,那是后端的事。

核心观点:IR的设计决定了编译器的灵活性和优化空间。一个好的IR,应该具备三个特点:

  • 表达能力:能覆盖所有主流算子的语义
  • 可优化性:方便做图变换、算子融合
  • 可移植性:不绑定任何硬件平台

我在项目中遇到过一个问题。当时我们设计IR时,把卷积和全连接层分成了两个不同的算子。结果做算子融合时,发现卷积+BN(批归一化)可以融合,但全连接+BN却不行。为什么?因为全连接层的权重排布和卷积不一样。后来我们统一了IR中“线性变换”的表示,才解决了这个问题。

3.2 TensorFlow模型导入

TensorFlow的模型格式,主要有两种:

  • SavedModel:官方推荐格式,包含计算图和权重
  • Frozen GraphDef:冻结后的图,权重固化在图中

解析TensorFlow模型,核心是读懂它的GraphDef。GraphDef是一个protobuf序列化的文件,里面定义了所有节点(NodeDef)和它们之间的连接。

每个NodeDef包含:

  • name:节点名称
  • op:算子类型(如Conv2D、Relu、MatMul)
  • input:输入节点列表
  • attr:属性字典(如卷积的stride、padding)

举个例子,解析一个卷积层:

// 伪代码示意
def parse_tf_conv(node):
    op_type = node.op  // "Conv2D"
    inputs = node.input  // ["input_tensor", "weight_tensor"]
    strides = node.attr["strides"].list.i
    padding = node.attr["padding"].s
    // 转换成IR中的卷积算子
    ir_node = IRConv2D(strides=strides, padding=padding)
    return ir_node

我曾经踩过一个坑。TensorFlow的NHWC格式和PyTorch的NCHW格式,在解析时很容易搞混。有一次,我解析完模型,发现推理结果全是错的。查了两天,才发现是数据排布没做转换。从那以后,我每次解析完模型,都会先跑一个简单的测试用例,验证结果是否正确。

避坑指南:解析TensorFlow模型时,注意以下几点:

  • 确认数据排布(NHWC vs NCHW)
  • 处理控制流节点(如Switch、Merge)
  • 忽略训练相关的节点(如梯度计算、优化器)

3.3 PyTorch模型导入

PyTorch的模型导入,比TensorFlow稍微复杂一点。因为PyTorch是动态图,模型结构是在运行时构建的。

常用的方法有两种:

  • TorchScript:通过torch.jit.trace或torch.jit.script导出静态图
  • ONNX导出:通过torch.onnx.export导出ONNX格式

我个人更推荐用TorchScript。为什么?因为它保留了更多的控制流信息。ONNX导出时,有些动态操作会被展开成静态图,丢失了灵活性。

解析TorchScript模型,核心是读懂它的Graph。TorchScript的Graph是一个有向无环图(DAG),每个节点对应一个操作。

举个例子:

// TorchScript Graph示例
graph(%x : Tensor, %w : Tensor, %b : Tensor):
  %conv = aten::conv2d(%x, %w, %b, %strides, %padding)
  %relu = aten::relu(%conv)
  return (%relu)

解析时,我们需要把aten::conv2d映射到IR中的卷积算子,把aten::relu映射到IR中的激活算子。

嗯,这里有个细节。PyTorch的算子命名和TensorFlow不一样。比如卷积,TensorFlow叫Conv2D,PyTorch叫aten::conv2d。在IR中,我们需要统一命名,方便后续优化。

注意事项:PyTorch模型导入时,容易遇到以下问题:

  • 动态shape:输入尺寸不固定,需要IR支持动态shape
  • 自定义算子:用户自己写的C++扩展,需要手动注册
  • 控制流:if/else、循环等,需要IR支持条件执行

3.4 ONNX格式解析

ONNX,全称Open Neural Network Exchange。它像一个“翻译官”,让不同框架的模型可以互相转换。

ONNX的模型结构,也是protobuf序列化的。核心是ModelProto和GraphProto。

  • ModelProto:包含模型元信息(版本、生产者等)
  • GraphProto:包含计算图(节点、边、初始值)

每个节点(NodeProto)包含:

  • op_type:算子类型(如Conv、Relu、Gemm)
  • input:输入名称列表
  • output:输出名称列表
  • attribute:属性列表(如卷积的kernel_shape、strides)

解析ONNX模型,核心是遍历GraphProto中的节点,逐个转换成IR中的算子。

举个例子:

// ONNX节点解析伪代码
def parse_onnx_node(node):
    op_type = node.op_type  // "Conv"
    inputs = node.input  // ["input", "weight", "bias"]
    outputs = node.output  // ["conv_output"]
    attrs = {}
    for attr in node.attribute:
        attrs[attr.name] = attr
    // 转换成IR中的卷积算子
    ir_node = IRConv2D(
        kernel_shape=attrs["kernel_shape"].ints,
        strides=attrs["strides"].ints,
        pads=attrs["pads"].ints
    )
    return ir_node

我在项目中遇到过一个问题。ONNX的算子版本经常更新,同一个算子在不同版本中,属性名称可能不一样。比如Conv算子,在opset 10中,pads属性是必须的;在opset 11中,pads变成了可选。如果不做版本兼容,解析就会报错。

我的经验:解析ONNX时,建议做两件事:

  • 检查opset版本,根据版本选择解析逻辑
  • 对未知算子做“跳过”处理,不要直接崩溃

3.5 从模型到IR的完整流程

好了,咱们把整个流程串起来。从模型文件到IR,大致分三步:

  1. 模型加载:读取模型文件,解析成框架特定的数据结构(如TensorFlow的GraphDef、PyTorch的Graph、ONNX的ModelProto)
  2. 算子映射:把框架特定的算子,映射到IR中统一的算子集合
  3. 图构建:根据算子之间的连接关系,构建IR图

下面这张图,展示了整个流程:

模型解析与IR生成流程 TensorFlow模型 (SavedModel/GraphDef) PyTorch模型 (TorchScript/ONNX) ONNX模型 (ModelProto) 框架特定解析器(TF解析器 / Torch解析器 / ONNX解析器) 算子映射(框架算子 → IR统一算子) 统一中间表示(IR)

你看,不管输入是TensorFlow、PyTorch还是ONNX,最终都汇聚到统一的IR。这就是编译器前端的核心价值——屏蔽框架差异,提供统一的优化入口。

好了,关于编译器前端设计,咱们就聊到这儿。记住一句话:前端做得好,后端优化事半功倍;前端做得糙,后面全是坑。

总结一下

  • IR是编译器的核心,设计要兼顾表达力和可优化性
  • TensorFlow模型解析,注意数据排布和控制流
  • PyTorch模型导入,推荐用TorchScript
  • ONNX解析,注意算子版本兼容
  • 整个流程:模型加载 → 算子映射 → 图构建

专注资料整理