4. NPU硬件架构基础

好,咱们今天聊聊NPU的硬件架构。说实话,很多做模型编译器的朋友,一开始都把这部分当成黑盒。我当年也是,总觉得反正上层有框架,底层有驱动,中间层我写写调度规则就行了。直到有一次,我优化一个卷积算子,怎么调都跑不满硬件利用率,最后一看,原来是数据在L1 Buffer和DDR之间搬来搬去,白白浪费了十几个cycle。嗯,从那以后,我养成了一个习惯:写编译器之前,先把NPU的datasheet翻烂。

NPU的架构,说白了就是三个核心计算单元加上一套存储体系,再配上专门的指令集。咱们一个一个拆开看。

4.1 脉动阵列:矩阵运算的“流水线工厂”

脉动阵列(Systolic Array)是NPU最标志性的部件。你想想看,神经网络里最耗时的操作是什么?卷积和全连接,本质上都是矩阵乘累加。脉动阵列就是专门干这个的。

它的结构像一张网格,每个格子是一个乘累加单元(MAC)。数据从左边和上边流进来,在每个格子里完成一次乘加,结果再往右边或下边传。整个过程就像流水线一样,数据“脉动”着通过阵列。

核心优势: 数据复用率高,减少访存次数。权重可以“驻留”在阵列里,输入特征图流过一次,就能算出所有输出通道的结果。

我在项目中遇到过一个问题:脉动阵列的尺寸是固定的,比如16x16。如果你的矩阵尺寸不是16的倍数,就会有“空洞”,利用率直线下降。所以编译器在做tiling(分块)时,一定要对齐到阵列的尺寸。

这里我画了一张图,帮你理解数据是怎么流动的:

脉动阵列数据流示意图(4x4) 输入特征图 权重 MAC MAC MAC MAC MAC MAC MAC MAC MAC MAC MAC MAC MAC MAC MAC MAC → → → → ↓ ↓ ↓ ↓ 部分和输出 蓝色箭头:特征图数据流(从左到右) 橙色箭头:权重数据流(从上到下) 绿色箭头:部分和输出(从右到下) 每个MAC单元完成一次乘累加,结果传递给下一个单元
避坑指南: 我曾经在写tiling策略时,没考虑脉动阵列的边界对齐,结果小batch推理时利用率不到50%。后来我加了一个padding逻辑,把输入矩阵补到阵列尺寸的整数倍,性能直接翻倍。

4.2 向量单元:非矩阵运算的“多面手”

脉动阵列虽然快,但它只擅长矩阵乘。那激活函数、BatchNorm、Element-wise加法这些操作怎么办?这就轮到向量单元出场了。

向量单元本质上是一个SIMD(单指令多数据)处理器。一条指令可以同时处理多个数据元素。比如ReLU,一条指令就能把整个向量里的负数全变成0。

我个人习惯把向量单元看作“胶水逻辑”。它负责处理那些矩阵运算之外的操作。很多编译器新手容易犯一个错:把所有操作都往脉动阵列上塞。其实有些操作在向量单元上跑更快,因为省去了数据搬运的开销。

向量单元通常支持的操作包括:

  • 激活函数: ReLU、Sigmoid、Tanh等
  • 归一化: BatchNorm、LayerNorm
  • 逐元素操作: Add、Mul、Max等
  • 类型转换: FP32转INT8等
注意: 向量单元的位宽是固定的,比如512位。如果你的数据类型是FP16,那一条指令就能处理32个元素。但如果用INT8,就能处理64个。编译器在做向量化时,一定要算清楚这个“并行度”。

4.3 标量单元:控制流的“大脑”

标量单元,说白了就是一个精简的CPU核心。它不负责大规模计算,而是负责控制流:循环计数、地址生成、分支跳转这些。

你想想看,一个卷积算子,需要嵌套好几层循环:输出通道、输入通道、高度、宽度。这些循环的索引计算、边界判断,如果都交给向量单元或脉动阵列,那太浪费了。标量单元就是专门干这个的。

