☰
NPU数据流与存储层次:从HBM到Shared Memory的搬运优化实战
2026/10/19 11:46:05 网站建设 项目流程

1. 从一张架构图说起:为什么数据流才是NPU的命门

很多人第一次接触NPU,注意力全在算力数字上——多少TOPS、支持什么精度、跑某个模型多少帧。但真正上手做部署或者写算子的人很快会发现,算力数字只是纸面参数,决定实际性能的往往是数据怎么从外面搬进来、怎么在片上流转、怎么喂给计算单元。我见过太多模型在纸面上算力绰绰有余,实测却卡在带宽上,计算单元大部分时间在等数据。

这一章要聊的就是这条数据通路:从片外的高带宽存储(HBM),经过DMA引擎,最终落到片上被划分成多个bank的Shared Memory。这条链路里每一个环节的设计取舍,都会直接反映到你的算子性能上。理解它,你才能解释为什么同样的计算逻辑,换个数据排布方式性能差三倍;你才能判断一个算子到底是被算力限制还是被带宽限制。

这篇文章适合两类人:一类是刚开始接触NPU编程、想搞明白片上存储体系的开发者;另一类是做性能优化、需要定位瓶颈的工程师。我会尽量把每个设计决策背后的“为什么”讲清楚,而不是只丢一堆参数。涉及具体数值的地方,我会说明这是基于常见架构的合理推断,不同芯片会有差异,但思路是通用的。

先说结论性的认知:NPU的数据流本质上是在解决一个矛盾——计算单元想要数据离自己越近越好、越多越好,但片上存储又贵又小。整个存储层次(HBM → 全局缓存 → Shared Memory → 寄存器)就是在这个矛盾下层层妥协的产物。理解了这条主线,后面所有细节都能挂上去。

2. 存储层次全景:每一层都在为什么买单

2.1 从HBM到寄存器的四级阶梯

一个典型的NPU存储层次大致是这样排布的:最外层是HBM,容量大(几GB到几十GB)、带宽高(几百GB/s到TB/s级),但延迟也高,而且功耗不低;往内是全局缓存或者叫L2,容量在MB级别,用来缓冲HBM和片上存储之间的流量;再往内是Shared Memory,也就是片上共享存储,容量通常在几百KB到几MB,被划分成多个bank;最内层是寄存器文件,直接对接计算单元。

这个阶梯的每一级,容量大约缩小一个数量级,带宽提升一个数量级,延迟降低一个数量级。这不是巧合,而是成本、功耗、面积三者博弈的结果。HBM用堆叠工艺换来高带宽,但没法做大;SRAM做片上存储速度快,但面积效率低,做不大;寄存器最快,但数量极其有限。

提示:理解这个阶梯的关键不是记住每层的具体数字,而是记住“越靠近计算单元,单位容量的成本越高”。这决定了任何数据搬运策略的核心目标——让数据在靠近计算单元的地方被复用尽可能多次。

2.2 为什么Shared Memory要被切成多个bank

这是很多人第一次看到架构图时的疑问:既然都是片上SRAM,为什么还要分bank?直接一整块不行吗?

核心原因是端口冲突。如果Shared Memory只有一个读写端口,那么当多个计算单元同时要访问它时,就得排队。假设你有16个计算单元,每个周期都要读一个数据,单端口存储一个周期只能服务一个请求,那15个单元就得干等。这显然不可接受。

解决办法就是分bank。把Shared Memory切成N个独立的bank,每个bank有自己的读写端口,可以并行访问。理想情况下,16个单元访问16个不同的bank,一个周期全部完成。这就是bank划分的初衷——用并行度换带宽。

但这里有个坑:如果多个单元访问的是同一个bank里的不同地址,还是会冲突,这叫bank conflict。解决冲突是写高性能算子的核心技能之一,后面会专门讲。

2.3 DMA在链路中的角色定位

DMA(直接内存访问)是这条链路上的搬运工。它的存在意义是让数据搬运不占用计算单元的时间。如果没有DMA,计算单元要亲自去HBM取数据,那计算单元就变成了搬运工,算力全浪费在等待上。

DMA引擎通常支持多维搬运描述,也就是说你可以告诉它“从HBM的某个地址开始,按某种步长取一块二维数据,放到Shared Memory的某个位置”。这个能力很关键,因为神经网络里的张量往往是多维的,如果只能一维搬运,就得搬很多无用数据。

