☰
NPU数据通路拆解:HBM、DMA与多bank共享内存如何决定算力利用率
2026/10/18 1:09:57 网站建设 项目流程

搞NPU(神经处理单元)的人,最常被问到的问题多半是:你的算力多少TOPS?但真正决定一颗AI芯片实不实用的,往往是数据能不能在计算单元需要的那一刻,恰好出现在它面前。我在某个端侧NPU项目上折腾过几轮之后,最深的一个体会是:NPU的数据通路其实可以压缩成一条极简单的链路——数据从大容量但访问代价高的HBM出发,由DMA负责按计划搬进片上Memory,最后落在计算阵列眼前的最后一层缓冲:多bank的共享内存(Shared Memory),再喂给MAC阵列。这一整套从HBM、DMA到多bank Shared Memory的data flow,设计得顺不顺,直接决定模型实际跑出来的算力利用率。

这篇文章是系列里的第三章,前面如果没看也不影响,我会把每个环节从头拆开讲清楚。适合正在啃NPU架构资料、或者做AI算子优化、写硬件调度代码的读者参考。读完你应该能理清NPU里数据流转的完整脉络,也知道想优化性能时,该从哪个环节下手最容易见效。

1. HBM:为什么大算力NPU离不开这颗“外挂内存”

1.1 算力与带宽的剪刀差:HBM为什么成为标配

先算一笔账,你就知道NPU为什么这么依赖外部存储带宽了。假设一颗NPU的峰值算力是100 TOPS(每秒100万亿次操作),以INT8精度为例,100 TOPS意味着每秒完成50万亿次乘累加操作,因为一次MAC要占用两次操作。每次MAC至少需要读一个激活值和一个权重值,按INT8算也就是2字节。那么仅输入数据就需要每秒读取:

50e12 MAC/s × 2B = 100 TB/s

这还没算中间结果写回、输出写回、权重更新这些额外流量。100TB/s这个数字是什么概念?普通的DDR内存,哪怕做到四通道,带宽也就一百多GB/s,差了将近三个数量级。而片上SRAM虽然能做到很高的带宽甚至几十TB/s,但容量有限,几十MB已经算奢侈,大模型权重动辄几百MB,根本塞不进片上。

所以必须有一种“住在芯片旁边、带宽特别高”的内存,这就是HBM。要特别强调一点:HBM的定位从来不是容量大,而是带宽高。它内部把多颗DRAM裸片像叠三明治一样堆在一起,目的就是为了把接口位宽做宽,把带宽顶上去。这也是为什么今天的AI加速芯片几乎清一色选择它。

1.2 HBM内部长什么样:channel、bank与并行

HBM不是一个黑盒子。它内部是好几层DRAM裸片堆叠,层与层之间靠硅通孔(TSV)垂直打穿连接,最底部还有一个逻辑接口die负责跟外部交互。访问后,地址会被拆解到不同的channel、rank、bank、row、column这层结构里去。

其中channel是最关键的并行单元。一颗HBM内部会划分出多个channel,每个channel拥有相对独立的控制逻辑和IO端口,可以彼此并行工作。以常见接口参数来说,单颗HBM的物理位宽可以做到1024bit,如果再乘上接口速率,理论带宽几百GB/s是很常见的。正是这种“超宽位宽”的设计,让总带宽能拉得非常高。打个比方:普通DDR像一条窄而限速高的车道,HBM则是路面极宽的高速路,即使车速一般,总流量也大得吓人。

1.3 访存脾气:为什么连续的地址才是好朋友

HBM容量大、带宽高,但它毕竟是DRAM,有着自己固有的访存脾气。DRAM访问的基本单位是“行”,每打开一行做读写之前都有激活(activate)开销,读写完之后还得预充电,才能打开另一行。如果软件只搬几个字节就切换到另一个地址区域,行切换的代价会吃掉大量时间,实际带宽可能只有理论值的零头。

所以DMA搬运数据时,第一条准则就是:尽量采用大块、连续地址的传输。连续地址在硬件上会被自动交错到不同channel和bank里,从而同时利用多个并行引擎;反过来,离散地址会让通道之间互相抢同一行,造成行冲突等待。实际项目里,我经常看到有人把一个特征图的搬运拆成几百个小块,结果有效带宽从八九成跌到三成,教训非常典型。

2. DMA:数据搬运的硬件大管家

2.1 CPU不搬砖:DMA在NPU里的定位

数据从HBM到共享内存的这段路,如果让CPU来搬,既没时间也没带宽。NPU里真正的搬运工是DMA引擎,它做的事情非常纯粹:从源地址读数据,写到目的地址,自己维护地址增长和计数,完全不占用CPU。CPU或者调度器只需要把搬运意图写成一个描述符,DMA收到后就可以自主执行。

