计算图与中间表示: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,需要遵循几个原则:

  1. 表达能力足够强:能覆盖所有前端算子和后端指令
  2. 层次清晰:从高层语义到底层指令,逐步降级
  3. 可扩展:方便添加新的算子、新的硬件特性
  4. 可优化:IR结构要方便做各种pass(比如常量折叠、算子融合)

我参与过一个自研NPU的项目,刚开始IR设计得太“薄”,只支持基本的加减乘除。结果后面要加一个特殊激活函数,整个IR结构都要改,那叫一个痛苦。后来我们参考了MLIR的设计思路,把IR分成多层,每层负责不同的抽象级别,这才解决了问题。

MLIR的Dialect机制:模块化的IR设计

MLIR(Multi-Level Intermediate Representation)是Google提出的多级IR框架。它的核心思想是:用Dialect(方言)来组织不同层次的IR

你可以把Dialect理解成一种“语言方言”。比如:

  • tensor dialect:描述张量运算
  • linalg dialect:描述线性代数运算
  • scf dialect:描述结构化控制流
  • gpu dialect:描述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编译器中,两者都会用到。通常的做法是:

  1. 前端解析计算图,生成Graph IR
  2. 在Graph IR上做高层优化(算子融合、数据布局转换)
  3. 将Graph IR降级为Linear IR
  4. 在Linear IR上做底层优化(指令调度、内存分配)
  5. 生成目标代码

注意:不要试图只用一种IR搞定所有事情。Graph IR做底层优化效率低,Linear IR做高层优化表达力不够。分层设计才是正道。

本章小结

计算图和中间表示,是NPU编译器的基石。静态图适合部署,动态图适合研究。IR设计要分层、可扩展。MLIR的Dialect机制是目前最成熟的方案。Graph IR和Linear IR各有优劣,实际项目中需要结合使用。

嗯,这一章的内容就到这里。下一章咱们聊聊算子映射的具体实现,包括如何把框架算子映射到NPU指令上。到时候我会分享一些实际项目中的踩坑经验。

NPU编译器中间表示知识体系 前端框架 PyTorch / TensorFlow / MindSpore 计算图 静态图 / 动态图 Graph IR 高层优化:算子融合、内存规划 Linear IR 底层优化:指令调度、寄存器分配 目标代码 NPU指令 / 二进制 MLIR Dialect 分层设计 tensor dialect(张量运算) linalg dialect(线性代数) scf dialect(结构化控制流) arith dialect(算术运算) npu dialect(NPU特有指令) 降级(lowering) Graph IR 负责高层语义,Linear IR 负责底层实现,MLIR Dialect 实现分层降级

专注资料整理