一个设计良好的DMA还支持异步——发起搬运后计算单元可以继续干别的,等数据到了再通过同步机制通知。这是实现计算与搬运重叠(overlap)的基础,也是性能优化的关键手段。

3. DMA搬运的实操细节:描述符、对齐与流水线

3.1 搬运描述符怎么写才高效

DMA搬运通常通过描述符来配置。一个描述符至少包含:源地址、目的地址、搬运总大小、步长信息。多维搬运还会包含每一维的大小和步长。

我以常见的二维搬运为例说明。假设你要从HBM搬一个形状为[M, N]的张量到Shared Memory,但只想搬其中一部分,或者源数据在HBM里不是连续的(比如是某个大张量的一个切片)。这时候描述符里的步长就派上用场了。

// 伪代码示意,不同平台API不同 dma_desc.src_addr = hbm_base + offset; dma_desc.dst_addr = smem_base; dma_desc.dim[0].size = N; // 最内层维度大小 dma_desc.dim[0].stride = 1; // 内层连续 dma_desc.dim[1].size = M; // 外层维度大小 dma_desc.dim[1].stride = N_total; // 外层步长,可能大于N dma_desc.total_bytes = M * N * sizeof(dtype);

这里的关键是dim[1].stride。如果源张量在HBM里是紧凑存储的,这个stride就等于N;如果源是某个更大张量的切片,stride就是那个大张量的行宽。DMA硬件会根据这些描述自动跳过不需要的数据,只搬你要的部分。

注意:步长搬运虽然灵活,但效率通常低于连续搬运。因为DMA在跳步时可能无法充分利用突发传输(burst)。如果可能,尽量让最内层维度连续且足够大。

3.2 对齐要求:被忽视的性能杀手

几乎所有DMA都对地址和大小有对齐要求。常见的是要求源地址、目的地址按16字节或32字节对齐,搬运大小也最好是某个粒度的整数倍。

为什么?因为底层总线传输是按固定粒度进行的。如果地址没对齐,DMA可能要先读一个完整粒度再截取,或者触发两次传输。这不仅浪费带宽,还可能直接报错。

我踩过的坑:有一次搬一个形状为[1, 100]的float张量,100*4=400字节,地址也是4字节对齐的。结果DMA报错,因为要求32字节对齐。解决办法是把搬运大小补齐到32的整数倍(416字节),多搬的16字节在Shared Memory里忽略掉。虽然多搬了一点,但避免了报错,而且这点开销可以忽略。

实操建议:在分配Shared Memory和规划HBM缓冲区时,就按最大对齐要求来分配。比如统一按128字节对齐,这样后续任何搬运都不会因为对齐问题翻车。

3.3 双缓冲与流水线:让搬运和计算重叠

如果每次都是“搬完再算,算完再搬”,那DMA和计算单元总有一个在闲着。性能优化的第一步就是让它们并行起来,这就是双缓冲(double buffering)的思路。

具体做法:把Shared Memory分成两块(或者多块),当计算单元在处理buffer A的数据时,DMA往buffer B搬下一批数据。等计算用完A,B也搬好了,直接切换。这样搬运时间和计算时间就重叠了。

// 双缓冲伪代码 for (int i = 0; i < num_tiles; i++) { int cur = i % 2; int next = (i + 1) % 2; if (i + 1 < num_tiles) { dma_load(tile[i+1], smem[next]); // 异步搬下一块 } compute(smem[cur]); // 同时算当前块 dma_wait(); // 等下一块搬完 }

这里有个细节:dma_wait()的位置。如果放在compute之前,那就没有重叠了。正确做法是compute之后等,这样compute和dma_load在时间上是并行的。

双缓冲的收益取决于搬运时间和计算时间的比例。如果搬运时间远大于计算时间,那双缓冲也只能掩盖一部分;如果两者相当,理论上可以接近翻倍的吞吐。判断方法很简单:分别测一下纯搬运和纯计算的时间,如果搬运占比超过30%,双缓冲就值得做。

4. Shared Memory的bank冲突:原理、检测与消除

4.1 bank冲突到底是怎么发生的

前面说了Shared Memory被切成多个bank是为了并行访问。但并行访问有个前提:不同单元访问的地址要落在不同bank里。

bank的映射规则通常是按地址低位来分的。假设有32个bank,每个bank宽度4字节,那么地址0-3字节在bank0,4-7在bank1,以此类推,128-131又回到bank0。也就是说,地址对(bank数 × bank宽度)取模,结果就是bank编号。