我记得有一次调试一个算子,发现性能瓶颈不在计算,而在地址生成。每次计算一个输出点,都要重新计算输入地址,标量单元忙不过来,导致计算单元在空等。后来我用了“地址预计算”的技巧,把地址模式提前算好存到寄存器里,性能提升了30%。

标量单元和向量单元、脉动阵列的关系,就像是一个指挥家:

  • 标量单元: 发号施令(循环控制、地址计算)
  • 向量单元: 执行段落(逐元素操作)
  • 脉动阵列: 执行重头戏(矩阵乘)

4.4 存储层次:数据搬运的“高速公路”

NPU的存储层次,是编译器优化的重中之重。我见过太多人只关注计算单元,忽略了数据搬运的开销。其实在大多数场景下,数据搬运的时间比计算时间还长。

典型的NPU存储层次是这样的:

存储层级 容量 带宽 延迟 谁在用
寄存器文件 几百字节 最高 1 cycle 标量单元
L1 Buffer 几十KB ~ 几百KB 高 几个cycle 脉动阵列、向量单元
L2 Buffer 几MB 中等 几十个cycle 所有单元共享
DDR 几GB ~ 几十GB 低 几百个cycle CPU/系统内存

这里有个关键点:L1 Buffer通常是和计算单元紧耦合的。比如脉动阵列有自己的L1 Buffer,向量单元也有自己的。数据从L2搬到L1,计算单元才能访问。

编译器要做的,就是尽量让数据在L1里待着,别老往L2或DDR搬。这就是“数据本地化”优化。我常用的策略是:

  • 权重驻留: 如果多个卷积层共享同一组权重,让权重一直留在L1里
  • 输出复用: 当前层的输出,直接作为下一层的输入,别搬回DDR
  • Double Buffer: 用两个L1 Buffer,一个在计算,一个在加载,隐藏搬运延迟
个人经验: 我曾经优化一个ResNet-50的推理,发现L1 Buffer的命中率只有60%。后来我调整了tiling策略,让每个tile的数据量刚好塞满L1,命中率提到了95%以上。说白了,就是“算着搬,搬着算”,别让计算单元闲着。

4.5 指令集特点:NPU的“语言”

NPU的指令集和CPU的指令集完全不同。CPU的指令集追求通用性,一条指令能干很多事情。NPU的指令集追求的是“批量处理”和“流水线并行”。

典型的NPU指令集有这几个特点:

  1. VLIW(超长指令字): 一条指令里包含多个操作,比如“加载数据 + 计算 + 存储结果”可以放在一条指令里。编译器要负责把这些操作打包好。
  2. 显式并行: 程序员(或编译器)要明确告诉硬件哪些操作可以并行。不像CPU有乱序执行,NPU的并行全靠编译器安排。
  3. 专用指令: 比如“矩阵乘”、“卷积”、“ReLU”这些都有专门的指令。一条指令就能完成一个复杂的算子。
  4. 同步指令: 用来同步不同单元的执行。比如等脉动阵列算完了,向量单元再开始做激活。

我举个例子,一个简化的NPU指令序列可能是这样的:

// 伪代码:NPU指令序列
LOAD  weight, L2->L1    // 把权重从L2搬到L1
LOAD  input,  L2->L1    // 把输入从L2搬到L1
MATMUL                  // 脉动阵列做矩阵乘
SYNC                    // 等待脉动阵列完成
VECTOR RELU             // 向量单元做ReLU
STORE output, L1->L2    // 把结果存回L2

你看,每一步都是显式的。编译器要负责生成这些指令,并且安排好它们的执行顺序。如果顺序错了,比如还没等数据加载完就开始计算,那结果就是错的。

踩坑提醒: 我曾经在写指令调度时,忽略了SYNC指令,结果脉动阵列还在算,向量单元就开始读数据了,读到的全是垃圾值。调试了两天才发现是同步问题。所以,指令调度时一定要把同步点标清楚。

好了,NPU的硬件架构基础就聊到这儿。脉动阵列、向量单元、标量单元,这三者各司其职;L1/L2 Buffer和DDR构成了数据搬运的通道;指令集则是我们和硬件对话的语言。理解这些,你写编译器时就知道该往哪个方向使劲了。