做NPU开发这几年我越来越觉得一个朴素的道理计算引擎永远在等数据。主频、MAC阵列规模决定了理论算力但实际跑一个卷积层吞吐多少往往由数据能不能按时喂到位决定。这个“喂数据”的链路就是本系列第三章要聊的核心——NPU的data flow从HBM开始经过DMA搬运落到多bank Shared Memory最后才轮到计算单元。这条链路设计得好不好直接决定利用率是30%还是90%。这一章内容会比较长因为链路上有太多细节值得展开HBM为什么采用三维堆叠而不是简单加宽总线DMA描述符怎么配置才能把带宽跑满多bank Shared Memory为什么反而会带来bank冲突等等。适合读的人面很宽——做AI芯片架构的、写算子库和底层库的、搞编译器优化和后端的还有对体系结构感兴趣的读者都可以参考。下面进入正题。1. 为什么NPU的数据流这么难设计——整体思路拆解1.1 计算引擎的“喂饭”问题打个比方。你去餐馆厨师手艺再高如果配菜小哥不能按时把菜送到灶台边上出菜速度照样上不去。NPU里的MAC阵列就是那位厨师HBM是仓库Shared Memory是案板DMA就是那个配菜小哥。很多人在评估NPU性能时只盯着算力数字比如多少TOPS、主频多少但真正跑起来你就会发现计算单元经常在空转——不是不想算是数据没到。这其实就是冯·诺依曼瓶颈的延续。CPU时代我们就在跟内存墙作斗争到了NPU这里问题更严重因为卷积和矩阵乘法这类操作的数据复用性极强同一个权重可能被几百个激活像素复用。如果每一份数据都从外部内存现取外部内存的带宽根本扛不住。做个简单估算一个典型的3x3卷积层假设某个特征图是64通道、128x128分辨率权重量大约就是64x64x3x3约14.7万个数激活量更大。如果这些全部实时从外部取再快的总线也会被压垮。所以NPU设计的第一原则就是能放片上就放片上能搬一次就不搬两次。数据流设计解决的就是“什么数据、什么时候、放在哪、谁负责搬”这四个问题。思路清楚了后面所有优化都是围绕这四个问题展开的。1.2 数据流三件套HBM、DMA、多bank Shared Memory整条链路可以拆成三段。第一段是HBM它是外部存储的顶级形态负责把海量权重和激活放在离计算引擎不太远的地方第二段是DMA引擎它负责执行实际的搬运动作把HBM里的数据块搬进片上第三段就是多bank Shared Memory它是片上数据的中转站和缓存区计算阵列直接从这里面取数。一条典型的数据流是这样走的权重从HBM通过DMA搬运到Shared Memory的权重区激活图通过DMA搬运到激活区计算阵列从这两个区取数做乘加中间结果写入Shared Memory的临时区最后再由DMA把输出结果写回HBM。注意一个关键点搬运和计算要重叠。最理想的状态是DMA在搬下一块权重的时候MAC阵列正在算当前这块数据。如果搬运和计算串行执行也就是先搬完再算、算完再搬下一步那性能会非常难看。比如某层计算只需要5微秒搬运要花4微秒串行做是9微秒但重叠后总耗时可能只有5微秒多一点点接近翻倍性能。流水设计就是整个数据流的灵魂。1.3 为什么不能直接照搬CPU的缓存方案有人会问CPU不是有cache吗GPU不是有庞大的线程池来掩盖延迟吗NPU直接照搬不就行了问题在于NPU的应用场景和设计目标跟CPU、GPU有很大的差异。CPU追求通用性跑的程序五花八门所以用硬件自动帮你缓存、预取你写代码不用关心数据在哪一层cache里GPU追求高吞吐靠成千上万个线程轮换着访问内存单个线程延迟高没关系整体吞吐照样能拉满。但NPU不一样NPU面向的是相对固定的深度学习算子追求确定性和能效比。它没有那么多线程来轮换也不希望硬件做太多不可控的预取决策因为深度学习计算的数据访问模式是高度规律、完全可预测的。所以绝大多数NPU都走“显式数据搬移”的路子编译器或算子库明确告诉DMA要搬哪块数据、搬到哪个缓冲区计算引擎就在那里等。这种设计的代价是软件变得复杂对工程师要求更高但收益是性能和功耗都可预测。你写算子时每一轮数据在哪、什么时候到位是清清楚楚的不会出现CPU上那种“明明数据在内存里却因为cache miss卡一下”的不可控情况。2. HBM把存储墙往前提一层的高带宽内存2.1 HBM到底解决了什么传统DRAM的问题可以总结为“位宽上不去、频率也上不去”。DDR内存走的是内存条上的一根根走线接口宽度有限要提升带宽只能靠提高频率而频率越高信号完整性和功耗问题越严重。HBM的思路完全不一样它把多层DRAM裸片垂直堆叠在一起通过硅通孔TSV从每一层直接穿透连接到下方的逻辑基片上于是接口宽度可以做得很宽——远超过传统DDR总线。怎么理解这件事你可以想象一排抽屉立在墙边每次只能用两只手取一本文件HBM则是把抽屉竖着堆起来每层抽屉都开着口你的一整只手臂能同时伸进去抓一叠文件出来。传统DDR是频率快但每次拿得少HBM是频率不那么夸张但一次拿一大把综合下来带宽高得多而且因为不用疯狂拉频率功耗也相对友好。落到数字上常见规格的HBM2E单颗理论带宽就能到400GB/s以上HBM3还能翻倍到800GB/s甚至更高。这对NPU意味着什么呢意味着一个20MB的权重矩阵用1TB/s左右的有效带宽搬运20微秒出头就能进片上内存。这个量级对计算单元来说是能喂饱的。2.2 带宽、伪通道与读写延迟的权衡HBM内部并不是一块完整的大内存它被切成很多个“伪通道”pseudo channel。每个伪通道有独立的命令和数据接口可以独立响应访问请求。这样设计访问并行度大幅提升多个请求可以同时分散在不同伪通道上执行互相不干扰。但伪通道的引入也让地址映射变得讲究。如果你把连续数据一股脑塞进同一个伪通道那这个通道会成为热点其他通道闲着总带宽大打折扣。所以HBM控制器会在地址上做交叉interleaving让连续地址均匀分布在各个伪通道之间。比如地址每隔几个KB切换一个伪通道。这样CPU或DMA发起的连续大块访问实际会被HBM控制器拆成多个并行小事务带宽自然就上去了。另外还有一个绕不开的物理现实延迟。HBM虽然带宽高但访问延迟依然在几百纳秒的量级。你可能觉得几百纳秒没什么但NPU片上SRAM的访问延迟是几个纳秒差了差不多两个数量级。这意味着计算单元完全没有办法直接从HBM取数来计算必须经过片上Shared Memory做缓冲一次把一大块数据搬到离计算单元很近的地方后续计算全部命中片上内存。2.3 HBM控制器与访存效率HBM控制器不是简单的“来一个请求发一个命令”它会做调度排队、合并、重排尽量让总线上一次burst尽可能长、方向切换尽可能少。我在实际调系统时发现HBM的访存效率对访问模式极其敏感。连续且对齐的4KB块访问效率可以接近峰值带宽的90%甚至95%但如果DMA把大搬运拆成很多个64B的小块随机撒在不同伪通道里效率可能直接掉到50%以下。另外读写方向切换是有代价的——总线从读切到写需要额外的切换周期清空数据线如果软件让DMA频繁做“读一点、写一点、再读一点”的操作总线上大量周期就浪费在切换上了。HBM还要定期做DRAM刷新刷新时那部分存储区域无法被访问又是一个隐藏的损耗。所以软件层做数据流规划时有几个经验值得记一下访问行为对带宽的影响优化建议连续大块读效率很高接近峰值DMA描述符尽量描述整块搬运块大小不低于4KB随机小粒度访问效率骤降可能打五折软件层提前做数据重排合并零散小数据频繁读写交替总线切换损耗吞吐下降同一时间段内集中读、集中写必要时做双缓冲未对齐访问一个事务拆成多个浪费带宽保证基址和步长按128B或256B对齐这一层是很多人容易忽略的。HBM指标再漂亮也只是一个“上限”真正能用多少取决于DMA怎么搬、地址怎么排。3. DMA搬运引擎数据流中的无名功臣3.1 DMA的职责边界很多初学者会把DMA理解成一个“更快的内存拷贝”这么说不完全错但掩盖了它真正的价值。DMA是个独立的硬件搬运引擎它可以脱离主控核运行。主控核只需要把搬运任务描述清楚比如“把地址A到地址B之间这段数据搬到SMEM地址C”然后启动DMADMA就自己完成剩下的所有动作搬完之后再通知主控核。为什么不直接让主控核用load/store指令搬数据原因有两点。一是主控核有自己的一套指令流水和取指逻辑让它逐字拷贝数据会占用大量指令周期直接影响它下发控制命令的效率二是主控核的访存带宽通常远不如专门优化的DMA引擎DMA可以一次发起很长的突发传输而CPU/控制器微核的load宽度有限。一句话专业的事交给专业的引擎。主控核是调度员DMA是运输队。3.2 二维描述符与Tile搬运实际搬运里最有价值的是二维DMA。深度学习算子里数据大多是矩阵或特征图。特征图在内存里是连续存放的不假但如果你想搬一个子区域——比如从一个大的特征图中取出一个tile它的行与行之间是有地址间隔的。如果DMA只支持一维搬运软件只能循环发N次一维请求每次都要配置寄存器、启动、等结束控制开销极大而且HBM侧的访问也被切成碎片带宽利用率很差。二维描述符一次就能描述一个矩形区域。描述符核心字段就这几个typedef struct { uint64_t src_addr; // HBM源地址或SMEM源地址 uint64_t dst_addr; // 目的地址 uint32_t width; // 每行搬多少字节 uint32_t height; // 一共搬多少行 uint32_t src_stride; // 源端相邻两行的首地址间距字节 uint32_t dst_stride; // 目的端相邻两行的首地址间距字节 } dma_desc_t;比如我要从HBM地址0x80000000处搬运一个64行、每行128字节的矩阵但源端每两行之间实际地址距离是256字节因为行间还夹着别的通道数据那我设置width128、height64、src_stride256。目的端如果希望矩阵在SMEM里紧密排列dst_stride就设成128。DMA硬件会自动按照这个几何信息完成搬运你在描述符里写“矩形”硬件就知道怎么跳地址。再进一步多个描述符可以串成描述符链DMA搬完一个自动从链上取下一条继续搬。这样主控核一次下发一整条链后续完全不需要干预。我在实际工程里接一个多分支网络时经常给每个分支准备一条独立的描述符链同步信号靠链尾的标记来做软件开销非常小。3.3 DMA带宽的计算与匹配要判断数据流设计好不好一个简单的方法是把搬运时间和计算时间放到一张表里对比。假设某层权重20MBHBM有效带宽1TB/s搬运耗时约为20微秒MAC阵列做这层乘加消耗的逻辑耗时估算为40微秒那搬运是追不上计算的来得及。反过来如果权重是80MB而计算耗时只有30微秒那不管怎么优化指令流水计算引擎必然挨饿因为饭根本送不到。这时候就要做“分块流水”把80MB权重拆成例如8个10MB的块DMA先把第1块搬进SMEMMAC开始算第1块的同时DMA搬运第2块等MAC算完第1块第2块已经就位。只要单块搬运时间小于单块计算时间流水就能一直保持打满状态。这就是双缓冲double buffering的基本原理——SMEM里准备两块缓冲区DMA写A区时MAC读B区下一轮交换两边互不踩脚。参数示例数值说明权重总量20MB单层权重HBM有效带宽1TB/s理论峰值打个折单块搬运时间20us20MB / 1TB/s单层计算时间40usMAC阵列逻辑耗时判定结果搬运计算流水可打满无需拆块直接整层搬运DMA配置上还有个容易被忽略的点描述符里的width、src_stride、dst_stride最好都按HBM的burst长度对齐。比如HBM控制器一个burst是256B你每个描述符的行宽只有64B效率会明显往下掉。对齐到256B或512B效率立刻不一样。我见过太多系统带宽不达标最后查下来根本不是硬件弱而是描述符把块切得太碎。4. 多bank Shared Memory片上数据的中转站4.1 为什么要多bankShared Memory在NPU里通常用SRAM实现SRAM速度极快但面积大、功耗高所以片上容量不可能做得太大。更关键的是一块大的单端口SRAM每个时钟周期只能响应一次读或一次写。MAC阵列通常有几十上百个处理单元同一周期想读几十个数据如果Shared Memory只有一个端口那就只能排队性能完全不可接受。这就是引入多bank的原因。把一块SRAM逻辑上划分成多个独立的bank每个bank都有自己的读写端口和地址解码逻辑。只要多个访问请求落在不同的bank上它们就能在同一个周期内并行完成。打个比方一个厨师做菜需要同时拿油、盐、酱、醋如果你把所有调料放在一个大抽屉里一次只能伸一只手进去拿一样那就慢了如果把调料分装到几个独立的小抽屉就能多只手同时取。实际芯片里bank数量常见的是8到32个每个bank的宽度可能是一百多字节到几KB。bank越多并行度越高但面积和布线复杂度也跟着涨。设计目标很朴素让MAC阵列同一周期发起的访问尽量均匀散落在不同bank上。4.2 Bank冲突最容易被忽略的性能杀手多bank能提升并行度但绝不意味着没有冲突了。所谓bank冲突就是两个访问地址在同一个周期落到了同一个bank上然后硬件只能串行处理其中一个请求被延迟一拍。bank索引的映射方式通常是对地址取某些位段。比如有16个bank、每bank宽度32B那地址的某个位段就决定bank编号。这种情况下如果软件里两个缓冲区的基址安排得不巧矩阵相邻行的起始地址都可能落到同一bankMAC阵列一访问就是连环冲突性能和预期差一大截。我在调一个卷积算子时遇到过很典型的情况卷积滑窗每轮要读一个3x3的窗口9个数据在SMEM里的地址若集中分布在少数几个bank上乘法阵列就在那里不断地等。解决办法看起来很简单——给每行末尾加padding字节让相邻行的起始地址相对偏移一个bank宽度或者把某个缓冲区的基址整体往后挪动一个bank对齐的偏移。效果立竿见影算子整体性能能提升20%到30%。需要特别提一句bank冲突不是只在芯片设计阶段要关心的事到了软件优化阶段它往往是性能分析工具里最容易定位到的热点。如果你跑算子时发现MAC利用率上不去先别急着怀疑计算单元去查一查访存trace里bank访问分布是不是集中了。4.3 容量与带宽的平衡规划Shared Memory容量有限规划时要做预算。我常用的分配策略是按权重、激活、临时数据三块来分。权重占用往往最大因为卷积层的权重会被反复复用激活区的大小取决于tile尺寸scratch区给中间结果和特殊算子用。以一个256KB的Shared Memory为例我会这么分区域容量说明权重缓冲128KB存放当前tile的权重循环复用激活缓冲96KB存放输入特征图tile双缓冲各48KB临时数据32KB池化、拼接等算子中间结果带宽预算也要提前估算。如果MAC阵列每个周期需要从Shared Memory读一个权重和一个激活都是fp16即2字节总共4字节主频1GHz那就是4GB/s的读带宽。Shared Memory按16个bank、每bank每周期32B来算总带宽是512B/周期远高于需求。但要注意这只是理想情况一旦bank冲突发生有效带宽会腰斩所以规划时留出余量非常必要。5. 最小数据流工程搭建全记录5.1 工程结构与模块划分说再多理论不如搭一个最小验证工程跑一遍。我建议用模拟项目X来做这套数据流闭环验证硬件平台可以是RTL仿真或FPGA原型如果只是学习写一个行为级仿真也完全够。工程至少要包含这几个模块HBM模拟器可以用一块大数组带宽模型来模拟、DMA控制器解析描述符并搬运数据、Shared Memory多bank数组、MAC计算引擎一组简单的乘加单元、主控逻辑负责下发描述符和触发计算。模块之间用读/写接口连接每个模块都要有状态寄存器和日志输出这样出了问题才能定位。模块划分的核心边界是HBM和Shared Memory之间只通过DMA交换数据主控核不直接读写Shared Memory的数据区MAC只从Shared Memory取数结果也只写回Shared Memory。这个边界划清晰以后每一条数据路径都很好追踪。5.2 DMA描述符配置示例下面给一个可参考的配置示例。假设我们要从HBM地址0x80000000搬运一个64行、每行128字节的特征图tile到SMEM权重区基址0x00008000。源端由于特征图通道交织相邻两行间距是256字节目的端为了规避bank冲突我们希望每行在SMEM里占用的地址间距是512字节也就是让每行起始地址都能分散到不同bank上。dma_desc_t desc { .src_addr 0x80000000, .dst_addr 0x00008000, .width 128, // 每行128字节 .height 64, // 共64行 .src_stride 256, // HBM侧行间距 .dst_stride 512, // SMEM侧行间距带padding };为什么dst_stride要设512而不是128因为SMEM有16个bank每bank连续宽度假设是32B那么512B正好是16个bank的一整个周期。如果每行只占128B四行的起始地址都会落在bank0访问时冲突非常集中。把行间距拉大到512B每行的起始地址就会均匀落到不同bank位置并行度立刻提上来。代价是SMEM利用率低了一点但换来的是访问不冲突这个交换非常划算。另外要注意地址对齐。描述符的源地址和目标基址最好按256B或512B对齐这样HBM的burst和SMEM的bank边界都能对齐事务不会被拆分。5.3 同步与启动顺序数据流闭环的启动顺序是有讲究的推荐按下面的顺序来主控下发权重搬运描述符启动DMA把权重从HBM搬到SMEM权重区主控等待权重搬运完成信号主控下发激活搬运描述符启动DMA把输入特征图搬到SMEM激活区等待激活搬运完成主控触发MAC阵列开始计算计算完成后主控下发写回描述符DMA把结果从SMEM写回HBM。如果你的描述符链里已经包含了多个同步点主控也可以一次启动整条链DMA自动按序执行。但这里有个非常常见的坑主控在第一个搬运还没完成时就下发了第二个针对同一缓冲区的搬运造成数据覆盖。所以每个缓冲区一定要有状态机管理比如“空闲、正在写、可读、正在读”四个状态主控在发起DMA前检查缓冲区状态搬运完成后再更新状态。我在验证工程里习惯给每个同步事件加一个计数器。比如权重搬运分8个块每块完成时计数器加1主控轮询直到计数器等于8才触发计算。这样即使描述符链比较复杂也不会出现漏同步的问题。6. 实操中踩过的坑与排查方法6.1 性能上不去先查bank冲突我调一个模拟卷积算子时遇到过很典型的场景理论上MAC利用率可以到85%结果实测只有53%。第一反应是DMA带宽不够结果搬运链路log显示带宽占用并不高再查MAC阵列的等待周期发现大量stall来自SMEM访问。后来用访存trace把每个周期所有SMEM请求的bank号打出来发现访问特别集中地落在bank 6和bank 10上其他bank大部分时间空着。原因就是缓冲区的基址安排跟卷积窗口的数据访问pattern产生了固定相位关系每轮访问都会撞在同一小撮bank上。解决方案就是我们前面提过的两个手段把激活缓冲区基址向后挪一个bank宽度的偏移再给行的步长做padding让相邻行的起始地址错开bank。改动不大但调整之后MAC利用率回到了79%左右。这件事之后我的习惯就变了新拿到一个NPU平台先写一段测试代码往SMEM里灌数据再用工具的访存分析看bank分布而不是直接跑业务算子。6.2 DMA传输停顿与死锁双通道并行搬运是死锁高发场景。比如DMA0负责搬权重到bank区ADMA1负责搬激活到bank区B但如果A区和B区在物理bank划分上有重叠两个DMA可能在某个bank上互锁DMA0占着bank 5不放等bank 7DMA1占着bank 7等bank 5两边谁也无法向前走。排查死锁要看每个DMA引擎的状态寄存器里面通常有waiting_on字段能告诉你它在等什么资源。根治办法一般有两个方向一是给DMA通道建立固定优先级低优先级通道遇到争用就让步重试二是引入超时机制任何DMA请求等待超过阈值就释放已占用的bank重新排队。我实际验证下来固定优先级在大多数深度学习场景下就够用了代码比超时机制简单得多也更容易保证时序确定性。还有一个“伪死锁”问题系统没死但带宽慢得吓人。遇到过DMA块大小设置成了64B导致HBM侧事务被拆得稀碎每个burst都落不到满效率区间。改成连续4KB大块搬运后带宽直接翻倍。这类问题不好查建议在跑真实算子之前先做一个纯搬运benchmark确认HBM到SMEM的实测带宽接近理论值再往下做。6.3 数据一致性检查数据流系统最隐蔽的问题是逻辑看起来全对、但结果偶尔错。我碰到过一次卷积输出偶发某几行数据不对查DMA描述符、查计算逻辑都没问题后来发现是同一段SMEM地址被两个描述符复用了。原因是描述符链中没有加同步第一个描述符还没搬完第二个描述符已经启动并把数据写进了同一块缓冲区后写覆盖了先写。正确的做法是在每个缓冲区旁边维护状态位。比如“空闲、正在写、可读、正在读”四个状态DMA和MAC都能查询和更新这个状态位只有当缓冲区处于“空闲”或“可读”状态时才允许新的DMA写入。状态机看着额外开销不大但对防错非常有效。我再分享一个特别实用的测试技巧在SMEM所有缓冲区的边界填上固定pattern我习惯用0xEBEB跑完一轮数据流之后检查这些pattern有没有被改动。如果边界值变了说明有写入越界或者缓冲区复用出了问题一步就能定位到具体区域。这个tripwire方法我用了很多年简单、粗暴、好用。做了几个NPU数据流方案之后我现在拿到一个新架构第一件事不是看主频和MAC数量而是先画一遍数据流HBM带宽多少、DMA描述符支持几维、SMEM分成几个bank、bank宽度多大。这几个数字对性能的影响比任何指令集优化技巧都直接。如果你也在调NPU算子建议先跑一个简单的搬运测试把HBM到SMEM的实测带宽打满再往下优化计算逻辑。数据通路顺了计算效率就是水到渠成的事。