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性能就上去了。