做到 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