不少NPU还不止一个DMA,有的会分出好几路,分别负责输入、权重、输出的搬运。多路DMA的好处是能同时发起多个方向的数据流,比如一边从HBM往共享内存搬下一块输入,一边把上一块计算结果从共享内存搬回HBM。描述符链路设计得好,这几条路可以彼此交错,像工厂里的多条流水线一样同时运转。

2.2 描述符与链:批量搬运任务的挂载方式

DMA的“工作单”叫作描述符(descriptor),通常是一个结构体,里面写明源地址、目的地址、传输长度、传输模式,可能还有触发事件和完成事件。硬件拿到这个结构体,就能自主完成整个搬运过程。

更进阶的是描述符链表。多个描述符通过next指针串在一起,DMA办完一个会自动取下一个,这样软件就能一次性提交一长串搬运任务。好处非常明显:减少了CPU和硬件之间的交互次数,搬运过程中硬件可以自行推进,只有一整条链都跑完才需要通知软件。

但这里有个实操中的常见陷阱:不要贪多,把一大堆小描述符拼成超长链。每个描述符本身有解析开销,DMA启动也有固定延迟,如果单个任务只搬几十字节,这些开销会超过传输本身。更好的做法是把相邻地址的小搬运合并成一个大描述符,让单次传输尽量长。

2.3 关键参数:突发长度、对齐与多通道并发

写DMA配置时,有三个参数几乎决定生死:突发长度、地址对齐、通道选择。

突发长度(burst length)指DMA在一次总线事务中连续传输的数据量,常见有32B、64B、128B等。HBM的DRAM行缓冲是KB级大小,一次burst如果能把行缓冲里的一段完整数据拖走,效率就高;如果每次只传一小段,总线上全是握手开销,带宽自然上不去。

对齐要求同样不能忽视。源地址、目的地址、传输长度最好都对齐到突发长度,甚至对齐到cache line大小。否则总线会把这个传输拆成好几个不完整事务,每个事务都多付一次地址阶段的开销。我调试过一个案例,仅仅因为目的地址没做64B对齐,DMA带宽就掉了将近一半。

通道选择上,多通道并行是好事,但不能毫无计划地乱打。两个通道如果同时在HBM同一bank区域里访问不同的地址行,它们就会互相踩踏,产生行冲突,结果两个通道都在原地等待。理想的做法是把不同通道的访问分散到不同channel/bank去,确保物理资源真并行。

2.4 同步与一致性:最容易翻车的角落

DMA搬完数据后,怎么让CPU知道?常见手段是写完成寄存器、发中断,或者直接更新一块带状态位的内存。调度器可以轮询这些标志来判断数据是否就绪。这里藏着一个高频坑:内存一致性问题。

如果CPU消息里缓存了共享内存地址的脏数据却没刷出去,DMA去搬就拿到旧数据;反过来计算单元已经把结果写进共享内存但还没写穿,DMA搬出去的内容也是残缺的。很多NPU因此要求软件在发起DMA前先执行同步指令,把缓存刷干净。我的建议很简单:所有涉及DMA的边界操作,一律显式加同步,别依赖硬件的默认行为。调用链里多一道fence,换来的是排查问题时少掉一整类疑难杂症。

3. 多bank共享内存:计算单元眼前的最后一级缓冲

3.1 为什么最后一级必须是SRAM,而不是cache

数据从HBM被DMA搬进来之后,停靠的地方就是共享内存。这块片上SRAM离计算阵列非常近,读取延迟只有几个周期,而且可以设计成多端口任意访问。它不是CPU里那种自动管理的cache,更准确说是一种软件管理的暂存存储器(ScratchPad Memory)。

为什么不直接做automatic cache?因为AI计算的数据访问模式太规整了。每个算子下一步要读哪块数据、什么时候读,编译器或调度器完全能提前算出来。既然能预判,就没必要在硬件里塞tag对比、替换策略这些逻辑,省下来的面积和功耗可以让共享内存更大、bank更多。代价也直接:程序员得自己负责把数据搬进来、处理生命周期,一步没管好就全是bug。

3.2 bank的由来:带宽需求逼出来的并行结构

如果共享内存只是一块单端口SRAM,一个周期最多输出一组数据。计算阵列一次要取8个float16,那就要8个周期才能供完,运算单元只能干等。多bank结构正是为了破解这个瓶颈。

