存储层次优化:从全局内存到寄存器文件的性能博弈
存储层次,说白了就是芯片上的数据搬运系统。我做了这么多年AI芯片优化,最深的体会就是:计算单元再快,数据喂不上去也是白搭。很多同学把精力都花在优化计算上,结果发现性能瓶颈其实在内存访问——这坑我踩过不止一次。
全局内存、共享内存、寄存器文件:三者的带宽与延迟特性
先看一张我整理的对比表,这玩意儿你最好记在心里:
| 存储层级 | 典型容量 | 延迟(周期) | 带宽(相对值) | 作用域 |
|---|---|---|---|---|
| 寄存器文件 | ~256KB/核 | 1~2 | 极高(~100x) | 单个线程/PE |
| 共享内存 | ~48~96KB/块 | ~20~30 | 高(~10x) | 线程块内 |
| 全局内存 | ~8~80GB | ~400~800 | 低(1x基准) | 所有线程 |
看到这个延迟差距了吗?全局内存访问一次,够寄存器做几百次运算了。我个人习惯是:能放寄存器的绝不放共享内存,能放共享内存的绝不碰全局内存。这话听起来简单,实际做起来全是细节。
寄存器文件:最贵也最快
寄存器文件是离计算单元最近的存储。每个线程私有,延迟只有1~2个周期。但它的容量极其有限——一个SM里通常也就几万个寄存器,分到每个线程头上可能就几十个。
我在项目中遇到过一个问题:某个卷积核的代码,寄存器压力太大导致编译器把变量溢出(spill)到局部内存。结果呢?性能直接掉了30%。后来我手动拆分了计算逻辑,把中间结果复用起来,才把寄存器占用降下来。
共享内存:可编程的缓存
共享内存是芯片上的一块SRAM,由程序员显式管理。它的延迟比全局内存低一个数量级,而且同一个线程块内的所有线程都能访问。
但共享内存有个大坑——Bank Conflict。我刚开始做优化时,被这玩意儿折磨得不轻。
Bank Conflict 的根源
共享内存被划分成32个Bank(以NVIDIA为例),每个Bank宽度4字节。如果多个线程同时访问同一个Bank的不同地址,就会发生冲突,导致访问串行化。
举个例子:
// 假设共享内存数组 shared[32][32]
// 线程 i 访问 shared[i][j]
// 如果按行访问:线程0访问[0][0],线程1访问[1][0]...
// 这些地址映射到不同的Bank,无冲突
// 但如果按列访问:线程0访问[0][j],线程1访问[1][j]...
// 这些地址可能映射到同一个Bank,产生冲突
如何避免Bank Conflict
几个实用技巧:
- 填充(Padding):在数组末尾加几个无用元素,改变地址映射关系。比如
shared[32][33]而不是shared[32][32] - 改变访问模式:尽量让连续线程访问连续地址,这样自然映射到不同Bank
- 使用向量化访问:一次读取128位(4个float),减少访问次数
你想想看,Bank Conflict的本质是什么?是地址映射的哈希函数出了问题。理解了这一点,你就能自己推导出解决方案。
数据预取:让数据在路上
数据预取的核心思想很简单:别让计算单元闲着等数据。在计算当前数据的同时,把下一批数据提前加载到缓存或共享内存里。
我习惯用软件预取(software prefetch),因为硬件预取对不规则访问模式基本没用。看个例子:
// 朴素版本:计算和加载串行
for (int i = 0; i < N; i++) {
load_data(&input[i]); // 等待内存
compute(input[i]); // 计算
}
// 预取版本:计算和加载重叠
load_data(&input[0]); // 先加载第一个
for (int i = 0; i < N-1; i++) {
prefetch(&input[i+1]); // 预取下一个
compute(input[i]); // 计算当前
}
compute(input[N-1]); // 处理最后一个
双缓冲:流水线化的极致
双缓冲是预取的升级版。它用两块缓冲区交替工作:一块用于计算,一块用于数据加载。说白了就是让内存访问和计算完全流水线化。
我画了一张流程图,帮你理解这个机制:
双缓冲的代码实现大概是这样的:
// 初始化
load(buffer[0], data_block_0);
current = 0;
for (int i = 0; i < num_blocks - 1; i++) {
int next = 1 - current; // 切换缓冲区
// 异步加载下一块数据
async_load(buffer[next], data_block[i+1]);
// 计算当前数据
compute(buffer[current]);
// 等待加载完成
sync_wait();
current = next;
}
// 处理最后一块
compute(buffer[current]);
综合优化策略:从全局视角看存储层次
说了这么多,其实就一句话:让数据尽量靠近计算单元。具体怎么做?
- 数据分块(Tiling):把大矩阵切成小块,让每个块能完全塞进共享内存
- 寄存器重用:同一个数据尽量在寄存器里多算几次,别急着写回
- 合并访问(Coalescing):全局内存访问要连续,这样硬件能一次加载一大块
- 异步传输:用DMA或者异步拷贝,让数据传输和计算并行
我记得有一次优化一个Transformer的推理,就是靠这三板斧——分块+双缓冲+寄存器重用,把端到端延迟降了40%。说白了,存储优化没有银弹,你得根据具体的数据流和计算模式来组合使用这些技术。
嗯,存储层次优化就聊到这儿。这些技术你多练几次,慢慢就能形成直觉——看到一段代码,大概就能猜到它的访存瓶颈在哪儿。到那时候,你离性能优化高手就不远了。
想要更多资料,联系可可