ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

NPU性能优化:HBM-DMA-Shared Memory数据流链路解析

NPU性能优化:HBM-DMA-Shared Memory数据流链路解析

提到NPU性能,大多数人第一反应是看TOPS、看MAC阵列规模、看主频。我过去也这么干,直到有一次带着一个模型去做效率测试,理论算力明明够,实际帧率却差了三成,最后定位到的问题全部集中在数据搬运上:HBM带宽利用率不到一半,DMA描述符配置错了一行,Shared Memory还因为bank冲突白白浪费了20%的访问周期。从那以后,我在做架构分析时都会先画一条数据流主线,从HBM出发,经过DMA到多bank Shared Memory,最后才看计算单元。

这篇文章就把这条主线完整梳理一遍。我不打算写那种教科书式的架构综述,而是按一个NPU实际执行算子时的数据路径来走:数据从HBM出来,DMA负责把它搬到片上,Shared Memory用多bank的并行访问保证计算单元随时能取到数。每一段我都会给参数计算方法、典型坑和实操心得。适合刚接触AI芯片架构、或者在做NPU算子调优时总被访存问题卡住的同学。

1. NPU数据流全景:先建立一张“搬运算力”的地图

1.1 一条完整的数据路径是什么

NPU和普通CPU最大的区别,在于它默认数据会以“块”为单位流动,而不是一条指令一条数据。常见的NPU执行链路是这样的:主机或内部的标量核先解析算子,生成一组搬运和计算指令;计算需要的数据,包括权重、激活值、可能的稀疏索引,最开始都存在容量大但延迟高的主存里,也就是HBM;标量核把搬运描述符交给DMA引擎,DMA按照指定地址和时间表,把数据块搬到片上的一块高速缓存区;这块缓存通常叫作Shared Memory或片上SRAM;计算阵列再从Shared Memory中取输入和权重,算完把结果写回Shared Memory,再由DMA搬回HBM。

整条链路里,真正参与计算的时间往往只占一小部分,更多的时间花在了“准备好数据”上。所以看NPU数据流,不要只盯着计算单元,要把HBM的带宽、DMA的搬运能力、Shared Memory的访问并行度三者放在同一个时钟域里去估算。任何一个环节拖后腿,计算单元的吞吐都会跟着掉。

1.2 算力是理论值,数据流才是真实值

Roofline模型在AI芯片调优里非常实用。它把性能上限看成两条线:一条是计算上限,单位是每周期能执行多少次乘加;另一条是访存上限,单位是每秒能从存储层次搬多少个字节。执行任何一个算子,实际性能都落在两条线之中较低的那条上。判断一个算子是计算密集还是访存密集,核心指标是算术强度,也就是每搬运一个字节的数据,平均能执行多少次乘加操作。

我用一个生活化的类比来记忆这条关系。计算单元像一口正在烧菜的大锅,HBM是离厨房很远的仓库,DMA是传菜员,Shared Memory是灶台边的备菜台。锅的火力再猛,若传菜员一趟一趟跟不上,备菜台上什么都没有,锅也只能空烧。很多人做算子调优时只顾着换更大的“锅”,也就是提升MAC利用率,却忘了先确认备菜台上的菜是不是按时到位。这个顺序反了,改来改去都改不到点子上。

1.3 数据流设计要回答的三个问题

任何NPU数据流方案,本质上都在回答三个问题:数据放在哪里、数据怎么过去、数据到了之后怎么并行取用。HBM负责解决第一个问题的容量部分,DMA负责第二个问题的搬运部分,多bank Shared Memory负责第三个问题的并行部分。三个问题互相牵制:HBM带宽决定了DMA能搬多快,DMA的吞吐决定了Shared Memory需要多高的访问带宽,Shared Memory的bank数量和宽度又反过来决定计算单元能不能在一个周期内拿到足够多的数据。

