EasyECS 最慢的地方,居然是 Resize

0 阅读6分钟

做到 EasyECS 1.1.0 以后,大部分 Benchmark 的结果我都比较满意。

单字段访问:

List<RoleData>   3.973 ms
ECS Direct       0.160 ms

RemoveAtSwapBack 甚至可以做到 O(1)。

但有一个地方始终没那么漂亮:

Resize

而且这件事很难靠一个“小优化”解决。

因为 Resize 干的事情,本身就很重。


为什么普通 List 扩容已经不便宜?

先看最普通的:

List<RoleData>

当 Capacity 不够时,大致会发生:

申请更大的数组
↓
复制旧数据
↓
切换到新数组
↓
等待旧数组被回收

如果 RoleData 有几十个字节,已有十几万条数据,那么一次扩容本身就意味着搬运大量内存。

所以 List<T> 一直都有:

new List<T>(capacity)

这种接口。

如果提前知道大概数量,预分配 Capacity 往往比让它一路自动扩容更稳定。

EasyECS 也一样。

但问题是:

SoA 的 Resize 比普通 List 更复杂。


一个 List 扩一次,EasyECS 可能扩很多次

假设:

[ECS]
public struct RoleData
{
	public int mHP;
	public float mSpeed;
	public float mPositionX;
	public float mPositionY;
	public int mState;
}

普通 AoS 可以理解成只有一块:

RoleData[]

扩容一次就是申请一块更大的连续区域。

但 SoA 是:

HP[]
Speed[]
PositionX[]
PositionY[]
State[]

Capacity 从:

65536

增长到:

131072

时,不是一块数据要重新分配。

而是:

HP          Resize
Speed       Resize
PositionX   Resize
PositionY   Resize
State       Resize

每个 Column 都要处理。

如果再加上 Hybrid Storage:

Native SoA
Managed SoA
Native AoS
Managed AoS

一次 Resize 背后就可能同时涉及多套 Storage。

于是 EasyECS 平时访问越“碎得漂亮”,扩容时需要维护的区域反而越多。

这是 SoA 很现实的代价。


这也是为什么 Resize Benchmark 没有访问 Benchmark 那么好看

热点循环中的 EasyECS 可以非常快。

因为 CPU 只需要连续扫:

HP HP HP HP HP...

但 Resize 完全是另一种工作负载。

它主要消耗的是:

申请内存
复制内存
释放旧内存
多列同步
Managed Array 扩容

这时候:

Cache Friendly
Direct Column
Ref

这些平时的优势基本帮不上什么忙。

所以如果有人问:

EasyECS 是不是所有操作都比 List 快?

答案当然不是。

Resize 就是一个典型反例。


最简单的优化,其实不是优化 Resize

而是:

少 Resize

例如明知道场景最多会有 5 万个怪物:

MonsterDataECSList monsters = new MonsterDataECSList(50000);

通常比:

MonsterDataECSList monsters = new MonsterDataECSList();

然后一路:

16
32
64
128
256
...
65536

更稳定。

尤其游戏中很多数据数量其实是可以估算的:

怪物上限
子弹池上限
Buff 数量
场景单位
网络实体
伤害数字
粒子对象

既然知道量级,就没必要让运行时替你猜。

很多性能问题最便宜的解决方案就是:

提前把内存准备好。


但 Resize 不能因为慢就写得冒险

这里有个比性能更重要的问题。

假设当前 Storage 是:

capacity = 100000

现在要扩到:

200000

一种很危险的写法是:

先修改内部指针
↓
再分配新内存
↓
再复制数据

如果中间分配失败或者抛异常呢?

容器可能已经处于:

旧数据不能用
新数据也没准备好

的半损坏状态。

对于 Native Storage,这种问题尤其麻烦。


所以我更在意“Resize 失败以后旧数据还活着”

EasyECS 现在更倾向这样的顺序:

也就是:

Allocate → Copy → Commit

核心原则是:

新内存没有完全准备好以前,不碰旧状态。

这会让代码比简单的:

ReAlloc()

麻烦很多。

但对一个自己管理 Native Memory 的容器来说,我认为这是值得的。

因为一次 Resize 慢一点,最多掉帧。

一次 Resize 把 Storage 搞坏,后面可能直接变成:

非法指针
数据错位
随机 Crash

那就不是性能问题了。


Managed Storage 又是另一套麻烦

如果 Struct 里存在:

string
object

Resize 时还需要处理 Managed Array。

例如:

Name[]
Payload[]

它们不能直接按照 Native Memory 那套逻辑处理。

所以同一个 Struct 的 Resize 可能同时出现:

Native Memory Copy

+

Array Copy

这也是 EasyECS 有 Hybrid Storage 以后,Resize 代码明显复杂起来的原因。

平时:

role.mHP
role.mName

看起来只是两个字段。

到了扩容阶段,它们其实完全不在同一套内存系统里。


Capacity 其实是一种性能承诺

我现在越来越觉得:

new ECSList(50000)

中的:

50000

不只是一个“容量参数”。

它实际上是在告诉容器:

我预计近期不会超过这个规模,你可以安心在这块内存上工作。

这会直接减少:

重新分配
大块复制
Ref/Column 失效
瞬时内存峰值

所以在大量数据系统里,我会更愿意认真设置 Capacity。

而不是把:

自动扩容

当成完全免费的便利功能。


为什么我没有做一个“永不 Resize”的 EasyECS?

因为那又会走到另一个极端。

可以规定:

创建时必须给最大容量
以后绝不允许扩容

这样实现当然简单,Ref 和 Column 也更稳定。

但真实项目里很多数量根本没办法预测得那么准。

如果为了避免 Resize,直接预分配:

100 万

而实际上只用:

2 万

那只是把 CPU 问题换成内存浪费。

所以 EasyECS 还是保留自动扩容。

只是必须明确:

自动 Resize 是兜底能力,不应该成为高频运行路径。


Resize 也是为什么 Direct Column 不能乱存

假设:

var hpColumn = roles.GetHPColumn();

然后 ECSList 发生一次扩容。

底层 HP Storage 很可能已经:

旧地址
↓
新地址

发生变化。

这时候之前保存的 Direct Column 就不能继续当成永久对象使用。

所以推荐方式一直是:

开始热点循环
↓
获取 Column
↓
批处理
↓
结束

而不是:

Awake 获取一次
↓
保存几小时

Resize 不只是内存复制问题。

它还会直接影响:

Ref
Column
生命周期

这些上层设计。


写在最后

EasyECS 很多地方的优化方向都是:

让数据更连续
减少抽象
减少无效访问

但 Resize 恰恰相反。

它几乎天然就是一次:

大块内存操作
+
多 Storage 同步

所以 EasyECS 对 Resize 的思路不是:

想办法让它变成一个“超快 API”。

而是:

正常支持它
↓
保证异常安全
↓
尽量减少发生次数
↓
热点系统提前预估 Capacity

这也是我觉得性能框架里很重要的一点:

有些操作最好的优化,不是让它执行得更快,而是尽量不要让它执行。

下一篇会继续讲 Resize 留下来的另一个问题:

为了让 Ref 在 Resize 后还能用,我没有让它直接指向数据

这也是 EasyECS 里一个我认为比单纯性能数字更重要的设计。


项目地址

EasyECS 是 MyFramework 仓库中的独立 Unity Package。

MyFramework:

https://github.com/ZHOURUIH/MyFramework

EasyECS UPM:

https://github.com/ZHOURUIH/MyFramework.git?path=/Packages/com.zhourui.easyecs