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 引擎工作流程 Scatter/Gather 引擎工作流程 描述符链表 Desc 0: src=0x1000, len=8 Desc 1: src=0x1008, len=8 Desc 2: src=0x1010, len=8 Desc 3: src=0x1018, len=8 Scatter/Gather 引擎 解析描述符 → 发起传输 内存(碎片数据) 0x1000 0x1008 0x1010 0x1018 输出缓冲区(连续) 连续地址:0x2000 ~ 0x201F 关键优势 一次配置,多次传输 硬件自动合并地址 CPU 开销极低

你看,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 长度、中断频率,这三个参数调好了,带宽利用率能翻倍