冲突场景举例:假设32个bank,宽度4字节。如果16个计算单元同时访问地址0、128、256、384……这些地址对128取模都是0,全部落在bank0。那这16个请求就得串行处理,性能直接掉16倍。

这就是bank冲突的本质:地址映射导致多个访问挤到同一个bank。

4.2 怎么判断有没有bank冲突

最直接的方法是看访问模式。如果所有单元访问的是连续地址(比如单元i访问地址i×4),那它们自然落在不同bank,无冲突。如果访问的是跨步地址(比如单元i访问地址i×128),那就全冲突了。

但实际算子里的访问模式往往更复杂。比如矩阵乘法里,A矩阵按行访问,B矩阵按列访问,转置操作等等,都可能产生冲突。

一个实用的检测方法:把访问地址序列列出来,对(bank数 × bank宽度)取模,看有没有重复。如果有重复,就有冲突。重复的次数就是冲突的倍数。

提示:很多平台的profiler会直接报告bank conflict次数。如果有这个工具,优先用它,比手算靠谱。没有的话再手动分析。

4.3 消除冲突的几种实战手段

手段一:填充(padding)。这是最常用的技巧。如果发现按列访问会冲突,就在每行末尾填充几个字节,改变列与列之间的地址间隔。比如原来行宽是128字节(正好是bank周期的整数倍),填充4字节变成132字节,那相邻行的同一列地址就对不齐了,冲突自然消失。

// 原来:行宽128字节,列访问全冲突 float smem[32][32]; // 填充后:行宽132字节,列访问错开 float smem[32][33]; // 每行多一个元素

代价是浪费一点存储空间,但换来的是无冲突访问,通常非常划算。

手段二:改变访问顺序。有时候冲突是因为访问顺序不合理。比如让单元i访问地址(i×k) mod size,如果k和bank数不互质,就会冲突。调整k或者调整映射方式可以解决。

手段三:广播。如果多个单元访问的是同一个地址(注意是同一个地址,不是同一个bank的不同地址),很多硬件支持广播,一个周期就能把数据发给所有请求者,不算冲突。所以读共享参数时不用担心冲突。

手段四:swizzle。这是更高级的技巧,通过异或等运算重新映射地址,让原本冲突的访问模式变得不冲突。实现复杂,但效果显著,常见于高性能矩阵乘法实现中。

5. 一个完整的搬运与计算流水线案例

5.1 场景设定与参数选择

假设我们要实现一个矩阵乘法C = A × B,A是[M, K],B是[K, N],C是[M, N]。为了简化,假设M=N=K=1024,数据类型float32。

计算量是2×1024³ ≈ 21亿次浮点运算。数据量方面,A和B各4MB,C是4MB。如果算力是10 TFLOPS,理论计算时间约0.2ms。但如果数据搬运跟不上,实际时间可能翻几倍。

我们的策略是分块(tiling):把大矩阵切成小块,每块能放进Shared Memory,然后逐块搬运、逐块计算。这是所有高性能矩阵乘法的基本套路。

5.2 分块大小的计算过程

Shared Memory容量假设是256KB。我们要放A的一块和B的一块。假设块大小是BM×BK和BK×BN。

存储需求:BM×BK×4 + BK×BN×4 ≤ 256KB。

如果取BM=BN=64,BK=64,那么每块是64×64×4=16KB,两块共32KB,远小于256KB。可以取更大的块。

取BM=BN=128,BK=32:128×32×4=16KB,两块32KB。还是小。

取BM=BN=128,BK=128:128×128×4=64KB,两块128KB。接近256KB的一半,留出空间给双缓冲的话,正好可以放两份,共256KB。

所以选BM=BN=128,BK=128,配合双缓冲。这样每个块的计算量是2×128³≈420万次,搬运量是128×128×4×2=128KB。

5.3 流水线的具体编排

外层循环遍历K维度(1024/128=8次),每次搬A的一块[128,128]和B的一块[128,128]到Shared Memory,然后计算C的累加。

配合双缓冲,流程是:

// 预取第一块 dma_load(A_tile[0], smem_A[0]); dma_load(B_tile[0], smem_B[0]); dma_wait(); for (int k = 0; k < 8; k++) { int cur = k % 2; int next = (k + 1) % 2; if (k + 1 < 8) { dma_load(A_tile[k+1], smem_A[next]); dma_load(B_tile[k+1], smem_B[next]); } // 计算当前块,累加到C matmul_accumulate(smem_A[cur], smem_B[cur], C_frag); if (k + 1 < 8) { dma_wait(); } }

