《游戏引擎实现原理》11.2 SoA / AoS / AoSoA 的真实差异

同样 1M 个元素,AoS(对象数组)与 SoA(平行 TypedArray)在本机实测出 22.94 ms 对 14.47 ms、约 1.59 倍的差距,但堆增量是 62.6 MB 对 0 MB——TypedArray 的底层 ArrayBuffer 属外部内存,不进 JS 堆。本节讲清三种布局的真实差异,并给出「什么时候不该用数据导向」的判据。

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堆增量每元素堆占用
AoS22.94 ms1.00×+62.6 MB62.6 B
SoA14.47 ms0.63×+0.0 MB0 B
AoSoA约 64 ms2.8×+0.0 MB0 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 后
heapUsedJS 堆(GC 管理的对象)71.9 MB71.9 MB(不变)
arrayBuffersTypedArray 的底层 ArrayBuffer0.1 MB16.1 MB
external绑定到 JS 的 C++ 内存(含 arrayBuffers)5.3 MB21.3 MB
rss进程常驻物理内存149.5 MB162.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 到批处理的重构方法 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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