4、编译器中间表示(IR):GraphDef与MLIR,软件IR如何成为硬件适配的桥梁
说实话,做AI芯片这几年,我越来越觉得编译器中间表示(IR)才是真正的「兵家必争之地」。你想想看,芯片厂商天天喊着要适配TensorFlow,但TensorFlow的模型千变万化,总不能每出一个新算子就让硬件团队去改一遍吧?这时候,IR就站出来了——它就像一座桥,把软件世界的「方言」翻译成硬件能听懂的「普通话」。
我个人习惯把IR比作「翻译官」。TensorFlow生态里有两个重要的翻译官:老牌的GraphDef和新秀MLIR。今天咱们就聊聊它们是怎么工作的,以及为什么说IR是硬件适配的关键。
4.1 GraphDef:TensorFlow的「老牌翻译官」
GraphDef是TensorFlow 1.x时代的核心IR。说白了,它就是一张计算图——把神经网络里的每个操作(比如卷积、池化)都变成一个节点,数据流动就是边。
我在项目中遇到过这样一个场景:某家芯片厂商想支持TensorFlow模型,但他们的硬件只支持INT8量化。如果直接跑原生的FP32 GraphDef,那肯定不行。怎么办?他们就在GraphDef层面做手脚——把FP32的节点替换成INT8的节点,再插入一些量化/反量化操作。这就是IR的威力:你不需要动TensorFlow的源码,只需要在IR层做转换。
GraphDef的格式其实很简单,就是一个protobuf序列化的文件。你可以用tf.io.write_graph把它存下来,然后用tf.io.read_graph读回来。代码大概长这样:
import tensorflow as tf
# 构建一个简单的计算图
a = tf.constant(1.0, name="a")
b = tf.constant(2.0, name="b")
c = tf.add(a, b, name="c")
# 保存GraphDef
tf.io.write_graph(tf.compat.v1.get_default_graph(), ".", "graph.pb")
# 读取GraphDef
with tf.io.gfile.GFile("graph.pb", "rb") as f:
graph_def = tf.compat.v1.GraphDef()
graph_def.ParseFromString(f.read())
嗯,这里要注意:GraphDef是静态的,它只描述计算流程,不包含权重数据。权重是单独保存在checkpoint文件里的。所以芯片厂商在适配时,通常需要同时解析GraphDef和checkpoint。
4.2 MLIR:新一代「多语言翻译官」
GraphDef虽然好用,但有个硬伤——它太「TensorFlow专属」了。你想想看,如果芯片厂商同时要支持PyTorch、ONNX、TensorFlow,难道每个框架都搞一套IR?那不得累死?
MLIR(Multi-Level Intermediate Representation)就是来解决这个问题的。它由Google提出,现在已经是LLVM项目的一部分了。MLIR的核心思想是:不搞「大一统」的IR,而是搞「多层级」的IR。从高层的TensorFlow操作,到低层的硬件指令,中间可以经过好几层IR的逐步降级。
我曾经帮一家初创芯片公司做过MLIR适配。他们的硬件有个特殊指令——矩阵乘加(MAC),但TensorFlow的GraphDef里只有通用的MatMul。如果用MLIR,我们可以这样搞:
- 先把TensorFlow的GraphDef转成MLIR的「tosa」方言(Tensor Operator Set Architecture)
- 再把tosa方言转成「linalg」方言(线性代数操作)
- 最后转成他们自定义的「my_chip」方言,直接映射到硬件指令
你看,每一层IR都只做一点点转换,但合起来就完成了从软件到硬件的完整映射。这就是MLIR的「渐进式降级」思想。
4.3 软件IR如何成为硬件适配的桥梁?
说白了,IR就是一层「抽象」。硬件厂商不需要关心TensorFlow的Python API怎么用,也不需要关心用户是怎么训练模型的。他们只需要关心IR长什么样,然后把自己的硬件指令映射到IR上。
我画了一张图,帮你理解这个流程:
你可能会问:「那芯片厂商到底该选GraphDef还是MLIR?」我的看法是:
| 对比维度 | GraphDef | MLIR |
|---|---|---|
| 成熟度 | 非常成熟,生态完善 | 较新,但发展迅速 |
| 灵活性 | 低,只能表示TensorFlow操作 | 高,支持多方言、多层级 |
| 硬件适配难度 | 中等,需要自己写转换器 | 较低,有现成的方言和pass |
| 社区支持 | TensorFlow原生支持 | LLVM社区,Google主导 |
| 推荐场景 | 只适配TensorFlow,快速出活 | 多框架适配,长期维护 |
4.4 一个小例子:从GraphDef到硬件指令
光说不练假把式。咱们看一个具体的例子:假设硬件有一个专用的「ReLU+Conv2D」融合指令,叫relu_conv2d。在GraphDef里,ReLU和Conv2D是两个独立的节点。我们需要把它们合并成一个。
在MLIR里,我们可以写一个pass来做这件事:
// 伪代码:MLIR pass 实现算子融合
func.func @relu_conv2d_fusion(%arg0: tensor<1x224x224x3xf32>) -> tensor<1x112x112x64xf32> {
// 原始GraphDef中的两个操作
%0 = "tf.Conv2D"(%arg0) {strides = [1,2,2,1], padding = "SAME"}
: (tensor<1x224x224x3xf32>) -> tensor<1x112x112x64xf32>
%1 = "tf.Relu"(%0) : (tensor<1x112x112x64xf32>) -> tensor<1x112x112x64xf32>
// 融合后的硬件指令
%2 = "my_chip.relu_conv2d"(%arg0) {strides = [1,2,2,1], padding = "SAME"}
: (tensor<1x224x224x3xf32>) -> tensor<1x112x112x64xf32>
return %2 : tensor<1x112x112x64xf32>
}
你看,这个pass做的事情很简单:检测到连续的「Conv2D+ReLU」模式,就替换成一个硬件指令。但就是这种简单的模式匹配,能带来30%以上的性能提升——因为减少了中间数据的读写。
嗯,这里要提醒一下:写pass的时候一定要注意边界条件。比如ReLU的输入是不是只有Conv2D一个?如果中间插了个Reshape怎么办?我曾经就因为这个bug,导致模型推理结果全错,排查了两天才发现是pass匹配错了。
4.5 小结
IR这东西,说白了就是「分层抽象」。GraphDef帮你把TensorFlow模型固化下来,MLIR帮你做多层级转换。对于芯片厂商来说,掌握了IR,就等于掌握了适配TensorFlow的钥匙。
我个人觉得,未来几年MLIR会成为主流。因为AI芯片越来越多样化,从GPU到NPU到存算一体,每种硬件都有自己的「脾气」。MLIR的多方言机制,正好能应对这种碎片化。你想想看,如果每个芯片厂商都贡献一套自己的方言,那整个生态就活了。
最后说一句:做IR适配,别想着一步到位。先跑通一个简单的模型,再慢慢加优化。我刚开始做的时候,光是一个Conv2D的映射就折腾了两周。但一旦跑通了,后面的路就好走了。
公众号:蓝海资料掘金营,微信deep3321