内存的物理原理(即 JavaScript ECS 能行吗?)
摘要
本文介绍了一份详细的基准测试,比较了 JavaScript 中用于二维物理模拟的 ECS 和 OOP 架构,测试了内存局部性和多维性能,包括 broad-phase 算法和排序策略,结果基于 M4 Mac。
<p><a href="https://lobste.rs/s/q8vdre/physics_memory_aka_can_javascript_ecs">评论</a></p>
查看缓存全文
缓存时间: 2026/07/02 06:08
# 内存的物理本质 - dmurph.com
来源:https://www.dmurph.com/posts/2026/06/ecs_vs_oop_benchmark/ecs_vs_oop_benchmark.html
我听说过,许多算法(比如排序)在访问的数据具有高内存局部性时性能会大幅提升(例如,CPU可以直接从L1/L2缓存加载所有相关信息进行处理,而不是访问RAM)。一种常见的处理方式是使用实体组件系统(ECS)软件架构。然而,对于解释型或面向对象语言(Java、Python、JavaScript)来说,实现这种内存布局往往很困难,因为这些语言通常不让开发者对对象的内存位置有那么多控制权。我的问题是:
> > **是否有可能在JavaScript中使用ECS风格的架构?对于适用的操作,它是否真的比对象 + V8的垃圾回收效果更好?**
为了测试这一点,我创建了一个足够复杂的基准测试……“球在盒子里弹跳”!这遵循了标准2D物理引擎技术:
- “**粗略阶段**”碰撞步骤(即“包围盒是否相交?”)
- “**精细阶段**”碰撞步骤(即“给定这些相交的包围盒,解决碰撞!”)
当然,我完全失控了,最终创建了下面大量的基准测试类型。试试看吧!
## 背景 - 什么是ECS?
如果你在游戏开发领域待过一段时间,你很可能听说过**实体组件系统(ECS)**。如果说OOP是行式数据库(每个对象将所有属性存储在一个内存块中),那么ECS就是列式数据库,将每个属性(或属性集合)存储在不同的列数组中。
换句话说(你可能在其他地方找到对ECS更好的描述)……不是构建复杂的类层次结构,比如`GameEntity`继承自`MovingObject`,而`MovingObject`又继承自`PhysicalObject`等等,ECS将一切分解为三个不同的概念:
1. **实体**:仅仅是唯一的整数ID。它们不包含任何数据或行为。
2. **组件**:表示状态各个方面的纯数据结构(例如`Position`、`Velocity`、`Color`)。它们不包含逻辑。每个实体可以拥有从0到全部组件的动态集合。
3. **系统**:执行游戏逻辑的函数或循环。它们根据特定的组件集合查询实体,然后对这些数据进行操作。
## 基准测试选择
我做得过头了,在五个维度上进行了测试:
- **语言**:**JavaScript**(测试V8引擎优化和TypedArray)对比 **WASM**(AssemblyScript,用于极端的内存控制)。
- **架构**:**传统OOP**(对象引用数组)对比 **ECS**(缓存友好的结构体数组SoA)。
- **粗略阶段**:**空间AVL树**(O(N log N)平衡树)对比 **扫描与裁剪**(按X轴排序,扫描重叠)。
- **排序策略(对于扫描与裁剪)**:测试了**插入排序**(教科书上推荐用于近乎有序数据的方法)、**混合快速排序**、零拷贝**混合归并排序**以及JS原生`Array.sort()`。
- **运动连贯性**:**漫游**(平滑移动;数组保持近乎有序)、**不稳定**(随机传送;极端缓存抖动)和**静态**(零运动但永久碰撞;纯粹测量带碰撞的内存读取速度)。
无论如何,让我们看看它是如何工作的!代码托管在GitHub(https://github.com/dmurph/ecs-on-web)上,如果你想摆弄它的话。
## Macbook M4 结果
在我本地的**Apple M4 Pro芯片**上(插电运行,200帧)运行所有这些配置,产生了大量数据。如果你想查看所有6次运行的完整数据集,请参考下面的附录:完整基准测试数据集(https://www.dmurph.com/posts/2026/06/ecs_vs_oop_benchmark/ecs_vs_oop_benchmark.html#full-benchmark-dataset)。
为了减少信息过载,我们聚焦于3个主要假设:
### H1: 空间树 vs. 扫描与裁剪
#### **连续的1D内存扫描始终优于层次化的2D空间树,即使两者都存储在扁平数组中。**
在15,000个漫游实体的情况下,扁平数组的扫描与裁剪(`WASM ECS S&P Quick`)运行时间为**1.81毫秒**(相对于基线的9.02倍加速),而扁平数组的空间树(`ECS Tree`)运行时间为**9.97毫秒**(仅1.64倍加速)。
| 粗略阶段系统 | 架构 | 平均帧时间 | 第99百分位 | 相对于OOP树的加速比 |
| --- | --- | --- | --- | --- |
| **WASM ECS S&P (Quick)** | 扁平1D数组 | **1.809 ms** | **2.031 ms** | **9.02x** |
| **ECS (Custom SoA) S&P (Quick)** | 扁平1D数组 | 4.668 ms | 6.111 ms | 3.50x |
| **ECS Tree** | 2D空间BVH | 9.970 ms | 43.666 ms | 1.64x |
| **OOP Tree** | 2D空间BVH | 16.321 ms | 40.756 ms | 1.00x |
为什么树如此慢,即使它使用了扁平数组,并且避免了JavaScript堆对象?
- **嵌套间接引用**:扫描与裁剪只有一层的索引间接引用(`posX[indices[i]]`),而AABB树需要**嵌套间接引用**。在遍历过程中,CPU必须查找子索引(`treeLeft[idx]`),然后使用该索引来查找节点的边界(`treeMinX[leftChild]`)。这种在数组之间的非线性跳跃阻止了CPU的硬件预取器预测下一个内存地址。
- **分支预测失败**:树遍历循环充满了条件分支(例如,根据重叠情况决定是向左下降、向右下降还是同时下降)。在15,000个移动实体的情况下,这些分支高度不可预测,导致频繁的CPU指令流水线清空。相比之下,扫描与裁剪是一个简单、高度可预测的线性扫描。
- **时间连贯性**:由于实体每帧只移动很小的量(高度的空间连贯性),扫描与裁剪中已排序的`indices`保持近乎连续。这使得索引查找在缓存中高度局部化,而树遍历总是跳转到远处的节点。
---
### H2: JavaScript vs. WebAssembly
#### **WebAssembly并非实现数据导向设计缓存局部性优势的必要条件,但它通过启用无检查内存访问提供了显著的二次提升。**
仅仅从OOP(对象数组)迁移到纯JavaScript中的ECS(扁平TypedArray),根据空间连贯性,就能产生**1.58倍到24.9倍的加速**。
| 运行时和内存布局 | 参赛者 | 平均帧时间 | 相对于OOP的加速比 |
| --- | --- | --- | --- |
| **WebAssembly (SoA)** | WASM ECS S&P (Quick) | **2.141 ms** | **3.91x** |
| **JavaScript (SoA)** | ECS (Custom SoA) S&P (Quick) | 5.318 ms | 1.58x |
| **JavaScript (AoS)** | OOP S&P (Quick) | 8.380 ms | 1.00x |
迁移到AssemblyScript WASM提供了额外的约2.5倍加速(降至**2.11毫秒**)。这种提升的主要驱动力是能够使用`unchecked()`操作符。这告诉编译器绕过数组边界检查,从而发出高度优化的向量指令并最大化CPU指令吞吐量。
---
## 有趣的发现
在制作过程中,我发现了一些有趣的事情!
### 1. 空间连贯性排序陷阱(为什么需要混合排序)
#### **教科书上的插入排序是实时物理性能的陷阱;需要混合排序来防止最坏情况下的帧抖动。**
在**漫游**(高连贯性)条件下,数组保持近乎有序,插入排序非常快(**4.21毫秒**)。然而,在**不稳定**(低连贯性)工作负载下(例如爆炸或传送),数组变得完全无序。插入排序崩溃为其二次最坏情况(O(N²)),帧时间膨胀到**36.39毫秒**,导致严重的抖动。
为了解决这个问题,我们实现了一种**混合排序**(快速排序或归并排序,当子数组元素少于12个时切换到插入排序)。这消除了小分区的递归栈开销,同时保证了在混乱运动下O(N log N)的性能(**5.60毫秒**)。
**为什么在子数组少于12个元素时切换到插入排序?**这消除了分治递归在小型数组上相对较高的调用栈和缓冲区开销,而插入排序在此情况下由于其几乎为零的初始化开销而非常快。改变后性能提升了!(不过我忘了具体多少)
### 2. 库的权衡(bitECS vs. 自定义SoA)
#### **使用ECS库比手动调优的SoA稍慢,但仍然具有高度竞争力,是现实工程中的最优选择。**
在15,000个不稳定实体的情况下,`bitECS S&P (Quick)`运行时间为**9.56毫秒**,而我们的自定义ECS为**5.60毫秒**。虽然慢了两倍,但它仍然比OOP基线(131.6毫秒)提供了巨大的**14倍加速**。
为什么bitECS会慢?
- **无属性打包**:在我们的ECS(自定义SoA)中,我们将`y`、`w`和`h`打包到一个`posYwh`数组中(`posYwh[i * 3 + 0]`),这意味着所有三个值都加载到单个CPU缓存行中。默认情况下,`bitECS`为组件中的每个属性分配一个**单独的TypedArray**(`y`、`w`和`h`是单独的数组),强制CPU从三个独立的内存流中获取数据。
- **查询和ID映射开销**:bitECS必须查询原型并将稀疏的实体ID映射到内部数组槽位,引入了少量的间接引用。
然而,对于真实的游戏,库(如`bitECS`)的可用性、API安全性和动态实体管理远比节省几毫秒的微优化更有价值。
另外,使用下面的一项性能优化技巧,在操作之前将物理数据提取到自定义结构中,所有这些开销都可以消除。
### 3. JavaScript Float32 陷阱
#### **在纯JavaScript中切换到32位浮点数(Float32Array)会由于V8的双精度提升开销而降低性能。**
在15,000个实体的情况下,使用`Float32Array`实现的自定义ECS(自定义SoA)**比`Float64Array`版本慢约5%**(5.60毫秒 vs. 5.32毫秒)。
为什么?JavaScript数字始终是64位双精度。当V8从`Float32Array`读取时,它必须在运行时将32位浮点数转换为64位双精度数,然后才能进行数学运算。写回时,又必须转换回来。这些运行时转换的开销超过了L1/L2缓存密度带来的好处。
在本地环境(如WASM、C++或Rust)中,`f32`直接在FPU寄存器上处理,无需转换,因此是纯粹的性能提升。
### 4. 确定性的物理
#### **一旦接触解析顺序发生分歧,跨算法轨迹确定性是一种虚假的经济性。**
由于树和排序扫描以不同的顺序遍历和解析接触,它们的舍入误差累积方式不同。这导致模拟路径几乎立即出现分歧(从第2帧开始)。应专注于统计正确性和局部稳定性,而不是跨不同范式精确匹配轨迹。
### 5. DOD和帧时间稳定性(第99百分位)
#### **数据导向设计通过消除垃圾回收和缓存未命中尖峰提供了坚如磐石的帧率稳定性。**
如果你查看基准测试中的第99百分位数,会发现一个显著的模式:在OOP实现中,第99百分位通常**比平均帧时间高出2到3倍**。而在ECS(自定义SoA)和WASM实现中,第99百分位几乎**与平均帧时间相同**。
在OOP中,实体分散在堆上。随着它们移动和交互,JavaScript引擎的垃圾回收器不断被触发,并且CPU经常因等待指针查找而停顿。这会导致间歇性的帧掉落(微卡顿)。由于ECS使用预分配的、扁平的TypedArray,内存访问100%可预测,GC开销为零,从而保证了完全平滑的帧交付。
---
### 🏆 最终对决
**问题:** 我们能想到的最快且架构合理的代码是什么?**裁决:** 将顶尖的空间树(`WASM Tree`、`JS ECS Tree`)与扫描与裁剪冠军(`WASM Quick S&P`、`JS Quick S&P`)在15,000个混乱的碰撞体中进行比较:
| 竞争者 | 范式 | 架构 | 运行时(不稳定) | 裁决 |
| --- | --- | --- | --- | --- |
| **WASM Quick S&P** | ECS | 1D扁平数组 | **2.14 ms** | **🏆 获胜者(约比WASM Tree快7.9倍)** |
| **JS Quick S&P** | ECS | 1D扁平数组 | 5.32 ms | 最佳纯JavaScript引擎 |
| **WASM Tree** | ECS | 2D空间BVH | 16.98 ms | 空间树性能峰值 |
| **JS ECS Tree** | ECS | 2D空间BVH | 16.64 ms | JS空间树基线 |
#### **我的要点:**
1. **缓存局部性 > 算法复杂度**:在15,000个实体时,指针追逐和不可预测的树分支无法与扁平1D数组排序的连续L1/L2缓存局部性相竞争——尽管树具有更好的理论Big-O复杂度。
2. **你不需要WASM来获得ECS的优势**:仅仅将你的JavaScript代码库切换到扁平的结构体数组(SoA)布局就能获得相对于OOP**高达24倍的加速**。WASM是锦上添花(再提升2.5倍),而不是入门门票。
3. **实用主义胜出**:虽然手动调优的SoA是绝对最快的,但使用生产级ECS库(如`bitECS`)仍然能获得相对于OOP的巨大**14倍加速**,同时提供干净、可扩展的API。在我看来,对于99%的应用,使用库是正确的工程选择。
---
## 潜在改进
本节概述如何进一步优化S&P粗略阶段,用于生产级游戏引擎。
### 1. 通过ETL物理缓冲模式消除索引间接引用
#### **我们可以通过提取并物理重排一个轻量级物理缓冲区,在扫描阶段消除索引间接引用。**
在我们的简单ECS S&P中,扫描阶段必须间接读取坐标:`posX[indices[i]]`。虽然我们可以物理地对所有组件数组进行排序使其连续,但在真实游戏中这样做非常不切实际,因为原型碎片化以及重排非物理组件(如库存或AI状态)会造成浪费。
相反,我们可以使用**提取-转换-加载(ETL)物理缓冲区**模式:
1. **提取**:在粗略阶段开始时,仅将所需的空间字段(`x, y, w, h, id`)从主ECS数组复制到一个专用的、扁平的`PhysicsBuffer`中。
2. **转换(排序和重排)**:对这个缓冲区的`indices`进行排序,然后仅对这个轻量级的`PhysicsBuffer`进行物理重排。
3. **连续扫描**:扫描阶段现在可以顺序读取已排序的`PhysicsBuffer`(使用`posX[i]`而不是`posX[indices[i]]`)。
4. **加载(输出)**:粗略阶段将生成的接触对(实体ID)输出到精细阶段,而主ECS数据库完全不受影响。
**为什么这非常实用:**
- **通用兼容性**:由于数据被提取到独立的缓冲区中,这种模式**100%兼容任何ECS库(如bitECS)**。你只需向库查询,填充缓冲区,求解,然后写回。
- **难以置信的快速复制**:在JavaScript中直接进行O(N)顺序数据复制(使用`.set()`或简单循环)非常快。现代CPU高度优化了顺序内存复制,能够轻松饱和内存带宽,同时保持预取器的快乐。
- **可衡量的增益**:在我们的基准测试中实现这一点,在15,000个实体下额外获得了**约6%的加速**(在不稳定负载下,帧时间从**5.32毫秒**减少到**5.01毫秒**)。
### 2. 空间哈希网格(替代空间划分)
#### **对于统一大小的实体,扁平数组空间哈希网格可以提供平均情况下的O(1)性能,且无需排序或树平衡开销。**
虽然我们比较了扫描与裁剪和动态AABB树,但另一种高度缓存友好的替代方案是**空间哈希网格**。
- 与AABB树(需要递归、非线性的树遍历)不同,空间哈希网格将2D坐标映射到1D网格数组。
- 实体在单次O(N)传递中插入到网格单元格中,查询通过检查相邻单元格在O(1)时间内完成。
- 这应
相似文章
Echo-Memory:动作世界模型中记忆的受控研究
Echo-Memory 对动作条件世界模型中的记忆机制进行了受控研究,揭示了记忆结构和容量对开放域返回性能的影响显著超越回放保真度。该研究引入了一个匹配评估协议,并发现原始上下文和状态空间递归是强大的机制。
@himanshutwtxs:一篇关于主要智能体平台(Claude Code 等)内存架构现状的完整分析文章
全面分析主要 AI 智能体平台(Claude Code、OpenAI Codex、Copilot、Windsurf、Devin 等)的内存架构,讨论内存管理方式、当前缺陷以及未来发展方向。
智能体记忆:剖析
探讨智能体记忆库的组件与设计决策,澄清认知科学术语与工程实现之间的差距。
@mem0ai: https://x.com/mem0ai/status/2064383137338233179
本文分析了GitHub Copilot的内存架构,该架构使用锚定到特定代码引用的结构化内存对象,并采用即时验证来对抗知识过时。启用内存后,在针对真实开发者的A/B测试中,Copilot的拉取请求合并率从83%提高到90%。
@mem0ai: https://x.com/mem0ai/status/2054580022049198513
这篇文章解释了Codex CLI(OpenAI的开源编码代理)中的记忆机制。它描述了基于markdown文件的记忆架构、包含分阶段提取和整合的写入路径,以及使用关键词搜索的读取路径,所有设计都是为了可预测性和低检索成本。