这份数据怎么来的
正文里每出现一个性能数字,背后都有一张本机跑出来的原始表。本附录把它们收在一起,并交代清楚「这些数字是怎么测的、能不能信、怎么自己复现」。三件事按顺序说:先讲方法论,再给三组基准的原始结果,最后认真声明这些数字的适用范围。
先给结论性的提醒,读表之前请记住:本附录所有数字都是同一台机器上的相对量级,绝对数值不可移植。 V8 的 JIT 会在运行时把热点代码编译成机器码,GC 会在不确定的时刻介入,微基准对循环写法、预热次数、机器负载极其敏感。看到「SoA 比 AoS 快 1.59 倍」,请把重点放在方向(SoA 更快、内存更省)而不是倍数上。
头部数字一览
赶时间的话,先看这张表。三组基准的核心数字都在这里,细节在后文各节展开。
| 对比 | 关键数字 | 一句话结论 |
|---|---|---|
| SoA vs AoS(1M × 10 帧) | 22.94 ms → 14.47 ms;62.6 MB → 0.0 MB | 快 1.59×,内存几乎归零 |
| 连续遍历 vs 指针追逐(1M) | 21.22 ms → 47.15 ms | 随机访问慢 2.22× |
| 对象池 vs new(10 万次 churn) | 1.12 ms → 1.41 ms;+1679 KiB → +8818 KiB | new 慢 1.26×,GC 压力差 5.2× |
测量方法
微基准最容易被写坏。同一段代码,循环顺序换一下、预热次数改一下,结果能差 50%,而作者往往毫无察觉,把噪声当成了结论。为了不让噪声冒充结论,本卷的基准脚本遵守五条硬规则。
| 规则 | 做法 | 为什么 |
|---|---|---|
| 进程隔离 | 每个 case 跑在自己的 worker_threads 工作者里 | 避免上一个 case 的 JIT 状态、内联缓存、GC 堆影响下一个 case |
| 充分预热 | 反复跑批,直到滚动中位数在连续 5 批内变化 < 5% 才计时 | 等 JIT 把热路径编译稳,避免把「编译开销」算进结果 |
| 高精度计时 | process.hrtime.bigint() | 纳秒级、单调,不受系统时间改动影响 |
| 稳健统计 | 每个 case 采样 21 次,报告中位数 + IQR | 中位数抗离群;IQR(四分位距)反映离散度,比均值/方差更稳健 |
| 足够大的工作集 | 工作集 > 8 MB,超过典型 L2/L3 | 测的是「受内存带宽/缓存约束」的真实行为,不是驻留缓存的玩具数据 |
第五条「工作集 > 8 MB」是这几条里最容易被忽略的。如果数据小到能整个装进 L2 缓存,那么无论 SoA 还是 AoS、顺序还是随机,访问全都命中缓存,差距会被抹平到接近零——测出来的「SoA 只快 1.05×」不是结论,是缓存把问题藏起来了。把工作集撑到超过 L2/L3,才逼出真实的内存行为。本卷两组基准的 100 万元素、每个元素几十字节,工作集都在 8 MB 以上,正好越过典型 L2/L3 的容量线。
为什么是 21 次、而不是 3 次或 1000 次?3 次太少,一个偶发的高负载会让中位数失真;1000 次太多,跑得慢且后面几十次只是在重复同一个稳态,边际信息为零。21 次够算出稳定的中位数和四分位,单次跑完一组四个 case 也就几秒。IQR 的定义是第 75 百分位减第 25 百分位(Q3 - Q1),它覆盖中间 50% 的样本,天然排除两端的偶发尖峰。
一条容易踩的坑:不要把分配也计进计时区间。数据结构的构造(new Array(1e6)、填充初值)应该在计时开始之前完成,计时区间里只留纯计算。否则测到的是「分配 + 计算」,而分配那部分恰恰是 V8 最擅长、也最不稳定的环节。
把这套方法写成骨架,大致是下面这个样子(计时区间里只有 runOnce() 的纯计算):
对每个 case:
起一个独立 worker_threads
loop: 预热批
m = 连续 5 批耗时的中位数
if m 与上一批中位数变化 < 5%: break
samples = []
for i in 1..21:
t0 = process.hrtime.bigint()
runOnce() # 只含纯计算,不含分配
t1 = process.hrtime.bigint()
samples.push((t1 - t0) / 1e6) # 毫秒
报告 median(samples)、quantile(0.25)、quantile(0.75)
为什么统计量是中位数而不是均值
一个常见疑问:既然采了 21 个样本,为什么不直接报平均值?因为微基准的样本分布是右偏的——大部分样本挤在低位,少数样本被一次 GC、一次系统调度、一次后台进程打断而飙到很高。均值会被这些高值拽上去,报出一个「谁也不像」的数;中位数则稳稳落在分布的正中,不受两端影响。
举个本卷实际遇到的例子:连续遍历的 21 个样本里,19 个在 8.9ms 上下,1 个因为一次恰好撞上的 GC 飙到 12.9ms。如果报均值,会得到约 9.1ms,看起来「还好」;但真实体验是「绝大多数时候 8.9ms,偶尔卡一下」。报中位数(8.98ms)配合 IQR([8.89, 9.02])才把这两件事同时说清楚:典型值是多少、有多稳。中位数回答「多快」,IQR 回答「多稳」,两个都要。
一个 case 为什么要独立进程
「进程隔离」这条规则容易被当成过度设计,其实它防的是一个隐蔽的 bug:JIT 的内联缓存会跨 case 污染。假设先跑 AoS、再跑 SoA,且两者跑在同一个进程里——AoS 那轮已经把「对象属性访问」这条路径优化热了,内联缓存里存着 AoS 的形状;轮到 SoA 时,如果两者共享某些辅助函数,SoA 反而可能因为缓存形状不匹配而先慢后快,测出来的数字既不是纯 AoS 也不是纯 SoA。
把每个 case 丢进独立的 worker_threads 工作者,就得到一份干净的初始 JIT 状态:上一个 case 留下的内联缓存、类型反馈、优化代码全部归零。代价是每个 case 多几百毫秒的进程启动开销,但换来的是「这次测的就是这个 case」的确定性。微基准的可信度,一半来自统计,一半来自隔离。
基准一:SoA vs AoS
第一个基准回答 11.2 节的问题:同样 100 万个粒子、每个 4 个字段(x / y / vx / vy)、跑 10 帧积分,**面向对象布局(AoS,数组里放对象)与数据导向布局(SoA,每个字段一个 Float32Array)**差多少。
| 变体 | 中位耗时 | 堆增量 | 相对量级 | 说明 |
|---|---|---|---|---|
| AoS(对象数组) | 22.94 ms | +62.6 MB(约 62.6 B/元素) | 基线 | 每个粒子是一个 4 字段对象 |
| SoA(字段数组) | 14.47 ms | +0.0 MB | 快 1.59×,内存约 0 | 四个平行的 Float32Array |
两个数字都值得停下来看。
速度上,SoA 快 1.59×。 这不是一个惊人的倍数——在 C++ 里同样的对比常常是好几倍。原因在下面「为什么 JS 里 SoA 的收益主要是内存」一节展开:V8 的 JIT 对对象属性访问优化得相当好,而 Float32Array 的元素访问反而带边界检查开销。所以 1.59× 是真实的,但不是这个基准的主角。
内存上,差距是数量级的:62.6 MB 对 0.0 MB。 这才是主角。100 万个 4 字段对象在堆上占了 62.6 MB,而且全部要受 GC 管辖——每次垃圾回收都要扫描它们、判断存活、必要时搬动。而 Float32Array 的底层 ArrayBuffer 是外部内存(external),不计入 V8 的 JS 堆,GC 根本不扫描它。这就是为什么 SoA 那一行的堆增量是 0.0 MB——不是「很少」,是「不在 JS 堆里」。
一个测量上的细节值得单独点出:process.memoryUsage().heapUsed 看不到 Float32Array 的 buffer,要用 arrayBuffers 或 external 字段才看得到。这既是 4.1 节的结论,也是它最大的工程价值——把数据放进 TypedArray,就把它从 GC 的扫描范围里挪了出去。
基准二:连续遍历 vs 指针追逐
第二个基准回答 11.1 节的问题:同样是遍历 100 万个元素,连续内存上的顺序访问与顺着指针随机跳的访问差多少。
| 变体 | 中位耗时 | 相对量级 | 说明 |
|---|---|---|---|
| 连续遍历(顺序读数组) | 21.22 ms | 基线 | 访问模式连续,硬件预取器可提前拉数据 |
| 指针追逐(随机跳) | 47.15 ms | 慢 2.22× | 每次访问的地址由上一次结果决定,无法预取 |
2.22× 的差距,全部来自缓存与预取。连续遍历时,CPU 的硬件预取器能识别「顺序向前」的模式,在你还不需要下一块数据时就已经把它拉进缓存;而指针追逐里,下一个地址要等当前这次访问算出来才知道,预取器完全失效,几乎每次访问都是一次缓存未命中,得去主存取。
这正是数据导向设计在引擎里成立的根本原因:把数据摆对,让热路径上的访问重新变得连续,比把算法写巧更能决定性能。ECS 之所以要把同类型组件紧凑地存在一起(12.2 节的稀疏集、13.1 节的查询),就是为了让「遍历所有带某组件的实体」这条热路径落进上面第一行、而不是第二行。
基准三:对象池 vs new
第三个基准回答 5.1 节的问题:ObjectPool<T> 到底比 new 快多少。这组数据来自 5.1 节的基准脚本,同样是本机实测,但统计口径是每组 9 次运行取中位数(比 21 样本略少,因为三个负载跑起来更慢)。
| 负载 | 对象池 | new + 丢弃 | 结论 |
|---|---|---|---|
| A:10 万次 acquire/release | 1.12 ms(created=1024) | 1.41 ms(created=100000) | new 慢 1.26× |
| B:一次性造 10 万个且不释放 | 2.25 ms | 2.03 ms | 池慢 1.11× |
| C:堆增量(不强制 GC) | +1679 KiB | +8818 KiB | 池的未回收字节少 5.2× |
这三行连起来看,比任何一行单独看都更有信息量。
- A 组是池子的主场:预分配 1024 个对象,10 万次循环里
created始终是 1024,一次新构造都没发生——recycled涨到 100000,说明每次acquire都从空闲链表里取到了旧对象。 - B 组池子反而输了:一次性造 10 万个、全程持有、从不归还,这恰恰是池子最不擅长的工作负载。每次
acquire都要走「弹链表 → 判空 → 判容量 → 扩容 → create」的路径,reset那一步还完全白费。复用率为零时,池子额外付的是簿记成本。 - C 组说明池化的真实价值在内存:不强制 GC 时,
new版本在账上留下 8.6 MiB 待回收的年轻对象,池版本只有 1.6 MiB,少了 5.2×。这是「降低 GC 压力」,不是「构造更快」。
一句话总结这组数据:池化不是「让分配变快的银弹」,而是一剂「针对 GC 抖动的药」。 什么时候该池、什么时候不该池,5.1 节给了一条够用的判据,这里不再重复。
离散度:IQR 说明了什么
中位数告诉你「典型情况多快」,IQR 告诉你「这个数字有多稳」。为了展示离散度的量级,下面给出在本机用同一套方法复现前两组基准时四个 case 的中位数与 IQR。请注意:这是撰写本附录时的一次独立复现,与上几节的主口径数字来自不同批次,两者并列只为说明「同一台机器上,微基准的中位数本身也会整体平移」。
| case | 复现中位 | IQR(Q1–Q3) | 离散度 |
|---|---|---|---|
| SoA(1M × 10 帧) | 15.42 ms | [15.10, 15.72] | 很紧,纯计算、无分配 |
| AoS(1M × 10 帧) | 33.68 ms | [32.27, 37.13] | 较宽,对象布局对 JIT 敏感 |
| 连续遍历(1M) | 8.98 ms | [8.89, 9.02] | 极紧,纯顺序访存 |
| 指针追逐(1M) | 35.24 ms | [31.60, 39.64] | 最宽,随机访存受缓存/调度扰动 |
读这张表能得出两条对工程有用的经验:
- 纯计算、无分配的 case,IQR 极窄(SoA 的 [15.10, 15.72]、连续遍历的 [8.89, 9.02])。这类结论可信度高,方向基本不会翻转。
- 涉及随机访存或对象布局的 case,IQR 明显更宽(指针追逐宽到 [31.60, 39.64])。这类 case 的绝对数字参考价值低,只有「谁快谁慢」的方向值得采信。
换句话说:IQR 窄的结论可以当结论用,IQR 宽的结论只能当方向用。 本卷正文里凡是引用这些数字的地方,都遵循这条判断。上表里复现的 AoS 中位数(33.68 ms)与基准一里的 22.94 ms 相差近五成,正是「同机复现也会整体平移」的活例——它们跑的是同一件事,差的是 harness 细节与机器当时的负载。
三组基准的横向对照
三组基准看似各说各话,其实指向同一条主线:在 JS 里,决定性能的往往不是「算法」,而是「数据摆在哪、怎么访问」。
| 基准 | 变量 | 结果 | 真正的原因 |
|---|---|---|---|
| SoA vs AoS | 数据布局(字段数组 vs 对象数组) | 快 1.59×、内存 62.6 MB → 0 MB | 数据离开 JS 堆,GC 不再扫描 |
| 连续 vs 指针追逐 | 访问模式(顺序 vs 随机) | 快 2.22× | 硬件预取器在顺序访问下生效 |
| 对象池 vs new | 分配策略(复用 vs 每次新建) | 高频 churn 快 1.26×、GC 压力少 5.2× | 复用消除构造与待回收对象 |
把三行连起来读,能看出一条递进:布局决定数据在不在 GC 的视野里,访问模式决定缓存能不能帮上忙,分配策略决定 GC 被触发的频率。三者共同作用的结果,最终体现在帧时间的长尾上——也就是 2.1 节里那些把 p95 和 max 拉高的尖峰。这正是本卷从第 4 章一路讲到第 13 章的动机:先把内存摆对,再谈算法。
一个容易被忽略的反面:这三条没有一条是「无脑套用就变快」。SoA 在小数据量下收益为零,池化在低复用率下反而更慢,顺序访问的前提是数据本来就连续。每一条收益都有它的前提条件,前提不满足时收益会翻转成负的。
其他实测数字速查
正文里还有一些不属于「两组对比」、但同样来自本机实测的数字,一并收在这里,方便对照。
| 出处 | 数字 | 含义 |
|---|---|---|
| 2.1 | samples=300 avg=17.12 p95=18.12 max=36.57 fps=58.4 | 300 帧耗时分布:均值贴着预算,长尾有 6 帧 ≥34ms |
| 2.2 | 死亡螺旋前 12 帧步数 1 → 2 → 4 → 8 → 16 → 32 → 63 → 127 → 254 → 508 → 1015 → 2031 | 无钳制时单帧步数每帧翻倍 |
| 2.2 | 钳制后 单帧最大步数 5、1000 帧累计丢弃 83.05 秒 | maxStepsPerFrame = 5 把帧率拉回有界 |
| 6.2 | makeHandle(42, 3) = 3145770 | 句柄位编码:索引 42、世代 3 拼回一个数 |
| 7.1 | DynArray push 100 万:1.25× → 53 次扩容 / 搬 4.17 M / 21.3 ms;1.5× → 30 / 2.10 M / 10.7 ms;2× → 18 / 1.05 M / 7.4 ms | 增长因子与累计搬运量成反比 |
| 7.1 | 装载 ~0.74、39 万键:线性探测 avg 2.36 / p99 19 / max 133;Robin Hood avg 2.36 / p99 8 / max 21 | 平均相同,尾部差一个量级 |
| 7.1 | 19.5 万存活、未命中查找:干净表 1.75 步 vs 50% 墓碑 7.78 步 | 墓碑把未命中查找拉长 4.4× |
| 7.2 | 1M 标记堆增量:BitSet 0.0 MB vs boolean[] 7.6 MB | 位压进 32 位字的省内存效果 |
| 7.2 | 遍历 1M 置位:BitSet 7.31 ms vs boolean[] 2.11 ms | 位运算比数组直读慢——省内存的代价 |
| 7.2 | RingBuffer 1000 万次 push 63.7 ms,零分配 | 定容队列不触发 GC |
| 7.3 | 1 万唯一名、100 万次查询:查 Map 22.6 ms vs 按句柄索引数组 3.4 ms | 句柄化取数快 6.6× |
| 7.3 | 10 万唯一名驻留进表 39.2 ms | 驻留是一次性成本 |
| 7.3 | guidFromPath('assets/mesh/island.gltf') = 16ed47d340de1877 | 路径哈希成 16 位十六进制 GUID |
| 14.1 | 串行 289.9 ms vs 4 worker 并行 107.9 ms | 多核吃下大头,2.69× |
| 14.2 | 1000 任务、每个约 0.4 ms:405.5 ms → 163.0 ms(2.49×) | 任务够粗时线程池有效 |
| 14.2 | 1000 任务、每个约 4 μs:3.9 ms → 46.9 ms(0.08×) | 任务太碎时慢 12 倍 |
| 14.3 | 96 个阶梯任务、4 worker:静态分片 119.5 ms vs work stealing 60.5 ms | 窃取把 makespan 砍半,1.98× |
2.1 那条尤其值得记住:avg = 17.12ms 对应 58.4 FPS,看起来「差一点点」,但真正危险的是那 6 帧 ≥34ms 的尖峰。平均值会骗人,长尾才是掉帧的元凶——这也是 FrameTimer 同时给 p95 和 max 的原因。
第 7 章容器:省内存与省时间的一对矛盾
第 7 章的三张表放在一起,是一组「省内存」与「省时间」的直接对撞:
| 选择 | 省下的 | 付出的 |
|---|---|---|
BitSet 代替 boolean[] | 1M 标记的堆增量从 7.6 MB 降到 0 | 全量遍历从 2.11 ms 升到 7.31 ms |
DynArray 因子 2× 代替 1.25× | push 100 万的耗时从 21.3 ms 降到 7.4 ms | 扩容瞬间的缓冲区翻倍,峰值内存更早触顶 |
| 名字驻留成句柄 | 100 万次取数从 22.6 ms 降到 3.4 ms | 启动时一次性 39.2 ms 的驻留成本 |
三行的方向是一致的:把一次性成本前置,换取热路径上的确定性。驻留表在启动时付 39.2 ms,之后每帧取数走 3.4 ms 的快路径;位集合用更慢的逐位扫描换掉 7.6 MB 的堆占用。取舍的依据不是「哪个更快」,而是这个容器在帧循环里被访问多少次、又占多少常驻内存。
第 14 章并行:任务粒度的边界
第 14 章给的是并行不能做什么。同一批 1000 个任务,粒度一变,结论完全反转:
| 每个任务的计算量 | 单线程 | 4 worker | 比值 |
|---|---|---|---|
约 0.4 ms(iter=200000) | 405.5 ms | 163.0 ms | 2.49× |
约 4 μs(iter=2000) | 3.9 ms | 46.9 ms | 0.08× |
任务粗到毫秒级,线程池拿到 2.5 倍;碎到微秒级,抢锁 + 唤醒 + 计数的固定开销反噬成 12 倍慢。这不是实现缺陷,是并行本身的边界——调度单位是「批」,不是「对象」。所以第 13 章的查询分组、第 15 章的任务图都会把大量小工作合并成粗粒度 job,而不是「一个实体一个任务」。
把第 7 章与第 14 章并排看,本卷的主线就收束了:先让数据待在 GC 视野之外(内存布局),再让 CPU 待在帧预算之内(并行粒度)——两者都不靠更聪明的算法,靠的是对「代价发生在哪一层」的判断。
「同机相对量级、绝对数值不可移植」声明
把话说透。本附录的每一个数字都满足两个条件:来自作者本机实测,且附带原始数据。但请务必同时接受第三个条件:
这些数字是同机相对量级,绝对数值不可移植。
为什么不可移植,有三层原因:
- JIT 是运行时的。 V8 会在代码变热之后把它编译成机器码,编译的时机、优化的激进程度、内联缓存的命中情况都随机器与负载变化。同一段源码在不同 CPU 上可能走不同的优化路径。
- GC 是不确定的。 垃圾回收何时触发、回收多少、是否搬动对象,取决于堆的实时状态。一次恰好发生在计时区间内的 GC,就能让一个「快」的 case 变成「慢」的。
- 微基准对写法极度敏感。 循环顺序、是否提前分配、字段是不是双精度字面量、数组是 packed 还是 holey,都会显著改变结果。换台机器、换个 Node 版本,数字整体平移是常态,而不是异常。
所以正确的用法是:看方向,不看倍数。 稳定的结论是量级差异——比如「对象数组的堆增量是 62.6 MB,TypedArray 是 0 MB」这种不会因为写法微调而翻转的对比;不稳定的结论是具体倍数——比如「快 1.59 倍」里的那个 1.59。你在自己机器上跑出来是 1.4× 还是 1.8×,都不影响「SoA 更省内存、通常也更快」这个方向。
为什么 JS 里 SoA 的收益主要是内存而非速度
这是本卷最反直觉、也最值得说清的一个结论。在 C++ 里,SoA 相对 AoS 常常快好几倍;在 JS 里,本机实测只有 1.59×。差距去哪了?
一是 JIT 把对象访问优化得很好。 V8 用隐藏类(hidden class)和**内联缓存(inline cache)**来描述对象的形状。只要一批对象的形状一致(同一处构造、同一组字段),属性访问会被优化成「按固定偏移取值」,接近直接访存。所以 AoS 在 JS 里并不像在 C++ 里那样必然付「跳指针」的代价。
二是 Float32Array 的元素访问不是免费的。 每次 arr[i] 都带一次边界检查——越界要抛异常,所以运行时必须比较下标。连续的边界检查累加起来,抵消了一部分 SoA 本该拿到的访存优势。两者一涨一消,净收益就落到了 1.59× 这个「有、但不大」的量级。
三是 SoA 的真正主战场在内存和 GC。 62.6 MB 对 0.0 MB 的堆增量差距,直接决定了 GC 的扫描量与停顿频率。而 GC 停顿恰恰是帧时间长尾的元凶——回看 2.1 节的帧耗时分布,平均值贴着预算跑,真正掉帧的是那些 ≥34 ms 的尖峰,而尖峰往往就来自一次 GC。SoA 让热数据离开 JS 堆,等于把这类尖峰的概率压下去。
一句话收束:在 JS 里用数据导向,首要目的是把数据从 GC 手里拿开,其次才是访存速度。 如果你的场景对象只有几百个、访问模式不规则,强行 SoA 只会让代码变难读而收益为零——先测量,再优化。
常见误读与自查
围绕这些数字,有几个反复出现的误读,值得单独拎出来。
| 误读 | 为什么错 | 正确读法 |
|---|---|---|
| 「书里说 1.59×,我测出来 1.4×,是不是书错了」 | 微基准的绝对倍数本就不可移植 | 看方向:SoA 更快、内存更省,方向对就行 |
| 「SoA 内存是 0,说明它不占内存」 | 0 是「不计入 JS 堆」,buffer 仍是实打实的外部内存 | 它省的是 GC 扫描,不是总内存 |
| 「对象池一定比 new 快」 | B 组实测池反而慢 1.11× | 池化的收益在降低 GC 压力,不在构造速度 |
| 「平均值 17.12ms 说明帧率稳」 | 均值贴着预算,但长尾有 6 帧 ≥34ms | 同时看 p95 与 max,长尾才是掉帧元凶 |
| 「指针追逐慢是因为算法差」 | 两者做的是同一件事,只是访问模式不同 | 差的是缓存与预取,不是算法 |
对应的自查清单:拿到一个基准数字时,先问四件事——样本量够不够(至少 21)、预热到位没有(滚动中位数稳定)、计时区间纯不纯(有没有混进分配)、离散度大不大(IQR 宽就只能当方向)。四条都过关,这个数字才值得写进结论;任何一条不过关,它顶多是个「感觉」。
把基准数字放回帧预算里
单看「21.22ms」没有体感,放进帧预算里才有。60Hz 的预算是 16.67ms(2.1 节)。也就是说,光是连续遍历 100 万个元素,就已经超过了整整一帧的预算——这还只是遍历,没算真正的游戏逻辑。这就是为什么引擎必须在「数量」和「布局」上做文章,而不是指望把循环写得再巧一点。
把本附录的数字和帧预算并排看,能读出三条工程判断:
- 100 万实体不是一个随便就敢上的规模。 连续遍历 21.22ms 已经吃满一帧;要么把规模压到几十万,要么把这次遍历切给 Job System(第 14 章)分到多核上,要么接受 30Hz。
- SoA 省下的内存直接换成帧时间的稳定性。 62.6MB 对象退出 GC 视野后,触发一次 stop-the-world 回收的概率随之下降,p95 与 max 跟着降——这正是 2.1 节
FrameTimer要盯住长尾的原因。 - 查询缓存(13.1)不是锦上添花。 如果每帧都重新求一次组件集合的交集,等于每帧多做几次稀疏集遍历;缓存命中把这次开销摊薄到只在组件增删时付出。
一句话:基准数字的意义不在于「快了几倍」,而在于它让你知道某个操作在帧预算里占多大一块,从而决定要不要为它动刀。
怎么自己复现
基准脚本不参与断言,只产出 JSON,所以它是「软门禁」:数字波动不会让流程失败,但会提示你复核。跑法如下:
node bench/soa-vs-aos.mjs
node bench/contiguous-vs-chase.mjs
node --expose-gc bench/soa-vs-aos.mjs
第一行跑 SoA/AoS 对比,第二行跑连续遍历/指针追逐对比,第三行在显式暴露 GC 的前提下重跑,便于观察堆增量。三行都产出 JSON,不写死在代码里。每个 case 输出的形状大致如下:
{ "case": "soa", "samples": 21, "medianMs": 15.42, "q1Ms": 15.10, "q3Ms": 15.72, "heapDeltaMb": 0.0 }
把 medianMs 与 q3Ms - q1Ms 一起看:中位数决定「值不值得信」,IQR 决定「能不能当结论」。只有中位数、没有 IQR 的基准输出,等于只报了一半。
复现时请对照本文的方法论自查:有没有做进程隔离(每个 case 独立工作者)、有没有预热到滚动中位数稳定、计时区间里有没有混进分配、工作集有没有超过 L2/L3。四条里漏掉任何一条,数字就可能偏离到没有参考价值。环境准备与各层代码的跑通命令见附录 B 环境与复现速查 ;基准数字的讨论散落在 4.1、5.1、11.1、11.2 各节。
阅读上一节:附录 B 环境与复现速查 · 阅读下一节:附录 D 常见错误与 FAQ
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。