4. 带宽与延迟优化:DMA传输优化、数据预取技术、计算与访存重叠

好,咱们进入第四章。这一章聊的是模型部署中最容易“卡脖子”的地方——带宽和延迟。

你想想看,模型算得再快,数据喂不进去,或者算完的结果吐不出来,那都是白搭。我见过太多项目,算子优化得飞起,一上板子跑起来,性能直接腰斩。查到最后,问题都出在数据搬运上。

说白了,芯片的计算单元就像个“高速工厂”,而内存系统就是“原材料仓库”和“成品仓库”。工厂再快,仓库的传送带跟不上,产能就上不去。我们今天要解决的,就是怎么让这条“传送带”跑得又快又稳。

4.1 DMA传输优化:把CPU从搬运工解放出来

先说说DMA。Direct Memory Access,直接存储器访问。这玩意儿不是什么新技术,但在AI芯片里,它是命脉。

没有DMA的时候,CPU得亲自把数据从DDR搬到SRAM,再从SRAM搬回去。CPU是什么?是发号施令的将军,不是扛麻袋的苦力。让CPU干这个,计算单元就得等着,这叫“串行搬运”,效率极低。

DMA的作用,就是让硬件自己完成数据搬运。CPU只需要告诉DMA:“从地址A搬N个字节到地址B”,然后就可以去干别的事了。DMA搬完数据,发个中断通知一下就行。

核心原则: 能用DMA搬的数据,绝不用CPU搬。CPU的时间应该花在计算和控制上。

4.1.1 单次传输与链式传输

DMA传输有两种基本模式:单次传输和链式传输。

  • 单次传输: 每次传输前,CPU都要配置一次DMA的源地址、目的地址和传输长度。适合简单的、零散的数据搬运。
  • 链式传输: 也叫描述符传输。CPU预先在内存里建好一个“任务链表”,每个节点描述一次传输任务。DMA自动按链表顺序执行,全部完成后才通知CPU。

我在项目中遇到过一个问题:模型有多个输入tensor,如果用单次传输,CPU要反复配置DMA,中断次数也多,开销很大。后来改成链式传输,一次配置,DMA自己把几个tensor全搬完,CPU几乎零干预,整体延迟降了30%以上。

我的建议: 对于模型推理中的固定数据流(比如权重加载、多输入预处理),优先使用链式DMA。它能有效减少CPU的配置开销和中断响应时间。

4.1.2 对齐与突发传输

DMA传输有个讲究:地址对齐。很多DMA控制器要求源地址、目的地址和传输长度是某个对齐粒度(比如16字节、32字节、64字节)的整数倍。不对齐的话,要么性能下降,要么直接报错。

为什么会这样?因为DMA的硬件设计是基于“突发传输”的。一次突发可以连续搬好几个字,效率远高于单字传输。地址不对齐,突发就发不起来,DMA只能退化成单字传输,带宽利用率直接打对折。

对齐粒度 突发传输效率 典型场景
未对齐 低(单字传输) 尽量避免,或使用CPU搬运
16字节对齐 中 小尺寸特征图
64字节对齐 高 大尺寸特征图、权重
128字节对齐 最高 DDR带宽敏感型算子

我曾经踩过的坑: 有一次模型移植,DMA传输总是丢数据。查了两天才发现,是tensor的宽度不是64字节对齐,导致DMA在跨行传输时地址计算错误。从那以后,我养成了习惯:所有tensor的stride都强制对齐到DMA的最大突发长度。

4.2 数据预取技术:让数据“等”计算,而不是计算“等”数据

DMA解决了“谁来搬”的问题,但没解决“什么时候搬”的问题。如果计算单元开始算一个tile了,DMA才开始搬下一个tile,那计算单元还是得等。这就是“数据饥饿”。

数据预取,就是提前把下一轮计算需要的数据搬到片上SRAM里。等当前计算完成,数据已经准备好了,计算单元可以无缝衔接。

4.2.1 软件预取 vs 硬件预取

  • 硬件预取: CPU或DMA控制器内部有预取逻辑,根据访问模式自动预测并提前加载数据。优点是透明,程序员不用管。缺点是预测不准时反而浪费带宽。
  • 软件预取: 程序员在代码里显式插入预取指令(比如ARM的pld指令),或者通过DMA提前发起传输。优点是精准可控,缺点是增加编程复杂度。

在AI芯片上,我更推荐软件预取。因为模型的访存模式是确定的——卷积的滑动窗口、矩阵的分块计算,都是可以提前算出来的。硬件预取在这种规则模式下的效果还行,但遇到不规则稀疏计算,基本就废了。

4.2.2 双缓冲(Ping-Pong Buffer)

