4. NPU硬件架构基础
好,咱们今天聊聊NPU的硬件架构。说实话,很多做模型编译器的朋友,一开始都把这部分当成黑盒。我当年也是,总觉得反正上层有框架,底层有驱动,中间层我写写调度规则就行了。直到有一次,我优化一个卷积算子,怎么调都跑不满硬件利用率,最后一看,原来是数据在L1 Buffer和DDR之间搬来搬去,白白浪费了十几个cycle。嗯,从那以后,我养成了一个习惯:写编译器之前,先把NPU的datasheet翻烂。
NPU的架构,说白了就是三个核心计算单元加上一套存储体系,再配上专门的指令集。咱们一个一个拆开看。
4.1 脉动阵列:矩阵运算的“流水线工厂”
脉动阵列(Systolic Array)是NPU最标志性的部件。你想想看,神经网络里最耗时的操作是什么?卷积和全连接,本质上都是矩阵乘累加。脉动阵列就是专门干这个的。
它的结构像一张网格,每个格子是一个乘累加单元(MAC)。数据从左边和上边流进来,在每个格子里完成一次乘加,结果再往右边或下边传。整个过程就像流水线一样,数据“脉动”着通过阵列。
我在项目中遇到过一个问题:脉动阵列的尺寸是固定的,比如16x16。如果你的矩阵尺寸不是16的倍数,就会有“空洞”,利用率直线下降。所以编译器在做tiling(分块)时,一定要对齐到阵列的尺寸。
这里我画了一张图,帮你理解数据是怎么流动的:
4.2 向量单元:非矩阵运算的“多面手”
脉动阵列虽然快,但它只擅长矩阵乘。那激活函数、BatchNorm、Element-wise加法这些操作怎么办?这就轮到向量单元出场了。
向量单元本质上是一个SIMD(单指令多数据)处理器。一条指令可以同时处理多个数据元素。比如ReLU,一条指令就能把整个向量里的负数全变成0。
我个人习惯把向量单元看作“胶水逻辑”。它负责处理那些矩阵运算之外的操作。很多编译器新手容易犯一个错:把所有操作都往脉动阵列上塞。其实有些操作在向量单元上跑更快,因为省去了数据搬运的开销。
向量单元通常支持的操作包括:
- 激活函数: ReLU、Sigmoid、Tanh等
- 归一化: BatchNorm、LayerNorm
- 逐元素操作: Add、Mul、Max等
- 类型转换: FP32转INT8等
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,一个在计算,一个在加载,隐藏搬运延迟
4.5 指令集特点:NPU的“语言”
NPU的指令集和CPU的指令集完全不同。CPU的指令集追求通用性,一条指令能干很多事情。NPU的指令集追求的是“批量处理”和“流水线并行”。
典型的NPU指令集有这几个特点:
- VLIW(超长指令字): 一条指令里包含多个操作,比如“加载数据 + 计算 + 存储结果”可以放在一条指令里。编译器要负责把这些操作打包好。
- 显式并行: 程序员(或编译器)要明确告诉硬件哪些操作可以并行。不像CPU有乱序执行,NPU的并行全靠编译器安排。
- 专用指令: 比如“矩阵乘”、“卷积”、“ReLU”这些都有专门的指令。一条指令就能完成一个复杂的算子。
- 同步指令: 用来同步不同单元的执行。比如等脉动阵列算完了,向量单元再开始做激活。
我举个例子,一个简化的NPU指令序列可能是这样的:
// 伪代码:NPU指令序列
LOAD weight, L2->L1 // 把权重从L2搬到L1
LOAD input, L2->L1 // 把输入从L2搬到L1
MATMUL // 脉动阵列做矩阵乘
SYNC // 等待脉动阵列完成
VECTOR RELU // 向量单元做ReLU
STORE output, L1->L2 // 把结果存回L2
你看,每一步都是显式的。编译器要负责生成这些指令,并且安排好它们的执行顺序。如果顺序错了,比如还没等数据加载完就开始计算,那结果就是错的。
好了,NPU的硬件架构基础就聊到这儿。脉动阵列、向量单元、标量单元,这三者各司其职;L1/L2 Buffer和DDR构成了数据搬运的通道;指令集则是我们和硬件对话的语言。理解这些,你写编译器时就知道该往哪个方向使劲了。