CPU Cache 是什么?


一、cache 到底是什么

cache 这个词本身是“缓存”的意思。

这里说的是 CPU Cache,也就是 CPU 缓存

它的作用可以简单理解成:

CPU 很快,内存相对慢,所以在 CPU 和内存之间放几层更小但更快的缓存,把最近常用的数据先放进去,让 CPU 少等一点。

常见会提到几层:

  • L1 Cache
  • L2 Cache
  • L3 Cache

知道它们是“离 CPU 更近、更快、容量更小”的缓存层就够用了。


二、为什么 CPU Cache 会影响性能

关键点在于:CPU 读取内存时,不是每次只取一个变量,而通常会把附近一小块数据一起带进缓存。

这样如果接下来你刚好还要用附近的数据,就能直接从 cache 里拿,不用再去主内存慢慢取。

这背后有两个经典概念。

1. 时间局部性

刚刚访问过的数据,接下来还可能继续访问。

例如:

for (int i = 0; i < 1000; i++)
{
    sum += array[0];
}

这里 array[0] 被重复读取很多次,很容易命中缓存。

2. 空间局部性

刚刚访问了某个地址的数据,接下来也很可能访问它附近的数据。

例如:

for (int i = 0; i < array.Length; i++)
{
    sum += array[i];
}

这是顺序访问数组,非常符合 CPU 的预期。

所以一句话总结就是:

CPU Cache 本质上是在赌你的访问是有规律的。你访问越连续、越重复、越可预测,命中率通常越高。


三、什么叫 cache 友好

所谓 cache 友好,本质上就是:

让数据的组织方式和访问方式更符合 CPU Cache 的工作习惯,提高命中率,减少 cache miss。

更直白一点:

  • 尽量让数据连续
  • 尽量顺序访问
  • 尽量把常用数据放一起
  • 尽量少让 CPU 到处跳着找数据

四、什么是 cache miss

既然说到命中率,就顺手补上 cache miss

它的意思就是:

CPU 需要的数据不在当前 cache 里,只能再去更下一层缓存,甚至去主内存找。

而“去更远的地方找数据”会带来等待,等待就意味着性能下降。

所以:

  • cache hit 多,程序更顺
  • cache miss 多,CPU 经常要等数据

这也是为什么有些代码逻辑看起来差不多,但性能差很多。问题可能不在“算得多”,而在“找数据太费劲”。


五、怎么做一个 cache 友好的机制

这个问题建议分成两个角度回答:

  1. 数据怎么组织
  2. 数据怎么访问

1. 数据尽量连续存放

连续内存通常更容易被 CPU 高效利用。

例如数组:

int[] hpArray = new int[1000];
for (int i = 0; i < hpArray.Length; i++)
{
    hpArray[i] -= 1;
}

这种写法通常就比较友好,因为 hpArray 的元素在内存中是连续排布的,CPU 顺着扫过去就行。

而下面这种就不一定友好:

class Enemy
{
    public int Hp;
}

Enemy[] enemies = new Enemy[1000];
for (int i = 0; i < enemies.Length; i++)
{
    enemies[i].Hp -= 1;
}

虽然表面上看也是数组,但数组里放的是引用,真正的 Enemy 对象可能分散在堆上的不同位置。CPU 读到 enemies[i] 后,还得跳到另一个地址才能取到 Hp

这类“多一次跳转”的访问,在大规模高频循环里就容易变慢。


2. 尽量顺序访问,不要随机乱跳

顺序访问通常更友好。

for (int i = 0; i < data.Length; i++)
{
    Process(data[i]);
}

而随机访问就容易让 CPU 难以预测:

for (int i = 0; i < indices.Length; i++)
{
    Process(data[indices[i]]);
}

如果 indices[i] 是乱序的,CPU 很难提前准备好后面要用的数据。


3. 把高频访问的热点数据放在一起

这叫做冷热分离

假设有一个敌人对象:

class Enemy
{
    public Vector3 Position;
    public Vector3 Velocity;
    public int Hp;

    public string Name;
    public string Description;
    public GameObject DropPrefab;
    public AudioClip DeathClip;
}

如果在每帧更新里,你只关心:

  • Position
  • Velocity
  • Hp

那么 NameDescriptionDropPrefabDeathClip 这些数据就属于低频使用的冷数据。

更好的做法是把高频运行时数据单独整理出来:

struct EnemyRuntimeData
{
    public Vector3 Position;
    public Vector3 Velocity;
    public int Hp;
}

这样每帧处理时只扫必要的数据,不会把一大堆不相关字段也一起带进 cache。


4. 按“使用方式”组织数据,而不是只按“对象”组织

这是一个非常重要的思路。

有两种经典组织方式:

AoS: Array of Structures

结构体数组。

struct Enemy
{
    public Vector3 Position;
    public Vector3 Velocity;
    public int Hp;
}

Enemy[] enemies;

SoA: Structure of Arrays

按字段拆成多个数组。

Vector3[] positions;
Vector3[] velocities;
int[] hps;

如果你有一段逻辑只更新位置:

for (int i = 0; i < positions.Length; i++)
{
    positions[i] += velocities[i] * deltaTime;
}

这时候 SoA 很可能更友好,因为 CPU 不需要顺便把 Hp 也读进来。

所以:

如果某段逻辑只关心部分字段,SoA 往往比 AoS 更 cache friendly。

这也是为什么很多数据导向设计和 ECS 会倾向这种组织方式。


5. 批量处理同类数据

