《游戏引擎实现原理》11.1 缓存层级与访存代价

缓存层级决定了一次访存要等多久:L1 一个周期,主存上百个周期。本节用 1M 元素的连续遍历与指针追逐、以及 4096 方阵的行主序与列主序求和做对照,在本机实测出 2.2 倍与 6.2 倍的差距,并说清在 V8 里数据导向的收益主要在分配与 GC 压力,而不在访存速度。

11.1 缓存层级与访存代价

前 10 章我们把 helio 的内存、句柄、容器和数学都搭好了,但一直没正面回答一个更基本的问题:同样一段循环,为什么换个内存布局就会快或慢? 这一章是数据导向设计(data-oriented design)的起点。11.1 先把「访存代价」这件事量化——不是照搬 C++ 教科书里的数字,而是在 V8 上、用 TypeScript 真跑出两组对照。

先说结论,免得你读到最后才发现重点错了:在 V8 里,数据导向的收益主要不在「访存速度」,而在「分配与 GC 压力」。 速度差距只有 1.6–2.2× 量级,远低于 C++ 里动辄 5× 以上的传说;但内存差距是数量级的。这一节负责把「速度」这一半讲清楚,11.2 负责「内存」那一半。

为什么先讲「代价」再讲「布局」?因为布局是手段,代价才是目标。不理解一次缓存未命中要等多少周期、预取器在什么情况下失灵,你就无法判断一个「把对象数组换成 SoA」的重构到底值不值。这一节先把尺子给你,后面的判断才有依据。

本章与站内既有内容的分工

数据导向与 ECS 的理论收益,站内已经有人讲过。本章不再重复「为什么理论上快」,只补上「在 V8 里到底快多少、快在哪」:

既有文章它负责什么本节只做什么
游戏引擎架构与 ECS从架构层面讲清 ECS 的组成、缓存友好性与「数据与行为分离」的理论收益,是概念与选型用本机实测的连续 / 指针追逐、行主序 / 列主序对照,把「理论收益」换算成 JS 里真实的毫秒
Defold 的 ECS 架构从一个成熟轻量引擎的子系统视角,看 ECS 在真实引擎里怎么落地不重复架构论述,只从零写出可运行的对照实验,让你能在自己机器上复现
向量代数与 TypedArray 布局第 8 章讲过向量运算与连续内存的零分配约定这里把「连续内存」的收益从约定升级成实测,并解释它为什么在 JS 里比在 C++ 里小

一句话分工:别人讲「为什么理论上快」,本节讲「在 JS 引擎里实际快多少」。 这两件事的答案差别很大,而后者才是你做技术决策时要看的。

缓存层级:一次访存要等多久

CPU 从内存里读一个字节,从来不是「一步到位」。数据要一层层从慢存储搬到快存储:

层级典型容量访问延迟(约)相对 L1
寄存器几百字节0 周期—
L1 缓存32–64 KB1–4 周期1×
L2 缓存256 KB–1 MB10–20 周期约 5×
L3 缓存几 MB–几十 MB30–70 周期约 15×
主存(DRAM)GB 级100–300 周期约 50–100×

关键在于两点。第一,缓存按「行」(cache line,通常 64 字节)搬运,不是按你读的那一个元素。 你顺序读一个数组,第一趟把整行拉进缓存后,同一行里剩下的元素几乎不要钱;如果每次跳到一个新地址、彼此不相邻,每一跳都可能是一次缓存未命中。第二,硬件有预取器(prefetcher),它会猜你接下来要读哪里、提前搬进缓存。连续访问可预测,预取器一路小跑;随机或大步长访问不可预测,预取器只能干等。

这两点合起来,就是「数据布局」全部魔力的来源:把会被一起访问的数据放在一起,让硬件能提前搬运。 引擎里最典型的例子是变换矩阵:如果每个实体的 {位置, 速度} 分散在堆的各个角落,更新循环每一帧都在做缓存未命中;如果它们连续排列,同样的算术会快得多。

