openmp并行编程_游戏引擎随笔 0x04:并行计算架构

5bf9bc485f9a7de4bae9921f1cc3cd36.png

自从多核处理器出现以来,并行化在软件行业一直是比较热门的话题,不仅仅是游戏,几乎所有密集型计算都有并行化需求,比如近年来的区块链技术、AI 等。一直以来,游戏并行化进程并没有其他领域那么顺利,问题就在于游戏本身的复杂性和特殊性。当今时代,无论是在 PC、主机或是移动平台,游戏几乎都是运行在拥有多核处理器的硬件之上。在可见的未来,基于冯.诺依曼体系架构的计算机将会继续朝着拥有更多核心的方向发展,这就要求游戏引擎必须具有充分发挥多核的能力,才能满足越来越复杂多样的游戏需求。而要具有这种能力,则必须在引擎的设计之初就要重点考虑并行化,并将之作为引擎的核心架构。

回顾

记得 2006 年我开始研发游戏引擎时,市面上主流的 CPU 还是具有硬件超线程 CPU,也就是 1 个物理核心,2 个逻辑核心,那年 Intel 发布了真正的物理双核 CPU,由此宣布 PC 的多核时代的到来。彼时恰逢新一代游戏主机 Xbox360PS3 发布,两家不约而同的都采用了多核心架构,Xbox360 有 3 个物理 PowerPC 核心,理论上可以有 6 个硬件线程,PS3 除了 1 个主 PPU 之外还有 6 个 SPU,也有 7 硬件线程。这样 PC 平台和游戏主机平台,为游戏的并行运算打下了硬件基础。在手游兴起的 2011 年,市面上就已经有装配了双核、甚至 4 核的 ARM 架构 CPU 的 Android 手机,苹果在 2011 年底发布了 iPhone4S,装配了拥有双核的 A5 处理器。至此移动平台也进入多核时代。在越来越高的渲染画面和更加复杂的游戏的需求以及单核心的算力几乎没有上升空间的情况下,游戏行业开始向利用多核来增加算力以提升性能的方向转变。基于多核硬件线程的并行计算成为游戏开发中主要的技术方案。

并行技术方案

在并行系统中主要由有如下三种并行方式:

  1. 系统级并行
  2. 数据级并行
  3. 任务级并行

这三种方式本质上本质上都是根据并行计算的粒度来划分的。

系统级并行

多核时代的早期,游戏引擎大多是采用系统并行化的方式,按照引擎的系统相对独立的大块功能来划分线程,比如游戏逻辑(一般也是主线程)、动画系统、粒子系统、渲染系统、物理引擎、网络 IO、资源加载等等,每个线程只负责执行系统内部的逻辑功能。

那时候我研发的引擎也采用了这样的并行架构。比如逻辑线程将 RenderCommand 通过 Lock-Free 的 RingBuffer 传递给渲染线程,渲染线程接收到命令后再执行真正的渲染指令。类似的,资源加载也通过 RingBuffer 传递加载命令,加载好之后将资源回传给主线程。关于逻辑线程和渲染线程的同步机制还有其他方案,比如共享状态数据等,但不是本文讨论的主题,在此不再赘述。

系统级并行的方式的问题在于如果没有可执行的任务时线程会处于空闲状态,并行任务的吞吐量比较低。

数据级并行

