搞AI芯片或者写底层算子的人,多半都有这种体会:硬件手册上写着算力有多少TOPS,跑benchmark之前心里预期拉满,结果一实测,实际吞吐常常只有峰值的一半甚至更低。排查到最后,十有八九问题都出在数据流上——不是算不过来,而是数据喂不过来。这个现象在NPU上尤其明显,因为你面对的是一条从片外HBM到片上多bank Shared Memory的完整搬运链路,中间每一环的吞吐和延迟都不一样,任何一环掉链子,整条计算流水线都得等数据。这篇文章就专门把NPU的data flow这条链路翻出来讲透,从HBM到底层DMA怎么搬数据,再到Shared Memory为什么非要拆成多个bank,以及这些硬件设计反过来如何决定你写卷积和矩阵乘时该怎么切分数据。看完你就能理解,所谓NPU软件栈里的"tiling调度",本质上就是一群工程师在配合数据流的物理规律做妥协。
1. 先盘一盘整条链路:HBM、DMA、Shared Memory各管哪一段
1.1 三个角色的分工与协作
如果把一颗NPU芯片比作一家餐厅,计算单元(MAC阵列)就是后厨的锅灶,而数据通路就是传菜生。HBM相当于后厨外面的食材大仓库,距离远、容量大、但是单次取货麻烦;Shared Memory相当于后厨门口的备料台,离锅灶最近,容量小但取用极快;DMA就是那个专门负责从仓库往备料台搬货的传菜生。它不负责"做菜",但是所有食材能不能在开火前就位,全看它。
这个类比里有几个数字值得记住:HBM的访问延迟通常是几十ns到上百ns的级别,而Shared Memory的访问延迟通常在几个cycle。一个cycle在多GHz主频下可能只有零点几ns,也就是说两者差了至少一个量级甚至两个量级。更重要的是带宽差异:HBM的带宽虽然高,但它是被所有计算核心共享的;而Shared Memory是每个计算核心私有的,带宽高得多。所以NPU的架构设计者才必须在中间加一层DMA,提前把数据从大仓库搬到备料台,让计算单元在计算时永远面对的是低延迟、高带宽的片上存储。
1.2 "算力是纸面参数,数据流才是真实性能"
我见过不少人在选型或者做算子优化时,第一眼只看算力——TOPS越高越好,仿佛算力上去了性能就一定上去。但芯片领域的常识是:系统的稳态吞吐等于资源瓶颈端的吞吐。如果数据流只能每秒搬运100GB,那你就算有再高的TOPS也是空转。反过来,如果数据流转得足够快,计算单元的利用率才能被拉满。
NPU的data flow设计是否合理,直接影响三件事:一是计算单元的空闲率(也叫pipeline bubble,气泡),二是功耗的浪费程度——搬数据本身要耗电,搬重复数据更耗电,三是软件编写的复杂度。硬件上把多bank Shared Memory的冲突消除做得好,软件上就不需要费尽心思去padding、去改变数据布局,开发效率高很多。所以不要觉得data flow只是硬件工程师操心的事,任何一个写高性能算子的人,都必须把这条链路刻在脑子里。
2. HBM:为什么NPU不用DDR而要叠DRAM
2.1 HBM的堆叠结构:把仓库建成楼房
我们平时熟悉的DDR内存,是平铺在一个PCB面上的颗粒,走线长,单位面积能提供的带宽有限。而HBM(High Bandwidth Memory)的逻辑完全不同:它把多颗DRAM裸片通过硅通孔(TSV)垂直堆叠起来,再用宽接口与底层的逻辑die相连。形象点说,DDR是郊区的一排平房,从仓库取一次货要走很长的路;HBM是把仓库盖成了高楼,并且每层楼都修了一条直达传送带,货容量不小,取货速度还快。
这个堆叠方式直接带来了一个关键特性:HBM的接口位宽非常大。普通DDR一条channel一般是64-bit,而HBM的接口总位宽可达1024-bit甚至更高。你想想,位宽翻十几倍,即使频率不提高,理论带宽也直接翻十几倍。所以HBM能够在一片相对小的面积内堆出TB/s级别的带宽,这是DDR望尘莫及的。
2.2 接口位宽、频率与读写粒度
HBM的读写请求也很有特点——很高的位宽意味着每次读写的固定开销非常大。你随便读一个字节和读一大块连续数据,在HBM上的效率差异非常明显。只有当访问地址连续、burst长度够长时,HBM才能维持一个接近峰值的有效带宽。如果地址跳来跳去,bank row切换和读写翻转的惩罚会把带宽拉到一半以下,这在实际项目中很常见。
所以你在很多NPU的DMA设计中会看到"cacheline对齐""建议64B/128B连续搬运"之类的要求。这不是硬件的洁癖,而是HBM这种高带宽介质的物理特性决定的。HBM适合大块连续数据,不适合细碎随机访问。这也是为什么数据布局和tiling在AI计算中如此重要——你要让DMA尽量发出连续的、大批量的读请求,而不是一会儿读A的尾巴一会儿读B的中间。
2.3 从HBM到片上网络:带宽被分摊后的现实
HBM的带宽是整个芯片共享的资源,不是某一个core独占的。一颗NPU里往往有好几个计算cluster、多个DMA通道,大家都在跟同一个HBM控制器要数据。为了保证公平和效率,芯片里通常还会有一层片上网络(NoC)把各个模块连起来。
这意味着即使HBM的标称带宽有800GB/s,你的某个计算核心真正能分到的带宽还要打折扣。我曾经测过一个模拟场景:某个cluster的DMA持续读HBM的需求是300GB/s,但实际系统里其他模块同时也在访问内存,最终这个cluster的可用带宽只有标称的一半。所以在设计算子的tiling大小和双缓冲策略时,一定要按照实际可用带宽去估算,不要拿理论峰值去套。
3. DMA:数据流的"搬运工",但绝不只是搬运工
3.1 描述符队列与搬运动作
DMA(Direct Memory Access)引擎干的活看起来简单:把HBM里的数据挪到片上Shared Memory。但它怎么知道搬什么、从哪搬到哪?这里就靠描述符(descriptor)了。软件事先在内存里写一批描述符记录源地址、目的地址、长度、方向等信息,然后告诉DMA引擎"你自己去读描述符,然后按描述符干活"。
为什么要搞一个描述符队列而不是一单一单地手动触发?因为这样可以批量下发、流水线执行。DMA引擎像一个自动化的装卸工,货单(描述符)已经排好队了,它一份份顺着搬,搬完一个自动看下一个,不需要CPU每次都插手。这个设计大幅减少了CPU的中断和配置开销,你可以轻松维持一个很深的搬运流水线。
3.2 双缓冲:用一块数据的计算时间掩盖另一块的搬运时间
DMA的核心价值在于和计算并行。假设我们要处理一个卷积层,数据被切成很多个小块。如果先用DMA把第一个块搬进Shared Memory,然后计算单元开始算,算完之后停下来等DMA搬第二块——那计算单元有一大半时间在干瞪眼。于是就有了双缓冲(double buffering):把Shared Memory分成两个区,DMA往这个区搬第二块数据的同时,计算单元正在算第一块数据。两块数据的操作在时间轴上重叠,计算单元几乎不需要等待。
这块的工程细节在于:双缓冲能不能完美重叠,取决于搬运时间和计算时间是否大致匹配。如果一块数据搬运需要30微秒,计算只用了10微秒,那计算单元还是要等,这时候说明tile切太大了;反之如果搬运只要5微秒,计算要30微秒,那DMA其实早就空了,浪费了带宽资源。真正理想的状态是两块时间接近,让DMA和计算引擎同时处在繁忙状态。
3.3 地址对齐、burst长度与bank交错
我列出几个DMA调优中非常实际的门道:
- 地址对齐:DMA搬运的源地址和目的地址最好对齐到cacheline(常见64B或128B)边界。错开的地址会造成burst被切碎,搬同样的字节数要多花几乎一倍的时间。
- burst长度:每次DMA请求尽量发出长burst,16拍、32拍甚至更长。短burst在HBM上频繁发起请求,固定开销占比高,带宽利用率上不去。
- bank交错:如果连续的大块数据恰好映射到同一个HBM bank,连续访问会撞在同一个row上,触发昂贵的bank预充电。所以HBM地址映射上通常做交错(channel interleaving),把连续地址散到不同channel和bank上。软件层面对此能做的就是:保持访问模式是顺序的,尽量不要人为制造跨bank的大跳跃。
这些点都指向同一个原则:DMA是被物理存储介质约束的,软件必须顺着它的性子来。写算子时多花五分钟看一看到底是不是做了对齐,可能比你在算法层面扣性能还管用。
4. 多bank Shared Memory:为什么要拆开,又为什么会有冲突
4.1 单口SRAM的带宽上限
如果Shared Memory做成一整块,那么一个cycle内只能支持一次读和一次写,无法同时喂给几十个甚至上百个并行的MAC单元。NPU的计算单元一个cycle要消费几百上千个数据,单块SRAM根本不够用。所以架构上必须把Shared Memory拆成多个bank,每个bank是独立的SRAM电路,可以并行读写。这就是"多bank Shared Memory"存在的最根本原因。
你可以把多bank理解成备料台的多个货架。货架编号0、1、2…,DMA把食材按顺序放在不同货架上。计算单元发出请求时,同时去多个货架取货,这样每个cycle能取到的总货量才能跟上计算速度。
4.2 bank冲突:两个请求同时打在一个货架上
多bank并行听着美好,但有个经典问题——bank conflict。当多个访客同时想从同一个bank的不同地址取数据时,这个bank必须串行服务,先服务一个,再服务另一个,于是总吞吐立刻下降。比如bank数量是8,你访问stride正好是8的倍数,那所有请求都会落在同一个bank上,效率相当于退化成了单bank,性能损失可能达到8倍。
这也是为什么所有共享内存的编程手册都会着重提醒:数组的某一维长度不要设置成bank数的整数倍。最常见的解法是padding(填充),比如明明需要访问8行数据,每行32个元素,但每行分配33个元素,让相邻行错开bank,避免行间冲突。这个"多出来的1列"就是为了打破访问冲突而牺牲的一点点容量。
4.3 广播、双bank与硬件层面的消除手段
硬件设计者当然不会把消除冲突的任务全部甩给软件,他们还会用一些电路上的技巧来缓解问题。
- 广播(broadcast):当很多lane想读同一个地址时(比如读一个共享的缩放因子),硬件支持把一份数据同时广播给所有请求者,不需要串行处理。
- 双bank读写:某些NPU把Shared Memory的每个物理bank再拆成双口bank,可以支持同一个cycle内两个不同地址的访问,相当于把冲突概率减半。
- 地址重映射:用某种散列函数把地址重新映射到bank上,让原来连续的数据天然地散开。代价是地址计算逻辑更复杂,而且软件很难预测最终落到哪个bank——但总比每个cycle都撞车强许多。
这些硬件的存在,让软件不用做太多底层的坑道计算。但作为一个算子开发者,你仍然需要知道:当数据访问模式有规律性时,冲突是可以被建模分析的。用一次profiling看到共享内存访问延迟高,第一时间就应该怀疑是不是stride撞上了bank数。
5. 从参数推演性能:一个卷积层的tiling算给你看
5.1 先定义一套教学级的NPU配置
光讲概念不落地很虚,我定义一个模型化的小型NPU配置(用于教学推演,不代表任何真实芯片):
| 参数 | 数值 |
|---|---|
| 计算核心数 | 32 |
| 每核心每个cycle的MAC数 | 256 |
| 主频 | 1.2 GHz |
| HBM总带宽 | 512 GB/s |
| 片上Shared Memory总量 | 1 MB |
总算力 = 32 × 256 × 2 × 1.2G = 19.66 TOPS(乘2是因为一个MAC等于两次浮点运算)。我们用这个配置跑一个3x3卷积,输入输出尺寸都是64×64,通道256,使用FP16。
5.2 不tiling会怎样:数据全走HBM,带宽直接爆炸
如果数据全放HBM、计算时一次性读入——这当然不可能,因为仅输入就有64×64×256×2B = 2MB,已经超出1MB的Shared Memory,更别提还有权重和输出。我们假设把它硬塞进Shared Memory分段处理,做最粗放的tile策略:比如每次只处理8个输出通道,输出块16×16。
每个tile需要的输入块是(16+2)×(16+2)×256×2B ≈ 166KB,权重块是8×256×3×3×2B ≈ 36KB,输出块4KB,总计约206KB,Shared Memory放得下。但糟糕的是,全卷积的计算量是2×64×64×256×3×3×256 ≈ 4.85 GFLOPs,理论计算时间约4.85G / 19.66T ≈ 0.25ms。而每个tile搬运约206KB,tile总数为(64/16)×(64/16)×(256/8)=4×4×32=512个,总搬运量约105MB。0.25ms内搬105MB,需要约420GB/s的带宽,接近HBM上限。一旦内容里还有其他模块占用带宽,实际分到的可能只有300GB/s左右,那计算就真的要停下来等数据了。
5.3 换一种切法:把tile放大,一切豁然开朗
如果我把tile改大——每次处理16个输出通道、输出块32×32。输入块就需要(32+2)×(32+2)×256×2B=566KB,权重块16×256×3×3×2B=72KB,输出块32KB,合计约670KB,依然放得下。
此时每个tile的计算量是2×32×32×256×3×3×16 ≈ 75.5 MFLOPs,搬运量约670KB,tile级算术强度从之前的9.4M/206K=46 FLOPs/B提升到了75.5M/670K≈112 FLOPs/B。整个网络需要搬64个tile,总搬运约43MB,0.25ms内搬运43MB只需要约172GB/s的带宽,HBM绰绰有余,双缓冲和计算重叠之后,计算引擎可以跑得很饱满。
这就是同一个算法的两种切法,性能差异可以接近2.5倍。原因不在计算,全在data flow。**tiling的切法和算术强度直接挂钩,算术强度低于硬件平台所需的FLOPs/B门槛,性能就会被带宽锁死。**很多算子库的auto-tune在做的事情,本质上就是在一个巨大的切法空间里,搜索一组能让DMA搬运量最小、且Shared Memory放得下的tile参数。
6. 工程上的几个常见坑:对齐、数据布局、profiling怎么看
6.1 地址对齐问题:你以为省的事,最终都会还回来
我在实际调试中不止一次遇到这种场景:软件工程师写了DMA搬移逻辑,源地址是从一个buffer里随手取的偏移,结果每次搬运都要比预期慢30%以上。用profiler一查,不是burst不够长,而是地址没有对齐到128B,硬件每次都要多搬一个跨边界的部分,或者把burst拆成两段来发起。
解决办法其实很简单:为DMA分配内存时,用对齐分配接口(比如对齐到128B或256B),并且确保tile的每一维跨距对齐。如果你用的是自研的runtime,也可以做一个地址归一化的封装,在DMA配置层把源地址、目的地址和长度统一取整。这一步做得好,不用改任何计算逻辑,带宽利用率能平稳许多。
6.2 数据布局:NHWC和NCHW不只差一个字母
数据布局对DMA效率的影响很大。HBM和DMA都偏爱顺序连续访问,可一个feature map的内存布局是NCHW还是NHWC,会直接改变卷积滑窗时的访问连续性。
- NCHW布局下,通道维连续,跨通道访问很友好,但卷积核在空间方向上滑动时,每次读到的地址跳跃较大。
- NHWC布局下,空间相邻点的数据在内存里挨着,非常适合做行方向上的连续搬运,很多深度优化的SDK偏好NHWC正是因为这个原因。
你在做数据排布转换(layout transform)的时候,要意识到这不是无用功,它是在把DMA的访问模式从"低效的分散读"变成"高效的顺序读"。有时你只是在网络输入输出端做了一次transpose,后端卷积整体吞吐就高了15%,这在项目里很常见。别嫌麻烦,该转的layout还是得转。
6.3 profiling时怎么看data flow瓶颈
最后聊聊怎么用profiling快速定位data flow问题。现在很多NPU的SDK都提供硬件计数器,可以统计计算繁忙周期、DMA繁忙周期、流水线stall周期等。拿到这些数之后,我一般按三步排查:
- 看计算引擎利用率:如果超过80%,恭喜,数据流没有拖后腿;如果只有30%-50%,继续往下查。
- 看DMA对比计算的繁忙率:如果DMA长时间满负荷而计算在等待,那就是搬运/计算重叠出了问题,优先检查双缓冲深度,以及搬运量是否因为tile选小了被放大。
- 看Shared Memory访存延迟:把这个计数器掺进来看,如果很高,去查bank冲突。我把多维访问的stride和bank数一比,通常马上就能定位到是不是stride撞了bank。
另外还有一个很容易被忽视的点:DMA和计算引擎共享同一份HBM带宽。你以为DMA搬运和计算是并行的所以总时间会被覆盖,但实际上计算引擎在访问HBM(比如某些算子绕过Shared Memory直接读)时也会跟DMA抢带宽。所以在profile里看到HBM控制器繁忙率接近100%,但计算利用率只有一半,这经常不是重叠没做好,而是带宽容量本身就不够了。这时候不要盲目加双缓冲深度,应该回去调tiling、降低搬运量。
我在实际项目里最深的体会是,NPU的data flow平时不说话,但一旦你压榨性能,它就变成了一面镜子,把你tiling方案、数据布局、对齐处理上的粗糙之处全部映出来。多数性能问题都不是某个环节"坏了",而是整个搬运链路的节奏没对齐。把HBM的访问偏好、DMA的搬移粒度、Shared Memory的bank冲突这三件事同时放在脑子里设计算子,性能问题往往在第一次写代码时就被规避掉了。相信我,这个功夫花得值。