3. 数据流优化:行优先与列优先的访存模式
做稀疏加速这么多年,我见过最多的性能杀手,不是计算单元不够快,而是数据根本喂不进去。说白了,你的加速器再强,访存模式不对,带宽利用率可能连 10% 都到不了。今天我们就聊聊这个老生常谈但又特别容易翻车的问题——行优先与列优先的访存模式,以及怎么用 DMA 和 Scatter/Gather 引擎把带宽吃满。
3.1 行优先 vs 列优先:一个简单的例子
先看一个最基础的场景。假设你有一个 4x4 的稀疏矩阵,用 CSR 格式存着。你要做 SpMV(稀疏矩阵向量乘)。
行优先存储,说白了就是按行存:第一行所有非零元素,接着第二行,以此类推。列优先则反过来,按列存。
我刚开始做硬件加速时,觉得这有啥区别?不都是线性地址空间吗?直到有一次,我在一个 FPGA 项目上调试 SpMV,带宽利用率死活上不去。用逻辑分析仪一看,DMA 每次 burst 读回来的数据,只有一半是有效的,另一半被 CPU 侧的内存布局给打散了。
为什么会这样?
你想想看,稀疏矩阵的非零元素分布是不规则的。行优先存储时,同一行的非零元素在地址空间里是连续的。但不同行之间,可能隔着很多零。如果你按列去访问,那每次读到的数据,地址跨度就很大,缓存行利用率极低。
核心结论:
- 行优先:适合按行访问的算法(SpMV、行压缩格式)
- 列优先:适合按列访问的算法(SpMV_T、列压缩格式)
- 混用:性能灾难,缓存行利用率可能低于 20%
3.2 DMA 的基本原理与带宽瓶颈
DMA 这东西,说白了就是个搬运工。CPU 告诉它:「从地址 A 搬 N 个字节到地址 B」,它就闷头干。但问题在于,DMA 的传输效率跟地址对齐、burst 长度、传输粒度强相关。
我习惯把 DMA 的带宽利用率拆成三个因素:
| 因素 | 理想情况 | 稀疏场景常见问题 |
|---|---|---|
| 地址对齐 | 64 字节对齐(缓存行) | 非零元素起始地址随机,对齐差 |
| Burst 长度 | 最大 burst(如 16 拍) | 非零元素间隔小,burst 被截断 |
| 传输粒度 | 连续大块 | 稀疏数据碎片化,粒度小 |
举个例子。你有一个稀疏行,非零元素分布在地址 0x1000、0x1008、0x1010、0x1018。如果直接用 DMA 读这 4 个 8 字节数据,每个都需要单独发起一次传输。DMA 的启动开销是固定的,比如 50 个时钟周期。你算算,有效数据传输只用了 4 个周期,效率不到 8%。
我曾经踩过的坑: 在一个稀疏 CNN 加速器项目里,我天真地以为 DMA 会自动合并相邻的传输。结果实测带宽只有理论峰值的 15%。后来发现,DMA 控制器不会帮你做地址合并——你得自己把连续的地址拼成一次 burst。
3.3 Scatter/Gather 引擎:救星来了
Scatter/Gather 引擎,说白了就是 DMA 的「智能版」。它不只能搬连续的数据,还能根据一个描述符链表,把不连续的数据拼成连续的流,或者把连续的数据散开写到不连续的目标地址。
我画了一张图,帮你理解 Scatter/Gather 的工作流程:
你看,Scatter/Gather 引擎拿到描述符链表后,会自己解析每个描述符里的源地址和长度,然后发起一次 burst 传输。关键点在于:它可以把多个不连续的地址,合并成一次连续的 DMA 传输,或者反过来。
3.4 实战:如何配置 Scatter/Gather 引擎
我以 Xilinx 的 AXI DMA 为例,给你看看实际怎么配。当然,不同厂商的寄存器名不一样,但思路是通用的。
// 伪代码:配置 Scatter/Gather 描述符链表
struct sg_desc {
uint32_t src_addr; // 源地址
uint32_t dst_addr; // 目标地址
uint32_t length; // 传输长度(字节)
uint32_t control; // 控制位(中断使能、链尾标记等)
};
// 假设稀疏矩阵有 4 个非零元素,地址不连续
struct sg_desc desc_list[4];
desc_list[0].src_addr = 0x1000;
desc_list[0].dst_addr = 0x2000;
desc_list[0].length = 8;
desc_list[0].control = SG_CHAIN_EN; // 链式传输
desc_list[1].src_addr = 0x1008;
desc_list[1].dst_addr = 0x2008;
desc_list[1].length = 8;
desc_list[1].control = SG_CHAIN_EN;
desc_list[2].src_addr = 0x1010;
desc_list[2].dst_addr = 0x2010;
desc_list[2].length = 8;
desc_list[2].control = SG_CHAIN_EN;
desc_list[3].src_addr = 0x1018;
desc_list[3].dst_addr = 0x2018;
desc_list[3].length = 8;
desc_list[3].control = SG_CHAIN_EN | SG_LAST_DESC; // 最后一个描述符
// 将描述符链表基地址写入 DMA 控制器的 SG 寄存器
write_reg(DMA_SG_BASE_ADDR, (uint32_t)&desc_list);
write_reg(DMA_SG_START, 1); // 启动传输
我的个人习惯: 描述符链表最好放在连续、对齐的内存区域。我一般用 64 字节对齐,这样 DMA 控制器读描述符时也能用 burst 传输,不会成为瓶颈。另外,描述符的数量要预留够——我曾经因为描述符池太小,在跑大矩阵时 DMA 直接挂掉,查了半天才发现是描述符用完了。
3.5 行优先与列优先的 DMA 优化策略
好了,现在我们把行优先/列优先和 DMA/Scatter/Gather 结合起来。不同访存模式,优化策略完全不同。
| 访存模式 | DMA 策略 | Scatter/Gather 策略 | 适用场景 |
|---|---|---|---|
| 行优先 | 按行发起 burst,每行一次 DMA | Gather:将同一行的非零元素收集到连续缓冲区 | SpMV、行压缩格式 |
| 列优先 | 按列发起 burst,每列一次 DMA | Gather:将同一列的非零元素收集到连续缓冲区 | SpMV_T、列压缩格式 |
| 混合模式 | 不推荐,效率极低 | Scatter:将连续数据散开到不同行/列 | 矩阵转置、重排 |
我举个例子。假设你有一个稀疏矩阵,行优先存储,每行有 4 个非零元素。用 Gather 模式,你可以把每行的 4 个元素收集到一个连续的缓冲区里。这样,计算单元只需要一次 burst 就能读完一整行的数据。
反过来,如果你要写回结果,而且结果需要按列存储,那就用 Scatter 模式——把连续的计算结果散开到不同的列地址。
3.6 避坑指南:我踩过的几个坑
做硬件加速这么多年,我在数据流优化上栽过不少跟头。挑几个典型的跟你说说:
- 描述符链表的 cache 一致性:CPU 更新描述符链表后,如果 DMA 控制器看不到更新(因为 cache 没刷),就会读到旧数据。我习惯在启动 DMA 前,手动 flush 描述符所在的内存区域。
- Burst 长度与稀疏度的匹配:如果稀疏矩阵的非零元素间隔很大,强行用长 burst 反而会读到大量无用数据,浪费带宽。我一般会根据非零元素的平均间隔,动态调整 burst 长度。
- 中断风暴:每个描述符都产生中断的话,CPU 会被淹死。我通常只在最后一个描述符或一批描述符完成后才产生中断。
- 地址对齐的陷阱:有些 DMA 控制器要求源地址和目标地址都对齐到 4 字节或 8 字节。如果非零元素是 1 字节或 2 字节的,就得做 padding 或者用字节使能。
我曾经遇到的一个经典问题: 在一个稀疏矩阵乘法加速器上,我用了 Scatter/Gather 引擎,但带宽利用率还是只有 40%。查了很久才发现,问题出在描述符链表本身——它被放在了一个非连续的内存区域,DMA 读描述符时反而成了瓶颈。后来我把描述符链表放到一个专用的 SRAM 里,带宽利用率直接飙到 85%。
3.7 小结
行优先和列优先的选择,说白了就是让你的访存模式跟数据的物理布局对齐。DMA 和 Scatter/Gather 引擎是帮你实现这种对齐的工具。记住几个关键点:
- 能用连续 burst 就别用离散传输
- Scatter/Gather 引擎是稀疏场景的利器,但描述符链表本身也要优化
- 地址对齐、burst 长度、中断频率,这三个参数调好了,带宽利用率能翻倍