为什么数据搬运是性能关键?
核心事实:每一层存储带宽差 5~20 倍
以 BM1684X 为例:
| 存储层级 | 带宽量级 | 相对 DDR |
|---|---|---|
| DDR(片外) | ~50 GB/s | 1× |
| L2 SRAM(片上) | ~数百 GB/s | 5~10× |
| 局部存储(每 Lane) | ~TB/s | 20~40× |
这意味着:数据从 DDR 搬到片上,代价是片内访问的 20~40 倍。
一个具体算例:逐元素加法
假设要算 y = x + 1,x 是 112×112×64 的 INT8 张量(约 800 KB)。
方案 A:不融合,直接从 DDR 读
text
① 从 DDR 读 x(800 KB)
② 算 y = x + 1
③ 写 y 回 DDR(800 KB)
总 DDR 流量:1600 KB
计算量:800K 次加法
算术强度 = 800K / 1600K = 0.5 FLOPs/字节
在 50 GB/s 带宽下:
访存时间=1600 KB50 GB/s=32 微秒 访存时间 = \dfrac{1600\ \text{KB}}{50\ \text{GB/s}} = 32\ \text{微秒} 访存时间=50 GB/s1600 KB=32 微秒
在 32 TOPS 算力下:
计算时间=800K32×1012≈0.025 微秒 计算时间 = \dfrac{800K}{32\times10^{12}} \approx 0.025\ \text{微秒} 计算时间=32×1012800K≈0.025 微秒
访存时间是计算时间的 1280 倍! 这个算子完全被带宽卡死。
方案 B:与前后算子融合
如果把 Conv → BN → ReLU 融合成一个 kernel:
text
① 从 DDR 读 x(800 KB)
② 算 Conv + BN + ReLU(中间结果留在片上)
③ 写 y 回 DDR(800 KB)
总 DDR 流量:1600 KB(但中间省掉了 2 次往返)
对比不融合:
text
不融合:Conv → 写DDR → 读DDR → BN → 写DDR → 读DDR → ReLU → 写DDR
中间张量往返 2 次,共 4 × 800 KB = 3200 KB 的纯浪费
融合省掉了 3200 KB 的 DDR 流量。
编译器的目标函数
最大化「每字节 DDR 数据被复用多少次」
数据一旦从 DDR 搬进片上,就尽量让它留在片上完成多轮计算再写回。
对应到不同算子:
| 算子类型 | 算术强度 | 瓶颈 | 优化重点 |
|---|---|---|---|
| 卷积(大通道) | 高 | 算力 | Tiling + 双缓冲,保证搬运与计算重叠 |
| 逐元素(ReLU/Add) | 低 | 带宽 | 与邻近算子融合,避免中间结果往返 DDR |
| 池化/下采样 | 低 | 带宽 | 与卷积融合、共享输入块 |
| 全连接(大权重) | 中 | 两者 | 权重驻留 L2,输入分批流动 |
量化的作用
量化把每字节数据承载的「信息密度」翻倍:
text
FP32:每个数 4 字节
INT8:每个数 1 字节
同样的 DDR 带宽(50 GB/s):
FP32:每秒搬 12.5G 个数
INT8:每秒搬 50G 个数 → 吞吐 4 倍
所以量化和融合都是在做同一件事:对内存墙做文章。
屏障(Barrier)与依赖
问题:多引擎并发导致数据竞争
BM1684X 有三个独立引擎:
| 引擎 | 职责 |
|---|---|
| GDMA | 数据搬运(DDR ↔ 片上 SRAM) |
| MME | 矩阵乘法(卷积、GEMM) |
| VEC | 向量运算(ReLU、Add、Pooling) |
它们可以同时工作,但 如果 MME 的结果是 VEC 的输入,VEC 算到一半 MME 又改了同一块内存怎么办?
text
时间线(无屏障):
GDMA: 搬输入块1 → BufferA
MME: 开始算卷积,读 BufferA,写 BufferC
VEC: 开始算 ReLU,读 BufferC ← 但 MME 还没写完!
结果:VEC 读到一半旧数据、一半新数据 → 错误
解决方案:屏障
屏障(Barrier):在指令流中显式插入同步点,等待前面的指令全部完成后再继续。
text
指令流(按顺序执行):
[GDMA] 搬运输入块1 到 BufferA
[GDMA] 搬运权重到 BufferW
[BARRIER] ← 等上述 GDMA 全部完成(数据就绪)
[MME] 卷积:BufferA → BufferC
[BARRIER] ← 等卷积完成(结果就绪)
[VEC] ReLU:BufferC → BufferC(原地)
[BARRIER] ← 等 ReLU 完成
[GDMA] 把 BufferC 搬回 DDR(L2G)
[BARRIER] ← 等搬运完成(CPU 可读结果)
屏障的代价
屏障越多,并行度越低(等待越多);屏障越少,正确性越危险
text
屏障过多(每个指令后都插):
GDMA → BARRIER → MME → BARRIER → VEC → BARRIER → GDMA
三个引擎完全串行,利用率 33%
屏障过少(只在必要时插):
GDMA → MME → VEC → GDMA(但依赖关系可能出错)
可能读到未完成的数据,导致错误
数据依赖分析
编译器通过数据依赖分析,算出「哪些指令之间真的有依赖」,只在有依赖的地方插屏障。
依赖类型:
| 依赖类型 | 含义 | 例子 |
|---|---|---|
| RAW(Read After Write) | 先写后读 | MME 写 BufferC,VEC 读 BufferC |
| WAR(Write After Read) | 先读后写 | VEC 读 BufferC,GDMA 写 BufferC |
| WAW(Write After Write) | 先写后写 | MME 写 BufferC,VEC 写 BufferC |
只有真正存在依赖的指令对之间才需要屏障。
具体例子:三引擎流水
假设我们要做:
text
Conv (MME) → ReLU (VEC) → 输出 (GDMA)
有依赖的指令对:
text
GDMA(搬输入) → MME(卷积) : RAW(MME 需要 GDMA 搬来的数据)
MME(卷积) → VEC(ReLU) : RAW(VEC 需要 MME 算出的结果)
VEC(ReLU) → GDMA(搬出) : RAW(GDMA 需要 VEC 算出的结果)
无依赖的指令对:
text
GDMA(搬输入) → VEC(ReLU) : 无直接依赖(VEC 读的是 MME 的输出)
MME(卷积) → GDMA(搬出) : 无直接依赖(GDMA 搬的是 VEC 的输出)
编译器生成的指令流:
text
[GDMA] 搬输入 → BufferA
[GDMA] 搬权重 → BufferW
[BARRIER] ← 等 GDMA 完成(RAW:GDMA → MME)
[MME] 卷积:BufferA × BufferW → BufferC
[BARRIER] ← 等 MME 完成(RAW:MME → VEC)
[VEC] ReLU:BufferC → BufferC
[BARRIER] ← 等 VEC 完成(RAW:VEC → GDMA)
[GDMA] 搬出 BufferC → DDR
关键洞察:屏障只在 RAW 依赖处插入,其他位置不插。这样三个引擎可以尽量连续跑:
text
优化后的时间线:
GDMA: ████████████
MME: ████████████████
VEC: ████████████
GDMA: ████████████
每个引擎连续跑一段,只在依赖点等待
死锁风险
问题:局部存储容量固定,若某算子的输入 buffer 被自己占了,而搬运指令又要写同一块 buffer 时,指令流水会卡死。
具体例子:
text
BufferA 大小:128 KB
当前状态:BufferA 被 MME 占用(正在算卷积)
GDMA 指令:搬下一块数据到 BufferA ← 但 BufferA 还没释放!
结果:
GDMA 等待 BufferA 释放
MME 等待 GDMA 搬来下一块数据
→ 死锁(互相等待)
解决办法:
- 双缓冲:用两块 buffer 轮流工作,一块被占时用另一块。
- 显式屏障:在合适位置插屏障,确保「写完 → 读完 → 释放」的顺序。
- 降低并发:关掉双缓冲,串行执行
调试手段:
- 降低并发(关双缓冲)
- 加显式屏障
- 逐个指令组验证
- 先单线程跑通,再上流水
软件栈全貌:从 Python 到硬件
理解了硬件机制,再看软件调用链:
text
Python 应用层(你的业务代码)
│ 调用 sail 推理接口:engine.process(input_tensor)
▼
sail SDK 层(Python/C++ 推理接口)
│ 解析 BModel → 拆出:指令组 + 权重表 + 算子元信息
│ 为输入/输出申请 DDR 缓冲(malloc),数据拷入设备内存
│ 把指令组交给驱动
▼
bm-sophon 内核驱动层(kernel driver)
│ 校验 → 写入硬件命令队列寄存器(MMIO)
│ 处理完成中断 → 回调上层
▼
NPU 硬件(BM1684X/BM1688)
│ 取指 → 解码 → 按依赖调度执行 GDMA/MME/VEC 指令 → 触发完成事件
▼
结果(回拷 CPU 内存 / 或直接由下一个算子消费)
每一层对应一个概念:
| 层 | 对应概念 |
|---|---|
| sail 的 forward() | 批量下发 + 最终同步 |
| 驱动层 | 命令队列、中断 |
| 硬件层 | 屏障、流水 |
同步的工程实现:轮询 vs 中断
CPU 怎么知道 NPU「算完了」?两种机制:
| 机制 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 轮询 (Polling) | CPU 循环读状态寄存器,直到完成位翻转 | 实现简单、延迟低(微秒级) | 空转烧 CPU;频繁轮询挤占主控 CPU 时间 |
| 中断 (Interrupt) | NPU 完成时触发中断,驱动在中断处理里推进任务 | CPU 空闲时可干别的(前后处理) | 中断开销(微秒级)、延迟略高、驱动复杂 |
实际驱动通常混合使用:
- 高频路径(短算子)用轮询
- 低频路径(整帧完成)用中断或「中断+忙等」组合
这直接决定推理框架的架构:生产者-消费者流水线里,「谁等谁、怎么等」就是同步机制在软件层的投影。
总结
| 问题 | 答案 |
|---|---|
| 为什么数据搬运是性能关键? | 每层存储带宽差 5~20 倍,DDR 是瓶颈;逐元素算子算术强度低,完全被带宽卡死 |
| 怎么优化? | 算子融合(省 DDR 往返)、量化(翻倍信息密度)、Tiling + 双缓冲(重叠搬运与计算) |
| 屏障是什么? | 指令流中的同步点,等待前面的指令完成后再继续 |
| 为什么要屏障? | 多引擎并发时,RAW/WAR/WAW 依赖需要同步,否则读到未完成的数据 |
| 怎么减少屏障? | 数据依赖分析,只在真正有依赖的地方插屏障 |
| 屏障的代价? | 屏障越多,并行度越低;屏障越少,正确性越危险 |
| 死锁风险? | Buffer 容量固定,读写冲突可能导致互相等待;用双缓冲、显式屏障解决 |
一句话:数据搬运慢,所以要尽量减少搬运(融合、量化、复用);多引擎并行时,又要用屏障保证顺序正确(依赖分析、指令调度)。这两件事共同决定了 NPU 的实际性能。