做到 ECSDictionary 的时候,我原本觉得遍历已经没什么好优化的了。
数据已经是 Dense 的:
bash
Dictionary<TKey, int>
+
Dense Keys
+
ECSList
Value 连续存储,SoA 也已经做好。
那遍历不就是:
bash
foreach (var value in dictionary.Values)
{
...
}
还能出什么问题?
结果 IL2CPP Benchmark 一跑,我发现:
真的能出问题。
而且不是慢一点。
有一条遍历路径直接从正常的 0.x ms,掉到了接近:
bash
1.79 ms
这种量级。
我第一反应甚至不是"Enumerator 太慢"。
而是:
这测试是不是写错了?
数据明明已经连续,为什么 foreach 还能这么慢?
这才是最奇怪的地方。
如果底层是:
bash
HP HP HP HP HP HP...
理论上 CPU 应该可以一路扫过去。
而 ECSDictionary.Values 本身也不是普通 Hash Table 那种到处跳的 Value。
它背后就是 Dense ECSList。
所以按理说:
bash
foreach (var value in dictionary.Values)
和:
bash
for (int i = 0; i < count; ++i)
差距不应该特别离谱。
但 IL2CPP 下真实结果告诉我:
数据布局对了,不代表访问路径就一定对。
我最开始用了一个很"正常"的 Enumerator
为了让 API 用起来像普通集合,我自然会给:
bash
Keys
Values
Dictionary itself
都做 Enumerator。
于是业务代码可以写:
bash
foreach (var value in dictionary.Values)
{
...
}
从 C# 设计上看非常正常。
问题是:
Enumerator 本身也是代码。
尤其当它还是:
bash
Generic Enumerator
接口抽象
Current
MoveNext
这一套组合时,
在 Mono 或 Editor 下可能完全感觉不到问题。
但是到了:
bash
IL2CPP Release
情况就不一定一样了。
这次真正让我警觉的是:for 很快,foreach 很慢
这种问题最好定位。
因为底层数据没变。
测试逻辑也没变。
唯一变化只是:
bash
for
换成:
bash
foreach
如果性能突然掉一个数量级,
那基本就不用继续怀疑:
bash
SoA
Cache
Hash
Memory Layout
这些东西了。
问题就在:
遍历抽象本身。
这也是我越来越喜欢做微基准的原因。
真实业务里一帧几十个系统一起跑,
你看到:
bash
总共慢了 1ms
很难知道是谁干的。
但微基准里:
bash
for 正常
foreach 爆炸
问题一下就暴露出来了。
最坑的是:代码看起来一点都不像热点
比如:
bash
public bool MoveNext()
{
++mIndex;
return mIndex < mCount;
}
这种代码你平时看源码时会专门优化吗?
大概率不会。
因为它看起来太简单了。
一个:
bash
++
<
return
还能慢到哪去?
但问题不是某一行 C# 写得复杂。
而是:
它最终在 IL2CPP 下被生成成什么样。
泛型、接口、结构体 Enumerator、属性访问、边界检查......
单独看每个成本都很小。
但如果:
bash
500000 次
全部走一遍,
"小成本"就不再小了。
我最后没有继续死磕 Generic Enumerator
碰到这种问题,一个很容易走偏的方向是:
继续在原来的 Enumerator 上一点一点抠。
比如:
bash
AggressiveInlining
少一个属性
少一个临时变量
改一下 struct
这些我当然也会看。
但如果整个访问模型本身在 IL2CPP 下就不理想,
继续修补意义有限。
后来我开始给真正的热点路径找更直接的遍历方式。
其中一个很重要的方向就是:
ReadOnlySpan
对于连续的 Keys / Values,
如果能够直接暴露成适合遍历的连续视图,
就没必要让热点循环绕太多层。
SafeSpan Backend 里有一条 Values 遍历路径,优化前后数据非常明显:
bash
约 1.79 ms
↓
约 0.275 ms
这已经不是"省几个百分比"。
而是整个访问方式被重新压了下来。
为什么 Span 这类东西很适合 EasyECS?
因为 EasyECS 本来就在努力维护:
bash
Dense
连续
按字段排列
的数据。
这种情况下最自然的热点访问方式就应该尽量接近:
bash
连续内存
+
简单索引
而不是:
bash
连续内存
↓
集合包装
↓
泛型 Enumerator
↓
抽象层
↓
Current
↓
真正的数据
这也是前面 Direct Column 的同一个逻辑。
当底层已经非常快以后,
上层抽象就开始变得显眼。
但我也没有因此删掉 foreach
这点很重要。
看到:
bash
for 比 foreach 快
最简单粗暴的结论是:
框架里禁止 foreach。
我不喜欢这种做法。
因为很多地方根本不是热点。
例如:
bash
foreach (var pair in dictionary)
{
Debug.Log(pair.Key);
}
可能一年也执行不了多少次。
为了省一点理论性能,把正常 API 全删掉,反而降低易用性。
所以 EasyECS 还是保留:
bash
foreach
Keys
Values
Enumerator
这些正常使用方式。
但同时给热点路径准备:
bash
Dense Index
Ref
Span
Direct Column
等更低层的入口。
还是那个原则:
普通代码优先可读,热点代码才逐步降低抽象。
这次坑最有价值的地方,不是 foreach 本身
我后来回头看这次优化,觉得最值得记住的其实不是:
IL2CPP 下 Generic Enumerator 可能慢。
而是:
Editor 快,不代表 Player 快
尤其 Unity 项目里经常同时存在:
bash
Mono
Editor
Development Build
IL2CPP Release
一个 API 在 Editor 中完全正常,
并不能证明它到了最终发布环境还是同一个性能表现。
而 EasyECS 本身就是一个:
bash
靠 0.x ms
靠 ns/op
靠大规模循环
吃饭的框架。
所以如果 Benchmark 不在:
bash
IL2CPP Release Player
里跑,
很多问题根本看不到。
这也改变了 EasyECS 后面的测试方式
从这个问题以后,
我不会再看到:
bash
Editor 下很快
就认为优化完成。
真正封版前必须关心:
bash
Unsafe Backend
SafeSpan Backend
IL2CPP
Release
真实循环
GC
Enumerator
结构操作
因为这些路径经常会互相打脸。
一个在 Unsafe 下很漂亮的写法,
SafeSpan 可能完全不同。
一个 Mono 下几乎免费的抽象,
IL2CPP 可能突然变成明显热点。
写在最后
这次 foreach 的坑让我印象很深。
因为整个问题里:
bash
数据布局没错
SoA 没错
Dense Storage 没错
算法也没错
慢的只是:
你怎么走到这些数据面前。
这也是做高性能容器后越来越明显的一件事:
当底层数据已经足够快,上层一个看起来很普通的抽象,都可能成为新的瓶颈。
所以 EasyECS 后来一直在做一件事:
bash
正常 API
保留易用性
+
Fast Path
给真正热点逃到底层
而接下来还有一个很有意思的问题。
Benchmark 里经常看到:
bash
0 GC
但有一次我发现:
这个 0,未必真的能这么信。
下一篇:
Profiler 显示 0 GC,我为什么还是不敢相信它?
会聊 EasyECS 在 Release Player 里验证 GC 时踩到的另一个坑。
项目地址
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
:::