3. NPU架构基础:NPU设计哲学、脉动阵列与数据流、NPU指令集概述
好,我们进入第三章。这一章我打算聊聊NPU的“内功心法”。很多同学刚接触NPU时,总盯着算力TOPS看,觉得数字越大越厉害。其实不然。我见过不少项目,算力堆得很高,但实际跑模型时效率惨不忍睹。为什么?因为没搞懂NPU的设计哲学。
说白了,NPU和CPU、GPU的思维方式完全不同。CPU是“万金油”,什么都能干,但干得不够快。GPU是“大力士”,擅长并行计算,但功耗高。NPU呢?它是“专才”,专门为神经网络推理而生。它的设计哲学,我总结为三个字:“少即是多”。
3.1 NPU设计哲学:为神经网络而生
NPU的设计,核心目标只有一个:高效执行矩阵乘法和卷积运算。神经网络里90%以上的计算量都在这上面。所以NPU的设计者干脆把其他“花里胡哨”的功能都砍掉,专心把这两件事做到极致。
我个人习惯把NPU的设计哲学拆成三点来看:
- 数据流驱动:NPU不像CPU那样靠指令流驱动,而是靠数据流驱动。数据来了,计算单元自动开始工作。这减少了指令解码的开销。
- 局部化存储:NPU内部有大量的片上SRAM(静态随机存取存储器),用来缓存权重和中间结果。为什么?因为从外部DDR(双倍数据速率内存)搬数据太慢了,功耗也高。我曾在项目中遇到过,模型推理速度上不去,最后发现是DDR带宽成了瓶颈。后来把权重全部塞进片上SRAM,速度直接翻倍。
- 专用化指令:NPU的指令集非常精简,主要就是“加载数据”、“执行矩阵运算”、“存储结果”这几条。没有复杂的分支预测、乱序执行那些东西。
核心观点:NPU的设计哲学,就是“用最少的资源,做最专一的事”。它牺牲了通用性,换来了极致的能效比。
3.2 脉动阵列:NPU的心脏
讲NPU,绕不开脉动阵列。这玩意儿是NPU性能的关键。我第一次接触脉动阵列时,觉得这名字起得真形象——数据就像血液一样,在计算单元之间“脉动”流动。
脉动阵列本质上是一个二维的乘法器阵列。每个处理单元(PE)只做一件事:乘加运算。数据从阵列的一侧流入,经过每个PE时,完成一次乘加,然后传递给下一个PE。整个过程像流水线一样,高效且规整。
我举个例子,假设我们要计算一个4x4的矩阵乘法。用传统CPU,需要一条条指令去算。用脉动阵列呢?数据一次性加载,然后像波浪一样在阵列里传播,几个时钟周期就出结果了。
脉动阵列有三种常见的数据流模式:
| 数据流模式 | 特点 | 适用场景 |
|---|---|---|
| 权重固定(Weight Stationary) | 权重数据留在PE内不动,输入数据流动 | 卷积层,权重复用率高 |
| 输入固定(Input Stationary) | 输入数据留在PE内不动,权重数据流动 | 全连接层,输入复用率高 |
| 输出固定(Output Stationary) | 部分和结果留在PE内,输入和权重都流动 | 需要减少中间结果写回的场景 |
实战经验:我在做某款自动驾驶芯片时,发现权重固定模式对卷积层最友好。因为卷积核的权重在整个计算过程中不变,留在PE里能省掉大量重复加载的时间。但全连接层就不一样了,权重变化频繁,用输入固定模式反而更高效。所以,好的NPU设计,会针对不同层动态切换数据流模式。
3.3 数据流:从“搬运工”到“计算工”
数据流,说白了就是数据怎么在NPU内部“跑”起来。我见过很多新手,只关注计算单元有多快,却忽略了数据搬运的效率。你想想看,计算单元再快,数据送不过来,也是白搭。
NPU的数据流,通常分为三个阶段:
- 加载阶段:从外部DDR把权重和输入数据搬到片上SRAM。这一步最耗时,也最耗电。我建议尽量用DMA(直接存储器访问)来做,别让CPU插手。
- 计算阶段:数据从SRAM流入脉动阵列,完成矩阵运算。这一步是NPU的强项,速度极快。
- 存储阶段:计算结果写回SRAM,或者直接传给下一层。这里要注意,如果下一层需要用到当前层的输出,最好让数据留在片上,别来回倒腾。
嗯,这里要注意一个关键点:数据重用。神经网络里,很多数据会被多次用到。比如卷积核的权重,会在整个特征图上滑动。如果每次滑动都重新加载权重,那效率就太低了。好的数据流设计,会尽量让数据在片上多待一会儿,减少对外部DDR的访问。
避坑指南:我曾经在一个项目里,为了图省事,把所有数据都放在DDR里,每次计算都去DDR里取。结果呢?推理速度慢得像蜗牛,功耗还高得吓人。后来改成“数据分块+片上缓存”的策略,速度提升了3倍,功耗降了一半。记住:NPU的性能瓶颈,往往不在计算,而在数据搬运。
3.4 NPU指令集概述:精简到极致
NPU的指令集,和CPU的指令集完全是两个世界。CPU的指令集动辄几百条,什么加减乘除、跳转、中断,应有尽有。NPU的指令集呢?我数过,主流NPU的指令通常不超过20条。
为什么会这样?因为NPU不需要处理复杂的控制逻辑。它只需要做三件事:
- 加载数据:从DDR或SRAM加载数据到计算单元。
- 执行计算:启动脉动阵列,完成矩阵乘法或卷积。
- 存储结果:把计算结果写回SRAM或DDR。
我举个例子,某款主流NPU的指令集大概长这样:
// 加载权重到PE阵列
LOAD_WEIGHT addr, size
// 加载输入数据到输入缓冲区
LOAD_INPUT addr, size
// 启动脉动阵列计算
COMPUTE mode, rows, cols
// 存储计算结果
STORE_OUTPUT addr, size
// 同步等待计算完成
SYNC
你看,就这么几条指令。没有循环,没有分支,没有中断。为什么?因为神经网络的计算模式是固定的,不需要那些花里胡哨的控制逻辑。你想想看,一个卷积层,无非就是“加载权重-加载输入-计算-存结果”,重复执行而已。NPU的设计者干脆把这些重复动作固化在硬件里,用一条COMPUTE指令就能搞定。
个人感悟:我刚开始做NPU编程时,总觉得指令集太简陋,啥都干不了。后来才明白,这种“简陋”恰恰是NPU的精髓。它把所有的复杂性都藏在了硬件里,让软件层变得极其简单。你只需要告诉NPU“做什么”,它自己就知道“怎么做”。
最后,我提一句指令集的扩展性。虽然NPU指令集很精简,但好的设计会预留一些扩展位。比如,未来如果出现了新的算子(比如Transformer里的Attention),可以通过扩展指令来支持。我在做某款NPU时,就特意留了4位操作码扩展位,后来果然用上了。
好了,这一章的内容就到这里。下一章,我会带大家深入NPU的微架构,看看那些PE单元内部到底是怎么工作的。到时候,我会分享一些我在实际项目中踩过的坑,保证让你少走弯路。