10000 Update() calls

未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器
对于有经验的开发人员来说,这段代码有点奇怪。
1.目前还不清楚这种方法究竟是如何调用的。
2.如果一个场景中有多个对象,则不清楚这些方法的调用顺序。
3.这种代码风格无法使用 intellisense。
不,Unity 不会在每次需要调用魔法方法时都使用 System.Reflection 来找到它。
相反,在首次访问给定类型的 MonoBehaviour 时,会通过脚本运行时(Mono 或 IL2CPP)检查底层脚本是否定义了任何魔法方法,并缓存该信息。如果 MonoBehaviour 有特定的方法,它就会被添加到相应的列表中,例如,如果脚本定义了 Update 方法,它就会被添加到需要每帧更新的脚本列表中。
在游戏过程中,Unity 会遍历这些列表并执行其中的方法,就是这么简单。此外,这也是为什么 Update 方法是公开还是私有并不重要的原因。
我们都使用某种集成开发环境来编辑 Unity 中的 C# 脚本,其中大多数人都不喜欢神奇的方法,因为他们不知道这些方法在哪里被调用。这会导致警告,并增加浏览代码的难度。
有时,开发人员会添加一个扩展 MonoBehaviour 的抽象类,称之为 BaseMonoBehaviour 或类似的类,并让项目中的每个脚本都扩展这个类。他们在其中加入了一些基本的实用功能,以及一些类似的虚拟魔法方法:
未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器
这种结构使在代码中使用 MonoBehaviours 更合理,但也有一个小缺陷。我打赌你已经想通了...
您的所有 MonoBehaviours 都将出现在 Unity 内部使用的所有更新列表中,您的所有脚本的每一帧都将调用所有这些方法,但大多数情况下什么也不做!
也许有人会问,为什么要关心一个空洞的方法呢?问题是,这些是从本地 C++ 环境调用到托管 C# 环境的调用,它们是有代价的。让我们看看这笔费用是多少。
为了写这篇文章,我在Github 上创建了一个小型示例项目。它有 2 个场景,可通过点击设备或按编辑器中的任意键进行更改:
(1)在第一个场景中,用这段代码创建了 10000 个 MonoBehaviours:
未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器
(2)在第二个场景中,又创建了 10000 个 MonoBehaviours,但它们没有更新,而是有一个自定义的 UpdateMe 方法,该方法由管理器脚本在每一帧中调用,就像这样:
未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器
测试项目在 2 台 iOS 设备上运行,这些设备在 Release 配置下以非开发模式编译为 Mono 和 IL2CPP。测量时间如下
在调用的第一个更新中设置秒表(在脚本执行顺序中配置)、
在 LateUpdate 停止计时、
在几分钟内平均计时。
统一版本:5.2.2f1
iOS 版本:9.0

哇太多了!一定是测试出了问题!
事实上,我只是忘了将脚本调用优化设置为 "快速 "但无异常,但现在我们可以看看这一特定设置对性能有什么影响......不过,有了 IL2CPP,也就没人在乎了。

好了,这样好多了。让我们切换到 IL2CPP。

在这里,我们看到了两件事:
1.这种特殊的优化在 IL2CPP 中仍然有效。
2.IL2CPP 仍有改进的余地,就在我写这篇文章的时候,脚本和 IL2CPP 团队正在努力提高性能。例如,最新的脚本分支包含优化功能,使测试运行速度提高了 35%。
稍后我将解释 Unity 在引擎盖下的工作。但现在,让我们修改一下管理器代码,使其速度提高 5 倍!
如果您还没有阅读过这一系列有关 IL2CPP 内部结构的文章,那么您应该在读完这篇文章后马上阅读!
事实证明,如果您想每帧遍历一个包含 10000 个元素的列表,最好使用数组而不是 List,因为在这种情况下生成的 C++ 代码更简单,数组访问也更快。
在下一个测试中,我将 List<ManagedUpdateBehavior> 更改为 ManagedUpdateBehavior[]。