这三个问题必须放在一起权衡。比如Shared Memory做得又大又宽,看似能缓解bank冲突,但片上面积有限,留给计算阵列的资源就少了;DMA做得很宽,看起来搬得快,但若HBM地址映射不均匀,DMA也只能干等。设计或调优时,脑子里要有这根链条,否则很容易陷入“改了这头、坏了那头”的死循环。

2. HBM:第一公里决定了整条通路的最高水位

2.1 HBM凭什么比普通DDR快

HBM的核心思路是堆叠和加宽。普通DDR走的是“窄而快”的路线,单颗颗粒位宽只有几十位,靠极高频率把带宽顶上去;HBM则用TSV硅通孔把多层DRAM die垂直堆在一起,对外一次性提供1024位甚至更宽的接口。你可以把DDR想象成一条单向双车道,车虽然跑得快但满载有限;HBM像是十六车道的干线,单车道频率未必最高,可是整体并行度是碾压式的。

所以NPU几乎都选HBM做大容量主存。以一套四堆栈的HBM配置来看,每堆栈位宽1024bit,数据率按3.2Gbps计算,理论总带宽是4×1024×3.2/8,也就是大约1.6TB/s。这个数字是DMA和后续存储层次设计的重要参考。设计Shared Memory时,若片上DMA搬运带宽只有HBM的一半,HBM性能再高也没意义;反过来,若DMA吞吐远超HBM可持续提供的速率,那DMA通道配置得再漂亮,也只能等数据慢慢从接口上挪出来。

2.2 实际带宽和理论带宽之间差了些什么

理论带宽只是峰值。实际跑起来,至少有四类开销会让可用带宽缩水。

第一是刷新。HBM是动态存储,必须周期性刷新,刷新期间通道不能做正常读写。堆叠越深、容量越大,刷新占用的时间占比越高,遇到某些刷新策略还可能在带宽图上形成周期性“谷底”。

第二是ECC。大容量主存需要错误校验,读出一条数据往往要多读ECC位,写一条数据也可能产生额外的读-改-写周期。对神经元数据来说,个别bit的轻微错误不一定影响最终精度,但控制器严格开启ECC后,吞吐下降是看得见的,这也是有些场景在功耗和稳定性之间权衡的由来。

第三是伪通道的粒度。HBM内部被切成多个伪通道,若请求地址集中在同一个伪通道的几个行里,外部虽然有多通道,实际有效并行度却上不去。想要吃满HBM带宽,得让请求地址尽量均匀散到各个伪通道和各个bank里。我常见的方式是地址映射做hash,把连续的物理地址按一定规则打散,避免多路DMA请求全部砸向同一片区域。

第四是访问模式本身的突发性。一次burst的长度、是否跨行、读和写是否频繁切换,都会影响总线效率。连续大块搬移永远是HBM最喜欢的模式,频繁的小块随机读写则是带宽杀手。

2.3 应用层面怎么跟HBM打交道

从软件和算子层的视角,跟HBM相关的主要有三件事。

第一是理解地址交织。配置多个DMA通道分别搬运不同tensor时,不要把相邻地址块硬分给不同通道,而要让它们各自持有地址跨度较大的区间,或依赖硬件hash自动完成分散。否则会出现“所有通道都忙,但带宽只有一条车道在用的假并发”。

第二是多轮复用数据。同一个权重若被多个输出tile使用,最佳策略是让它尽量留在片上,而不是每次都从HBM重新搬。用大白话说,仓库里的菜要一次多拿点放备菜台,不要炒一个菜回一趟仓库,这点在后面的tile切分里还会细说。

第三是预留足够的请求队列深度。HBM控制器都有原子队列,若请求队列太浅,吞吐会被拥堵式的背压限制住。搭性能测试环境时,记得把控制器对读写的仲裁策略测一遍,看看混合读写下面谁占了便宜,这一项很影响最终模型跑批的表现。

3. DMA:数据流的血管,也是最容易写错的地方

3.1 为什么必须有一个专门干粗活的引擎

