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