4. 数据流与存储层次:NPU存储层次与数据搬运

大家好,我是你们的老朋友。今天我们来聊聊NPU里一个特别“实在”的话题——存储层次和数据搬运。说实话,我早年做编译器的时候,觉得算子映射嘛,把计算逻辑安排好就行了。结果第一次上板调试,性能惨不忍睹。后来才发现,瓶颈根本不在计算,而在数据怎么搬、怎么存。

你想想看,NPU里成千上万个PE(处理单元)同时干活,如果数据喂不上去,再强的算力也是白搭。所以,理解存储层次,是写好算子映射的关键一步。

4.1 NPU的三级存储体系

NPU的存储,说白了就是三级:Global Buffer(全局缓存)、Local Memory(本地内存)、Register(寄存器)。我习惯把它们比作“中央仓库”、“车间货架”和“工人手边的工具”。

存储层级 容量(典型值) 访问延迟 谁在用
Global Buffer 几MB ~ 几十MB 几十个cycle 所有PE共享
Local Memory 几十KB ~ 几百KB 几个cycle 单个PE或PE簇
Register 几百Byte 1个cycle 单个计算单元

嗯,这里要注意:Global Buffer可不是CPU里的L3 Cache。它是软件直接管理的,说白了就是一块大SRAM。我在项目中遇到过有人把它当Cache用,结果数据一致性搞得一团糟。

4.2 数据复用策略——别让数据闲着

为什么要搞复用?因为搬数据太贵了。从DDR搬到Global Buffer,一次可能要几百个cycle。如果能在一个数据上做多次计算,那就赚了。

常见的复用策略有三种:

  • 输入复用(Input Reuse):同一个输入特征图,被多个不同的卷积核使用。比如3x3卷积,输入数据在窗口滑动时被反复读取。
  • 权重复用(Weight Reuse):同一个权重,被多个不同的输入通道使用。这个在batch推理时特别明显。
  • 输出复用(Output Reuse):部分和(partial sum)在累加过程中暂存,避免反复读写。

我个人习惯:在写算子映射时,先画一张“数据依赖图”,标出哪些数据可以被复用。然后根据复用次数,决定把它放在哪一级存储里。复用次数多的,就往寄存器里塞;次数少的,放Local Memory就行。

4.3 数据搬运与DMA——真正的幕后英雄

DMA(Direct Memory Access)是NPU里最忙的模块之一。它负责在Global Buffer、Local Memory和DDR之间搬数据,而且不占用计算单元的时间。

我曾经踩过一个坑:DMA配置错了地址对齐,结果搬出来的数据全是乱的。后来我养成了一个习惯——DMA的源地址和目标地址,必须按照128字节对齐。这不是硬性规定,但很多NPU的DMA控制器就是这么设计的。

来看一个典型的DMA搬运流程:

// 伪代码:从DDR搬运数据到Global Buffer
dma_config_t cfg;
cfg.src_addr = ddr_base + input_offset;   // DDR中的输入数据
cfg.dst_addr = global_buf_addr;           // Global Buffer目标地址
cfg.size = 64 * 1024;                     // 64KB数据块
cfg.stride = 128;                         // 行步长(字节)
cfg.burst_len = 16;                       // 突发长度

dma_start(&cfg);                          // 启动DMA
dma_wait();                               // 等待完成

这里有个细节:stride参数。如果输入特征图是三维的(HWC格式),DMA需要支持跨行搬运。我见过不少新手直接把整个tensor平铺搬运,结果Local Memory里数据排布和计算单元期望的不一致,又得做一次重排,白白浪费带宽。

4.4 数据流与存储的协同设计

好的算子映射,一定是数据流和存储层次一起设计的。我画了一张图,帮你理清它们的关系:

DDR(外部存储) DMA搬运 Global Buffer(全局缓存) 容量:几MB ~ 几十MB | 延迟:几十cycle 数据分发 Local Memory(本地内存) 容量:几十KB ~ 几百KB | 延迟:几个cycle 数据加载 Register(寄存器) PE计算单元 数据送入 图例 DDR Global Buffer Local Memory Register PE计算单元

从这张图你能看到,数据从DDR一路流到寄存器,每一级都在做“减速增容”的转换。DMA负责最耗时的那一段(DDR到Global Buffer),而Local Memory到Register的搬运,通常由编译器生成的加载指令完成。

避坑指南:我曾经在映射一个深度可分离卷积时,把权重全部放在Global Buffer里,结果每个通道都要从Global Buffer读权重,带宽直接被打满。后来我把权重提前搬到Local Memory里,性能提升了3倍。记住:能放Local Memory的,就别放Global Buffer。

4.5 实战中的存储分配策略

好了,理论讲完了,咱们来点实际的。假设你要映射一个3x3卷积,输入是112x112x64,输出是112x112x128。你会怎么分配存储?

我的做法是这样的:

  1. 权重:3x3x64x128 = 288KB。Local Memory放不下?那就分块。一次只加载16个输出通道的权重,算完再换下一批。
  2. 输入特征图:112x112x64 = 800KB。这个必须放Global Buffer。但可以只加载当前计算需要的tile,比如32x32x64。
  3. 输出特征图:同样放Global Buffer,算完一部分就写回DDR。
  4. 部分和:这个放Local Memory,因为累加过程中频繁读写,放Global Buffer太慢了。

注意:分块大小不是随便定的。块太大,Local Memory装不下;块太小,DMA搬运次数太多,开销反而更大。我一般用“搬运时间 = 计算时间”这个等式来估算最优块大小。说白了,就是让DMA和计算单元流水起来,谁也不等谁。

嗯,关于存储层次和数据搬运,今天就聊到这儿。记住一句话:NPU的性能,一半在计算,一半在数据搬运。下次你写算子映射时,不妨先想想数据怎么流,再想计算怎么算。

可可精品资料库