如果让标量核亲自去从HBM把几十上百KB的数据一块块搬进Shared Memory,那标量核会完全被搬运工作淹没,既没法解析下一条指令,也没法做同步调度。所以NPU里必须有独立的DMA引擎。它的任务很纯粹:按照描述符里给出的源地址、目的地址、长度、步长和计数,自动把数据搬完,并在完成后发一个事件。

DMA在数据流里的角色有点像物流调度中心。它不用理解数据是什么,只需要可靠地按计划发货。一旦计划本身错了,比如目标地址重叠、步长算错、粒度不对齐,那它也会非常高效地搬出一堆错误数据,而且不报错。这是DMA最具迷惑性的地方,后面我会专门展开。

3.2 从一维搬送到二维,描述符的正确打开方式

绝大多数NPU的DMA都支持多种搬运模式。一维模式最简单,给定源地址、目标地址、总长度,一次搬完。但算子里的数据大多是二维或三维的,比如一张tensor的某个矩形tile、权重矩阵中按行取的一段,这时一维模式就不够用了,得用带stride的描述符。

我见过的最常见错误,就是把二维搬运里的行间步长当成行内长度。举个例子,要搬一个32行、每行64个float的tile,每个float占4字节,行内数据长度应该是256字节。可源矩阵实际是按512字节一行排列的,每行后面还存着别的数据。正确的二维描述符应当让DMA每次从源地址出发搬256字节,然后地址向后跳512字节作为下一行的起始。所以行间步长要设为512字节,而不是256字节。写错步长的表现是:搬出来的数据每一行都比预期错开一段,越到后面的行错得越离谱。

下面给一个常见的描述符结构,不限定具体硬件,字段基本相同。