还有一个常被忽略的层级是 TLB(地址转换旁路缓冲):虚拟地址到物理地址的翻译本身也要查缓存。步长一大,跨过的页(通常 4 KB)就多,TLB 未命中会和缓存未命中叠加,让「大步长」比「单纯跳得远」还要贵。下面第二组实验里,列主序的 16 KB 步长正好每跳换一个页,把这一层代价也一起测了出来。

还有一层要分清:V8 的 JIT 对「形状稳定」的对象字段访问优化得极好。 形状稳定的对象,字段访问走内联缓存(inline cache),本身几乎没有额外开销。所以对象访问不一定慢——慢的是它背后的内存跳跃。这一区分很重要,它解释了为什么下面第一组对照的倍数没有 C++ 那么夸张。

可运行产物:连续遍历 vs 指针追逐

第一份脚本把两种访存模式钉在同一段循环上:都从下标 0 出发、都走满 1M 步、都迭代 10 轮,唯一差别是 next 的类型。连续版 next 是 Int32Array 里的整数索引,跳转始终落在同一段连续内存;追逐版 next 是堆上对象的引用,每次跳转都要先解引用再读字段。每个 case 单开一个 worker 做进程隔离,预热到滚动中位数变化小于 5% 再取 21 次中位数。

// file: bench/cache-access.ts
// tier: B
// verify: node bench/cache-access.ts
//
// 连续遍历 vs 指针追逐:同一段「跳 N 步」的循环,唯一差别是 next 的类型。
//   连续版 —— next 是 Int32Array 里的整数索引,跳转始终落在同一段连续内存;
//   追逐版 —— next 是堆上对象的引用,每次跳转都要先解引用再读字段。
// 每个 case 单开一个 worker(进程隔离),预热到滚动中位数变化 <5%,取 21 次中位数。

import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
import { fileURLToPath } from 'node:url';

const N = 1 << 20; // 1M 元素:远超 L2/L3
const ITERS = 10;

if (!isMainThread) {
  const { kind } = workerData as { kind: 'contiguous' | 'pointerchase' };
  let run: () => number;
  if (kind === 'contiguous') {
    const a = new Int32Array(N);
    for (let i = 0; i < N; i++) a[i] = (i + 1) % N;
    run = () => { let idx = 0, s = 0; for (let k = 0; k < ITERS; k++) for (let i = 0; i < N; i++) { idx = a[idx]!; s += idx; } return s; };
  } else {
    const a: { next: number }[] = new Array(N);
    for (let i = 0; i < N; i++) a[i] = { next: (i + 1) % N };
    run = () => { let idx = 0, s = 0; for (let k = 0; k < ITERS; k++) for (let i = 0; i < N; i++) { idx = a[idx]!.next; s += idx; } return s; };
  }

  const med = (arr: number[]) => { const s = [...arr].sort((p, q) => p - q); const m = s.length >> 1; return s.length % 2 ? s[m]! : (s[m - 1]! + s[m]!) / 2; };
  const warm: number[] = [];
  for (let w = 0; w < 30; w++) {
    const t = process.hrtime.bigint(); run(); const ms = Number(process.hrtime.bigint() - t) / 1e6;
    warm.push(ms);
    if (warm.length >= 10) {
      const recent = warm.slice(-5), prev = warm.slice(-10, -5);
      const m1 = med(recent), m0 = med(prev);
      if (m0 > 0 && Math.abs(m1 - m0) / m0 < 0.05) break;
    }
  }
  const samples: number[] = [];
  for (let s = 0; s < 21; s++) { const t = process.hrtime.bigint(); run(); samples.push(Number(process.hrtime.bigint() - t) / 1e6); }
  parentPort!.postMessage({ kind, median: med(samples) });
  process.exit(0);
}

