3、DMA引擎优化:DMA传输模式(1D/2D/3D)、DMA描述符链、DMA与计算单元重叠执行

DMA引擎,说白了就是NPU里的搬运工。数据从DDR搬到SRAM,再从SRAM搬回DDR,全靠它。我见过不少团队,计算单元利用率低得可怜,一查原因,全是DMA在拖后腿。这一节,咱们就聊聊怎么把这个搬运工调教好。

3.1 DMA传输模式:1D、2D、3D

DMA传输模式,本质上就是数据在内存中的排列方式。你想想看,神经网络里的数据,大多是张量(Tensor)。一个卷积层的输入特征图,就是三维的:高度、宽度、通道数。如果只用一维DMA去搬,你得手动算偏移量,代码写起来又臭又长,还容易出错。

1D传输:最基础的线性传输。适合搬向量、权重矩阵的一行。我刚开始做NPU时,所有数据都用1D搬,结果搬个特征图要拆成几十次DMA请求,效率极低。

2D传输:支持行和列的二维搬移。比如搬一个矩阵,你可以指定起始地址、行宽、列数、行步长。这在图像处理里非常常见。我个人习惯,只要数据是二维排布的,优先用2D DMA,一次请求搞定,省时省力。

3D传输:支持三维张量的搬移。你可以把它想象成搬一个立方体。指定起始地址、每一维的大小和步长。我在项目中遇到过,搬一个4D的权重张量(输出通道、输入通道、核高、核宽),用3D DMA配合循环展开,代码量减少了一半,带宽利用率提升了30%。

核心原则:能用高维DMA,就别用低维。高维DMA一次请求能搬更多数据,减少DMA引擎的启动开销和总线仲裁次数。

来看一个实际例子。假设你要搬一个形状为 [16, 64, 112, 112] 的激活值张量(NCHW格式)。用1D DMA,你得写16*64次请求。用3D DMA,一次请求就能描述清楚。

// 伪代码:3D DMA 配置
dma_desc.src_addr = &input_tensor[0][0][0][0];
dma_desc.dst_addr = &local_sram[0];
dma_desc.dim_x = 112;          // 宽度
dma_desc.dim_y = 112;          // 高度
dma_desc.dim_z = 64;           // 通道数
dma_desc.stride_x = 1;         // 连续元素步长
dma_desc.stride_y = 112;       // 行步长
dma_desc.stride_z = 112 * 112; // 通道步长
dma_desc.repeat = 16;          // batch size
// 启动DMA
dma_start(&dma_desc);

嗯,这里要注意:不同硬件对3D DMA的支持粒度不一样。有的要求维度必须是2的幂次,有的要求对齐到128字节。我曾经踩过一个坑,某个IP的3D DMA不支持stride为奇数,结果搬出来的数据全是乱的。所以,用之前一定要看手册。

3.2 DMA描述符链

DMA描述符链,是解决「多个DMA任务如何高效调度」的关键。单个DMA请求,CPU得写寄存器、等待完成、再写下一个。这中间CPU被占着,计算单元也在空等。

描述符链的思路很简单:把多个DMA任务串成一个链表。DMA引擎自己从链表头取任务,做完一个自动取下一個。CPU只需要一次性把链表地址告诉DMA,然后就可以去干别的事了。

我建议,描述符链的长度要精心设计。太短了,CPU频繁打断;太长了,链表占用的SRAM太多。一般来说,8到16个描述符是一个不错的起点。

描述符链长度 优点 缺点
4 占用SRAM少 CPU中断频繁,DMA可能空等
16 CPU干预少,DMA连续性好 占用SRAM约1KB(每个描述符64B)
64 几乎不需要CPU参与 SRAM占用大,链表维护复杂

实战技巧:描述符链可以用环形缓冲区实现。DMA引擎有一个「当前描述符指针」和「尾指针」。CPU往尾指针后面追加新描述符,DMA引擎自动消费。这样CPU和DMA可以并行工作,互不阻塞。

我曾经在一个项目中,把描述符链从8个扩展到32个,CPU中断频率从每微秒一次降到每10微秒一次。计算单元的利用率从65%直接飙到92%。

3.3 DMA与计算单元重叠执行

这是DMA优化的终极目标。说白了,就是让DMA搬数据和计算单元算数据同时进行。你想想看,如果计算单元算完一批数据,DMA才刚开始搬下一批,那计算单元就得干等着。这叫「串行执行」,效率极低。

重叠执行的核心是「乒乓缓冲」(Ping-Pong Buffer)。在SRAM里开两块缓冲区:Ping和Pong。计算单元算Ping里的数据时,DMA往Pong里搬下一批数据。算完Ping,交换角色。

// 伪代码:乒乓缓冲 + DMA重叠
void pingpong_infer() {
    // 初始化:DMA搬第一块数据到Ping
    dma_load(ping_buf, input_addr, size);
    dma_wait();

    for (int i = 0; i < total_batches; i++) {
        // 启动DMA搬下一块到Pong(与计算重叠)
        dma_load(pong_buf, input_addr + (i+1)*size, size);

        // 计算单元处理Ping里的数据
        compute(ping_buf, output_buf);

        // 等待DMA完成(通常已经完成了)
        dma_wait();

        // 交换Ping和Pong指针
        swap(ping_buf, pong_buf);
    }
}

这里有个关键点:DMA启动后,计算单元要立刻开始工作,不要等DMA完成。我见过有人写代码,DMA启动后先等DMA完成,再开始计算。那重叠就完全失效了。

避坑指南:我曾经在一个项目中,DMA和计算单元共享同一个总线端口。结果DMA搬数据时,计算单元读SRAM也被阻塞了。后来我把DMA和计算单元的总线端口分开,才真正实现了重叠。所以,硬件设计时就要考虑总线隔离。

重叠执行的效果,可以用一个公式估算:

理想加速比 = (T_dma + T_compute) / max(T_dma, T_compute)

如果T_dma和T_compute相等,加速比是2倍。如果T_dma是T_compute的两倍,加速比只有1.5倍。所以,要尽量让DMA时间和计算时间匹配。我一般会调整DMA的burst长度和计算单元的并行度,让两者尽量平衡。

最后说一个我自己的经验:DMA描述符链和乒乓缓冲是绝配。描述符链让DMA可以连续搬多块数据,乒乓缓冲让计算单元可以连续算多块数据。两者结合,就能实现「流水线式」的DMA-计算重叠。我曾经在一个视频处理芯片上,用这个方案把整体吞吐量提升了2.3倍。

嗯,DMA优化这块,说到底就是三个字:别闲着。DMA别闲着,计算单元也别闲着。让它们各干各的,互相配合,你的NPU性能就上去了。