我以为 ECSList 已经够快了,直到我直接拿到了 Column

做到 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
相关推荐
SmalBox1 天前
【动态着色】Unity 实现全息投影着色器
unity3d·游戏开发·图形学
SmalBox2 天前
【动态着色】Unity 实现箭头图案
unity3d·游戏开发·图形学
SmalBox3 天前
【扭曲着色器】Unity实现-黑洞扭曲效果
unity3d·游戏开发·图形学
SmalBox4 天前
【扭曲着色器】Unity实现-冰纹理折射
unity3d·游戏开发·图形学
Behavior5 天前
Unity文字显示带竖线问题
c#·unity3d
SmalBox5 天前
【Vertex着色器】Unity实现-体积雪效果
unity3d·游戏开发·图形学
SmalBox6 天前
【Vertex着色器】Unity实现-从黑洞中生成对象
unity3d·游戏开发·图形学
GitLqr7 天前
Flutter + Unity 混合开发:用 unity_kit 实现高效的双向通信
flutter·unity3d·全栈
SmalBox7 天前
【Vertex着色器】Unity实现-程序性游鱼动画
unity3d·游戏开发·图形学