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 KB | 1–4 周期 | 1× |
| L2 缓存 | 256 KB–1 MB | 10–20 周期 | 约 5× |
| L3 缓存 | 几 MB–几十 MB | 30–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 ms | 2.22× |
| 行主序 vs 列主序 | 行 16.7 ms | 列 105.2 ms | 6.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 的真实差异 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。