这样看起来好多了!
更新:我在 Mono 上用阵列进行了测试,结果为0.23ms。
我们已经知道从 C++ 到 C# 调用函数的速度并不快,但让我们来看看 Unity 在所有这些对象上调用 Updates 时究竟在做什么。最简单的方法是使用 Apple Instruments 的 Time Profiler。
请注意,这不是单声道与单声道的对比。IL2CPP 测试 - 对于 Mono iOS 构建来说,进一步描述的大部分内容也是正确的。
我在 iPhone 6 上用 Time Profiler 启动了测试,记录了几分钟的数据,并选择了一分钟的时间间隔进行检查。我们对从这条线开始的一切都感兴趣:
未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器
如果您以前没有使用过 Instruments,您可以在右侧看到按执行时间排序的函数和它们调用的其他函数。最左边一列是这些函数和它们调用的函数的 CPU 时间(毫秒)和百分比,左边第二列是函数的自执行时间。请注意,由于在本实验中,Unity 并未完全使用 CPU,因此我们看到在 60 秒的时间间隔内,Update 占用了 10 秒的 CPU 时间。显然,我们对执行时间最长的函数感兴趣。
我利用我疯狂的 Photoshop 技术,用颜色标记了几个区域,以便大家更好地了解发生了什么。

中间是我们的 Update 方法,也就是 IL2CPP 对它的称呼--UpdateBehavior_Update_m18。但在到达那里之前,团结号还做了很多其他事情。
Unity 会对所有行为进行更新。特殊的迭代器类 SafeIterator 可以确保在有人决定删除列表中的下一个项目时不会出现任何问题。在总共 9979 毫秒的时间里,仅遍历所有已注册行为就需要 1517 毫秒。
接下来,Unity 会进行一系列检查,以确保它调用的是活动 GameObject 上的有效方法,而该活动 GameObject 已被初始化,其 Start 方法也已被调用。你不会希望在更新过程中破坏 GameObject 而导致游戏崩溃吧?在总共 9979 毫秒的时间里,这些检查又花费了 2188 毫秒。
Unity 会创建一个 ScriptingInvocationNoArgs 实例(代表从本地调用到托管调用)以及 ScriptingArguments,并命令 IL2CPP 虚拟机调用方法(scripting_method_invoke 函数)。这一步骤耗时 2061 毫秒,而总耗时为 9979 毫秒。
scripting_method_invoke 函数检查传递的参数是否有效(900ms),然后调用 IL2CPP 虚拟机的 Runtime::Invoke 方法(1520ms)。首先,Runtime::Invoke 会检查是否存在此类方法(1018ms)。接下来,它会调用生成的 RuntimeInvoker 函数进行方法签名(283ms)。它反过来调用我们的 Update 函数,根据 Time Profiler 的数据,该函数的执行时间为 42 毫秒。
还有一张色彩斑斓的桌子。

现在,让我们使用 Time Profiler 进行管理器测试。您可以从截图中看到,有相同的方法(其中一些方法的总耗时不到 1 毫秒,因此没有显示),但大部分执行时间实际上是在 UpdateMe 函数(或 IL2CPP 如何称呼它 - ManagedUpdateBehavior_UpdateMe_m14)上。此外,IL2CPP 还插入了空值检查,以确保我们迭代的数组不是空值。
下一张图片使用了相同的颜色。

那么,你现在怎么想,是否应该在乎一个小小的方法调用呢?
老实说,这个测试并不完全公平。Unity 可以很好地保护您和您的游戏,防止意外行为和崩溃:该游戏对象是否处于活动状态?它不是在这次更新循环中被毁了吗?对象上是否存在更新方法?如何处理在更新循环中创建的 MonoBehaviour?- 我的管理器脚本不处理任何此类内容,它只是遍历要更新的对象列表。
在现实世界中,经理人脚本可能会更加复杂,执行速度也会更慢。但在这种情况下,我是开发者--我知道我的代码应该做什么,而且我在设计我的管理器类时也知道在我的游戏中哪些行为是可能的,哪些是不可能的。遗憾的是,"团结 "并不具备这样的知识。
当然,这完全取决于您的项目,但在实际游戏中,在场景中使用大量 GameObjects 的情况并不少见,每个 GameObjects 每帧都在执行某些逻辑。通常情况下,这只是一点点代码,似乎不会影响任何事情,但当数量增长到非常大时,调用数千个 Update 方法的开销就开始明显了。此时再改变游戏架构,将所有这些对象重构为管理器模式,可能为时已晚。
您现在已经掌握了数据,请在下一个项目开始时考虑一下。
