EasyECS 最慢的地方,居然是 Resize

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

单字段访问:

bash 复制代码
List<RoleData>   3.973 ms
ECS Direct       0.160 ms

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

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

Resize

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

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


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

先看最普通的:

bash 复制代码
List<RoleData>

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

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

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

所以 List<T> 一直都有:

bash 复制代码
new List<T>(capacity)

这种接口。

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

EasyECS 也一样。

但问题是:

SoA 的 Resize 比普通 List 更复杂。


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

假设:

bash 复制代码
[ECS]
public struct RoleData
{
	public int mHP;
	public float mSpeed;
	public float mPositionX;
	public float mPositionY;
	public int mState;
}

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

bash 复制代码
RoleData[]

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

但 SoA 是:

bash 复制代码
HP[]
Speed[]
PositionX[]
PositionY[]
State[]

Capacity 从:

bash 复制代码
65536

增长到:

bash 复制代码
131072

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

而是:

bash 复制代码
HP          Resize
Speed       Resize
PositionX   Resize
PositionY   Resize
State       Resize

每个 Column 都要处理。

如果再加上 Hybrid Storage:

bash 复制代码
Native SoA
Managed SoA
Native AoS
Managed AoS

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

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

这是 SoA 很现实的代价。


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

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

因为 CPU 只需要连续扫:

bash 复制代码
HP HP HP HP HP...

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

它主要消耗的是:

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

这时候:

bash 复制代码
Cache Friendly
Direct Column
Ref

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

所以如果有人问:

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

答案当然不是。

Resize 就是一个典型反例。


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

而是:

少 Resize

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

bash 复制代码
MonsterDataECSList monsters = new MonsterDataECSList(50000);

通常比:

bash 复制代码
MonsterDataECSList monsters = new MonsterDataECSList();

然后一路:

bash 复制代码
16
32
64
128
256
...
65536

更稳定。

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

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

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

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

提前把内存准备好。


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

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

假设当前 Storage 是:

bash 复制代码
capacity = 100000

现在要扩到:

bash 复制代码
200000

一种很危险的写法是:

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

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

容器可能已经处于:

bash 复制代码
旧数据不能用
新数据也没准备好

的半损坏状态。

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


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

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

也就是:

Allocate → Copy → Commit

核心原则是:

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

这会让代码比简单的:

bash 复制代码
ReAlloc()

麻烦很多。

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

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

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

bash 复制代码
非法指针
数据错位
随机 Crash

那就不是性能问题了。


Managed Storage 又是另一套麻烦

如果 Struct 里存在:

bash 复制代码
string
object

Resize 时还需要处理 Managed Array。

例如:

bash 复制代码
Name[]
Payload[]

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

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

bash 复制代码
Native Memory Copy

+

Array Copy

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

平时:

bash 复制代码
role.mHP
role.mName

看起来只是两个字段。

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


Capacity 其实是一种性能承诺

我现在越来越觉得:

bash 复制代码
new ECSList(50000)

中的:

bash 复制代码
50000

不只是一个"容量参数"。

它实际上是在告诉容器:

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

这会直接减少:

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

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

而不是把:

bash 复制代码
自动扩容

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


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

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

可以规定:

bash 复制代码
创建时必须给最大容量
以后绝不允许扩容

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

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

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

bash 复制代码
100 万

而实际上只用:

bash 复制代码
2 万

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

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

只是必须明确:

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


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

假设:

bash 复制代码
var hpColumn = roles.GetHPColumn();

然后 ECSList 发生一次扩容。

底层 HP Storage 很可能已经:

bash 复制代码
旧地址
↓
新地址

发生变化。

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

所以推荐方式一直是:

bash 复制代码
开始热点循环
↓
获取 Column
↓
批处理
↓
结束

而不是:

bash 复制代码
Awake 获取一次
↓
保存几小时

Resize 不只是内存复制问题。

它还会直接影响:

bash 复制代码
Ref
Column
生命周期

这些上层设计。


写在最后

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

bash 复制代码
让数据更连续
减少抽象
减少无效访问

但 Resize 恰恰相反。

它几乎天然就是一次:

bash 复制代码
大块内存操作
+
多 Storage 同步

所以 EasyECS 对 Resize 的思路不是:

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

而是:

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

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

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

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

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

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


项目地址

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

MyFramework:

bash 复制代码
https://github.com/ZHOURUIH/MyFramework

EasyECS UPM:

bash 复制代码
https://github.com/ZHOURUIH/MyFramework.git?path=/Packages/com.zhourui.easyecs
相关推荐
fujisheng6612 小时前
Unity 异步 UI 实战:取消令牌、版本校验与旧句柄隔离
c#·unity3d
SmalBox2 小时前
01-02-认知篇-基础-Unity资源管理发展史
unity3d·游戏开发
SmalBox20 小时前
01-01-认知篇-概念-YooAsset是什么
unity3d·游戏开发
SmalBox2 天前
YooAsset-全篇导览
unity3d·游戏开发
_zhourui_h_3 天前
EasyECS:一个 foreach,让 IL2CPP 性能直接掉了一个数量级
unity3d
SmalBox3 天前
【交互着色效果】Unity实现-交互式雪地效果
unity3d·游戏开发·图形学
fujisheng6613 天前
Unity MVVM 最小实现:从手写事件到可测试 BindingContext
unity3d·mvvm
EzSharer4 天前
为什么你开发的 Unity 游戏越玩越烫?(系列 · 第 1 篇)
unity3d
陈言必行4 天前
Unity开发实战技巧:脚本优化与性能提升
unity3d