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一直在搬运数据,把带宽吃光了。

核心观点:NPU的性能,不是由峰值算力决定的,而是由“数据搬运效率”决定的。

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%。

小技巧:Local Memory的Bank冲突是个坑。如果两个计算单元同时访问同一个Bank,就会产生冲突,导致延迟。我建议你在分配数据时,尽量让不同通道的数据落在不同Bank上。

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倍。

避坑指南:我曾经在项目中犯过一个错误——DMA搬运的数据量太小。比如每次只搬运64字节,但DMA的启动开销是固定的。这样会导致带宽利用率极低。我建议每次搬运至少1KB以上,才能充分利用DMA的带宽。

4.5 数据搬运策略:如何“喂饱”计算单元?

数据搬运的核心目标只有一个:让计算单元永远不空等。为了实现这个目标,我们通常采用以下几种策略:

  1. Double Buffering(双缓冲):在Local Memory里分配两个缓冲区。一个用于计算,另一个用于DMA搬运。这样计算和搬运可以重叠。
  2. Prefetching(预取):在计算当前tile的同时,提前搬运下一个tile的数据。
  3. Data Reuse(数据复用):尽量复用Local Memory中的数据,减少对Global Memory的访问。

下面这张图展示了双缓冲的工作流程:

时间 DMA搬运 Tile 0 计算 Tile 0 DMA搬运 Tile 1 计算 Tile 1 DMA搬运 Tile 2 计算 Tile 2 DMA搬运 Tile 3 计算 Tile 3 Buffer 0 Buffer 1 Buffer 0 Buffer 1 计算 计算 计算 计算

你看,通过双缓冲,DMA搬运和计算完全重叠。这样计算单元就不会因为等待数据而空转。我个人习惯在写算子时,优先考虑双缓冲,因为它实现简单,效果显著。

4.6 带宽优化:从“搬运工”到“调度师”

带宽优化的本质,就是减少无效的数据搬运。我总结了几个实用技巧:

  • 数据复用:比如卷积的权重,可以在多个输出通道间复用。尽量把权重留在Local Memory里,不要反复搬运。
  • 合并搬运:把多个小数据块合并成一个大块,减少DMA启动次数。
  • 对齐访问:DMA通常要求地址对齐(比如128字节对齐)。不对齐的访问会导致带宽浪费。
  • 流水线深度:适当增加DMA的流水线深度,可以隐藏更大的延迟。
实战经验:我在优化一个Transformer模型时,发现Self-Attention的QKV矩阵搬运占了60%的带宽。后来我用了权重共享和分块计算,把带宽占用降到了30%。说白了,就是让数据在Local Memory里多待一会儿,少去Global Memory“串门”。

4.7 总结:存储与带宽的“黄金法则”

嗯,今天的内容有点多。我帮你梳理一下核心要点:

  • Local Memory是性能的关键:尽量把热点数据放在片上,减少对Global Memory的访问。
  • DMA是隐形英雄:用好DMA的双缓冲和预取功能,让计算和搬运重叠。
  • 带宽是硬约束:优化数据复用和搬运策略,避免带宽成为瓶颈。
  • Bank冲突要小心:合理分配数据到不同Bank,避免访问冲突。

最后,我想说一句:存储和带宽的设计,决定了NPU的上限。算力再强,如果数据搬不进来,一切都是空谈。希望今天的分享能帮你少走一些弯路。

课后思考:如果你有一个卷积层,输入是224x224x64,权重是3x3x64x128。你会如何设计tiling策略和DMA搬运方案?欢迎在评论区和我讨论。

想要更多资料,联系可可