ECS 的账本不在迭代上
结构性变更等于数据搬家;线程被唤醒一次 30 微秒;一个 transform 传播吃掉 1 毫秒。这三笔才是我判断要不要上 ECS 的尺子。
我写架构文章有个习惯:不接受任何没有数字的方案说明。所以这篇把 ECS 相关的一手实测数字集中放一起 —— 它们大多不发生在"遍历"这一项上。
尺子先立起来:0.30ms 对 11.31ms
Richard Fabian 的 Data-Oriented Design 里有一张我认为最该被引用的表。同样的工作量,只改数据在哪:
| 情况 | 平均查询耗时 |
|---|---|
| 朴素做法(每次都去问地图) | 11.31 ms |
| 25% 命中缓存 | 9.62 ms |
| 50% 命中 | 8.77 ms |
| 保证在场 | 3.71 ms |
| 95% | 1.51 ms |
| 完全 in-cache | 0.30 ms |
同一份计算,约 38 倍的差别来自放置而不是算法。Robert Nystrom 的 Data Locality 那章给了同一件事的另一面:"从 RAM 取一个字节可能要几百个周期",以及他手上那对基准 —— 慢的那个比快的慢了五十倍,差别纯粹来自缓存行为。他还指出 64~128 字节的缓存行会顺带预取,所以"按 CPU 想要的顺序排数据"是有硬件杠杆的。
有了这两组数,我的判断标准变成一句话:先量缓存命中率,再决定要不要重构这个系统。 如果命中率本来就很高,重构不会给你 38 倍,只会给你风险。
同一章里有个特别小但特别好的技巧:粒子池不要扫描 isActive 标志,而是把活的对象和第一个未激活槽位对调,让死掉的都堆在尾部。这样既省掉一个分支,又保住了密度。Unity 的 ECS 存储文档里移除实体的做法一模一样 —— "把 chunk 里最后一个实体的组件搬进刚空出来的槽位"(swap-with-back)。同一个招在二十年前的高性能语言和现代引擎里都存在,说明它是布局决定的,不是风格决定的。
但 DOD 不是"把所有 class 拆成 struct"
Fabian 那本书开头一句话是我最喜欢的反炒作表述:关于面向数据设计一个常见的误解是"它全是关于缓存 miss 的"。而他承认得也很干脆:在驱动/硬件抽象那一层,面向对象设计明显更好。
他更狠的论点是思维层面的:把完整对象当思考的基本块会导致非常低效的算法;封装"看起来只是提供了一种藏 bug、并让程序员做隐含假设的手段";继承则把人锁进"IS-A 关系"的思考方式里。到了他所谓 There is no entity 那一节,立场走到极端:组件不是关于实体的事实,组件就是实体;让 manager 控制代码何时执行,是迈向完全可并行代码的关键一步。他的动机是数据库规范化(每个组件类型一张表),不是 bitmask。
我的看法:这条路线是自洽但贵的。它要求你放弃" GameObject 上挂组件"这个大家一看就懂的心智模型,换来的是流水线性。所以它适合"变换密集、结构稳定"的系统(粒子、动画、渲染准备),不适合"抽象层"(设备、平台、第三方 SDK)—— 后者恰好是 Fabian 自己划出去的。
真正的账本:结构性变更 = 数据搬家
Unity 那篇文档最值钱的不是介绍 archetype,而是明确写出:"组件类型的唯一组合叫做 Archetype",每个 ArchetypeChunk 只装同 archetype 的实体,于是 add/remove 组件会把该实体的组件搬到别的 chunk 里。查询便宜的原因则是"archetype 的数量通常远小于实体数量"。
这就是 ECS 的隐性成本中心。所以我评估一个 ECS 方案时不问"迭代多快",问这两句:
- 我每帧做多少次 add/remove?
- 这些变更会不会导致实体在 chunk 之间反复搬家?
Sander Mertens 的 ECS FAQ 把这个取舍写成了可查表的三条:archetype/table 存法"查询和迭代快",sparse set"增删快",bitset 存法用实体 id 做数组下标 + 位图标记。也就是说 —— 选型的输入是"结构性变更频率 vs 查询基数"这两条曲线,不是框架的名气。
引擎侧还有两笔常数税,很容易被漏掉
第一笔在调度器。Bevy 的 0.14 发布说明(2024-07-04)里那句我反复引用:线程被唤醒一次,冷缓存下可能要花到 30us。他们把许多"每事件一个 system"合并成一个,减少一次调度里的上下文切换,实测跑一遍 schedule 从 140us 降到 25us(约 5.6 倍)。
注意这跟 ECS 理论无关 —— 这是执行器 signalling 的成本。16.6ms 的帧预算里,115us 白白花在"叫醒工人"上,等于 0.7% 的帧;而如果你的游戏有几十上百个细碎 system,这笔就会线性变差。我的做法是把"每帧的调度次数"也当成一个指标量出来,它和 draw call 是同类科目。
第二笔在隐藏的 O(n) 遍历。Bevy 0.16(2025-04-24)给了一个干净的 before/after:Transform 传播从 1.1ms 降到 0.1ms,做法是把"逐实体递归更新层级"改写成一个数据批次传递。
1.1ms 是什么概念?它是 16.6ms 预算的 6.6%,花在"每个父节点都有孩子,所以要走一遍树"这件事上 —— 而多数人一辈子没量过它,因为它在 profiler 里长得像"引擎自己的开销"。这类"每帧整棵树/整个表"的隐藏遍历,是我现在优先审的对象:先找出吃掉毫秒级的固定项,再谈要不要换架构。
我现在的判断顺序
- 量缓存行为(命中率、或至少"一次遍历 touched 多少字节")。没有 38 倍的空间就别动布局。
- 数结构性变更。每帧 add/remove 多的系统,archetype 是负债;几乎不变的系统,它才是资产。
- 量调度次数与隐藏遍历。30us × N 和 1ms × 1 都比你以为的大。
- 以上都做完才轮到"要不要引入一个 ECS 框架"。
顺便,Nystrom 在 Entity-Component 那章里那句关于性能的话,我认为是全套讨论里最冷静的定位:组件让"用数据局部性模式按 CPU 想要的顺序组织数据"变得更容易 —— 是"更容易",不是"更快"。他同时也承认组件之间的指针解引用会伤害内层循环。同章那个容器为中心的广播设计还有个具体提醒:components_[MAX_COMPONENTS] 这种固定数组广播消息时,发起者自己也会收到消息,所以你需要一个重入保护。这类细节才是"读了文章但没实现过"和"实现过"的差别。
一句自我提醒
我见过太多"ECS 让我们快了"的复盘,最后发现真正变快的是固定步长、对象数组或者一次 profiler 定位。 Glenn Fiedler 的 Fix Your Timestep!(2004)里那套 dt = 1/60 + accumulator + alpha = accumulator / dt 的渲染混合,是不含任何 ECS 也能拿到的收益,而且它顺带解决了"死亡螺旋"(追不上就一直追,越追越卡)。所以我把这一项排在所有架构讨论之前。
参考来源
- Data Locality、Object Pool、Entity-Component — Robert Nystrom, Game Programming Patterns
- Helping the compiler、There is no entity、框架与批评 — Richard Fabian, Data-Oriented Design(2018)
- ECS core storage / Archetype & chunk — Unity Entities 文档
- Frequently asked questions about Entity Component Systems — Sander Mertens
- Bevy 0.14 与 Bevy 0.16 — Bevy Contributors(调度与 Transform 传播的实测数字出自这两篇)
- Fix Your Timestep! — Glenn Fiedler(2004)