把共享内存切分成多个独立的小存储块,每个bank带独立端口和控制逻辑。理想情况下,只要这8个数据分别落在8个不同bank里,就能一拍全部取走。比如共享内存总共512KB,分成8个bank,每个bank 64KB,地址低3位决定去哪个bank。阵列一拍发8个地址,若这8个地址的bank号各不相同,硬件就能并行响应。

这里顺便说一个概念误区:bank多不等于万事大吉。多bank只是允许“同时访问不同bank”,但如果几个请求落到同一个bank,就只能排队,这就是后面要讲的bank冲突。

3.3 bank映射与冲突:最经典的性能杀手

最简单的bank映射是bit-select,也就是拿地址的低几位当bank号。这种映射廉价、直接、延迟低,但有个致命弱点:只要访问步长等于bank数的整数倍,所有请求就会持续砸在同一个bank上。

举个例子,8个bank、存int16数组,如果数组的stride正好是8,那访问a[i]和a[i+8]的bank号永远一样。很多初学shared memory优化的同学,写的循环明明挺规整,性能却奇差,多数就是这个原因。解决办法其实非常朴实:padding。分配数组时每行多留一个元素的空间,让下一行的起始地址错开,破坏“步长=bank数”的整除关系。别小看这一两个元素的冗余,实测能把冲突降掉七八成。

3.4 共享内存的管理:固定分区与生命周期

共享内存怎么分配?我基本没见过直接上malloc的。它是硬件固定资源,地址必须预先规划,常见方式是在加载阶段就把整块SRAM划成几个固定区域:低地址放权重,中间放激活缓冲和输出缓冲,各区域大小写死在驱动里。运行期间不移动,更像一个静态arena。

生命周期管理才是真正的难点。共享内存总量有限,buffer之间经常要复用。复用本身没问题,可怕的是前一个任务还没读完,后一个任务就开写。这种覆盖问题极难排查,因为错误是间歇性的,跟调度时序强相关。我的经验是:给每个buffer维护一个状态标记,记录当前在用还是可回收,等DMA完成或者计算完成事件到达之后,才允许分配给下一个任务。宁可保守一点,也不要为了省那么一点空间去赌时序。

4. 完整数据流:从HBM到计算的调度链路

4.1 一次推理的数据流转全景

现在把整条链路串起来看。一次推理开始,调度器会先做两件事:把模型权重从HBM批量搬到共享内存里,把输入特征图按块搬进来。权重通常搬完就长期驻留片上,因为后续每个计算步骤都会反复用到它;输入则是流式进入的,按tile切成一帧一帧往里送。

之后计算阵列开始执行:从共享内存读激活和权重,做MAC运算,结果写回共享内存的输出缓冲。等到输出缓冲攒满一块,DMA再把这部分数据搬回HBM。整体上看,高频访问的权重被固定在离计算阵列最近的SRAM里,激活数据只是短暂路过,输出数据则采取“攒够再走”的策略。这种安排的本质,是把HBM昂贵的带宽省出来给真正必须传的数据用。

4.2 数据重用与多级缓冲:效率的关键

为什么要花这么大力气把数据一层层搬到片上来?核心原因是数据重用。卷积和全连接里,同一份权重会被不同位置的激活反复使用,同一个激活也会被不同输出通道的权重反复读。把重用数据放到最近的缓冲里,HBM到片上SRAM的等效带宽就被放大好多倍。

以3×3卷积为例,滑动窗口之间重叠的输入有8/9。如果不利用局部性,每个窗口都重新从HBM抓数据,带宽开销高好几倍。共享内存里如果用滚动式窗口设计,只需要保留当前窗口的数据以及下一行的边界数据,每次滑动只要搬很少几个新元素,整体数据流量就能显著下降。

4.3 乒乓缓冲与流水线:让搬运和计算重叠起来

乒乓缓冲是我最推荐优先掌握的技巧。所谓乒乓,就是构造两组buffer A和B:DMA往A搬下一块数据的同时,计算阵列正在处理B里的当前数据;下一轮二者互换角色。这样一来,搬运时间的开销就被计算时间掩盖掉了。

工程实现上有一个很要命的细节:乒乓缓冲区的地址必须是固定的。如果每次都用不一样的地址,DMA描述符就得重写;地址固定之后,描述符可以复用,只需要更新长度或者事件ID就行。我在某项目里踩过这个坑,一开始给每个tile动态分配共享内存地址,结果DMA描述符里地址字段每次都变,还引发了后续同步混乱,改成固定双buffer之后一下子清爽多了。

4.4 调度策略与tile切分的三道对齐

