做到 EasyECS 的 ECSList 以后,我一度觉得这东西已经差不多了。
毕竟原来这种代码:
bash
RoleData role = roles[i];
role.mHP -= 1;
roles[i] = role;
已经可以写成:
bash
roles[i].mHP -= 1;
底层又是 SoA。
从使用体验到数据布局,看起来都已经解决了。
直到我跑了一次 50 万数据的 Benchmark。
结果是:
| 访问方式 | 50 万次单字段修改 |
|---|---|
List<RoleData> |
3.973 ms |
RoleData[] |
0.294 ms |
| ECS Indexer | 0.243 ms |
| ECS Ref | 0.244 ms |
| ECS Direct Column | 0.160 ms |
前面几个结果其实都在预期内。
但 Direct Column 的:
0.160 ms
让我开始重新考虑一个问题:
SoA 都已经做完了,剩下的 0.08ms 到底去哪了?
SoA 只是第一步
假设:
bash
[ECS]
public struct RoleData
{
public int mHP;
public float mSpeed;
public float mPositionX;
public float mPositionY;
}
底层已经变成:
bash
HP[]
Speed[]
PositionX[]
PositionY[]
如果我要更新 HP:
bash
for (int i = 0; i < roles.Count; ++i)
{
roles[i].mHP -= 1;
}
数据布局已经很好了。
CPU 实际访问的是连续的:
bash
HP HP HP HP HP HP HP...
但这里还有一个容易被忽略的问题:
roles[i] 本身仍然是一层抽象。
roles[i].mHP 不是直接访问数组
EasyECS 的 Indexer 返回的是:
bash
RoleDataRef
于是:
bash
roles[i].mHP
大致可以理解成:
bash
ECSList
↓
Indexer
↓
构造/返回 RoleDataRef
↓
Ref 找到 Storage
↓
定位 HP Column
↓
index
↓
HP
这些步骤每一个都很轻。
很多还会被:
bash
AggressiveInlining
内联掉。
所以最终 Benchmark 已经能做到:
bash
0.243 ms
甚至比普通数组测试中的:
bash
0.294 ms
还低。
但在一个执行:
bash
500000 次
的极端热点循环里,
哪怕每次只剩一点点抽象成本,乘上 50 万以后也看得见。
那先把 Ref 存下来呢?
如果一次循环里要访问多个字段,我更推荐:
bash
RoleDataRef role = roles[i];
role.mHP -= 1;
role.mPositionX += role.mSpeed;
role.mPositionY += role.mSpeed;
而不是不断:
bash
roles[i].mHP
roles[i].mPositionX
roles[i].mSpeed
roles[i].mPositionY
因为 Local Ref 可以避免重复走 Indexer。
这也是 EasyECS 设计 Ref 的原因之一。
不过在只修改一个字段的测试里:
bash
Indexer 0.243 ms
Ref 0.244 ms
几乎没有区别。
这其实是个好结果。
说明 JIT/IL2CPP + 内联已经把这层成本压得很低。
但如果我连 Ref 都不要呢?
于是我直接把 Column 暴露出来了
对于真正的热点循环,可以直接拿到底层字段 Column。
概念上类似:
bash
var hpColumn = roles.GetHPColumn();
for (int i = 0; i < count; ++i)
{
hpColumn[i] -= 1;
}
这时候访问路径突然变得非常短:
bash
HP Column
↓
index
↓
HP
不再需要:
bash
ECSList
→ Ref
→ 字段
于是结果变成:
bash
0.160 ms
已经开始非常接近"直接操作裸 SoA 数组"。
那为什么不干脆所有地方都用 Column?
因为这样写下去,EasyECS 很快就会退化成:
bash
hp[i]
speed[i]
positionX[i]
positionY[i]
也就是最开始我不想手写的那套东西。
性能当然很好。
但业务代码会重新开始关心:
bash
这个字段在哪个数组?
哪个 index 对应哪个对象?
结构变化以后 Column 还能不能用?
这就违背 EasyECS 的目标了。
所以我最终没有选择:
最快的 API 就应该成为默认 API。
而是留下了三层。
第一层:Indexer
普通代码直接写:
bash
roles[i].mHP -= 10;
优点很明显:
bash
简单
直观
接近普通 List 使用体验
对于绝大多数代码已经足够快。
第二层:Local Ref
当一个循环里连续访问多个字段:
bash
RoleDataRef role = roles[i];
role.mPositionX += role.mSpeed;
role.mPositionY += role.mSpeed;
role.mHP -= damage;
这时候先保存 Ref。
既保证可读性,又减少重复访问路径。
这是我认为大部分真正热点业务代码最舒服的一层。
第三层:Direct Column
只有当 Profiler 已经告诉我:
这个循环就是热点。
并且代码本身就是典型的:
bash
几十万数据
+
重复访问固定几个字段
我才会继续降到:
bash
Direct Column
例如:
bash
位置批处理
血量批处理
Buff Timer
粒子数据
服务器状态更新
这时候可读性让一点位置给性能,是值得的。
这让我改变了对"零抽象"的看法
以前做高性能代码时,很容易产生一种想法:
既然底层最快,就应该所有地方都直接用底层。
后来我越来越觉得这并不是一个好框架应该做的事情。
因为:
bash
0.243 ms
和:
bash
0.160 ms
之间确实有差距。
但如果这段代码一帧只执行:
bash
1000 次
这个差距几乎毫无价值。
此时为了省下那一点时间,把业务代码写成一堆裸 Column,反而得不偿失。
真正应该做的是:
bash
普通代码
→ Indexer
开始变热
→ Local Ref
确定是极端热点
→ Direct Column
按需降低抽象。
Direct Column 也有代价
它虽然快,但生命周期要求也更严格。
例如:
bash
Add
Resize
Insert
Remove
这类结构变化可能改变底层 Storage。
所以拿到 Column 以后,不能把它当成一个永久有效的东西到处保存。
更合理的方式是:
bash
进入热点循环
↓
获取 Column
↓
完成批处理
↓
结束使用
这也是为什么 EasyECS 没有把 Direct Column 包装成普通业务对象长期持有的 API。
它本质上就是一个:
给极端热点路径开的逃生舱。
这次 Benchmark 最有意思的不是"快了 34%"
从:
bash
0.243 ms
降到:
bash
0.160 ms
当然很不错。
但我觉得更重要的结论其实是:
数据布局优化以后,API 抽象本身开始变成下一个可见成本。
当 List<struct> 还要:
bash
3.973 ms
的时候,我们根本不会在乎一次 Ref 属性访问。
可当整个循环已经被压缩到:
bash
0.x ms
以后,
原本"不值得讨论"的东西也开始进入性能账单。
这就是性能优化很有意思的地方:
每消灭一个大问题,下一个小问题才有资格暴露出来。
写在最后
EasyECS 最终没有选择一个统一的"最佳访问方式"。
因为不存在这样的东西。
我更希望它提供:
bash
Indexer
易用
↓
Ref
性能与可读性的平衡
↓
Direct Column
极限性能
开发者可以根据热点程度选择不同层级。
而不是因为用了一个"高性能框架",就被迫让整个项目都写成最低级的数据访问代码。
下一篇我准备继续追着性能问题往下挖:
50 万条数据,List 为什么会输得这么惨?
同样只是改一个 int:
bash
List<RoleData> 3.973 ms
RoleData[] 0.294 ms
EasyECS Direct 0.160 ms
差距为什么能拉到这种程度?
下一篇不讲抽象概念,直接拆这组 Benchmark。
项目地址
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