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. 数据尽量连续存放
连续内存通常更容易被 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
那么 Name、Description、DropPrefab、DeathClip 这些数据就属于低频使用的冷数据。
更好的做法是把高频运行时数据单独整理出来:
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 - 热路径里大量
Dictionary、HashSet、LINQ - 一个逻辑要跨很多组件层层拿数据
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 少跑路。
相关阅读建议
如果要继续往下学,我觉得可以顺着这条线继续看:
- 栈、堆、寄存器、内存、cache 的关系
struct和class在高频逻辑中的差别- Unity 中
NativeArray、IJobParallelFor、Burst 的基本使用 - 为什么 ECS 更容易做大规模对象更新
- 为什么有些“面向对象上很好理解”的写法,在性能上不一定划算
Comments
评论区
欢迎在这里留言交流。