调度器的作用是决定什么时候启动哪条DMA传输、什么时候唤醒哪个计算单元。描述符里可以带依赖条件,比如等待某个事件ID;计算单元也有自己的状态寄存器。硬件调度器就能根据这些信息发布指令、检测依赖、发出同步信号,让数据流像排练好的舞蹈一样有序进行。

实际做tile切分时,我建议同时对照三条约束来调整。第一是HBM的行缓冲与burst长度,要求tile的搬运尽量连续、长度对齐到burst;第二是共享内存的bank数和容量,要求tile不能超缓冲区,同时要避免bank冲突;第三是计算阵列固有的形状约束,tile边界最好正好能被阵列吞下。这三个条件经常打架,我之前做某图像处理Demo时,花在调整tile参数上的时间比写算子本身还多,但调完之后性能提升是肉眼可见的。

5. 常见问题与排查技巧实录

5.1 性能上不去?先看bank冲突

症状:逻辑完全正确,计算结果也对,但性能比目标低30%甚至一半。排障第一步不是看算法,而是看性能计数器里的memory stall和conflict stall。如果冲突占比很高,基本就锁定了方向。确认之后,按优先级处理:给行宽加padding、调循环顺序让访问分布到不同bank、如果硬件支持,把缓冲区起始地址平移几个字节,改变起始bank相位。

这里有个经验分享:不要上来就改算法,先加padding试试。padding的成本极低,有时候一个数字的变化就能让冲突从50%降到10%以内。我之前实测过一个矩阵乘算子,只改了行宽padding,吞吐直接从60%拉到85%,改动不到十行代码。

5.2 DMA搬运效率低:三查三改

症状:HBM理论带宽很高,但DMA实际吞吐就是上不去。查三点:地址连续吗?长度对齐到burst了吗?访问HBM时是不是反复行冲突?修复方法也直接:把多个小搬运合并成一个大的,用2D拷贝描述符一次搬完带stride的矩形数据,而不是逐行发描述符。

实际项目里,我遇到过把一组离散的小搬运合并成连续搬运后,DMA有效带宽从大约40%升到近80%的情况,收益非常明显。这类问题往往不是硬件不行,而是软件把任务切得太碎了。

5.3 缓冲区被覆盖:间歇性错误的元凶

症状:计算结果偶尔出错,而且不稳定,时好时坏。这种问题最磨人,因为它跟调度时序强相关,常规断点调试几乎无效。我的排查套路是:先给共享内存填充固定模式,比如0x5A5A,让程序正常运行,然后检查哪些区域的数据不再是0x5A5A。如果一块本该是旧数据的区域变了,基本就能定位是谁提前复用了这块缓冲。

另一个更系统的做法是给每个DMA描述符挂一个完成标记,软件侧维护“有效数据版本号”,只有版本号匹配才允许复用缓冲。这个机制虽然要多写不少代码,但对避免隐性覆盖非常有效。

5.4 数据格式转换:转置是bank冲突的重灾区

HBM里的数据布局和共享内存里计算单元喜欢的布局经常会不一样。比如HBM里权重可能是通道交错存储,而计算单元更喜欢一行连续排布。如果直接原样搬进共享内存,结果会错得莫名其妙。所以搬运阶段就要明确布局,利用DMA的维度参数或者转置tile。

转置操作在共享内存里是bank冲突的高发区,因为行方向和列方向的访问模式天然冲突,bit-select映射很难同时兼顾。我的建议是用tile转置:每次只转一个8×8小块,在小块内借助寄存器交换,并给每个小块配一行padding。这样即使有冲突,也被限制在很小的局部,性能损失可控。

典型问题常见原因优先处理方案
bank冲突导致stall步长等于bank数整数倍padding行宽、调整循环顺序、平移缓冲区起点
DMA带宽低地址不连续、未对齐、行冲突合并小搬运、用2D描述符、注意burst对齐
结果间歇性错乱buffer复用过早填充模式定位覆盖者、引入完成事件同步
数据布局不对HBM和计算单元布局不一致DMA维度参数转换或做tile转置

最后分享一个我自己的切身体会:做NPU上的算子优化,真正难住人的往往不是数学推导,而是数据流里这些看似琐碎的细节。地址对齐差一个字节、bank映射差一位、同步事件少等一拍,性能天差地别。如果你也在做类似的事情,我建议先从本章讲的链路整体视角入手,先搞清楚当前瓶颈在HBM、DMA还是共享内存,再动手调优,方向对了才不白费力气。下一步想继续深入的话,可以关注多bank共享内存的硬件实现细节,比如不同bank数量的面积与延迟权衡,那是单独一笔很高的账。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询