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