双缓冲是数据预取最经典的实现方式。说白了,就是在片上准备两块缓冲区:Buffer A和Buffer B。

  • 计算单元处理Buffer A里的数据时,DMA往Buffer B里搬下一批数据。
  • 处理完Buffer A,计算单元立刻切换到Buffer B,同时DMA开始往Buffer A里搬新数据。

这样,计算和访存就“重叠”起来了,谁也不用等谁。

关键指标: 双缓冲的“切换开销”必须远小于计算时间。如果切换一次要几百个cycle,而计算一个tile才几千个cycle,那重叠效果就大打折扣。

4.3 计算与访存重叠:榨干芯片的最后一丝性能

双缓冲是计算与访存重叠的一种具体实现。但更广义的“重叠”,还包括计算和DMA传输在时间上的完全并行。

理想情况下,我们希望计算单元的利用率接近100%。这意味着,DMA必须在计算单元处理当前数据的同时,把未来N轮计算所需的数据全部准备好。这需要精细的“流水线调度”。

4.3.1 三级流水线模型

我习惯把模型推理的访存-计算过程抽象成三级流水线:

  1. 预取阶段(Prefetch): DMA从DDR搬运数据到片上SRAM。
  2. 计算阶段(Compute): 计算单元处理SRAM中的数据。
  3. 写回阶段(Writeback): DMA将计算结果从SRAM搬回DDR。

这三阶段如果能完全流水起来,性能就能达到理论峰值。但现实中,总会有“气泡”——比如预取还没完成,计算单元就得空转等待。

计算与访存重叠的三级流水线模型 时间 预取 Tile 0 预取 Tile 1 预取 Tile 2 计算 Tile 0 计算 Tile 1 计算 Tile 2 写回 Tile 0 写回 Tile 1 写回 Tile 2 流水线说明 • 预取: DMA搬入 • 计算: NPU处理 • 写回: DMA搬出 三阶段完全重叠 无气泡产生

4.3.2 如何量化重叠效果

我一般用“计算访存比”来评估重叠的潜力。公式很简单:

计算访存比 = 计算时间 / 访存时间

如果这个比值大于1,说明计算是瓶颈,访存有富余,重叠比较容易实现。如果小于1,说明访存是瓶颈,你得先优化DMA传输效率,否则计算单元永远在等数据。

我的经验: 对于卷积层,如果计算访存比小于0.5,我会优先考虑“权重预加载”和“输出融合”,减少中间结果的写回次数。如果比值大于2,我会尝试增加片上缓存大小,让DMA一次搬更多的数据,减少传输次数。

4.4 实战:一个卷积层的DMA调度示例

光说不练假把式。咱们看一个具体的例子:一个3x3卷积,输入是112x112x64,输出是112x112x64,权重是3x3x64x64。

假设片上SRAM只有256KB,装不下整个特征图和权重。我们需要分块(tiling)。

// 伪代码:双缓冲卷积调度
void conv_with_dma_overlap() {
    // 初始化两个buffer
    dma_buffer_t buf_a, buf_b;
    
    // 预取第一个tile到buf_a
    dma_prefetch(&buf_a, input_tile_0, weight_tile_0);
    
    for (int i = 0; i < num_tiles; i++) {
        // 预取下一个tile到buf_b(与计算重叠)
        if (i + 1 < num_tiles) {
            dma_prefetch_async(&buf_b, input_tile_{i+1}, weight_tile_{i+1});
        }
        
        // 计算当前tile(buf_a中的数据)
        conv_compute(&buf_a, &output_tile_i);
        
        // 等待DMA完成(如果还没完成)
        dma_wait();
        
        // 交换buffer指针
        swap(&buf_a, &buf_b);
    }
    
    // 写回最后一个tile
    dma_writeback(&output_tile_last);
}

这段代码里,dma_prefetch_async是非阻塞的,它启动DMA后立即返回,计算单元可以继续工作。等计算完当前tile,再调用dma_wait确保下一个tile已经就绪。

注意: dma_wait的位置很关键。放得太早,重叠效果变差;放得太晚,可能读到脏数据。我一般把它放在计算完成之后、切换buffer之前,这样既能保证数据就绪,又能最大化重叠时间。

4.5 总结一下

带宽与延迟优化,说白了就是三件事:

  • 用DMA代替CPU搬运数据,并且用好链式传输和对齐突发。
  • 用数据预取让数据提前到位,双缓冲是最实用的手段。
  • 让计算和访存重叠起来,通过流水线调度榨干芯片的每一cycle。

这些技术单独拿出来都不难,难的是把它们组合成一个整体,适配到具体的模型和芯片上。我每次做模型移植,都会先画一张“数据流图”,标出每个算子的输入输出、数据大小、片上位置,然后手动排一下DMA和计算的时序。虽然费点功夫,但效果立竿见影。