EasyECS:一个 foreach,让 IL2CPP 性能直接掉了一个数量级

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

:::

相关推荐
SmalBox9 小时前
【交互着色效果】Unity实现-交互式雪地效果
unity3d·游戏开发·图形学
fujisheng6619 小时前
Unity MVVM 最小实现:从手写事件到可测试 BindingContext
unity3d·mvvm
EzSharer1 天前
为什么你开发的 Unity 游戏越玩越烫?(系列 · 第 1 篇)
unity3d
陈言必行1 天前
Unity开发实战技巧:脚本优化与性能提升
unity3d
SmalBox1 天前
【高级着色器】Unity实现-卡通风格水着色器
unity3d·游戏开发·图形学
_zhourui_h_2 天前
Unity WebSocket全平台极限兼容:浏览器禁掉原生 Socket 后,WebGL 到底怎么连服务器?
unity3d
SmalBox2 天前
【高级着色器】Unity实现-彩虹泡泡着色器
unity3d·游戏开发·图形学
fujisheng6613 天前
从 GetTypes() 到强类型 Route:FUI Source Generator 的设计演进
unity3d
SmalBox3 天前
【动态着色】Unity实现3D扫描线
unity3d·游戏开发·图形学