改进 2022.2 中的工作系统性能缩放 - 第 1 部分:背景和应用程序接口

KEVIN MACAULAY VACHERESSE / UNITY TECHNOLOGIESLead Engineer
Feb 24, 2023|13 Min
改进 2022.2 中的工作系统性能缩放 - 第 1 部分:背景和应用程序接口
为方便起见,此网页已进行机器翻译。我们无法保证翻译内容的准确性或可靠性。如果您对翻译内容的准确性有疑问,请参阅此网页的官方英文版本。

在2022.2和2021.3.14f1,我们改进了Unity作业系统的派发成本和性能缩放。这里,我将简单回顾下并行编程和作业系统,讨论作业系统的性能开销,分享Unity缓解开销压力的方法。

在第一部分,我们会讨论并行编程和作业系统API的背景信息。如果您已经熟悉并列法,请随意略过,跳到第二部分

并行模式背景

在 2017.3 版本中,为内部 C++ Unity 作业系统添加了公共 C# API,允许用户编写称为 "作业 "的小函数,并异步执行。用作业替代老函数的目的在于让主线程上的代码能方便、安全、高效地运行于“工作”线程上,最好是能并行运行。这有助于减少主线程完成游戏模拟所需的总体挂壁时间。使用作业系统完成CPU运算可以极大地提升性能,允许游戏根据运行硬件自然地缩放性能。

如果我们将算力视作一种有限资源,单个CPU核心在一定时间内所能完成的“工作”也是有限的。例如,如果一个单线程游戏需要其模拟Update()的时间不超过 16 毫秒,但目前需要 24 毫秒,那么 CPU 就有太多工作要做,需要更多时间。为了达成16毫秒这个目标,我们只有两种选择:让CPU运行得更快(即抬高游戏的最低系统要求,一般不太合适),或者做更少的工作。

未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器

Update()函数在主线程上会执行24毫秒
Update()函数在主线程上会执行24毫秒

我们的目的在于砍掉这8毫秒,或通过改进算法,或将子系统的工作分散到多张帧上,抑或移除开发期间积累起来的多余运算,等等。如果这样还不能达成性能目标,我们也能试着砍掉游戏内容或玩法来降低模拟的复杂程度,比如限制同一时间生成的敌人数量,当然这种做法并不理想。

但倘若,我们不必消灭这些工作,转而将其分发给另一个CPU核心来运行如何呢?今天,大部分CPU都有多个核心,总的算力等于单条线程乘上CPU的核心数。若我们能巧妙并安全地把Update()里的所有工作分到两颗CPU核心上,24毫秒可以被分作两个12毫秒的并行任务。这就很好地满足了16毫秒的目标。甚至,如果工作能分成四份,在四颗核心上运行,那么Update()只会花费6毫秒!

这种在所有可用内核上分工和运行的方式被称为性能扩展。核心越多,可以并行执行的工作也越多,不用修改代码,Update()的实际耗时就能大幅下降。

未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器

Update()被分成四次更新,运行在自己的线程上

可惜,这只存在于幻想里。光靠Update()自己是无法把工作分到不同的核心上运行的。即便我们用上了128核的CPU,上边的Update()在同样的时钟速度下还是得花24毫秒。其他的核心都浪费了!那么,我们怎样才能写出运用所有CPU核心的应用,增强并行运行能力?

一种方法是多线程运行。也就是说,你的程序会创建线程来运行一个功能,而操作系统会为你安排运行这个功能。当CPU有数颗核心,那每条线程都能同时运行在单独的核心上。如果线程数量多于可用内核,操作系统就会负责确定哪个线程可以在某个内核上运行,以及运行多长时间,然后再切换到另一个线程,这个过程称为上下文切换

不过,多线程编程实行起来有一堆小问题。在上方的理想情况下,Update()函数可以被平均地分成四段更新。而实际上,事情通常不会这么简单。由于线程将同时运行,因此当它们同时读取和写入相同的数据时,需要小心谨慎,以免破坏彼此的计算。

这通常涉及到使用锁定同步原语(如互斥或 semaphore)来控制线程之间对共享状态的访问。这些基础类型限制着特定代码并行运行的次数(一般为零),“锁定”并防止其他线程运行代码,直到上锁的片段执行完毕,并“解锁”片段供其他等待中的线程使用。该过程会降低多线程的性能,使线程不会一直并行运行,但它也能保证程序的正确性。

况且由于数据的依赖关系,把更新拆分成多个片段运行并不合适。举例来说,几乎所有游戏都需要从手柄读取输入、储存输入到缓冲区,然后读取缓冲区的输入,根据输入值做出反应。

未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器

让代码读取输入缓冲区来确定角色是否要跳远,同时又让代码在这一帧写入输入缓冲区是不可行的。即使使用互斥来确保读取和写入m_InputBuffer是安全的,也总是希望先写入m_InputBuffer,然后再运行读取m_InputBuffer的代码,这样就能知道当前帧(而不是过去的帧)是否按下了跳转按钮。类似这种数据依赖关系非常常见,它们会降低并行运行的可能性。

编写多线程程序的方法有很多种。您可以使用平台特有的API来直接创建和管理线程,或者使用带抽象层的API来帮助缓解多线程编程的障碍。

我们的作业系统就是这种抽象层。它有多种手段能将单线程代码拆分成数块逻辑,分离出需要的数据,同时控制数据的访问权限,尽可能多地并行运行代码,以利用起CPU的算力来满足需求。

作业系统API

如今,我们无法自动将任意函数分割成块,因此 Unity 提供了一个作业 API,使用户能够将函数转换成小的逻辑块。编写完后,作业系统会负责片段的运行。

作业系统有几个核心组成:

  • 作业(job)
  • 作业句柄(job handle)
  • 作业派发器(job scheduler)

未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器

前边提到,一项作业仅仅是一段函数和一些数据,这种封装方式可以限制作业读写特定数据的范围。

未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器

作业实例被创建后,需要由系统派发。这是通过 C# 的扩展机制为所有作业类型添加.Schedule()方法来实现的为了识别和跟踪计划任务,需要提供一个JobHandle

这些句柄能识别队列中的作业,因此被用于设立作业的依赖关系。作业依赖关系可保证作业只在其他依赖项运行完毕后执行。其直接结果是,通过创建有向无环作业图,它们还能告诉我们何时允许不同作业并行运行。

未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器

最后,派发器在派发了作业后会负责跟踪这些作业(为每个作业实例贴上JobHandle),保证作业得以尽快运行。这里如若不加以注意,作业系统的设计与使用模式可能会出现潜藏的冲突,导致多线程的性能开销远远大于性能收益。随着越来越多的用户开始采用C#作业系统,我们逐渐看到部分系统开销远大于预期的情形。于是,我们在2022.2 Tech Stream里再度改进了Unity内部作业系统的实施方式。

在第二部分的博文里,我们将了解C#作业系统的开销来源以及Unity 22022.2对其的改进,敬请期待。

如果您有问题或想了解更多信息,请访问我们的C# 工作系统论坛。您也可以直接通过统一讨论区与我联系,用户名是 @Antifreeze#2763。作为Tech from the Trenches系列的一部分,请务必关注其他 Unity 开发人员的新技术博客