const runCase = (kind: string) => new Promise<{ kind: string; median: number }>((resolve, reject) => {
  const w = new Worker(fileURLToPath(import.meta.url), { workerData: { kind } });
  w.once('message', resolve);
  w.once('error', reject);
});

const acc: Record<string, number> = {};
// 交错两种顺序,抵消「先跑的 case 占便宜」的顺序偏差,各取一半权重。
for (const order of [['contiguous', 'pointerchase'], ['pointerchase', 'contiguous']]) {
  for (const k of order) acc[k] = (acc[k] ?? 0) + (await runCase(k)).median / 2;
}
console.log(`N = ${N}, ITERS = ${ITERS}`);
console.log(`contiguous   ${acc.contiguous!.toFixed(2)} ms`);
console.log(`pointerchase ${acc.pointerchase!.toFixed(2)} ms`);
console.log(`chase/contig = ${(acc.pointerchase! / acc.contiguous!).toFixed(2)}x`);

可运行产物:同一批数据,换个访问顺序

第二份脚本更极端:数据一模一样、加法次数一模一样,只换访问顺序。一个 4096×4096 的 Float32Array(16 MB,远超 L2),一次按行主序扫、一次按列主序扫。行主序每一步只挪 4 字节,一条缓存行能喂饱 16 次读;列主序每一步跨 16 KB,每读一个元素就换一条缓存行,还会不断踩 TLB。

// file: bench/cache-stride.ts
// tier: B
// verify: node bench/cache-stride.ts
//
// 访问顺序对访存的影响:同一个 4096×4096 的 Float32Array,
// 按行主序(内层扫列)与按列主序(内层扫行)各求和一次。
// 加法的次数完全相同,唯一差别是每一步落在哪个内存地址:
//   行主序 —— 步长 4 字节,一条缓存行喂饱后几乎不要钱;
//   列主序 —— 步长 16 KB,每读一个元素就换一条缓存行,还会踩 TLB。
// 预热后取中位数。

const ROWS = 4096;
const COLS = 4096;
const m = new Float32Array(ROWS * COLS);
for (let i = 0; i < m.length; i++) m[i] = i & 0xff;

const med = (xs: number[]) => {
  const s = [...xs].sort((p, q) => p - q);
  const mid = s.length >> 1;
  return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2;
};

function bench(run: () => number, reps = 7): number {
  for (let w = 0; w < 3; w++) run();
  const t: number[] = [];
  for (let r = 0; r < reps; r++) {
    const t0 = process.hrtime.bigint();
    run();
    t.push(Number(process.hrtime.bigint() - t0) / 1e6);
  }
  return med(t);
}

// 行主序:内层沿一行连续扫。
const rowMajor = () => {
  let s = 0;
  for (let i = 0; i < ROWS; i++)
    for (let j = 0; j < COLS; j++) s += m[i * COLS + j]!;
  return s;
};

// 列主序:内层跨行跳,步长 = 一行的字节数。
const colMajor = () => {
  let s = 0;
  for (let j = 0; j < COLS; j++)
    for (let i = 0; i < ROWS; i++) s += m[i * COLS + j]!;
  return s;
};

const rowMs = bench(rowMajor);
const colMs = bench(colMajor);
console.log(`matrix ${ROWS}x${COLS} Float32`);
console.log(`row-major ${rowMs.toFixed(2)} ms`);
console.log(`col-major ${colMs.toFixed(2)} ms`);
console.log(`col/row = ${(colMs / rowMs).toFixed(2)}x`);

读数字:2.22× 与 6.2× 意味着什么

本机跑出的结果:

对照快的一侧慢的一侧比值
连续 vs 指针追逐连续 21.22 ms追逐 47.15 ms2.22×
行主序 vs 列主序行 16.7 ms列 105.2 ms6.2×

两组的倍数差了三倍,原因值得说清。指针追逐虽然「跳」,但 1M 个 {next} 对象在 V8 里往往是连续分配的,落点仍有一定局部性,而且 JIT 把对象字段访问优化得很好——真正的代价被摊薄了。而 4096 方阵的列主序是纯随机大步长,每一跳都换缓存行,预取器完全失效,倍数自然大得多。倍数的大小取决于「跳跃有多不可预测」,而不是「是不是用了对象」。

