4. 片上存储与带宽设计:Local Memory、Global Memory、DMA与数据搬运
大家好,我是你们的老朋友。今天我们来聊聊NPU里一个特别“实在”的话题——存储与带宽。说实话,我在刚入行那会儿,总觉得算子优化就是调调循环、改改指令。直到第一次做大规模模型部署,才发现数据搬运才是真正的性能瓶颈。你想想看,算力再强,数据喂不进去,一切都是白搭。
4.1 存储层次:为什么NPU需要“小”内存?
NPU的存储架构,说白了就是一套“分级缓存”的变体。但它和CPU的L1/L2/L3不太一样。NPU更强调确定性和可预测性。我个人习惯把NPU的存储分为三层:
- Global Memory(全局内存):通常挂在片外,比如DDR或HBM。容量大(GB级别),但延迟高(几百个cycle)。
- Local Memory(本地内存):片上SRAM,容量小(MB级别),但延迟低(几个cycle)。
- Register File(寄存器文件):最靠近计算单元,容量极小(KB级别),延迟几乎为0。
嗯,这里要注意:Global Memory和Local Memory之间的带宽,往往是整个系统的瓶颈。我在项目中遇到过,明明算力利用率只有30%,但功耗却跑满了——查了半天,发现是DMA一直在搬运数据,把带宽吃光了。
4.2 Local Memory:你的“工作台”
Local Memory是片上SRAM,通常被划分为多个Bank。每个Bank可以独立读写。为什么这么设计?因为NPU需要同时喂给多个计算单元数据。
举个例子,一个卷积层有3x3的kernel,输入特征图是64通道。如果你把整个特征图都放在Local Memory里,那当然好——但容量不够。所以我们需要做tiling(分块)。
// 伪代码:Local Memory上的tiling策略
for (int tile_h = 0; tile_h < H; tile_h += TILE_H) {
for (int tile_w = 0; tile_w < W; tile_w += TILE_W) {
// 1. DMA从Global Memory搬运一个tile到Local Memory
dma_load(local_mem, global_mem + tile_h * W + tile_w, TILE_H * TILE_W);
// 2. 计算单元处理这个tile
compute(local_mem, output);
// 3. DMA将结果写回Global Memory
dma_store(global_mem + tile_h * W + tile_w, local_mem, TILE_H * TILE_W);
}
}
你看,这个循环里,DMA搬运和计算是串行的。但实际工程中,我们会用double buffering(双缓冲)来隐藏搬运延迟。我曾经在一个项目中,通过双缓冲把卷积层的性能提升了40%。
4.3 Global Memory:你的“仓库”
Global Memory通常是HBM或DDR。它的特点是容量大、延迟高。在NPU中,Global Memory主要用来存储模型权重、输入特征图和中间结果。
但这里有个问题:Global Memory的带宽是有限的。比如一块HBM2e的带宽是1.6TB/s,听起来很大对吧?但如果你跑一个ResNet-50,每秒钟需要搬运几十GB的数据,带宽很快就会成为瓶颈。
| 存储类型 | 容量 | 延迟 | 带宽 | 典型用途 |
|---|---|---|---|---|
| Global Memory (HBM) | 8-32 GB | ~100 ns | ~1.6 TB/s | 权重、大特征图 |
| Local Memory (SRAM) | 1-32 MB | ~1 ns | ~10 TB/s | Tile数据、中间结果 |
| Register File | ~256 KB | ~0.1 ns | ~100 TB/s | 算子内部变量 |
你想想看,从Global Memory读一次数据要100ns,而从Local Memory读只要1ns。这100倍的差距,就是我们需要优化的空间。
4.4 DMA:数据搬运的“隐形英雄”
DMA(Direct Memory Access)是NPU里最容易被忽视的组件。它的作用就是在不需要CPU干预的情况下,自动搬运数据。
DMA通常支持以下几种模式:
- 1D搬运:连续地址的数据搬运,最简单。
- 2D搬运:支持行跨步(stride),适合搬运矩阵或特征图。
- 3D搬运:支持三维数据搬运,适合多通道特征图。
- Gather/Scatter:非连续地址的搬运,适合稀疏数据。
我记得有一次,我在做稀疏卷积的优化。标准的DMA只支持连续地址搬运,但稀疏数据是非连续的。后来我用了Gather模式,让DMA直接读取非连续地址,性能提升了3倍。
4.5 数据搬运策略:如何“喂饱”计算单元?
数据搬运的核心目标只有一个:让计算单元永远不空等。为了实现这个目标,我们通常采用以下几种策略:
- Double Buffering(双缓冲):在Local Memory里分配两个缓冲区。一个用于计算,另一个用于DMA搬运。这样计算和搬运可以重叠。
- Prefetching(预取):在计算当前tile的同时,提前搬运下一个tile的数据。
- Data Reuse(数据复用):尽量复用Local Memory中的数据,减少对Global Memory的访问。
下面这张图展示了双缓冲的工作流程:
你看,通过双缓冲,DMA搬运和计算完全重叠。这样计算单元就不会因为等待数据而空转。我个人习惯在写算子时,优先考虑双缓冲,因为它实现简单,效果显著。
4.6 带宽优化:从“搬运工”到“调度师”
带宽优化的本质,就是减少无效的数据搬运。我总结了几个实用技巧:
- 数据复用:比如卷积的权重,可以在多个输出通道间复用。尽量把权重留在Local Memory里,不要反复搬运。
- 合并搬运:把多个小数据块合并成一个大块,减少DMA启动次数。
- 对齐访问:DMA通常要求地址对齐(比如128字节对齐)。不对齐的访问会导致带宽浪费。
- 流水线深度:适当增加DMA的流水线深度,可以隐藏更大的延迟。
4.7 总结:存储与带宽的“黄金法则”
嗯,今天的内容有点多。我帮你梳理一下核心要点:
- Local Memory是性能的关键:尽量把热点数据放在片上,减少对Global Memory的访问。
- DMA是隐形英雄:用好DMA的双缓冲和预取功能,让计算和搬运重叠。
- 带宽是硬约束:优化数据复用和搬运策略,避免带宽成为瓶颈。
- Bank冲突要小心:合理分配数据到不同Bank,避免访问冲突。
最后,我想说一句:存储和带宽的设计,决定了NPU的上限。算力再强,如果数据搬不进来,一切都是空谈。希望今天的分享能帮你少走一些弯路。
想要更多资料,联系可可