概述
Ping-Pong 双缓冲为了让"搬数据"和"算数据"同时进行,用两块缓冲区轮流工作,把搬运时间"藏"在计算时间后面。
串行调度的浪费
假设我们要处理一个张量,把它切成 4 块,每块需要:
- 搬运时间 Tdma=2T_{dma}=2Tdma=2 个时间单位(从 DDR 搬到片上 SRAM)
- 计算时间 Tcalc=2T_{calc}=2Tcalc=2 个时间单位(在计算单元上算)
串行调度
text
步骤:
搬块1 → 算块1 → 搬块2 → 算块2 → 搬块3 → 算块3 → 搬块4 → 算块4
时间线(每个格子 = 1 个时间单位):
搬1: ██
算1: ██
搬2: ██
算2: ██
搬3: ██
算3: ██
搬4: ██
算4: ██
总时间 = 4 × (2 + 2) = 16 个时间单位
问题:搬运时计算单元闲着,计算时 DMA 闲着。两个硬件资源永远只有一个在工作,利用率只有 50%。
双缓冲的思路
核心思想
准备两块缓冲区(Buffer A 和 Buffer B):
- 当 DMA 在往 Buffer A 搬第 i 块时,计算单元从 Buffer B 读第 i-1 块并计算。
- 当 DMA 搬完第 i 块、计算单元算完第 i-1 块后,两者交换角色。
text
Buffer A: [搬块1][算块1][搬块3][算块3]
Buffer B: [搬块2][算块2][搬块4][算块4]
↑
搬块2 与 算块1 同时进行
时间线
text
时间 → 0 1 2 3 4 5 6 7 8 9 10
─────────────────────────────────
DMA: 搬1 搬1 搬2 搬2 搬3 搬3 搬4 搬4
计算: 算1 算1 算2 算2 算3 算3 算4 算4
具体:
t=0~1: DMA 搬块1 到 Buffer A;计算单元空闲(等第一块)
t=2~3: DMA 搬块2 到 Buffer B;计算单元同时算块1(从 Buffer A 读)
t=4~5: DMA 搬块3 到 Buffer A;计算单元同时算块2(从 Buffer B 读)
t=6~7: DMA 搬块4 到 Buffer B;计算单元同时算块3(从 Buffer A 读)
t=8~9: DMA 空闲;计算单元算块4(从 Buffer B 读)
总时间 = 2(第一块搬运)+ 4 × 2(计算,与后续搬运重叠)= 10 个时间单位
加速比 :16 / 10 = 1.6×
为什么不是 2×?
因为第一块搬运时计算单元必须等待(没有数据可算)。如果块数很多,这个"启动开销"会被摊薄:
总时间=Tdma+N×max(Tdma,Tcalc)T_{dma}+N\times\max(T_{dma},T_{calc})Tdma+N×max(Tdma,Tcalc)
当 N 很大时,TdmaT_{dma}Tdma可以忽略,加速比趋近于:
N×(Tdma+Tcalc)N×max(Tdma,Tcalc)=Tdma+Tcalcmax(Tdma,Tcalc) \frac{N\times(T_{dma}+T_{calc})}{N\times\max(T_{dma},T_{calc})}=\frac{T_{dma}+T_{calc}}{\max(T_{dma},T_{calc})} N×max(Tdma,Tcalc)N×(Tdma+Tcalc)=max(Tdma,Tcalc)Tdma+Tcalc
当 Tdma=TcalcT_{dma} = T_{calc}Tdma=Tcalc 时,加速比 = 2x
完整数值例子:4 块数据
参数
| 参数 | 值 |
|---|---|
| 数据块数 NNN | 4 |
| 每块搬运时间 TdmaT_{dma}Tdma | 2 |
| 每块计算时间 TcalcT_{calc}Tcalc | 2 |
| 缓冲区数量 | 2(A 和 B) |
串行调度逐步计算
text
块1: 搬2 + 算2 = 4
块2: 搬2 + 算2 = 4
块3: 搬2 + 算2 = 4
块4: 搬2 + 算2 = 4
总时间 = 16
双缓冲调度逐步计算
text
t=0~1: DMA 搬块1 → Buffer A
t=2~3: DMA 搬块2 → Buffer B | 计算单元算块1(Buffer A)
t=4~5: DMA 搬块3 → Buffer A | 计算单元算块2(Buffer B)
t=6~7: DMA 搬块4 → Buffer B | 计算单元算块3(Buffer A)
t=8~9: DMA 空闲 | 计算单元算块4(Buffer B)
总时间 = 10
对比
| 调度方式 | 总时间 | 加速比 | DMA 利用率 | 计算单元利用率 |
|---|---|---|---|---|
| 串行 | 16 | 1× | 50% | 50% |
| 双缓冲 | 10 | 1.6× | 80% | 80% |
当搬运和计算时间不相等时
场景 A:搬运是瓶颈(Tdma=2,Tcalc=1)(Tdma=2,Tcalc=1)(Tdma=2,Tcalc=1)
text
t=0~1: 搬块1 → A
t=2~3: 搬块2 → B | 算块1(A)
t=4~5: 搬块3 → A | 算块2(B)
t=6~7: 搬块4 → B | 算块3(A)
t=8~9: 搬块5 → A | 算块4(B)
...
总时间 = 2 + N × max(2, 1) = 2 + 4 × 2 = 10
串行总时间 = N × (2 + 1) = 12
加速比 = 12 / 10 = 1.2×
结论:当搬运是瓶颈时,双缓冲只能把计算"藏"在搬运后面,但搬运总量没变,总时间仍由搬运主导。
场景 B:计算是瓶颈(Tdma=1,Tcalc=2)(Tdma=1,Tcalc=2)(Tdma=1,Tcalc=2)
text
t=0: 搬块1 → A
t=1~2: 搬块2 → B | 算块1(A)
t=3~4: 搬块3 → A | 算块2(B)
t=5~6: 搬块4 → B | 算块3(A)
t=7~8: 搬块5 → A | 算块4(B)
...
总时间 = 1 + N × max(1, 2) = 1 + 4 × 2 = 9
串行总时间 = N × (1 + 2) = 12
加速比 = 12 / 9 = 1.33×
结论:当计算是瓶颈时,双缓冲把搬运完全藏住了,总时间由计算主导。
此时应该考虑减少计算量(如量化、更高效的算子)或增加并行度(更多 Lane、更大阵列)。
Python 模拟代码
python
def simulate(n_blocks, t_dma, t_calc, double_buffer=True):
"""模拟串行和双缓冲两种调度下的总耗时"""
if not double_buffer:
# 串行: 搬完一块才能算一块
return n_blocks * (t_dma + t_calc)
# 双缓冲: 第一块搬运先行, 之后搬运与计算重叠
return t_dma + n_blocks * max(t_dma, t_calc)
# 三组场景
for t_dma, t_calc in [(1, 1), (2, 1), (1, 2)]:
serial = simulate(100, t_dma, t_calc, double_buffer=False)
pp = simulate(100, t_dma, t_calc, double_buffer=True)
print(f"t_dma={t_dma} t_calc={t_calc} "
f"串行={serial:6.0f} 双缓冲={pp:6.0f} 加速比={serial/pp:.2f}x")
输出:
text
t_dma=1 t_calc=1 串行= 200 双缓冲= 101 加速比=1.98x ← 平衡,几乎翻倍
t_dma=2 t_calc=1 串行= 300 双缓冲= 201 加速比=1.49x ← 搬运是瓶颈
t_dma=1 t_calc=2 串行= 300 双缓冲= 201 加速比=1.49x ← 计算是瓶颈
M1684X 的实际应用
BM1684X 每个 Lane 有 256KB 局部存储。
编译器通常把它拆成两块(甚至更多):
text
每个 Lane 的 256KB 局部存储:
┌─────────────────────────────────────┐
│ Buffer A: 128KB │
│ Buffer B: 128KB │
└─────────────────────────────────────┘
工作流程:
GDMA 搬第 i+1 块 → Buffer A
计算单元算第 i 块 ← Buffer B
下一轮交换:
GDMA 搬第 i+2 块 → Buffer B
计算单元算第 i+1 块 ← Buffer A
代价:每块数据最大只能占 128KB(因为要留一半给另一块)。如果块太大,放不下,就需要缩小分块,可能增加搬运次数。
编译器要权衡:
| 分块大小 | Buffer 数量 | 搬运次数 | 片上空间 | 重叠效果 |
|---|---|---|---|---|
| 大块 | 2 | 少 | 刚好 | 好 |
| 小块 | 2 | 多 | 充裕 | 好,但搬运开销占比高 |
| 大块 | 1(无重叠) | 少 | 充裕 | 无重叠,串行 |
总结
| 概念 | 含义 |
|---|---|
| 串行调度 | 搬完才能算,算完才能搬下一块,硬件利用率 50% |
| 双缓冲 | 两块 buffer 轮流工作,搬第 i+1 块的同时算第 i 块 |
| 总时间 | Tdma+N×max(Tdma,Tcalc)T_{dma} + N \times \max(T_{dma}, T_{calc})Tdma+N×max(Tdma,Tcalc) |
| 加速比 | 接近 2×2\times2×(当 Tdma≈TcalcT_{dma} \approx T_{calc}Tdma≈Tcalc 时) |
| 瓶颈 | 若 Tdma>TcalcT_{dma} > T_{calc}Tdma>Tcalc,总时间由搬运主导,需减少搬运量 |
| 代价 | 片上空间翻倍,分块大小受限制 |
一句话:Ping-Pong 双缓冲让"搬"和"算"同时进行,把搬运时间藏在计算时间后面。
但它只能消除"等待",不能消除"总量"------如果搬运本身是瓶颈,还需要从量化、融合、数据复用等角度减少搬运量。