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*4400字节地址也是4字节对齐的。结果DMA报错因为要求32字节对齐。解决办法是把搬运大小补齐到32的整数倍416字节多搬的16字节在Shared Memory里忽略掉。虽然多搬了一点但避免了报错而且这点开销可以忽略。实操建议在分配Shared Memory和规划HBM缓冲区时就按最大对齐要求来分配。比如统一按128字节对齐这样后续任何搬运都不会因为对齐问题翻车。3.3 双缓冲与流水线让搬运和计算重叠如果每次都是“搬完再算算完再搬”那DMA和计算单元总有一个在闲着。性能优化的第一步就是让它们并行起来这就是双缓冲double buffering的思路。具体做法把Shared Memory分成两块或者多块当计算单元在处理buffer A的数据时DMA往buffer B搬下一批数据。等计算用完AB也搬好了直接切换。这样搬运时间和计算时间就重叠了。// 双缓冲伪代码 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[i1], 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字节在bank04-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 × BA是[M, K]B是[K, N]C是[M, N]。为了简化假设MNK1024数据类型float32。计算量是2×1024³ ≈ 21亿次浮点运算。数据量方面A和B各4MBC是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。如果取BMBN64BK64那么每块是64×64×416KB两块共32KB远小于256KB。可以取更大的块。取BMBN128BK32128×32×416KB两块32KB。还是小。取BMBN128BK128128×128×464KB两块128KB。接近256KB的一半留出空间给双缓冲的话正好可以放两份共256KB。所以选BMBN128BK128配合双缓冲。这样每个块的计算量是2×128³≈420万次搬运量是128×128×4×2128KB。5.3 流水线的具体编排外层循环遍历K维度1024/1288次每次搬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[k1], smem_A[next]); dma_load(B_tile[k1], 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。我个人在实际项目中的体会是一个算子的性能上限在数据流设计阶段就基本确定了。后面写代码、调参数只是逼近这个上限。如果数据流设计得不好再怎么优化代码也突破不了。所以每次动手写代码前我都会先在纸上把数据流画一遍算清楚每一层的容量和带宽需求确认没有明显瓶颈再开始写。这个习惯帮我省了很多返工的时间。
阅读完成 · 觉得有帮助?