把同一类工作放到一起做,通常比在很多对象之间反复切换更友好。

例如:

for (int i = 0; i < enemyCount; i++)
{
    UpdateEnemy(i);
}

它通常比把逻辑分散到 10000 个对象自己的 Update() 里更容易形成连续处理。

这并不是说 Update() 一定不能用,而是说:

  • 当数据量很小时,差距可能不明显
  • 当数据量很大时,批处理更容易发挥 cache 优势

6. 减少频繁分配,避免数据越来越散

频繁 new 很多小对象,会让数据逐渐分散,访问时就更容易跳来跳去。

工程上常见的改善方式有:

  • 对象池
  • 预分配数组
  • 复用容器
  • 少在热路径里产生临时对象

这不只是 GC 问题,也和 cache 表现有关。


六、什么情况不 cache 友好

1. 大量引用跳转

典型情况:

  • 链表
  • 树节点分散
  • 数组里装大量 class 引用
  • 一个对象里层层引用另一个对象

因为 CPU 为了拿到最终数据,需要多次跳转地址。


2. 随机访问

例如:

  • 随机索引数组
  • 热路径里频繁哈希查找
  • 图结构遍历且节点分散
  • 数据访问顺序不可预测

随机访问不代表一定错误,但它通常比顺序访问更难让 cache 发挥作用。


3. 热数据和冷数据混在一起

高频逻辑只需要 16 个字节,结果每次都把 128 个字节的对象拖进来,其中大半还没用。

这会浪费 cache 空间。


4. 内存布局和遍历顺序不匹配

例如数据本身是按行连续存储,但你非要按列跳着读。

或者本来有连续数组,却通过打乱的索引顺序去访问。


5. 多线程里的 false sharing(伪共享)

多个线程虽然在修改不同变量,但如果这些变量恰好落在同一个 cache line 中,就可能互相影响缓存一致性,导致性能下降。

这就叫 false sharing

你不用展开讲太深,只要知道:

多线程里即使操作的是不同字段,只要它们共享同一个 cache line,也可能互相拖慢。


七、放到 Unity 里怎么理解

1. 为什么 Unity 里经常提“数据导向”

因为传统 OOP 风格里,很容易出现这种情况:

  • 一个敌人是一个 MonoBehaviour
  • 身上挂很多组件
  • 每帧逻辑跨多个对象、多个组件来回取数据
  • 数据分散在很多托管对象里

这种写法开发起来直观,但在大批量对象时,CPU 访问数据就不够连贯。

而数据导向思路更关注:

  • 哪些数据是高频访问的
  • 能不能把这些数据放连续一点
  • 能不能一批一批处理

所以 ECS / Job System / Burst 本质上都在帮你做一件事:

让数据更连续,让处理更批量化,让 CPU 更容易吃到 cache 红利。


2. Unity 里哪些写法通常不太友好

下面这些不代表不能用,而是说在高频 + 大数量场景下,可能会慢:

  • List<Enemy>,但 Enemy 是 class
  • 每个对象各自 Update(),做同类批量逻辑
  • 高频逻辑里大量 GetComponent()
  • 热路径中频繁 new List<>()new class
  • 热路径里大量 DictionaryHashSetLINQ
  • 一个逻辑要跨很多组件层层拿数据

3. Unity 里更友好的方向

比较常见的思路有:

  • 高频运行时数据尽量集中管理
  • 能用 struct / NativeArray 的地方尽量别绕太多引用
  • 热路径少做零碎对象分配
  • 批量更新同类对象
  • 把“显示数据”和“运行时热数据”分开

例如做一个大批量敌人系统时:

  • 配置数据仍然可以是 ScriptableObject
  • 但运行时高频数据可以集中在数组 / NativeArray 里

这样既保留开发友好性,也更利于性能。


八、一个容易理解的小例子

假设有 10000 个敌人,每帧都要更新位置。

不太 cache 友好的写法

  • 每个敌人是一个 class
  • 每个敌人有多个组件
  • 每帧在对象之间跳着取位置、速度、状态

更 cache 友好的写法

Vector3[] positions;
Vector3[] velocities;

for (int i = 0; i < positions.Length; i++)
{
    positions[i] += velocities[i] * deltaTime;
}

为什么后者更容易快?

因为它更像一队人整整齐齐排队,CPU 可以一路扫过去。前者则更像到处散落的门牌号,CPU 要不停跑路。


九、总结

所谓 cache 友好,就是让 CPU 更容易在附近、连续、可预测的位置拿到它想要的数据,而不是频繁跳着找数据。

对于 Unity 开发来说,这个意识特别重要。因为当项目从“能跑”走向“要扛住大量对象和高频逻辑”时,性能瓶颈很多时候不只是算法本身,而是数据怎么摆、CPU 怎么拿

这也是为什么后面如果你继续深入到:

  • 对象池
  • 批处理
  • NativeArray
  • Job System
  • Burst
  • ECS

你会发现这些东西经常会绕回同一个主题:

让数据更连续,让处理更批量,让 CPU 少跑路。


相关阅读建议

如果要继续往下学,我觉得可以顺着这条线继续看:

  1. 栈、堆、寄存器、内存、cache 的关系
  2. structclass 在高频逻辑中的差别
  3. Unity 中 NativeArrayIJobParallelFor、Burst 的基本使用
  4. 为什么 ECS 更容易做大规模对象更新
  5. 为什么有些“面向对象上很好理解”的写法,在性能上不一定划算