数据级并行是指在主线程中将某一密集计算分组给多个核心线程执行,等待所有结果返回,然后汇总到主线程。这种方式也称为 Fork/Join 模型,比如 OpenMP 采用的就是这种模型:

  • 程序以单个线程开始,主线程(线程#0)
  • 主线程在并行区域开始时创建并行“工作”线程组(FORK)
  • 并行块中的语句由每个线程并行执行
  • 在并行区域结束时,所有线程同步,并加入主线程(JOIN)

095c235b8bd9aad383438499a2489de2.png
OpenMP Fork/Join 模型

这种方式需要面向数据设计,避免使用 OOD ,任务中的内存数据尽量线性连续分配,这样多核在并行计算访问数据时降低 Cache Miss,否则很难充分发挥并行性能。

任务级并行

任务一般称为 Task 或 Job,是一个可并行化的小型工作单元,可以分发到每个工作线程去并行执行。任务可以具有依赖性,有依赖关系的任务会形成 DAG 的任务图(Task Graph),在执行任务时会根据依赖关系保证任务的执行顺序,如果一个任务所依赖的任务没有完成则不会进入执行队列,直到它所有依赖的任务都完成才会执行。

a7c15243baa84e4aef0e48586f80fffa.png
有依赖关系的任务在工作线程中的执行情况

任务图的工作原理虽然并不复杂,但是实现难度极高,要开发一套性能优异的并行任务图系统是一件非常不容易的事情,实现的不好甚至会降低性能,无法发挥多核并行优势,这不但需要对多线程编程有很深入的了解和多年的多线程编程功底以及大量的实践,还需要大量的测试工作,配以方便的开发、调试工具,才算是比较完整的任务图系统。任务图以及相关配套工具的开发工作量,甚至都不亚于开发一个小型游戏引擎的 Runtime。这里我推荐一个在性能和稳定性可以达到商用产品级的开源并行库:Intel TBB,它可以很方便地集成到自己的项目中,感兴趣的可以通过链接了解,在此不再详述。

现状

现代游戏引擎几乎都有各自的内置并行系统,有的称之为 Job,有的称之为 Task。例如 Frostbite 引擎提出的基于 Job 的并行系统,还有 Naughty Dog 提出基于 fibers 的 Job System。主流的商用游戏引擎也有并行机制,下面着重讨论下 UE4 和 Unity 的并行架构设计。

UE4 的并行架构

UE4 使用上面三种并行方式共同协作实现了引擎的并行机制。首先根据系统功能来划分几个内置固定线程,比如游戏逻辑线程、渲染线程、渲染抽象接口、数据统计线程、音频线程等。这些线程也被称为命名线程,专门负责某一子系统的特定职责。这一点和 UE3 基本一致。

UE4 还提供了基于线程池的异步任务系统:AsyncTask ,用于执行某些临时的比较独立的并且有一定耗时的计算任务。比如材质编辑器中的异步编译、数据后台解压、包文件的异步压缩和异步存储、特定平台的纹理格式压缩等等。

UE4 也提供了任务级的并行系统:TaskGraph。TaskGraph 采用了 work stealing 的任务调度策略,可以在任意工作线程中动态创建 Task 并指定依赖关系,另外除了 task-base 的基本并行功能外,TaskGraph 可以将任务分发到指定线程执行,比如渲染命令任务都会被分发到渲染线程中,构造渲染命令列表,然后再由渲染线程分发到渲染抽象接口线程中执行,如下图所示:

352bc92913adff6ebde267169d939ad8.png
UE4 中并行化渲染命令列表的生成流程

UE4 的 TaskGraph 是完全自己实现的一套系统,整个系统实现的非常高效,得益于无锁队列、线程本地存储、高度优化的内存分配(包括 Task 以及 GraphTask)、CPU Friendly 的 Cache 队列、任务的线程亲缘性以及充分利用 C++11 左值引用和 Move、Forward 语义(没有使用 std,自己实现了一套 )来降低内存复制开销,还有自己实现的一套和 C++11 对应的 FuturePromise 同步机制。UE4 的并行是深入到引擎的骨子里的, 无论是在 Runtime 还是 Editor 或是 Tool Chain 中,都能看到使用并行的代码。这和引擎在设计初期引入并行架构有直接关系,引擎也因此受益匪浅,成为性能最好的商业游戏引擎。

Unity 的并行架构

早期的 Unity 版本,几乎没有并行机制,引擎的性能负荷基本都集中在主线程,从 5.4 开始,Unity 开始引入 Job System,逐渐将引擎的工作并行化,比如将大量的 Camera.Render 工作从主线程当中移除。下图是新旧版本的对比图,可见 Unity 引擎内核的并行能力越来越好,多核利用率越来越高。

aade729dbc6abdd971d1c289ddd8b6af.png
2017 版本(左)和 5.6.6 版本(右)的多核利用率对比

Unity 内部的 Job System 是基于无锁栈与无锁队列来构建的,通过汇编来编写,针对异构平台的 CPU 有对应的优化实现。Job System 把一些计算量比较重的系统挪到其他的工作线程,例如粒子系统、动画系统、布料计算、遮挡剔除、视锥体剔除、蒙皮和静态裁剪等等。另外与 UE4 类似,Unity 也是将不同的功能的系统分配到不同的线程上,比如主线程(逻辑)、渲染线程、异步读取和预加载线程等等。从 2018 版本开始,Unity 将内部的 Job System 开放给脚本使用,这就可以在无需引擎源码的情况下,在脚本层面实现游戏逻辑的并行计算功能,对使用脚本的游戏开发者来说非常方便。

为了最大化发挥 Job System 的性能,Unity 在 2018.2 版本中加入了 Burst 编译器(ECS+Job System+Burst 组成了 Unity 新一代的开发利器 DOTS ),专用于将 Job System C# 代码生成极度优化的 Native C++ 代码,其原理是将 C# 编译后(Mono、Roslyn 编译器)的 IL 代码,使用 Burst 转换成 LLVM IR 并附加 Job 的元数据,再生成平台相关向量化的 Native C++ SIMD(SSENEON)代码。这样就可以充分利用每个平台的 SIMD 机制,实现最大化的计算性能。

64c6acc606cbdb90332993267fe22615.png
Burst 的工作流程

从目前的 Unity Job System API 的文档来看,Job 只能在主线程中调度,也就是说不能在 Job 的执行过程中再创建另一个 Job,无法实现动态的并行任务系统。另外,Job System 只能将任务派发给工作线程,不能分发给系统功能线程(主线程、渲染线程等),这就会导致系统功能线程在等待闲置时不能消化队列中的 Job,降低了引擎的整体任务的吞吐量。而以上两点在 UE4 中已经得到解决,比如第二点,UE4 将执行系统功能的线程也看作是工作线程,当系统功能线程闲置时可以充当工作线程的职能执行 task,提高引擎整体的任务消化率,进而使整体性能更好。从这方面来看,UE4 的 TaskGraph 比 Unity 的 Job System 要略胜一筹。

未来和结论

理想情况下可以使用并行系统来驱动整个引擎,而不需要所谓的“主线程”,任何线程都可作为工作线程,任何线程都可发起计算任务,从而彻底消除系统功能线程。而这需要程序语言(C++2x?)、操作系统、图形 API (D3D12VulkanMetal)的不断进化共同协作才能实现。

并行计算架构已然成为现代引擎的标配,因此一个面向未来的新引擎,首先要设计一套优秀的并行系统,基于并行架构,根据每个系统的特性,面向数据设计,仔细规划和分析,将每个系统的功能分发到并行系统中执行,按照计算粒度正确的使用三种并行方式,这样无论是 Runtime 还是 Editor,整个引擎的所有系统都能通过并行机制实现最大化的性能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值