这里C_frag是累加器,放在寄存器里,避免反复读写Shared Memory。

5.4 实测数据与瓶颈分析

假设实测下来,纯计算时间是0.25ms,纯搬运时间是0.15ms。如果不做重叠,总时间是0.4ms。做了双缓冲后,理想情况是max(0.25, 0.15)=0.25ms,提升约1.6倍。

但实际往往达不到理想值,因为:

  • 第一次搬运无法重叠,有启动开销
  • 同步操作本身有开销
  • bank冲突可能让计算变慢
  • DMA和计算可能争抢Shared Memory端口

实测可能是0.3ms左右。即便如此,相比0.4ms也是明显提升。

如果发现瓶颈在搬运(搬运时间接近甚至超过计算时间),那就要考虑:

  • 增大分块,减少搬运次数(但受Shared Memory容量限制)
  • 用更高效的搬运模式(连续搬运代替步长搬运)
  • 检查是否有冗余搬运(比如A的某块被重复搬了多次)

6. 常见问题与排查速查

6.1 性能不达预期时的排查顺序

遇到性能问题,我通常按这个顺序排查:

排查项检查方法常见问题
是否被带宽限制算算术强度(计算量/搬运量)强度低说明搬运太多
是否有bank冲突profiler或手动分析地址冲突导致计算变慢
是否重叠了搬运和计算看时间线串行执行浪费一半时间
对齐是否达标检查地址和大小不对齐触发额外传输
分块是否合理算Shared Memory占用块太小搬运频繁

6.2 几个我踩过的坑

坑一:以为搬得越多越好。早期我总想把整块数据一次搬完,结果Shared Memory放不下,只能反复搬。后来明白,分块大小要匹配Shared Memory容量,宁可多搬几次,也不要溢出。

坑二:忽视同步开销。双缓冲里每次循环都有dma_wait,如果块很小,同步开销占比就很高。解决办法是增大块大小,减少循环次数。

坑三:bank冲突没当回事。有一次矩阵乘法性能只有理论值的三分之一,查了半天发现是B矩阵按列访问导致严重bank冲突。加了padding后性能直接翻倍。

坑四:DMA描述符配置错误。步长填错导致搬了错误的数据,计算结果全错但又不报错,调试了很久。建议每次改描述符后先用小数据验证搬运结果。

6.3 一个实用的调试技巧

如果怀疑数据流有问题,可以做一个“搬运-only”的测试:只做DMA搬运,不做计算,测一下纯搬运时间。再做一个“计算-only”的测试:数据已经在Shared Memory里,只做计算。两个时间一对比,就知道瓶颈在哪。

这个方法的另一个好处是能验证DMA配置是否正确。如果搬运时间异常长,很可能是描述符有问题。

7. 从数据流视角看算子设计

写到这里,我想强调一个观念:设计NPU算子时,数据流应该是第一考虑,而不是计算逻辑。计算逻辑是数学问题,数据流是工程问题。数学上等价的两种实现,工程上性能可能差好几倍。

具体来说,设计算子时先问自己几个问题:数据从哪来、到哪去、中间经过几层存储、每层停留多久、能不能复用。把这条链路画清楚,再考虑计算怎么组织。

比如卷积算子,同样的卷积计算,可以用im2col转成矩阵乘法,也可以直接用滑动窗口。前者数据膨胀但计算规整,后者数据紧凑但访问模式复杂。选哪个,取决于你的数据流能不能高效支撑。

再比如,如果发现某个中间结果被反复使用,就应该考虑把它留在Shared Memory甚至寄存器里,而不是每次从HBM重新读。这就是数据复用,是提升算术强度的核心手段。

数据流优化的本质,是让数据在靠近计算单元的地方被用尽可能多次。每往上提一层存储,带宽和延迟都改善一个数量级。所以优化的方向永远是:能放寄存器的别放Shared Memory,能放Shared Memory的别放HBM。

我个人在实际项目中的体会是,一个算子的性能上限,在数据流设计阶段就基本确定了。后面写代码、调参数,只是逼近这个上限。如果数据流设计得不好,再怎么优化代码也突破不了。所以每次动手写代码前,我都会先在纸上把数据流画一遍,算清楚每一层的容量和带宽需求,确认没有明显瓶颈再开始写。这个习惯帮我省了很多返工的时间。

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

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

立即咨询