Chancy game dev notes · 全栈工程师

缓存行不关心你的继承树有多漂亮

组件、ECS 和面向数据是三件事。64 字节、AoS/SoA 的取舍、false sharing,以及"我把两个字段从结构体里挪走之后快了三倍"。

· 5 分钟读完

#architecture #performance

上一篇我引了 Nystrom 那句"组件会把数据打散,从而伤 cache locality",当时觉得是脚注。后来我发现这句话是整件事的核心,只是它说得太轻。

先统一度量衡

Sander Mertens(Flecs 作者)的 ECS FAQ 里那条基础事实值得每次优化前默念一遍:CPU 不是按字段读内存的,是按缓存行读的 —— 一次把起始地址往后的整块(64 字节)搬进来。

于是所有"面向数据"的收益和亏损都能用一句话算出来:你迭代的时候,需要的字节有没有连续落在同一条 64 字节里。

  • AoS(Array of Structs):{pos, vel, mass, sprite, netId, debugTag} 排在一起。你写位置系统时只用 pos(12 字节),但每个实体都把 sprite / netId / debugTag 一起拉进缓存 —— 一条缓存行装不下几个实体,遍历 1000 个实体要打出远多于必要次数的 miss。
  • SoA(Struct of Array):位置一个数组、速度一个数组。迭代密度最好。但代价是随机访问要跳好几个数组,各自打中不同的缓存行;而且不同数组之间的对齐/填充会各自浪费。

Mertens 那篇里有两条我以前的理解是错的:

  1. SoA 不是万能答案。 访问模式是"按 id 随机取一个实体的全部属性"时,SoA 反而更差。缓存收益取决于访问模式,不取决于布局的名字好不好听。
  2. false sharing:同一缓存行被多个线程各写各的不同字段,硬件会把整行当成冲突点反复同步 —— 你的两个线程在逻辑上毫无共享,性能却退化得像在抢锁。解决办法是填充到行边界,而这又反过来浪费带宽。

还有一条明确写着的:ECS ≠ 面向数据设计。ECS 是一套"实体=ID、组件=数据、系统=逻辑"的组织方式;DOD 是对内存布局和数据流的取向。你可以写一个完全不 DOD 的 ECS(archetype 里塞满指针,或者每个组件指向堆上分散的对象),也可以不用 ECS 做纯 DOD(粒子数组 + 结构体数组就够)。

Mertens 对两种主流存法的划分我也认为比一般介绍更准:

存法迭代增删组件
archetype / table(组件是列,实体是行)极好,连续内存直接扫差,换 archetype 要整行搬家
sparse set(每种组件一个稀疏索引)好,但跨组件要对齐 id极好,加一个组件只是写一个数

所以"用 ECS 还是不用"根本不是信仰问题,而是问你:你更常做哪件事,遍历还是改组件。角色属性几乎不改 → archetype 舒服;Buff/临时标签满天飞 → sparse set 明显更值。

那个"砍掉两个字段快了三倍"

Nystrom 的 Component 那章讲组件的设计动机时其实完全没提性能(他后来在别处也强调过这点):组件是为了让一个实体横跨多个领域而不耦合这些领域。他举的失败案例是死亡钻石(多层继承在叶子节点撞车)。

但工程上真正的收获是副产物:当你把 Sprite、Audio、Collider 拆成组件之后,"位置系统能看到的数组"自然就只含位置相关的数据了。也就是说——

组件模式给你的是语义上的分离;面向数据给你的是物理上的分离。前者是后者的前提,但前者不会自动带来后者。

我判断自己有没有真的吃到收益,就一个动作:把系统实际读取的字段列出来,算它占总字节的百分比。这个比例如果低到 30% 以下,那就还有明显的钱躺在桌上 —— 因为多数缓存行都在传你没读的东西。这一步是纯算术,不需要 profiler,也不需要引入任何 ECS 框架。

顺带一句 Godot 的 CPU optimization 里的方法论:它把"线性访问内存"排在很前面,理由就是让硬件预取真的能预取;同一篇要求手写基准跑 1000 次以上取平均,因为首次运行的数据被缓存预热污染,改完必须复测。我觉得这是最省事的自检:你的优化如果只在第一次运行有效,那你测的是冷缓存,不是你的改动。

工业参照:Overwatch

Overwatch 的玩法架构与网络代码(Timothy Ford,GDC 2017)的官方摘要确认玩法层采用 ECS + 确定性同步的网络模型 —— 这算是"组件分离"和"确定性重放"两个需求互相成就的例子:玩法要能被固定步长重放,逻辑就越不能有隐状态和深继承,ECS 恰好是那个形状。

我把这个链接放这儿,但要说清楚:GDC Vault 的完整视频需要登录,我这次只读到了官方摘要,没有逐字核对演讲内容。真按里面细节引用的应该是看完整版的人。

我会带走的四条

  1. 先量"系统读取的字节 / 结构体总字节",再决定要不要换布局。
  2. 遍历为主用 archetype,增删为主用 sparse set —— 别用别人的访问模式做自己的选型。
  3. 多线程时先想 false sharing,再想加锁。
  4. ECS 是组织方式不是性能方案;把它当性能方案引入,最后往往既没性能也没组织。

参考来源