struct dma_desc { uint64_t src_addr; // 源地址,需按DMA对齐要求 uint64_t dst_addr; // 目的地址,通常也要求对齐 uint32_t xfer_len; // 单次搬运长度,单位字节 uint32_t src_stride; // 源侧相邻两次搬运的地址间隔 uint32_t dst_stride; // 目的侧相邻两次搬运的地址间隔 uint32_t count; // 重复次数 uint32_t flags; // 中断使能、方向等 };

需要特别提醒的是,stride的计算必须用字节而不是元素个数。int32的数组每元素4字节,若一行有64个元素但行间有128字节的填充,行内xfer_len是256字节,src_stride是256加128等于384字节,而不是64或128。我在多个项目中都用同样的检查方法:手动推演第一行、第二行的搬运目标地址,确认不重叠、不错位,再上板跑,能省掉大量调试时间。

3.3 同步、预取和乒乓缓冲

DMA是异步引擎,描述符下发后,计算单元不能立刻读Shared Memory里的数据,必须等DMA完成事件。粗暴的同步方式是发完描述符后原地轮询完成标志,这种办法简单但把DMA的并行能力全浪费了。

我更推荐乒乓缓冲的做法。Shared Memory或片上缓冲被划分成两个半区,计算单元在读半区A时,DMA已经把下一块数据往半区B搬;等计算完成,再让DMA填半区A,计算单元切到半区B读。只要单次搬运时间不大于计算时间,计算单元就永远不会等数据。这个“用空间换流水”的思路,几乎适用于所有访存密集的算子。

描述符队列的有效管理也能明显提升效率。DMA引擎通常支持环形描述符队列,标量核可以提前把一批描述符连续写入队列,DMA自动取指执行。每条描述符做完后通过事件通知,不必每条都打断标量核。批量下发还方便硬件做调度优化,比如在描述符里提前做地址排序,减少总线切换和bank冲突。我习惯把同一个算子的所有搬运描述符一次性下发,再启动计算,这种批处理方式在硬件上往往能形成更连续的总线访问流。

3.4 对齐与burst长度的隐藏门槛

DMA描述符里还有一个容易踩坑的字段是地址对齐。很多DMA引擎要求源地址、目的地址按16字节或32字节对齐,否则要么报错,要么把一次搬运拆成两次低效的burst。更麻烦的是,不对齐的搬运在边界上会写入相邻字节,破坏其他数据。处理tensor切片时,尤其要小心起始地址在中间的情况。一个实用的做法,是在算子设计阶段就把tile的起始地址统一按DMA的对齐粒度计算,必要时在Shared Memory里做索引偏移,而不是直接换行搬运。

burst长度直接影响HBM效率。DMA搬长连续块时,控制器能把同一行打开,用连续burst把一整行读完;若burst被限制得很短,每个小burst都要重新预充电和激活,HBM带宽会白白损失。因此设计描述符时,xfer_len尽量取大的连续段,而把stride差异留到二维层级去表达,这比拆成几百条一维小描述符要高效得多。

4. 多bank Shared Memory:让计算单元的取数不用排队

4.1 片上缓存为什么非建不可

HBM延迟高,几十个周期起步,计算单元直接去读根本等不起;而且每读一个数就耗费一次片外带宽。权重若反复利用,带宽在第一次循环就会被耗尽。所以在DMA和计算阵列之间放一层Shared Memory,本质上是做数据和权重的复用。数据从HBM搬上片一次,可以在片上被计算单元访问很多次,牺牲一点片上的面积,换取带宽的成倍节省。

几乎所有的NPU设计里,Shared Memory容量都是跟MAC阵列规模和常用算子tile大小绑定设计的。容量太小,权重放不下,算子每做一遍都得等HBM喂;容量太大,又浪费面积和功耗,留给计算阵列的资源就少了。实际选型时,通常会统计目标模型里最大的单层权重尺寸、最大滑窗输入块,再乘上一个安全的冗余系数,才定下来。

4.2 多个bank到底解决了什么问题

Shared Memory虽然叫SRAM,物理上由一组SRAM子阵列组成。如果只有一个阵列,一个周期只能被访问一次,即使计算单元同时想读四份数据,也只能排队。多bank设计就是把这些子阵列做成多个可独立寻址的单元,只要访问请求落在不同bank,它们就能在同一周期并行完成。

用数字说话。假设Shared Memory容量64KB,按每bank宽度4字节划分成32个bank,一个32位地址的bank编号可以近似用addr/4再对32取模得到。计算单元同一周期发起四个地址的读请求,如果这四个地址分布在四个不同bank,一次周期全部返回;假如其中两个地址落在同一个bank,这一个bank就得承担两次访问,等于该周期少返回了一份有效数据,这就是bank冲突。bank冲突直接削减有效带宽,但不会导致结果错误,所以特别容易被忽略。

4.3 bank冲突的典型场景和padding解法

最容易触发bank冲突的访问模式之一是“按固定stride取连续元素”。比如要处理一个按行存储的矩阵A[32][32],每行32个float,某次计算需要同时取A[i][0]、A[i+1][0]、A[i+2][0]三行同列的数据。它们的地址分别相差32个float,也就是128字节。bank编号按4字节切分,这三行第0列的bank编号恰好相等,一次并行读三份数据就会发生冲突。反过来,如果只按顺序访问同一行的连续元素,它们天然落在相邻的不同bank,反而不会有问题。

解决冲突的标准手法是在数组行尾做padding。把分配改成float A[32][33],每行多一个float,行间间隔从128字节变成132字节。由于132除以4后对32取模余1,相邻行的同列数据bank编号正好错开1,三行同列访问便能并行返回。付出的代价只是每行多4字节的存储浪费,换来的是访问带宽显著提升。

padding不是唯一解法。有些场景可以直接改遍历顺序,把原来按行访问改成按列分块;有些场景靠硬件在地址映射里增加随机化或位重排。但站在算子开发者的角度,padding是最可控、最不需要改硬件的方法。我的经验是:先把数组维度列一张表,对每一个二维访问点算出它在每个维度下的stride模bank数的值,如果某个维度的stride模bank数等于0,且该维度确实存在并行访问,那就优先对这一维做padding。

4.4 Shared Memory里的容量分配与分区原则

实际部署算子时,Shared Memory要同时装下权重、输入tile、输出tile,还得给乒乓双缓冲留空间。通常来说,权重占用最大,因为它要反复使用;输入tile随滑窗移动不断更换;输出tile算一块搬走一块,不必长期占用。分配时按优先级排序:权重常驻,输入给足,输出够用即可。若容量紧张,优先压缩输出tile,其次压缩输入tile,权重尽量不要反复从HBM拉,因为权重通常是被复用次数最多的数据。

多bank结构也会影响分配策略。如果两个数组在地址上恰好错开整数倍于bank周期的宽度,它们即使独立分配,也会形成“地址对齐”的固定冲突。分配时让不同数组的起始地址故意偏移几个bank宽度,常能被动规避一批冲突。这类优化很难靠肉眼发现,最好留一个性能计数器统计bank conflicts,专门用来引导分配调整。我在代码里一般会保留一份当前算子的bank布局表,每次改动后重跑统计,确认冲突率没有回流。

5. 串联起来:一个卷积tile的完整数据流核算

5.1 先给一个小型算子的配置

下面用一个完整的卷积例子,把HBM、DMA、Shared Memory串起来核算一遍。假设输入是64×64×64,通道数64,卷积核3×3×64,输出通道64,输出也是64×64×64。片上参数按一套紧凑配置来定:Shared Memory 256KB、32个bank、每bank 4字节,DMA峰值搬运带宽16GB/s,MAC阵列512个乘加单元、主频1GHz,HBM理论带宽1.6TB/s。

权重总量是3×3×64×64个float,共36864个数,144KB。它是需要反复使用的数据,所以设计目标很明确:尽量整个留在Shared Memory里。但输入、输出tile也要占空间,容量就紧张了。这里把权重按输出通道切成两批,每批72KB,分两次遍历tile。每次使用一半权重,输出也只算一半通道。

5.2 单tile的搬运量和计算量到底是多少

输入tile取16×16×64,一共16384个数,64KB。输出tile在第一批权重时取16×16×32,32KB;第二批同样取32KB。第一批要从HBM搬入输入tile 64KB、权重72KB,计算完再把32KB输出搬回HBM,合计168KB;第二批重复一次,也是168KB。整个tile的全部往返搬运量约336KB。

计算量方面,每批权重只覆盖32个输出通道,但两批加起来要对全部64个输出通道完成16×16×64×3×3×64次乘加。这个数算下来约940万次乘加,换算成512并行度、1GHz主频,两批计算时间合计约18微秒。真实硬件还要考虑填充、边界处理和累加路径,这里把公式简化成每个输出像素对应完整的3×3窗口加所有输入通道,为的是把数量级和比例关系讲清楚。

再看DMA时间。336KB的搬运量除以16GB/s峰值带宽,理论耗时约21微秒。一个tile的计算时间约18微秒,搬运明显比计算慢,所以这个算子在当前tile策略下是访存瓶颈。此时掐表观察,会看到计算单元的PE stall信号长期拉高,整卡利用率上不去。

5.3 优化方向:把权重变成“常住户口”

同样的算子,如果改变tiling策略,把144KB权重整体常驻,一次搬上片后不再反复读取,每个tile只需要搬输入和输出。为了给权重让出空间,输入tile从16×16×64缩小为8×8×64,这时输入tile是16KB,输出tile是16KB。空间占用144加16加16等于176KB,完全放得下。

重新核算单个tile的时间。8×8×64对应的计算量约236万次乘加,计算时间约4.6微秒;输入输出合计搬运32KB,DMA时间约2微秒。此时计算时间明显大于搬运时间,瓶颈稳定移到计算端。做这个优化时我总结出的原则是:复用次数越多的数据越要固定住在Shared Memory里,临时数据可以频繁换血,让数据流从“每次都长途搬运”变成“长途一次、短途多次”。

这里还得补一句,缩小输入tile并不总是免费的午餐。tile变小后,总共需要处理的tile数量会变多,边界重叠区域也会有重复计算和重复搬运。在真正落地时,要看tile变小带来的冗余开销会不会抵消权重常驻的收益。比较好的做法,是用总搬运量和总计算量同时做一次全局核算,不要只看单个tile的快慢。

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

6.1 一张速查表解决八成数据流问题

我把日常调试中源于数据流的典型问题做成一张速查表,现象、根因、排查顺序,按表走能少走很多弯路。

现象根因排查方法常规解法
计算单元长期等待数据,效率不到五成没有做预取或乒乓缓冲,DMA和计算串行看PE stall计数器,对比DMA busy时间上双缓冲,把搬运和计算重叠
DMA搬出来的数据错位,越到后面越乱二维描述符的stride写成了行内长度手推第一行、第二行的目标地址用字节数重算src_stride和dst_stride
Shared Memory带宽上不去,延迟异常高访问stride模bank数为0,发生bank冲突打开bank conflict性能计数器行尾padding或重排存储维度
多通道DMA都在跑,但HBM带宽利用率仍低地址交织不合理,请求扎堆在少数伪通道观察各伪通道的活跃度统计调整地址hash,分散连续访问块
计算结果偶尔不对,且难以复现DMA还没完成就去Shared Memory取数查事件同步是否遗漏,barrier是否加上在DMA完成事件后再发计算指令
同一份数据,读出来有时新有时旧缓存或Shared Memory与HBM的一致性策略没理清检查写回时机和invalidate命令在合适位置插数据同步指令

6.2 调数据流问题的顺序比技巧更重要

查数据流问题,我强烈建议按“从近到远”的顺序来。先确认计算单元等的是不是数据,也就是PE stall;再查Shared Memory的bank冲突计数,这一步能排除片上访问瓶颈;最后才查DMA和HBM带宽。很多人上来就盯HBM,结果查了半天发现瓶颈在片上的bank冲突,HBM压根没被压满。顺序反了,定位时间会成倍增加。

我还会在算子日志里把DMA描述符的源地址、目标地址、stride、长度全部打出来,再写一个离线校验脚本,用模拟器把每个描述符的地址序列算一遍。不要以为自己人肉推演很可靠,二维甚至三维tile的地址索引,手一抖就会错一个括号。脚本能在一秒内把几十个描述符全部验证完。我踩过的最深的一次坑,是一个看起来完全正常的描述符,因为第4行之后的地址落到了一组偏置数据所在的区域,搬完数据后把模型权重连带改了,结果丢了一整个下午的定位时间。

6.3 三组数字比TOPS更能反映NPU健康度

我个人在实际调试中养成了一个习惯:每个算子落地后,固定记录三组数字,HBM实际带宽、DMA有效带宽、bank冲突率。这三组数字比TOPS更能反映一个NPU算子的健康程度。HBM实际带宽可以用来判断远端的搬运是否压满;DMA有效带宽能看出描述符和队列配得顺不顺;bank冲突率则直接度量片上并行访问的效率。下次性能回退时,先翻这三组数,数据流的问题通常一眼就能被揪出来。

比如某次模型迭代后帧率掉了10%,其他人都在查算子改动,我翻了记录发现bank冲突率从15%涨到30%,马上想到是新增的那个tensor布局把某两路访问的stride模bank数变成了0。改完布局,帧率立刻回来。这条经验,比任何高大上的工具都好使。

最后再分享一个小技巧:如果是在仿真环境里查问题,除了统计计数器,还可以在DMA完成事件和计算启动指令之间插一个按周期计数的监测点,看看这两者之间到底隔了多少个空转周期。空转越少,说明预取和调度做得越好;空转一旦明显,优先去看是等待HBM数据、等待DMA完成事件,还是等Shared Memory访问结果,确认等待类型后再对着上面的速查表动手,基本就不会瞎忙了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进