11.2 SoA / AoS / AoSoA 的真实差异
11.1 讲清了「访存有代价」。这一节把它落成三种具体的数据布局,并用一组对照回答一个很实际的问题:把对象数组换成平行数组,到底能省下多少时间、多少内存? 答案会让你有点意外——时间省得不多,内存省得离谱。
先给结论:速度差距只有 1.59× 量级,内存差距是 62.6 MB 对 0 MB。 前者在噪声边缘,后者是数量级。如果你的动机是「让遍历快几倍」,大概率会失望;如果动机是「别让 GC 每帧都在收拾垃圾」,那这正是你想要的。
这一节的写法是「先摆三种布局的形态,再用一份可跑的脚本把它们的耗时和内存并排测出来,最后给出该不该用的判据」。因为布局之争在网上的讨论里常常变成信仰之争,而这里只有一个裁判:你自己机器上的那组数字。
三种布局:数据怎么摆
假设每个元素有四个字段 x, y, z, w,用 1M 个元素存。三种摆法:
AoS [x y z w][x y z w][x y z w] ... 每个元素一个结构体,结构体连续排列
SoA [x x x x ...][y y y y ...][z z z ...][w ...] 每个字段一条独立数组,各自连续
AoSoA [x0..x7 y0..y7 z0..z7 w0..w7][x8..x15 ...] 先分块,块内再按字段分组
- AoS(Array of Structs) 是最自然的写法:一个元素一个对象,
{x, y, z, w}。在 TypeScript 里就是一个对象数组。读一个元素的四个字段,一次解引用就够;但遍历时,每个元素的所有字段都会进缓存,哪怕你只用得到其中一两个。 - SoA(Struct of Arrays) 把每个字段拆成独立数组。遍历「对所有元素的 x 求和」时,只需扫一条数组,缓存里全是有效数据;代价是读「同一个元素的四个字段」要跨四条数组。
- AoSoA(Array of Structs of Arrays) 是折中:先把元素按 8 个一组分块,块内再按字段分组。它存在的唯一理由是让 SIMD 能一次加载同一字段的连续 8 个值。在 C++ 里这是性能利器;在 JS 里没有自动向量化,这个理由基本不成立——本节会实测给你看。
三种布局没有绝对优劣,只有「和你的访问模式合不合」。一个粗略的经验:如果循环体里同时用到了四个字段,AoS 可能更自然;如果循环体里只碰一两个字段、却要跑遍所有元素,SoA 几乎稳赢。真正决定胜负的,是「你每次访问用掉缓存行里多少字节」。
三者怎么选,取决于你的循环「按元素读全字段」还是「按字段扫全体」:
| 布局 | 适合的访问模式 | 内存局部性 | 代码可读性 |
|---|---|---|---|
| AoS | 随机读单个元素、按元素读全字段 | 单元素内好,遍历时浪费带宽 | 最好,和业务对象一一对应 |
| SoA | 按字段批量扫描(更新、求和、筛选) | 遍历时最好 | 较差,字段散在多条数组里 |
| AoSoA | 需要 SIMD 批量处理(C++ 场景) | 块内好 | 最差,多一层块索引 |
三种布局在《微光岛》里各自用在哪
脱离场景谈布局是空谈。把三种布局放回贯穿项目,你能一眼看出各自的落点:
| 数据 | 布局 | 理由 |
|---|---|---|
| 玩家的位置、速度、朝向 | AoS(单个结构体) | 只有一个主角,字段总是一起用,SoA 没有意义 |
| 岛上所有收集品的变换 | SoA | 上千个收集品、每帧统一更新,按列扫最省带宽 |
| 粒子系统的位置、寿命、颜色 | SoA | 数量大、逻辑简单、每帧全扫,典型批处理场景 |
| 场景里的几十盏灯 | AoS | 数量少,重构的收益为负 |
| 导航网格的邻接表 | 都不合适 | 天然是图,强行数组化只会更难读 |
| 顶点缓冲的打包 | AoSoA | 只有这里、且只在 C++ / SIMD 场景才值得分块 |
一条简单的判断法:「数量大 + 每帧全扫 + 逻辑简单」三者同时成立,才上 SoA。 缺一个,就回到 AoS,甚至就用普通对象。
可运行产物:三种布局的速度与内存
下面这份脚本在 1M 元素上跑三种布局,每个 case 单开一个 worker 做进程隔离,预热到滚动中位数变化小于 5% 再取 21 次中位数。它同时测两样东西:遍历耗时和构建后的堆增量(process.memoryUsage().heapUsed 在 build 前后各取一次)。
// file: bench/do-layout.ts
// tier: B
// verify: node bench/do-layout.ts
//
// 三种数据布局的遍历耗时与堆增量:AoS / SoA / AoSoA。
// AoS —— 每个元素一个对象 {x,y,z,w},数组里是引用;
// SoA —— 4 个平行 Float32Array,同名字段各自连续;
// AoSoA —— 单个 Float32Array,按 [块][字段][通道] 排(BLOCK=8)。
// 每个 case 单开 worker(进程隔离),预热到滚动中位数变化 <5%,取 21 次中位数。
// 堆增量用 process.memoryUsage().heapUsed 在 build 前后各取一次。
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
import { fileURLToPath } from 'node:url';
const N = 1 << 20; // 1M 元素:远超 L2/L3
const ITERS = 10;
const BLOCK = 8; // AoSoA 的块大小
type Kind = 'aos' | 'soa' | 'aosoa';
if (!isMainThread) {
const { kind } = workerData as { kind: Kind };
let run: () => number;
const heapBefore = process.memoryUsage().heapUsed;
if (kind === 'aos') {
const a = new Array(N);
for (let i = 0; i < N; i++) a[i] = { x: i, y: i * 2, z: i * 3, w: i * 4 };
run = () => { let s = 0; for (let k = 0; k < ITERS; k++) for (let i = 0; i < N; i++) { const o = a[i]!; s += o.x + o.y + o.z + o.w; } return s; };
} else if (kind === 'soa') {
const x = new Float32Array(N), y = new Float32Array(N), z = new Float32Array(N), w = new Float32Array(N);
for (let i = 0; i < N; i++) { x[i] = i; y[i] = i * 2; z[i] = i * 3; w[i] = i * 4; }
run = () => { let s = 0; for (let k = 0; k < ITERS; k++) for (let i = 0; i < N; i++) { s += x[i]! + y[i]! + z[i]! + w[i]!; } return s; };
} else {
const data = new Float32Array(N * 4);
const blocks = N / BLOCK;
for (let b = 0; b < blocks; b++) {
const base = b * BLOCK * 4;
for (let l = 0; l < BLOCK; l++) {
const i = b * BLOCK + l;
data[base + 0 * BLOCK + l] = i;
data[base + 1 * BLOCK + l] = i * 2;
data[base + 2 * BLOCK + l] = i * 3;
data[base + 3 * BLOCK + l] = i * 4;
}
}
// 逐块、逐通道、每通道读 4 个字段(和 AoS 的读取结构对齐,只换存储)。
const stride = BLOCK * 4;
run = () => {
let s = 0;
for (let k = 0; k < ITERS; k++)
for (let b = 0; b < blocks; b++) {
const base = b * stride;
for (let l = 0; l < BLOCK; l++)
s += data[base + l]! + data[base + BLOCK + l]!
+ data[base + 2 * BLOCK + l]! + data[base + 3 * BLOCK + l]!;
}
return s;
};
}
const buildHeapMB = (process.memoryUsage().heapUsed - heapBefore) / 1048576;
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); }
const sorted = [...samples].sort((p, q) => p - q);
const q1 = sorted[Math.floor(sorted.length * 0.25)]!, q3 = sorted[Math.floor(sorted.length * 0.75)]!;
parentPort!.postMessage({ kind, median: med(samples), iqr: q3 - q1, buildHeapMB });
process.exit(0);
}
const runCase = (kind: Kind) => new Promise<{ kind: Kind; median: number; buildHeapMB: 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, { ms: number; heap: number }> = {};
// 三种布局轮流当「第一个」,抵消顺序偏差。
for (const order of [['aos', 'soa', 'aosoa'], ['aosoa', 'soa', 'aos']] as Kind[][]) {
for (const k of order) {
const r = await runCase(k);
(acc[k] ??= { ms: 0, heap: 0 }).ms += r.median / 2;
acc[k]!.heap += r.buildHeapMB / 2;
}
}
for (const k of ['aos', 'soa', 'aosoa']) {
const a = acc[k]!;
console.log(`${k.padEnd(6)} ${a.ms.toFixed(2)} ms heap +${a.heap.toFixed(1)} MB ${(a.heap * 1048576 / N).toFixed(1)} B/elem`);
}
console.log(`soa/aos = ${(acc.aos!.ms / acc.soa!.ms).toFixed(2)}x`);
核心结论:速度 1.6×,内存 62.6 MB 对 0
本机跑出的结果:
| 布局 | 中位耗时 | 相对 AoS | 堆增量 | 每元素堆占用 |
|---|---|---|---|---|
| AoS | 22.94 ms | 1.00× | +62.6 MB | 62.6 B |
| SoA | 14.47 ms | 0.63× | +0.0 MB | 0 B |
| AoSoA | 约 64 ms | 2.8× | +0.0 MB | 0 B |
请把注意力放在两列上。速度那一列:SoA 比 AoS 快约 1.59 倍——方向明确,但绝不是「快一个数量级」。内存那一列:AoS 每个元素占 62.6 字节的 JS 堆,SoA 占 0。这个「0」不是四舍五入的 0,是真正的一字节都没进 JS 堆。
AoSoA 的结果尤其值得说:它比 AoS 还慢。这不是实现 bug,而是它在 JS 里的宿命——分块布局的全部价值在于让编译器把同一字段的连续 8 个值一次性向量化加载,而 V8 不会为这段 JS 生成 SIMD 指令,于是你只剩下「块索引 + 通道索引」的额外算术开销,白付了。在 C++ 里 AoSoA 常常是三种里最快的,在 JS 里它最慢。这个反差本身就是「别照搬 C++ 经验」的最好注脚。
还有一个容易被忽视的点:SoA 之所以能快,部分原因是它让「同一字段的连续元素」落在一段连续内存里,于是 11.1 讲的预取器能一路小跑;而 AoS 遍历时,每个元素的四个字段虽然在一个对象里,但对象与对象之间隔着对象头、属性指针等开销,有效数据密度反而更低。换句话说,AoS 的缓存浪费不只来自「读了不用的字段」,还来自「每个对象都带着管理开销」。 这两项叠加,才解释了那 1.59×。
顺带一提,AoSoA 的速度对块大小极其敏感:同一份代码把 BLOCK 从 8 改成 4,耗时能从约 64 ms 掉到约 30 ms。这说明它的慢更多来自循环形状与索引运算,而非内存布局本身——但它始终没跑赢简单的 SoA。
为什么 TypedArray 的堆增量是 0
「SoA 堆增量 0」听起来违反直觉:16 MB 的数据明明存在,凭什么不算?因为 process.memoryUsage().heapUsed 统计的是 JS 堆——对象、字符串、闭包这些由 GC 管理的分配。而 TypedArray 的底层 ArrayBuffer 是外部内存(external memory),由 V8 直接向操作系统申请,不计入 heapUsed,也不参与 GC 的标记-清扫。下面是一次实测(1M 元素):
| 字段 | 含义 | 只建 AoS 后 | 再建 SoA 后 |
|---|---|---|---|
heapUsed | JS 堆(GC 管理的对象) | 71.9 MB | 71.9 MB(不变) |
arrayBuffers | TypedArray 的底层 ArrayBuffer | 0.1 MB | 16.1 MB |
external | 绑定到 JS 的 C++ 内存(含 arrayBuffers) | 5.3 MB | 21.3 MB |
rss | 进程常驻物理内存 | 149.5 MB | 162.1 MB |
读法很清楚:AoS 把 64 MB 全塞进了 heapUsed,GC 每次都得为这 1M 个对象做标记和清扫;SoA 把 16 MB 放进了 arrayBuffers(外部内存),heapUsed 纹丝不动,GC 完全不管它。这就是「内存差距是数量级」的机制:不是 SoA 省了内存(16 MB 实打实占着),而是它把内存从「GC 要管的」挪到了「GC 不管的」。
对应到游戏里,这意味着两件事。第一,GC 停顿大幅减少:每帧新建上万个对象会让 GC 频繁触发、造成帧时间尖刺;换成 TypedArray 后,分配压力几乎消失。第二,内存预算更可预测:外部内存不会被 GC 回收,也不会因压缩而移动,峰值水位稳定。对帧率敏感的游戏来说,这两点比「遍历快 1.6 倍」重要得多。
要验证这一点,只需在自己的机器上跑一小段:连续构建 60 帧的临时对象,观察 heapUsed 的锯齿与 GC 停顿;再换成预分配的 TypedArray,观察曲线变成一条平线。「平线」就是数据导向在 JS 里最值钱的东西。
一个常见的误区:SoA 真的「省内存」吗
「SoA 堆增量 0 MB」很容易被误读成「SoA 不占内存」。不是的。1M 个元素 × 4 个 Float32 = 16 MB,这 16 MB 实打实躺在物理内存里(见上表的 arrayBuffers 与 rss)。SoA 省下的不是总内存,而是GC 需要管理的内存。
这两者的区别,决定了三种布局各自的真实代价:
- AoS 的真实成本:62.6 MB 的 GC 托管对象 + 每次遍历的缓存浪费 + 每帧的 GC 压力。省下的是代码可读性。
- SoA 的真实成本:16 MB 的外部内存(同样占着)+ 读单元素要跨多条数组 + 多一层 ID→下标映射。省下的是 GC 压力与缓存带宽。
- AoSoA 的真实成本:16 MB 外部内存 + 最复杂的索引运算。它想换的是 SIMD 向量化,但 JS 给不了,于是只剩代价、没有收益。
所以正确的说法是:SoA 用「一点可读性 + 一点间接性」换「GC 压力消失 + 遍历带宽提升」。 这笔交易划不划算,取决于你的数据量和 GC 是否真的是瓶颈——这也正是下一节判据要回答的。
从 AoS 迁到 SoA 的工程步骤
真要动手时,别一次性重写整个数据模型。下面这个顺序能把风险压到最低:
- 第一步:找出热点。 用性能剖析确认某个遍历循环占了大头时间,并且剖析里能看到 GC 停顿。没有这一步,后面全是赌博。
- 第二步:只抽热点用到的那几列。 不要动业务对象的全部字段,只把热点循环真正读写的字段(比如
x, y, vx, vy)拆成平行数组。 - 第三步:留一个「视图」适配层。 业务代码仍然按 ID 访问,适配层负责把 ID 映射到下标、把列拼回一个临时对象,避免调用方大改。
- 第四步:让旧路径与新路径并存。 先用开关同时跑两条路径、对比结果一致,再切流量;出问题能一键退回。
- 第五步:量收益。 分别记录切换前后的帧时间与 GC 停顿。如果收益没超过你为可读性付出的代价,就退回去——这不是失败,是正确的工程判断。
这套流程和第 5 章对象池的引入方式如出一辙:局部替换、双轨验证、可回退。 数据导向重构最怕的就是「一次性推倒重来」,那样既拿不到对照数据,也把风险放到了最大。
需要强调的是,第三步的「适配层」不是可有可无的装饰。它把「数据怎么存」和「业务怎么用」解耦,让你可以随时换回 AoS 而不动业务代码。很多团队数据导向重构失败,不是因为布局选错,而是因为把布局决策泄漏到了每一行业务逻辑里,回头改不动了。
什么时候不该用数据导向
这一节最重要的部分在这里。数据导向不是「正确姿势」,而是一种「数据量够大才划算」的优化。下面这些情况下强行 SoA,只会让代码变难读、收益为零:
- 对象数量少。 几十到几百个元素,任何布局的差距都在测量噪声里。为 50 个装饰物写一套 SoA,纯属自找麻烦。
- 访问模式不规则。 如果每个元素都要走不同的业务分支、或者需要频繁按 ID 随机查改,SoA 的「连续扫描」优势用不上,反而多了一层「从 ID 找下标」的间接。
- 逻辑难以批处理。 有些行为天生是「一个对象做一长串复杂操作」,拆成「对整列数组做一步」会让代码碎成十几段,可读性暴跌。
- 数据量会变。 SoA 的数组通常要预分配固定容量,扩容意味着同时搬多条数组;如果元素数量频繁剧烈变化,管理成本会盖过收益。
- 你还没确认瓶颈。 如果剖析显示时间花在别的地方(GC 之外的算法、IO、字符串),换布局一点用都没有。先测,再改。
- 元素本身很大、字段很少被用全。 这种情况更该做的是「把大元素拆小」,而不是在 AoS 和 SoA 之间选。
- 团队没有剖析工具。 靠猜做数据导向,几乎必然把精力花在错误的地方;先把性能剖析接进工作流再说。
- 团队读不懂。 如果 SoA 让你的业务代码变成一堆下标运算、没人敢改,那这点收益不值得——可维护性也是一种性能。
- 只需要一次遍历、之后就扔掉。 初始化阶段的批处理、编辑器里的工具脚本,跑一次就完,重构收益微乎其微。
- 数据规模会随时间剧烈波动。 从 100 涨到 10 万再缩回 100 的场景,预分配与扩容的成本可能盖过收益。
- 数据要频繁随机增删。 SoA 的 swap-remove 会打乱顺序,如果业务依赖稳定顺序(比如渲染排序),维护成本会抵消收益。
判断的黄金标准只有一条:这个循环是不是热点,且数据量是不是大到「内存带宽」成为瓶颈。 两条都满足才动手;只满足一条,收益大概率被代码复杂度的代价吃掉。
把这条标准反过来读,就是本节最想让你带走的一句话:数据导向不是一种信仰,而是一笔交易。 你用可读性和间接性,换 GC 压力和带宽。数据量小的时候这笔交易亏本,数据量大的时候才划算——而「大不大」必须用你自己的剖析数据来回答。
反过来说,当数据量上万、循环无分支、且每帧都要扫一遍时——比如粒子系统、变换更新、碰撞宽相位——SoA 就是明显更优的选择,11.3 会把一段真实的 OOP 更新循环改造成批处理,让你看到这套判断怎么落地。
小结:这一节留下了什么
- 速度差距小、内存差距大。 本机实测 SoA 比 AoS 快约 1.59 倍,但堆增量是 62.6 MB 对 0 MB。前者在噪声边缘,后者是数量级。
- 「0 MB」是「不进 GC 堆」,不是「不占内存」。 TypedArray 的 16 MB 在
arrayBuffers/external里,实打实占着物理内存,只是 GC 不管。 - AoSoA 在 JS 里输。 分块布局为 SIMD 而生,V8 不生成 SIMD,于是它只剩额外索引开销,比 AoS 还慢。
- 数据导向是「数据量够大才划算」的优化。 对象少、访问不规则、逻辑难批处理时,强行 SoA 只会让代码变难读、收益为零。
- 先测、再改、可回退。 局部抽取热点列、双轨验证、量收益,收益不达标就退回——这比一次性重写安全得多。
11.3 把这一节的结论落到一段真实的更新循环上:先写一个 OOP 版,再一步步改造成批处理版,最后把两版的耗时与堆增量摆在一起。你会看到「1.59×」和「62.6 MB 对 0」在真实业务代码里长什么样。
阅读导航:上一节:11.1 缓存层级与访存代价 · 下一节:11.3 从 OOP 到批处理的重构方法 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。