4、缓存层次结构详解:NPU缓存架构

聊到NPU的缓存,我得先说说我的一个感受。很多人觉得缓存就是“快一点的内存”,其实没那么简单。在NPU里,缓存层次的设计直接决定了算力能不能跑满。我见过太多项目,理论算力很高,一跑实际模型就拉胯,十有八九是缓存没玩明白。

今天咱们就把NPU的缓存层次彻底拆开看看。从最贴近计算单元的L1 Scratchpad,到中间的L2 Cache,再到最远的Global Memory。每一级都有它的脾气,也有它的坑。

4.1 三级缓存架构概览

NPU的缓存架构,说白了就是一套“数据搬运”的体系。计算单元要数据,数据从远到近,一层层喂过来。我习惯把它想象成一个三层漏斗:

核心要点:NPU缓存架构的核心矛盾是“计算速度”与“数据供给速度”之间的差距。每一级缓存都是为了弥合这个差距而存在的。

NPU三级缓存架构图 L1 Scratchpad 容量: 64KB - 512KB 延迟: 1-2 cycles 软件管理 L2 Cache (SRAM) 容量: 1MB - 8MB 延迟: 10-30 cycles 硬件管理 Global Memory (DRAM/HBM) 容量: 4GB - 32GB 延迟: 200-500 cycles 靠近计算单元 远离计算单元

你看这个图,越往上离计算单元越近,速度越快,但容量越小。越往下容量越大,但速度越慢。这个道理大家都懂,但真正做算子映射的时候,怎么用好每一级缓存,才是真功夫。

4.2 L1 Scratchpad:软件掌控的“私房钱”

L1 Scratchpad跟CPU里的L1 Cache有个本质区别——它是软件管理的。什么意思呢?就是数据什么时候搬进来、什么时候踢出去,全由你(编译器或手写代码)说了算。

我个人特别喜欢这种设计。为什么?因为确定性。在AI推理场景里,数据的访问模式几乎是固定的。卷积的滑动窗口、矩阵的分块计算,这些模式我提前就知道。用Scratchpad,我可以精确控制数据生命周期,不会有硬件Cache那种“冷不丁被踢出去”的意外。

我的经验:做算子映射时,我习惯把最常复用的数据放在L1 Scratchpad。比如卷积的权重,一旦加载进来,在整个tile计算期间都不会被换出。这比依赖硬件Cache的LRU策略靠谱多了。

L1 Scratchpad的典型参数:

参数 典型值 说明
容量 64KB - 512KB 每个计算核心独立拥有
延迟 1-2 cycles 单周期访问,几乎无等待
带宽 数百GB/s 与计算单元直连
管理方式 软件(DMA) 程序员/编译器控制

嗯,这里要注意一个坑。L1 Scratchpad虽然快,但容量极其有限。我曾经在一个项目里,把整个特征图都往L1里塞,结果发现根本放不下。后来学乖了,用tiling(分块)策略,一次只处理一小块数据,算完再换下一块。

4.3 L2 Cache:硬件管理的“共享仓库”

L2 Cache是硬件管理的,通常被多个计算核心共享。它的角色有点像“中转站”——L1放不下的数据,暂时搁在L2;Global Memory太慢的数据,先预取到L2。

你想想看,如果每个核心都直接去Global Memory拿数据,那总线早就挤爆了。L2 Cache的存在,就是为了减少对Global Memory的访问次数。

L2 Cache的典型参数:

参数 典型值 说明
容量 1MB - 8MB 多核心共享
延迟 10-30 cycles 比L1慢一个数量级
带宽 数十GB/s 受限于总线宽度
管理方式 硬件(LRU/随机) 自动替换策略

这里有个常见的误解。很多人觉得L2 Cache越大越好。其实不一定。L2太大,延迟会跟着涨。我见过一个芯片,L2做到16MB,结果延迟飙到50 cycles,反而拖累了性能。所以L2的大小是个权衡——既要能装下足够的数据,又要保证访问速度。

避坑指南:我曾经在优化一个深度卷积网络时,发现L2 Cache命中率只有60%。排查了半天,原来是多个核心同时访问同一块数据,导致频繁的Cache Line冲突。后来通过数据对齐和padding,把命中率提到了90%以上。所以,L2的优化不只是看容量,还要看访问模式。

