1. 从一张架构图说起:为什么NPU的数据搬运比计算本身更值得琢磨
第一次接触NPU架构的时候,我和大多数人一样,注意力全在MAC阵列的算力峰值上——多少TOPS、支持什么精度、峰值功耗多少。直到真正上手调优一个卷积算子,才发现瓶颈根本不在乘加单元上,而是卡在数据供给这一环。算力单元大部分时间在等数据,就像一条八车道的高速公路,入口却只有一个收费站。
这个现象在业内其实不算秘密。NPU的data flow,也就是数据从外部存储一路搬到计算单元旁边的完整路径,决定了这颗芯片实际能跑出多少有效算力。标题里提到的三个关键词——HBM、DMA、多bank Shared Memory——恰好构成了这条路径上最核心的三段:片外的大容量存储、负责搬运的引擎、以及计算单元伸手就能够到的片上暂存。
我打算把这条链路拆开来讲清楚。从HBM的物理特性怎么影响访问模式,到DMA描述符怎么设计才能跑满带宽,再到Shared Memory为什么要分bank、bank冲突到底怎么产生的、怎么在写kernel的时候绕开它。整篇内容适合正在做NPU算子开发、性能调优,或者单纯想搞明白AI芯片数据通路设计的读者。不需要你手上有板子,但最好对计算机体系结构有一点基本概念,比如cache、总线、DDR这些词不陌生。
下面这张表是我自己总结的这条数据通路上各环节的核心关注点,先给个全局印象:
| 层级 | 典型容量 | 关键指标 | 主要瓶颈 |
|---|---|---|---|
| HBM | 8-64GB | 带宽、延迟 | 行激活开销、刷新 |
| DMA | 描述符队列 | 吞吐、并发度 | 描述符粒度、对齐 |
| Shared Memory | 几十KB-几MB | 带宽、bank数 | bank冲突、容量 |
2. 整体数据通路设计:为什么是HBM加DMA加多bank SRAM这套组合
2.1 从冯诺依曼瓶颈到NPU的存储层次
传统CPU靠多级cache来缓解内存墙,但NPU的工作负载有个显著特点:数据复用模式高度可预测。卷积的权重、feature map的复用规律在编译期就能分析出来,所以NPU不需要像CPU那样靠硬件自动预测,而是可以用软件显式管理片上存储。这就催生了scratchpad memory(便签式存储)的设计思路,也就是我们说的Shared Memory。
这套层次的设计逻辑其实很朴素:HBM负责大容量,因为模型动辄几百MB到几个GB,片上根本放不下;DMA负责高效搬运,因为计算核心不应该浪费周期去处理地址计算和总线握手;Shared Memory负责低延迟高带宽供给,因为MAC阵列每个周期都要吃进大量操作数。
我见过一些新手会问:为什么不直接用HBM喂给计算单元?答案很简单,HBM的延迟在几百纳秒量级,而MAC阵列的周期是纳秒级,差了两三个数量级。如果每次乘加都去HBM取数,算力利用率会低到没法看。所以必须有一层靠近计算单元的SRAM做缓冲,把延迟藏起来。
2.2 三段式流水线的设计取舍
把这条通路画成流水线,大概是这个样子:HBM到Shared Memory是一段,Shared Memory到寄存器是一段,寄存器到MAC是一段。每一段都有自己的带宽和延迟特性,设计时要保证三段流水线的吞吐匹配,否则最慢的那段就是瓶颈。
这里有个常见的误区:很多人只盯着HBM带宽,觉得带宽越大越好。但实际上,如果DMA引擎的并发度不够,或者Shared Memory的bank设计不合理,HBM带宽再大也灌不进去。我实测过一个case,HBM理论带宽900GB/s,但实际算子只跑出不到200GB/s的有效带宽,问题出在DMA描述符切得太碎,每次搬运的数据量太小,总线握手开销占比过高。
提示:评估数据通路时,不要只看单点峰值,要看端到端有效带宽。从HBM读出的数据量除以算子总耗时,才是真实可用的带宽。
2.3 为什么Shared Memory要分bank
Shared Memory分bank的根本原因和DDR分bank类似:单端口存储每个周期只能服务一次访问,如果多个计算单元同时要取数,就会排队。分bank之后,只要访问落在不同bank上,就能并行服务。
但NPU场景和GPU有个区别:NPU的访问模式往往更规整,编译器可以提前规划。所以NPU的Shared Memory bank设计有时候会更激进,比如bank位宽做得更宽,或者支持多播。这些设计选择背后都是对典型算子访问模式的统计结果。
3. HBM侧的关键细节:访问粒度、行缓冲与刷新开销
3.1 HBM的物理结构决定了访问不能太碎
HBM(High Bandwidth Memory)通过硅中介层和芯片连在一起,位宽极大,通常1024bit以上,靠宽总线堆出高带宽。但它的内部结构和DDR一样,是bank、row、column的层次。一次访问要先激活一行(row activate),把整行读到行缓冲,再从行缓冲里选列。
这个特性意味着:如果连续访问落在同一行,效率极高;如果频繁跨行,每次都要重新激活,开销很大。HBM的行大小通常在1KB到2KB量级,所以DMA搬运的数据块最好大于这个尺寸,并且地址连续。
我做过一个对比实验:同样搬运1MB数据,按4KB块连续搬和按256B块随机搬,有效带宽差了将近三倍。前者能充分利用行缓冲,后者几乎每次都在激活新行。
3.2 刷新开销:那个容易被忽略的固定成本
HBM是DRAM,需要定期刷新,刷新期间不能响应访问。这个开销在带宽利用率高的时候占比不大,但在低负载或者访问模式不好的时候会变得明显。更麻烦的是,刷新时机不受控制,可能正好卡在关键路径上。
实际调优时,我一般会建议把大块连续搬运安排在流水线的稳定阶段,避免在算子启动和收尾的敏感时期做大量HBM访问。编译器调度时也会考虑这一点,把DMA的发起时机和计算重叠起来。
3.3 访问对齐:不是玄学,是硬性要求
HBM访问有对齐要求,通常要求地址对齐到总线位宽。比如总线位宽是256bit,那地址最好是32字节对齐。不对齐的访问会触发读改写,带宽直接打对折甚至更惨。
这个坑我在早期项目里踩过。当时DMA描述符里的源地址没做对齐,结果性能只有预期的一半。后来查手册才发现,非对齐访问会被拆成两次。解决办法很简单:在数据布局阶段就把tensor的起始地址对齐到256字节,同时保证每个维度的stride也是对齐的。
| 对齐情况 | 有效带宽 | 说明 |
|---|---|---|
| 256B对齐 | 100% | 理想情况 |
| 128B对齐 | ~85% | 轻微拆分 |
| 64B对齐 | ~60% | 明显拆分 |
| 非对齐 | <50% | 读改写 |
4. DMA引擎:描述符设计、并发度与流水线重叠
4.1 DMA描述符的粒度怎么定
DMA搬运不是一次搬一个字节,而是按描述符来。一个描述符通常包含源地址、目的地址、搬运长度、stride等信息。描述符的粒度直接决定了DMA引擎的效率和灵活性。
粒度太粗,灵活性差,遇到非连续数据就要多次搬运;粒度太细,描述符数量爆炸,引擎处理描述符本身的开销就上来了。我的经验是,描述符的搬运长度最好在1KB到16KB之间,这个区间既能保证行缓冲命中率,又不会让描述符队列太深。
对于卷积这种有大量非连续访问的场景,可以用多维描述符。比如一个描述符描述一个二维块,包含行数、行内长度、行间stride。这样硬件可以自动展开,不需要软件生成大量一维描述符。
4.2 并发度:多通道DMA怎么配合
高端NPU通常有多个DMA通道,可以并行搬运不同的数据块。但并发度不是越高越好,因为HBM的带宽是共享的,多个通道同时抢带宽反而可能互相干扰。
我一般会这样配置:权重搬运用一个通道,feature map用另一个通道,输出写回用第三个通道。三个通道的优先级根据流水线阶段动态调整。比如计算阶段权重已经就位,就可以降低权重通道的优先级,把带宽让给feature map。
这里有个细节:DMA通道之间的仲裁策略很关键。如果是轮询仲裁,可能出现某个通道饿死的情况;如果是优先级仲裁,低优先级通道可能一直拿不到带宽。实际调优时要看具体硬件的仲裁机制,必要时通过软件调度来规避。
4.3 流水线重叠:让DMA和计算同时跑
数据通路设计的精髓在于重叠。理想情况下,DMA搬运第N+1块数据的同时,计算单元在处理第N块数据。这样HBM带宽和计算算力都能被充分利用。
实现重叠的关键是双缓冲或者多缓冲。Shared Memory里开两块区域,一块给当前计算用,一块给DMA写入。计算完成后交换角色。这个机制听起来简单,但实际实现时要注意同步:DMA写完要通知计算单元,计算单元用完要通知DMA可以覆盖。
注意:双缓冲的同步如果靠轮询,会浪费周期;如果靠中断,会有延迟。很多NPU用硬件信号量或者barrier指令来做,效率最高。写kernel时要确保同步指令插在正确的位置,否则要么死锁,要么数据竞争。
5. 多bank Shared Memory:bank冲突的成因与规避
5.1 bank冲突到底是怎么产生的
Shared Memory分bank之后,每个bank独立服务访问。如果同一个周期内,多个访问请求落在同一个bank的不同地址上,就会冲突,只能串行处理。这就是bank冲突。
举个具体例子:假设有32个bank,每个bank位宽4字节,总位宽128字节。如果32个线程分别访问地址0、4、8、...、124,正好每个bank一个,无冲突。但如果32个线程都访问地址0、128、256、...,这些地址的bank索引相同(因为bank索引通常是地址除以bank位宽再模bank数),就会全部落到同一个bank,冲突32次。
5.2 常见的冲突模式与规避方法
最常见的冲突模式是stride访问。比如矩阵转置时,按列访问就会产生stride冲突。解决办法有两种:一是padding,在每行末尾加几个填充元素,改变stride,让bank索引错开;二是swizzle,把地址做异或或者位重排,打散bank分布。
padding的方法简单直接,但浪费存储空间。swizzle更优雅,但需要编译器和硬件配合。我在实际项目里更常用padding,因为可控性强,调试也方便。
| 冲突模式 | 典型场景 | 规避方法 | 代价 |
|---|---|---|---|
| stride冲突 | 矩阵转置 | padding | 存储浪费 |
| 广播冲突 | 权重共享 | 多播支持 | 硬件复杂度 |
| 随机冲突 | 稀疏访问 | swizzle | 地址计算开销 |
5.3 多播:当多个计算单元要同一份数据
卷积里有个典型场景:多个输出通道共享同一份输入feature。如果每个计算单元都去Shared Memory读一遍,bank压力很大。多播机制允许一次读取把数据同时送给多个消费者,大幅降低bank访问次数。
多播的实现通常是在Shared Memory输出端加一个广播网络。硬件检测到多个请求地址相同时,只读一次,然后复制多份。这个机制对卷积非常友好,能显著提升有效带宽。
但多播也有代价:如果请求地址不完全相同,硬件还是要分别处理。所以编译器要尽量把共享访问组织成地址完全一致的形式,才能吃到多播的红利。
6. 实操过程:手写一个数据搬运与bank优化的小例子
6.1 场景设定与参数计算
假设我们要实现一个简单的矩阵乘法C = A × B,A是64×256,B是256×64,C是64×64。数据放在HBM里,我们要通过DMA搬到Shared Memory,再喂给计算单元。
先算一下数据量:A是64×256×4字节=64KB,B是256×64×4字节=64KB,C是64×64×4字节=16KB。Shared Memory假设有256KB,足够放下A和B的一块tile。
分块策略:把A按行分块,每次搬16行,即16×256×4=16KB。B按列分块,每次搬16列,即256×16×4=16KB。这样每次计算16×16的输出块。
6.2 DMA描述符配置
对于A的搬运,源地址是A的起始地址加上行偏移,目的地址是Shared Memory的A缓冲区。由于A是行优先存储,16行连续数据在HBM里是连续的,可以用一个一维描述符搞定,长度16KB。
对于B的搬运,B是行优先存储,但我们要搬的是16列,这些列在HBM里不连续,每列之间间隔64×4=256字节。所以需要用二维描述符:行数256,每行搬16个元素(64字节),行间stride 256字节。
// 伪代码:配置B的二维DMA描述符 dma_desc.src_addr = B_base + col_offset * 4; dma_desc.dst_addr = smem_B_buffer; dma_desc.row_count = 256; dma_desc.row_size = 16 * 4; // 64字节 dma_desc.src_stride = 64 * 4; // 256字节 dma_desc.dst_stride = 16 * 4; // 目的端连续存放6.3 Shared Memory的bank规划
假设Shared Memory有32个bank,每个bank位宽4字节。A缓冲区按行存放,每行256个元素,正好是256×4=1024字节,是32的倍数。这意味着A的每一行起始地址的bank索引都是0,如果计算单元按列访问A,就会产生严重的bank冲突。
解决办法:给A的每一行加padding。原本每行256个元素,改成260个元素,多出的4个元素做填充。这样每行起始地址偏移260×4=1040字节,1040除以4再模32等于4,bank索引每行错开4个。按列访问时,相邻行的同一列落在不同bank上,冲突大幅减少。
B缓冲区类似处理。B的每一列在Shared Memory里是连续的,按行访问B时也可能冲突。给B的每行也加padding,从16个元素加到20个。
6.4 同步与流水线
双缓冲的实现:Shared Memory里开两组A和B缓冲区,一组给计算用,一组给DMA写。用一个硬件信号量标记哪组可用。
流程大概是:DMA写buffer0,写完置信号量;计算单元等信号量,读到后开始算buffer0,同时DMA写buffer1;计算完buffer0后置信号量,DMA可以覆盖buffer0,计算单元转去算buffer1。如此循环。
提示:双缓冲的深度可以根据HBM延迟和计算时间调整。如果HBM延迟远大于计算时间,可能需要三缓冲甚至四缓冲来填满流水线。
7. 常见问题与排查技巧实录
7.1 有效带宽远低于理论值
这是最常见的问题。排查思路按顺序来:先看DMA描述符的搬运长度是否太小,如果小于1KB,优先加大;再看地址是否对齐,非对齐访问会直接砍半;然后看访问模式是否连续,跨行频繁的话考虑调整数据布局;最后看DMA通道之间是否互相干扰,尝试调整优先级或错开搬运时机。
我遇到过一个case,理论带宽800GB/s,实测只有150GB/s。查了一圈发现是描述符长度只有256字节,而且地址没对齐。改成4KB对齐搬运后,直接拉到600GB/s以上。
7.2 bank冲突导致计算单元等数据
如果计算单元的stall周期很高,而DMA侧看起来正常,大概率是bank冲突。用性能计数器看Shared Memory的冲突次数,如果冲突率高,就要检查访问模式。
常见的冲突来源是stride访问和广播缺失。先确认数据布局是否加了padding,再看编译器有没有生成多播指令。有时候手动调整一下循环顺序,把最内层循环的访问变成连续访问,冲突就消失了。
7.3 DMA和计算之间的同步死锁
双缓冲的同步如果写错,很容易死锁。典型症状是DMA和计算互相等对方的信号量。排查时先确认信号量的初始状态,再检查wait和post的配对是否正确。
我一般会在仿真环境里先跑一个小case,把信号量的状态打印出来,确认流水线能正常流转。上板之后再跑大case,避免在硬件上调试死锁,效率太低。
| 问题现象 | 可能原因 | 排查手段 | 解决方向 |
|---|---|---|---|
| 带宽低 | 描述符碎、非对齐 | 看描述符长度和地址 | 加大粒度、对齐 |
| 计算stall | bank冲突 | 看冲突计数器 | padding、swizzle |
| 死锁 | 同步错误 | 仿真打印信号量 | 检查wait/post配对 |
| 数据错 | 竞争 | 看时序波形 | 加barrier |
7.4 一些不太常见但很坑的问题
HBM刷新导致的偶发性能抖动,这个很难复现,但确实存在。如果发现性能曲线有周期性毛刺,可以查一下刷新周期是否和算子执行周期有共振。
还有DMA描述符队列溢出。如果软件生成描述符的速度超过硬件消费速度,队列会满,DMA引擎会stall。这时候要么降低描述符生成速率,要么加大队列深度。
最后是Shared Memory的容量规划。如果tile切得太大,Shared Memory放不下,就要回退到更小的tile,但这样DMA搬运次数增加,带宽压力更大。这个平衡点需要根据具体硬件的Shared Memory容量和HBM带宽来算。
8. 写在最后:几个我踩过的坑和常用技巧
关于HBM访问,我的经验是尽量让DMA搬运大块连续数据,单次搬运不要小于4KB。如果数据布局不允许,宁可先在HBM里做一次重排,也不要让DMA去搬碎片。
DMA描述符的设计上,能用多维就用多维,让硬件去展开循环,比软件生成一堆一维描述符效率高得多。描述符的队列深度要留足余量,至少能覆盖两到三个流水线阶段。
Shared Memory的bank规划,padding是最简单有效的办法。padding的大小要算清楚,让相邻行的bank索引错开,错开的量最好是bank数的互质数,这样冲突最少。
双缓冲的同步,能用硬件信号量就别用软件轮询。硬件信号量的延迟是周期级的,软件轮询可能浪费几十个周期。如果硬件不支持,那就把轮询循环写得紧凑一点,减少开销。
最后,性能调优一定要有数据支撑。不要凭感觉猜瓶颈在哪里,用性能计数器把HBM带宽、DMA吞吐、bank冲突率、计算单元利用率都打出来,哪个低就查哪个。我见过太多人凭直觉优化了半天,结果瓶颈根本不在他以为的地方。