这里要给出一条贯穿本卷的立论:在 V8 里,数据导向的收益,主要不在「访存速度」,而在「分配与 GC 压力」。 11.2 会用 AoS 与 SoA 的对照把这个落差量化——速度差距只有 1.6× 量级,但内存差距是 62.6 MB 对 0 MB,是数量级。禁止照搬 C++ 的「快 5 倍」:JS 没有手动布局能力,JIT 又抹平了一部分差距,把 C++ 的数字搬过来只会误导。

所以看到这些倍数时,请记住三件事。第一,方向是确定的:连续一定快于跳跃、行主序一定快于列主序,这个结论在任何机器、任何语言上都成立。第二,倍数是不可移植的:同一段代码换个循环顺序、换个预热次数,结果可能差 50%,本卷一律只把「方向」当结论,具体倍数请当作「作者机器上的参考值」。第三,别把 2.2× 当成「重构就能提速 2.2 倍」的承诺——真实业务循环里大部分时间花在别的地方,只有当你确认访存是瓶颈时,这个方向才有意义。

为什么 JS 吃不满这份收益

同样的对照在 C++ 里往往更夸张,原因是 JS 多了几层「保护」,每一层都在替你把差距抹平一点:

因素在 C++ 里在 V8 里
内存布局控制你自己决定 struct 怎么摆、对齐到几字节由 GC 决定对象在堆上放哪,你只能选「对象」还是「TypedArray」
字段访问编译期定死偏移量,一次 load走内联缓存 + 形状检查,快但非零开销
边界检查通常没有TypedArray 每次索引访问都有一次边界检查
分配回收手动 / arena,代价可控GC 在不确定时刻介入,可能移动对象、打断局部性

这四条解释了为什么本卷从头到尾都在强调「先测、再信方向、别信倍数」。C++ 社区那些「数据导向快 5–10 倍」的案例,前提是你能完全控制内存布局;而在 JS 里,你能控制的只有「用不用 TypedArray、数据怎么分组」,剩下的交给 JIT 和 GC。收益因此被压缩,但压缩的是倍数,不是方向。

还有一点容易被忽略:GC 会移动对象。一次 compacting GC 之后,原本连续的对象可能被打散,你精心设计的局部性瞬间失效。这也是为什么在 JS 里,「不分配」往往比「分配得漂亮」更值钱——11.2 的内存对照会把这一点讲透。

一条可操作的检查清单

在决定「要不要为了访存重构」之前,按下面几条过一遍,任何一条不满足就说明收益大概率为零:

  • 数据量够大吗? 几百个元素以内,任何布局的差距都在测量噪声里,重构纯属自找麻烦。
  • 访问模式规则吗? 如果每个元素都要走不同的业务分支,连续布局救不了你,分支预测才是瓶颈。
  • 循环是热点吗? 先用性能剖析确认这个循环真的占了大头时间,再谈布局;凭直觉猜的热点十有八九是错的。
  • 瓶颈是访存还是别的? 如果剖析显示时间花在分支预测、除法、或字符串操作上,那换布局一点用都没有。
  • 数据会被一起访问吗? 只用到 {x,y} 却把 {z,w,...} 也搬进缓存,是在浪费带宽。
  • 重构后还能读吗? 如果 SoA 让你的业务代码变成一堆下标运算、没人敢改,那这点收益不值得。
  • 有没有更省的解法? 很多时候真正的瓶颈是「每帧都在分配」,把对象池接上(第 5 章)比换布局便宜得多、风险也小得多。
  • 能不能只改热点那一段? 不必为了一个热点循环把整个数据模型推倒重来,局部地把某一列抽出来单独存往往就够了。