4.4 Global Memory:大而慢的“数据仓库”

Global Memory就是NPU的“主存”。在大多数NPU里,它用的是HBM(高带宽内存)或者DDR。容量大,但延迟也大。

Global Memory的典型参数:

参数 典型值 说明
容量 4GB - 32GB 可容纳整个模型
延迟 200-500 cycles 比L2慢10倍以上
带宽 数百GB/s (HBM) HBM2e可达1.6TB/s
管理方式 硬件+软件 DMA传输

说白了,Global Memory就是最后一道防线。所有数据最终都存在这里。但每次访问它,代价都很大。所以优化的核心思路就是:尽量让数据待在L1和L2里,少去Global Memory串门。

4.5 缓存一致性:多核心的“沟通难题”

缓存一致性,这是个老生常谈但又绕不开的话题。在多核心NPU里,每个核心都有自己的L1 Scratchpad,多个核心共享L2 Cache。如果核心A修改了L1里的数据,核心B怎么知道?

我举个例子。假设核心A和核心B都在处理同一个特征图的不同部分。核心A算完一部分,把结果写到了L1里。核心B需要用到这个结果,但它读到的可能是L2里的旧数据。这就出问题了。

解决缓存一致性问题,常见的方法有几种:

  • 软件同步:程序员手动插入同步指令。比如在核心A写完数据后,显式地flush到L2,核心B再从L2读取。这种方法灵活,但容易出错。
  • 硬件监听:硬件自动检测数据冲突,并维护一致性。比如MESI协议。但硬件开销大,而且会增加延迟。
  • 无共享架构:干脆不让核心共享数据。每个核心只处理自己独有的数据块。这种方法最简单,但限制了算法的灵活性。

我的建议:在实际项目中,我倾向于用“软件同步 + 无共享”的组合。对于可以独立计算的任务(比如逐元素操作),直接用无共享,省心。对于需要共享的任务(比如卷积的累加),用软件同步,精确控制数据流。硬件监听?嗯,除非芯片强制要求,否则我尽量不用——太不可控了。

4.6 各级缓存的延迟与带宽对比

为了让你有个直观感受,我整理了一张对比表。这些数据来自我实际接触过的几款NPU芯片:

层级 延迟 (cycles) 带宽 (GB/s) 容量 典型用途
L1 Scratchpad 1-2 500-1000 64-512KB 权重、输入tile
L2 Cache 10-30 100-300 1-8MB 中间结果、共享数据
Global Memory 200-500 200-1600 4-32GB 模型参数、特征图

你看这个延迟差距。L1访问一次只要1个cycle,Global Memory要200个cycle。也就是说,一次Global Memory访问的时间,够L1跑200次了。所以,优化的本质就是:把200次L1访问,换成1次Global Memory访问加199次L1访问。说白了,就是用计算换数据搬运。

4.7 实战中的缓存优化策略

讲了这么多理论,最后分享几个我在实战中常用的策略:

  1. 数据复用最大化:同一个数据,尽量在L1里多算几次再换出去。比如卷积的权重,一次加载,反复使用。
  2. 预取与计算重叠:在计算当前tile的时候,用DMA把下一个tile的数据提前搬到L2。这样计算和搬运可以并行,隐藏延迟。
  3. 避免Cache Thrashing:不要让多个核心频繁竞争同一块L2 Cache Line。通过数据对齐和地址偏移,让每个核心访问不同的Cache Set。
  4. 显式Flush:在关键同步点,手动flush L1数据到L2。虽然有点麻烦,但能保证一致性不出问题。

一个小技巧:我习惯在算子开发初期,先用性能计数器把各级缓存的命中率打出来。如果L2命中率低于80%,或者L1命中率低于90%,那基本可以断定缓存优化没做到位。这时候别急着调计算逻辑,先看看数据是怎么流动的。

好了,关于NPU缓存层次结构,今天就聊这么多。记住一句话:缓存优化的本质,就是让数据尽量待在离计算单元最近的地方。剩下的,就是根据你的具体芯片和算法,去找到那个最优的“数据摆放方案”。

想要更多资料,联系可可