4、数据搬运与DMA:DMA引擎原理、Scatter/Gather操作、双缓冲机制
数据搬运这件事,听起来好像就是把一块内存复制到另一块。但在NPU里,这往往是整个推理管线的瓶颈。我见过太多团队把精力全放在算子优化上,结果发现大部分时间都花在等数据上。说白了,DMA就是那个帮你偷偷搬砖的硬件搬运工,而你要学会怎么用好它。
4.1 DMA引擎原理 —— 硬件搬运工
DMA的全称是Direct Memory Access,直译就是直接内存访问。CPU只需要告诉DMA「从哪搬,搬多少,搬到哪」,然后就可以撒手不管了。DMA自己搞定总线仲裁、地址递增、传输完成中断。我个人习惯把DMA看作NPU的「二级心脏」—— 没有它,数据根本喂不饱计算单元。
在NPU里,DMA引擎通常有多个通道,每个通道可以独立配置描述符。描述符里包含源地址、目的地址、传输长度、传输维度(比如2D/3D拷贝)。我记得第一次调NPU驱动时,发现描述符里有个「地址回绕」参数没配对,结果数据全写到同一行,模型输出全是乱码。嗯,这种坑踩过一次就记住了。
| 参数 | 说明 | 典型值 |
|---|---|---|
| 通道数 | 并行DMA传输路数 | 4~16 |
| 描述符深度 | 每个通道可挂载的描述符数量 | 8~64 |
| 最大传输长度 | 单次DMA搬运上限 | 1MB ~ 16MB |
| 地址对齐 | 源/目的地址对齐要求 | 32字节 / 64字节 |
| 中断方式 | 完成传输后通知CPU | 中断 / 轮询 / 事件 |
4.2 Scatter/Gather操作 —— 专治非连续内存
你想想看,张量在内存里不一定是一整块连续的。比如卷积的输入可能是从不同内存池里拼出来的,或者做了padding之后地址变得七零八落。如果每次搬运都要CPU先做一次memcpy拼好,那效率就太低了。
Scatter/Gather就是用来解决这个问题的。它允许你用一组描述符链表,每个描述符指向一小块连续内存,DMA自动遍历链表,把数据「散开」写到目标区域(Scatter),或者从多个源「收集」到一块连续区域(Gather)。
我曾经在一个视频分析项目里,需要把四路摄像头的ROI区域拼成一个batch送给NPU。用Scatter/Gather,我只需要构造四个描述符,DMA自己就把数据拼好了。如果不用这个机制,你得先申请一块大内存,然后CPU挨个拷贝,帧率直接掉一半。
我曾经在描述符链表中忘记设置「链结束标志」,结果DMA一直往下读非法内存,整个系统挂死。后来我习惯在链表末尾强制加一个空描述符,并检查硬件状态寄存器。
4.3 双缓冲机制 —— 让搬运和计算重叠
双缓冲,也叫Ping-Pong缓冲。说白了就是准备两个缓冲区:当NPU计算单元在处理Buffer A时,DMA往Buffer B里搬下一批数据;下一轮两者互换。这样计算和访存就重叠起来了,隐藏了DMA的延迟。
我建议在设计流水线时,至少使用三级缓冲:一个用于当前计算,一个用于预取,一个用于写回。但双缓冲是最基础的范式,很多NPU的硬件直接支持Ping-Pong地址切换,你只需要配置两个描述符,硬件自动交替。
// 伪代码:双缓冲调度示例
void double_buffer_loop() {
// 初始化两个buffer
dma_load(buffer_a, input_slice_0);
for (int i = 0; i < num_slices - 1; i++) {
// 启动下一块DMA搬运(到另一个buffer)
dma_load(buffer_b, input_slice_i+1);
// 同时计算当前buffer
compute(buffer_a);
// 等待DMA完成
dma_wait();
// 交换指针
swap(buffer_a, buffer_b);
}
// 处理最后一块
compute(buffer_a);
}
双缓冲虽然好,但缓冲区大小要匹配DMA粒度。如果每个slice太小,DMA启动开销会吃掉收益。我一般建议每个缓冲区至少能装下4~8次计算的数据量,让DMA跑得「饱满」一点。
4.4 三者如何配合 —— 一个实际案例
假设我们要搬运一个非连续的4D张量(NCHW格式)到NPU的本地SRAM。我会这样做:
- 用Scatter/Gather链表描述每个channel的起始地址和stride,每个channel对应一个描述符。
- DMA引擎按链表顺序搬运,硬件自动处理地址跳跃。
- 同时开启双缓冲:DMA往Buffer0搬channel 0~3时,NPU计算Buffer1里的channel 4~7(上一轮数据)。
这样,搬运和计算完全流水起来。我记得在某个端侧NPU上,仅靠这个优化,单帧延迟从45ms降到了22ms。说白了,数据流设计得好的话,性能翻倍不是梦。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 描述符链表深度 | ≥ 8 | 保证DMA连续工作,不饥饿 |
| 双缓冲对齐 | Cache Line (64B) | 避免伪共享和部分写失效 |
| 中断合并 | 每N次传输中断一次 | 减少CPU干预,N=4~8 |
| DMA优先级 | 高优先级通道给实时流 | 防止被其他搬运阻塞 |
最后说一句,DMA的配置其实很灵活,但一定要仔细看芯片手册。每个NPU的DMA引擎都有一些「隐藏寄存器」,比如传输完成后的自动链式启动、错误状态清除等等。我习惯在驱动初始化时把所有通道的状态寄存器读一遍,确保没有残留错误。嗯,这都是经验之谈。