这份清单不是要劝你别做数据导向,而是提醒你:数据导向是一种「数据量到了一定规模才划算」的优化,不是默认的正确姿势。11.2 会给出更细的判据。

回到《微光岛》:哪些循环在跳

把结论落到贯穿项目上,才知道该在哪里动手。下面几处是《微光岛》里最典型的「访存模式问题」:

  • 每帧更新所有实体的变换。如果每个实体的 {x,y,z,vx,vy,vz} 是各自一个对象,更新循环就是一次指针追逐;改成按列存(11.3 会做),同一个循环变成连续扫。
  • 遍历所有收集品判断拾取。收集品数量大、访问模式规则,最适合连续布局;反之如果只是几十个装饰物,收益可以忽略。
  • 粒子系统的每帧积分。粒子生命周期短、更新频繁,是分配压力的重灾区——这时收益更多来自「不分配」而不是「访问快」。
  • 把可见物体填进渲染批次。这一步是「读得规则、写得连续」的典型:从 SoA 的位置数组里挑出可见项,写进连续的顶点缓冲。
  • 碰撞检测的宽相位。要遍历所有 AABB 两两配对,访问模式规则、数据量大,是连续布局最能兑现收益的地方。
  • 音频源的每帧衰减计算。距离衰减对每个音源做一遍,逻辑简单、无分支,适合批处理。
  • 导航网格的邻接查询。这一处反而是反例:邻接关系天然是图、访问模式不规则,强行压成数组只会更难读,收益也有限。

反过来,也有几处不该为了「连续」而重构:物体数量只有几十个的场景、访问模式本身就依赖业务分支的逻辑、以及需要频繁随机查改的编辑期数据。11.2 的最后一节会给出更完整的判据。

这里有个工程上的先后顺序值得强调:先消灭分配,再谈布局。 一个每帧 new 出上万个小对象的更新循环,即使数据摆得再连续,GC 的压力也会盖过一切访存收益。这也是为什么本卷第 4、5 章(内存分配器与对象池)排在第 11 章前面——它们是数据导向的前提,不是并列的选项。

需要提醒的是,这两份脚本的数字都强烈依赖机器状态。本机跑这两组对照时,后台同时有别的进程在吃 CPU,AoS 那组在 21–24 ms 之间来回浮动。这不是脚本的问题,而是微基准的宿命:在没有独占机器的前提下,只有大倍数(比如 6×)才值得信任,小倍数(1.5× 上下)请当作「方向一致、数值存疑」来看。 如果你要拿这里的结论做决策,务必在自己机器上、关掉其他负载再跑一遍。

小结:这一节留下了什么

把 11.1 的结论压成三句话:

  • 访存是有价格的,价格随「不可预测程度」上升:连续 < 大步长跳跃,行主序 < 列主序,本机实测 2.2× 与 6.2×。
  • 在 V8 里,方向可信、倍数不可信:连续一定快,但别把 C++ 的「5 倍」搬过来;JS 的 JIT 与 GC 把差距压到了 1.6–2.2× 量级。
  • 真正的数量级收益在内存:不是「访问快几倍」,而是「压根不分配」。这句话 11.2 会用 62.6 MB 对 0 MB 证明。

想自己复现,按下面三步走:

  • 把两份脚本的 // file: 路径还原到你的工程里,Node 26 可直接 node bench/cache-access.ts。
  • 先跑一遍记录基线,再关掉后台的其他负载(浏览器、构建进程、同步盘)重跑一遍,对比两次的差距。
  • 把 N 和 ITERS 调大或调小,观察倍数怎么随规模变化——你会看到「数据量越大、差距越明显」这条规律。

下一节把「访存」这条线收成三张具体的布局:AoS、SoA、AoSoA,并把这一节反复提到的「内存差距是数量级」实测出来,再给出「什么时候不该用数据导向」的完整判据。

阅读导航:上一节:目录 · 下一节:11.2 SoA / AoS / AoSoA 的真实差异 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径