2、数据本地化策略:数据复用原理、Tiling技术详解、数据分块与搬移策略
好,咱们接着聊。上一章讲了存储层次和带宽,说白了都是「硬件给你什么」。但怎么用好这些硬件,才是咱们架构师的本事。这一章,我重点聊聊数据本地化——这是NPU性能优化的核心,没有之一。
你想想看,NPU算力再强,数据搬不过来,全是白搭。我见过太多项目,算力利用率不到30%,问题就出在数据本地化没做好。嗯,咱们今天就把这块掰开揉碎了讲。
2.1 数据复用原理:为什么能省带宽?
数据复用,说白了就是「一份数据,多次使用」。NPU里最常见的是卷积操作——同一个权重,要被成百上千个输入像素共享。如果每次计算都从DDR取数据,带宽早就爆了。
我个人习惯把数据复用分成三类:
- 时间复用:同一份数据,在不同时间步重复使用。比如权重在多个输入通道间共享。
- 空间复用:同一份数据,在同一个时间步被多个PE使用。比如输入特征图被多个卷积核共享。
- 混合复用:时间+空间一起上。这是实际项目中最常用的。
关键指标:数据复用因子
复用因子 = 总计算次数 / 数据加载次数。这个值越高,带宽效率越好。我见过最极致的案例,复用因子做到128倍,带宽需求直接降了两个数量级。
为什么会这样?因为NPU的算力密度越来越高,但DDR带宽的增长速度远跟不上。你算算看,一个1TOPS的NPU,如果数据复用做得差,可能需要100GB/s的带宽;但复用做得好,10GB/s就够了。这差距,就是架构师的价值所在。
2.2 Tiling技术详解:把大问题拆小
Tiling,中文叫「分块」。但我觉得「切蛋糕」更形象——一个大矩阵,你一口吃不下,就得切成小块慢慢嚼。
我在项目中遇到过最典型的场景:一个224x224x64的特征图,加上3x3的卷积核,如果一次性计算,中间结果缓存要几百KB。但NPU的本地SRAM通常只有几十KB到几百KB。怎么办?切!
2.2.1 分块维度
Tiling不是随便切的。你得考虑三个维度:
- 输入通道分块:把Cin切成多个小块,每次只算一部分通道。
- 输出通道分块:把Cout切成小块,减少权重加载量。
- 空间维度分块:把HxW切成小块,控制中间结果大小。
我建议你画个三维图,把这三个维度标出来。每个维度切多少,直接决定了SRAM的利用率。
2.2.2 分块大小计算
这里有个公式,我每次做架构设计都会用:
SRAM需求 = 输入块大小 + 权重块大小 + 输出块大小
输入块 = Tin_h * Tin_w * Cin_tile
权重块 = Kh * Kw * Cin_tile * Cout_tile
输出块 = Tout_h * Tout_w * Cout_tile
嗯,这里要注意:分块不是越小越好。块太小,搬移次数暴增,反而浪费带宽。我见过有人把块切成4x4,结果搬移开销占了总时间的60%。
我的经验法则:分块大小尽量让SRAM利用率达到80%以上。剩下的20%留给double buffer和临时变量。这样既不会溢出,也不会浪费。
2.3 数据分块与搬移策略:怎么搬最划算?
分块方案定好了,接下来就是怎么搬数据。这部分我踩过不少坑,跟大家分享几个关键策略。
2.3.1 Double Buffer:隐藏搬移延迟
说白了就是「一边算,一边搬」。用两块SRAM,一块给计算单元用,另一块同时从DDR加载下一批数据。等计算完了,直接切换。
我曾经在一个项目里,没用double buffer,结果计算单元有40%的时间在等数据。加上double buffer后,利用率直接飙到85%。
// 伪代码示例
buffer_A = load_from_ddr(tile_0)
for i in range(num_tiles):
buffer_B = load_from_ddr(tile_{i+1}) // 预加载下一块
compute(buffer_A) // 计算当前块
swap(buffer_A, buffer_B) // 交换指针
2.3.2 数据搬移粒度
搬数据不是按字节搬的,是按「行」或「块」搬的。我建议:
- 行搬移:适合空间维度分块较小的情况,每次搬一行数据。
- 块搬移:适合通道维度分块,一次搬一个完整的通道块。
- 混合搬移:先搬行,再搬通道。这是最灵活的方式。
避坑指南:我曾经犯过一个错误——搬移粒度太小。每次只搬4个字节,结果DDR的burst效率极低,带宽利用率不到10%。后来改成64字节对齐搬移,带宽利用率到了70%以上。记住:DDR喜欢大块数据,别让它做零碎活。
2.3.3 数据布局优化
数据在DDR里怎么放,直接影响搬移效率。我推荐两种布局:
| 布局方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| NHWC | 空间维度分块为主 | 空间局部性好,搬移连续 | 通道维度搬移效率低 |
| NCHW | 通道维度分块为主 | 通道局部性好,适合权重复用 | 空间搬移需要跨步 |
我个人习惯用NHWC,因为大多数NPU的卷积操作,空间复用比通道复用更频繁。但如果你做的是depthwise卷积,NCHW可能更合适。具体选哪个,得看你的算子特征。
2.4 实战案例:一个3x3卷积的Tiling设计
咱们拿一个具体例子练练手。假设:
- 输入特征图:112x112x64
- 卷积核:3x3x64x128
- 输出特征图:112x112x128
- NPU本地SRAM:256KB
第一步,算算一次性计算需要多少SRAM:
输入块:112 * 112 * 64 * 2B = 1.6MB
权重块:3 * 3 * 64 * 128 * 2B = 144KB
输出块:112 * 112 * 128 * 2B = 3.2MB
总计:1.6MB + 144KB + 3.2MB ≈ 5MB
5MB,远超256KB。必须分块。
第二步,确定分块方案。我建议:
- 空间分块:把112x112切成4x4的小块,每块28x28
- 输入通道分块:Cin=64,切成4块,每块16通道
- 输出通道分块:Cout=128,切成4块,每块32通道
第三步,验证SRAM是否够用:
输入块:28 * 28 * 16 * 2B = 25KB
权重块:3 * 3 * 16 * 32 * 2B = 9KB
输出块:28 * 28 * 32 * 2B = 50KB
总计:25KB + 9KB + 50KB = 84KB
84KB,加上double buffer的2倍,168KB。还有88KB的余量,完美。
最终效果:这个Tiling方案,数据复用因子做到了16倍(输入通道复用)x 4倍(输出通道复用)= 64倍。原来需要5MB的SRAM,现在只用168KB。带宽需求从原来的几十GB/s,降到了不到5GB/s。
嗯,这就是数据本地化的威力。你想想看,同样的硬件,不同的Tiling策略,性能可以差出好几倍。做架构设计,说白了就是在「算力」和「带宽」之间找平衡。而数据本地化,就是那个平衡点。
下一章,咱们聊聊更高级的——数据流调度。到时候我会讲怎么用ping-pong buffer和预取机制,把搬移延迟彻底隐藏掉。到时候见。