3. 缓存层次结构:L1/L2/L3缓存的作用、缓存行与缓存组、缓存替换策略

说到NPU的缓存,我得先跟你聊聊一个扎心的现实——访存速度跟计算速度的差距,比蜗牛跟高铁还大。我当年调优一个卷积算子时,发现计算单元闲着等数据,利用率不到20%。查了半天,罪魁祸首就是缓存没用好。

说白了,缓存就是给计算单元准备的“快取小卖部”。你想想看,去大超市(主存)买个东西要跑老远,但小卖部(L1缓存)就在门口,随手就拿。NPU里这个逻辑一模一样。

3.1 三级缓存,各司其职

NPU的缓存层次,我习惯把它理解成三个级别的“弹药库”:

  • L1缓存:离计算单元最近,速度最快,但容量最小(通常几十KB到几百KB)。每个处理单元(PE)独享。我见过不少新手把大数组往L1塞,结果频繁换入换出,性能反而更差。
  • L2缓存:容量大一些(几百KB到几MB),速度稍慢。通常是多个PE共享。这里有个坑——共享意味着竞争。我曾经调试一个多核并行的算子,发现L2冲突率高达40%,后来改了数据分块策略才降下来。
  • L3缓存:容量最大(几MB到几十MB),速度最慢,但比主存快得多。一般所有核共享。说实话,在NPU里L3更像一个“缓冲池”,用来吸收突发的大流量数据。

核心原则:能放L1不放L2,能放L2不放L3,能放L3就别去主存。但别死板——数据复用次数少的话,直接走主存反而更省事。

我画了一张图,帮你理清这三层的关系:

NPU三级缓存层次结构 PE 0 PE 1 PE 2 PE 3 L1 缓存 L1 缓存 L1 缓存 L1 缓存 L2 缓存(共享) L3 缓存(共享) 速度最快 速度中等 速度较慢 容量最小 容量中等 容量最大

3.2 缓存行与缓存组——数据怎么放?

缓存不是按字节存的,而是按缓存行(Cache Line)来管理。一个缓存行通常64字节。为什么是64?因为局部性原理——你访问一个地址,它旁边的数据很可能马上也要用。

举个例子,你读一个int(4字节),缓存会把相邻的60字节也拉进来。我优化过一个矩阵乘法,就是利用这个特性,把数据排布改成按行连续存储,命中率从65%飙到了92%。

缓存组(Cache Set)又是什么?简单说,就是缓存行分成了若干组。每个内存地址只能映射到特定的一组。这叫做组相联(Set-Associative)。

映射方式 特点 适用场景
直接映射 每个地址只对应一个缓存行,冲突率高 简单、低功耗场景
全相联 任意地址可放任意位置,硬件复杂 小容量缓存(如TLB)
组相联 折中方案,每组有N个位置(N路) NPU主流方案,4路~16路常见

我的经验:NPU里常用4路或8路组相联。路数太少容易冲突,路数太多硬件开销大。我曾经在一个项目里把8路改成16路,命中率只提升了3%,但面积增加了15%——不值当。

3.3 缓存替换策略——谁走谁留?

缓存满了怎么办?得有人滚蛋。这就是替换策略的活。NPU里最常见的两种:

LRU(最近最少使用)

思路很简单:把最久没被访问的那条踢出去。我刚开始觉得这策略挺合理,直到遇到一个循环访问的场景——每次访问的都是新数据,LRU反而把刚用过的数据踢了,下次又要重新加载。

// 伪代码:LRU实现思路
// 维护一个访问时间戳列表
// 每次访问时更新时间戳
// 替换时找时间戳最小的那条

void lru_replace(cache_set *set) {
    int oldest_idx = 0;
    uint64_t oldest_time = set->lines[0].last_access;
    
    for (int i = 1; i < set->num_ways; i++) {
        if (set->lines[i].last_access < oldest_time) {
            oldest_time = set->lines[i].last_access;
            oldest_idx = i;
        }
    }
    // 踢掉最旧的,加载新数据
    load_new_line(&set->lines[oldest_idx]);
}

LFU(最不经常使用)

看的是访问频率,谁用得少谁走。这策略有个毛病——如果一个数据曾经很火,后来没人用了,它还会赖着不走。我调过一个NPU的算子,就是被LFU坑了,一个历史热点数据占着位置,新数据进不来。

避坑指南:我曾经在一个实时推理场景里,默认用了LRU,结果发现缓存命中率忽高忽低。后来分析发现,数据访问模式是“突发+间歇”,LRU在这种模式下表现很差。换成伪LRU(PLRU)后,命中率稳定多了。PLRU用二叉树近似LRU,硬件开销小,性能接近。

3.4 实战:怎么选策略?

没有银弹。我一般这么判断:

  • 数据流是顺序的(比如卷积的滑动窗口):LRU够用,甚至直接映射也行
  • 数据有热点(比如某些权重反复用):LFU更合适
  • 模式不确定:试试自适应策略,或者用PLRU保底

嗯,说到这我想起一个项目。当时做NPU编译器优化,发现一个算子在不同输入尺寸下,最优缓存策略居然不一样。最后我们干脆在编译期做策略预测——根据数据流特征,自动选择LRU还是LFU。效果还不错,平均性能提升了12%。

缓存这东西,说白了就是用空间换时间,用硬件换性能。理解清楚L1/L2/L3的分工,搞明白缓存行和缓存组的映射关系,再选对替换策略——你的NPU算子优化就成功了一大半。

可可精品资料库