PPT下载链接:https://u3d.sharepoint.cn/:f:/s/UnityChinaResources/EiiiMzsuiL1FouTNpSkE5qABHBR69kSzmePA1FGkAvSCwA?e=a0BlzX
在 2024 年 12 月 6 日 Unity 技术开放日厦门站,Unity 中国 DOTS 技术主管李中元带来分享《团结引擎高性能 ECS 架构》,深入解读团结引擎 ECS 架构高性能的真相。本文为演讲实录。
李中元:大家好!很高兴有机会和大家聊一下 ECS,以及关于性能方面的一些进展。
观看演讲视频:https://www.bilibili.com/video/BV1F4k1Y3E8q/?spm_id_from=333.1387.collection.video_card.click&vd_source=6ad5666ecbc7fe0e80d963da7e237d92
今天给大家带来的两个大的话题,一个是 ECS 架构的简介,给大家带来一些认知层面上的感受,ECS 为什么快?第二会告诉大家,ECS 快的真相是什么?是用了 ECS 就一定快吗?第三,能不能更快?
ECS 架构简介
接下来进入 ECS 架构的主题,分了三部分,首先介绍一下什么是 ECS 架构;第二,和面向对象作对比;第三,我们为什么要使用 ECS 架构?
什么是 ECS 架构?Entity Component System,这个名字起得非常简单直接,就是三个 ECS 系统核心名词的组合。Entity 就是指向数据的 Handle,大家可以认为,我拿了这个 Entity,就可以访问这个 Entity 对应的这些数据。Component 才是真正存储数据的东西,但是这些数据是按照这些类型去统一归类放在一起的。System 就是指我们的业务逻辑,通过读取数据,处理数据,再把数据写回去。就像我们去访问数据库一样,把数据 select 出来,处理掉,再写回数据库,这个和数据库的操作是非常像的。
ECS 架构面向的是什么?面向的是数据;思考的是什么?思考的是数据如何存储和处理。
大家可以看到右边这张图,数据是分类存在一起的,所有同样颜色的数据都会分类放在一起。比如左上角这一大块的数据,我们纵向看,橙色、黄色、绿色、蓝色,这些数据放在一起。但是横向看,所有橙色的数据放在一个大数组里面,黄色的数据放在一个大数组里面等等,相当于几个大数组罗列在一起,就是数据的排布了。先通过业务去设计我们的数据,再看我们业务会导致数据怎么样进行流转,这是在 ECS 架构里面去思考的非常重要的一个部分。
设计一个好的数据流转,自然就能带来非常好的性能。
对比一下面向对象,面向对象的特点是什么?封装、继承和多态,主要是注重可扩展性和可维护性。让大家利用抽象,再加上很多好的设计模式,来降低人类对于软件设计和维护上面的负担。看上去面向对象是对于人类进行优化的一个方法。
对比一下,面向数据更多是面向机器执行进行思考,对于机器来讲是一个非常舒服的状态,自然就会很快。面向对象,对我们人来讲会帮我们降低整个项目开发的难度,但实际上对机器来讲是并不舒服的。
面向对象既然是对我们人类做出优化,必然要付出一些代价。ECS 面向机器进行一些优化,就会获得一些好处,好处是什么?就是性能。当面向有性能要求的应用场景的时候,ECS 往往会获得一些比较好的结果。
这里介绍一下 CPU 如何读取数据。CPU 不能直接从内存里面读数据,只能从 Cache 里面去读数据。大家知道 CPU 有一、二、三级的 Cache,只能从一级的 Cache 里面去读取数据。CPU 从一级的 Cache 里面读取数据也是非常快的,但是内存相对于 CPU 来讲是非常慢的。如果 CPU 要读取的数据不在它的缓存里面,就需要等待非常漫长的过程,让数据从内存加载到 Cache,再从 Cache 里面去读这个数据。
理解了这个,来看一下通常情况下我们使用面向对象的模式去写代码,会导致这样一个非常典型的面向对象的内存里面的数据分布。这时候问你一个问题,把所有的黄色的数据挑出来,对我们人类来讲也是很难的事情。
如果换一张图,按照 ECS 的方式,我让你把所有的绿色数据都挑出来,对我们人类来讲是很方便的,对于机器来讲,对于 CPU 来讲也是的。当我们去读取第一个绿色数据的时候,我们现在的硬件 CPU 就会去猜第一个绿色数据附近的数据,会顺带把后面那些数据都加载进来。而在我们 ECS 里面,就会按照 CPU 的工作模式去使用这些数据,也就是我们访问完第一个绿色,就会访问第二个、第三个,这样就充分利用现在硬件的架构。
当我们去处理数据的时候,在 ECS 里面大家应该怎么想呢?这个数据和刚才的图一模一样,只不过把它们按照颜色进行了对齐。这时候如果我告诉你说,我们去处理黄色和绿色的数据,你就可以把它理解成我是在遍历黄色和绿色这两个大数组,对我们程序员来讲非常好理解,就是变成两个大循环,去把它们进行处理。
这就是我们 ECS 架构的第一个优势,对于硬件的缓存非常友好,可以帮助硬件发挥它的硬件性能。
再来看一下这张图。这里面的 Entity 可以直接访问竖向的资源。横向是一个大数组。能看到这些黄色的、绿色的数据,天然是分块的。既然分了块,我们是不是可以用分置的方式去处理它们?也就是我们非常方便把这些一块的数据都放在一个个单独的线程里面分别处理,这样能够利用现在多核的 CPU 架构。所以 ECS 对于并行来讲,也是一个非常好的方式。
看点儿不一样的,这是我们引擎里面存储 Transform 数据,如果大家做游戏的话,天天会和这些位置、旋转、缩放打交道。最左边的图,是引擎里面现在去存储位置、旋转、缩放的信息,把它们放在一起。中间这个是 Unity Entities 现在的做法,把位置、旋转、缩放拆成了 3 个,所有的位置信息放在一起,所有的旋转信息放在一起,所有的缩放信息放在一起。最右边的这一个,是在我们团结引擎的粒子系统里面采用的方式,甚至把位置的 X、Y、Z 三个分量也拆了出去,所有位置的 X 信息放在一起,Y 信息放在一起,Z 信息放在一起。
大家可以想一下,如果没有 ECS 架构,把这些数据拆得如此稀碎,你在写代码的时候是一个很痛苦的过程,满屏幕都是大数组,在添加删除这些数据的时候都是会非常痛苦的。但是如果有了我们 ECS 架构,可以非常方便把数据拆成像中间或者右边这样的,看你业务的需要。正常情况下应该不会用到最右边这样非常极端的方式,因为我们粒子系统要求是不太一样的。
这是 ECS 架构的第三个优势,数据可以非常方便地以 SOA 的方式进行存储。SOA 可以方便转换成 SIMD 的指令,方便 CPU 使用 SIMD 去对数据的处理进行加速。
这里简单对比一下 Unity 的 Entities,也就是大家能够用到的 ECS 的包,和团结引擎的 ECS。左边是 Unity 的 Entities,存储是以 16K 的 chunk 为单位进行存储,你可以认为它是固定大小的一个存储。可以看下面几个数据,正好不是 16K 的倍数,就会产生一些空间的浪费。像团结这边,我们是以 128 个数据为一组一个单位,我们是定数据长度的,而不是定大小的,把它们整齐排列在一起。可以认为右边很像一个 excel,只是根据数据的大小,把一列的宽度调整一下,它们仍然是能够非常满地存在这个表格里面。
这样能够获得什么好处?如果使用 ECS,一个非常常用的 API 是 AddComponent,或者是 RemoveComponent。在左边的 ECS 架构里面会付出一些代价,因为大小容量是固定的,当我们去添加或者删除 Component 的时候,其他所有数据都会产生一次数据的拷贝。但是在团结定长的大小里面,当你添加或者是删除一个 Component 的时候,我们只是需要把指针从这个位置移动到另外一个位置就好了,不会牵扯到数据的移动。
这里面有一些简单的性能对比。在创建或者是删除 Entity 的时候,因为大家在使用 ECS 的时候会牵涉到频繁大量的数据创建和删除。可以看到随着创建数据量的增多,在团结的设计里面是能够拿到越来越大的性能优势的。
在 AddComponent 里面我们的优势会相对大一些。在 Unity 的 Entity 中是需要复制和拷贝数据的,但对团结来讲,我们只需要拷贝指针。
在 ECS 架构中我们经常会用到 Shared Component 进行数据的分组,我们对于 API 的设计和工作流各方面都进行了一些调整和优化,最终也是拿到了非常不错的性能优势。
ECS 架构高性能真相
今天的第二部分是 ECS 架构高性能的一些真相,里面包含了 Burst 编译器和并行,这里面会分享一些也许会让大家大开眼界的看法。
首先看一下 Burst 编译器。我在最开始使用 Burst 编译器的时候经常会有三个问题:第一,它真的有那么快吗?第二,它为什么快?第三,如果我们想做到那么快,真的只是需要添加一个 Burst Compile 这个标签,奇迹就这么发生了吗?你不觉得这个事情非常的简单吗?简单我就认为一定会有问题。
这是我选择的一个图,在 Burst 刚发布的时候,有人拿 Burst 编译器和其他的编译器做了一些 benchmark。
先来看左边的三列,是 Burst 编译器,GCC 和 Clang,在同样的算法情况下,会发现它们是在一个量级的,Burst 甚至要慢 10% 左右。大家能够得到一个结论,Burst 编译器和普通的 C/C++ 编译器没有什么区别,甚至还慢一点。
后面两个是 IL2CPP 和 MonoJIT,MonoJIT 对应着我们在 Editor 里面看到的一个函数执行的时间长度。IL2CPP 就是大家打出来的包以后,只要用了 IL2CPP 能看到的性能大概是这样的。如果用 Burst 和 IL2CPP 和 Mono 对比,它还是很好的。
看这个图能得出一个结论,Burst 和普通的 C/C++ 编译器编译出来的效果是差不多的,甚至还要差一点,但是和 IL2CPP 和 MonoJIT 比是要更好的。如果你得到这个结论,你就掉到我的陷阱里面去了。
我们来看一下,首先 Burst 是 DOTS 一部分,它是 HPC# 的编译器。HPC# 并不是 C#,也就意味着它不是一个通用的 C# 的语言。它是针对 ECS 场景做了很多特别的优化。刚才讲了,ECS 是方便我们把数据组织成 SOA 的方式,也就是 Burst 对于 SOA 的数据会有一些特别的处理。Burst 还有一个唯一的依赖的 package,就是 Mathmatics。Burst 对于这里面,做了大量的优化。
Burst 编译器既然是一个 DOTS 的编译器,肯定有一些它特别的能力。它特别的能力就是向量化的能力,是它跟其他的通用编译器非常不一样的地方。也就是说对于像 HPC# 里面那些 NativeContainer,还有所有标注了 NoAlias 属性的数据,它都会去做一些更激进的自动向量化的工作。如果没有这个假设,像通用的编译器很难做这种自动向量化的工作。向量化的数据结构,像 float3 和 float4,都是 Math 里面提供的数据。在 Burst 里面是直接会把 float3、float4 转化成向量化的这些寄存器存储起来的,不需要做另外的转换。你会发现它在处理 float3 和 float4 的时候,生成的汇编代码非常的精简。
最后一个也是 Burst 快的一个非常重要的秘密,Mathmatics 提供了非常多的数学函数,这些数学函数在 Burst 编译的时候,会直接映射成 Sleef 库里面的实现。大家可以在 Github 上找,这个库是开源的,是非常出名的一个 SIMD 的向量化的库,也就是说很多函数你看到的实现和 Burst 编译出来的实现是不一样的,这是 Burst 里面非常大的一个秘密。
看一个代码,这是我们在做粒子系统的时候有一个 curve 采样,这是采样里面的核心代码。这是一个点击,最重要的是上面的公式,ax^3+bx^2+cx+d=[(ax-b)x+c]x+d,里面套一个乘加,外面也是一个乘加,最外面也是一个乘加。Mathmatics 库里面也提供了 Math 的指令,如果用上面的这个实现的话,其实是等效的,但是我们把它上面的实现换成了下面的实现,经过测试我们的代码快了 3 倍。这是同样的代码,但是只是换了一个实现,快了 3 倍。
为什么呢?是因为现代 CPU 里面对于乘加这种计算是有专门的处理的,相当于乘加是一个特别的操作。在 Burst 编译这段代码的时候,就会把乘加换成相应的汇编指令,一般现代的 CPU 都有这样的优化,它的速度是和你普通写乘法和加法的速度是不一样的,这是 Burst 里面非常重要的一些细节。
这是给大家一些建议,如果大家想去发挥 Burst 的实力,就不要只是拿 float 或者 int 做这种计算,你应该把你的数据尽量 pack 成一些 float2、float4 这样的方式做数学计算,这样你会获得意想不到的性能提升。还有就是尽量要使用 Mathmatics package 里面提供的很多数据类型,这些 Burst 都会对它们进行一些特别的优化,因为 Burst 认识这些数据格式,会把它直接转换成更高效的一些实现。所以它和普通编译器的思路是不一样的,普通编译器是会把你的代码当成一段黑盒,不知道代码里面有什么东西,但是 Burst 认识 Mathmatics 库,它认识 HPC#,它和 HPC# 之间是有约定的,所以 Burst 不是一个通用的编译器。如果大家把它当成一个通用的编译器去做测试,Burst 不会很快的。
另外,Position 是大家计算里面非常常用的,它是一个 float3,在我们的粒子系统实现里面,它是一个 X,一个 Y,一个 Z,三个分量都是拆开的,我们怎么对它进行优化呢?
看一个例子,这是一个位置的坐标变化,是一个矩阵乘法,就是 float4×4,再乘一个 float4,就是一些乘加乘加乘加,这里面有什么可以优化的吗?
第一个想法,把 float4×4*float4,转化成一个 float4×4*float4×3。这并不是一个标准的矩阵乘法,这里面 float4×3,是把 4 组 X、Y、Z 打包在一起,就是 4 个 X 放在一起,4 个 Y 放在一起,4 个 Z 放在一起,这样形成了这么一个矩阵乘法。
能看到 float4 是 128 bit 的宽度,但是在我们 PC 上提供了 AVZ2 指令集,提供了 256 bit 宽度的向量化指令。我们就自然想到了,能不能把 float4 翻一倍变成 float8。
这样就形成了下面的接口,把 float4×4*float4,变成 float4×4*float8×3,这样我们可以一次处理 8 个位置,800 个 XYZ,但是代码没有任何变化,只不过数据格式从 4×3 变成了 8×3。
测一下,在 M2 的机器上,可以看到 float4×3 比单独的一个 float4 要快 1.5 倍左右,float8×3 快 1.6 倍左右。差不多,这是因为在 Arm 芯片上,只有 128 bit 向量化的宽度,也就意味着这里面我们没有拿到任何的好处,但是也没有坏处,稍微快一点点。
但是如果在 PC 上,结果就不一样了,因为有 AVX2 指令集的存在,我们的 float8×3 直接比 float4 快了 3.6 倍,如果你有大量数学计算的话,能够带来非常好的优势。
刚才说完了 Burst,再来说一下并行,这一块也比较复杂,我只是提一些问题。
第一个问题,在我们 Profiler 里面有一个非常好玩的图,我们做粒子系统的时候尝试把所有的东西都并行起来,但是并行的时候发现我们有一个地方实现的不好,异常的突出。这里我想反映一个现实,在一个并行系统里面,能够决定这个并行系统快还是慢的,其实取决于最慢的那个 Job,因为所有人都会等这个 Job 执行完才会进行下面的操作。这是并行和并发不一样的地方,并行强调的是同时开始和同时结束。如果说这里面有一个 Job 执行的慢,整个并行系统可能最后还是亏的,可能还不如单线程快。
因为这个事比较复杂,我就打算直接给大家讲一些建议。首先大家一定要注意这个 Schedule 本身是有开销的,包括线程调度、数据竞争,都会给你的整个并行系统带来一些复杂性,并且如果你的数据设计不好的话,往往一个并行的算法可能还不如单线程来得快。像刚才我们已经把数学计算优化到极致的情况下,做 Profile 一般都是在 10 微秒这个尺度进行。如果一个乘法写得不好,很可能就增加了 1 微秒,这样我们很可能就亏掉了。
我可以给大家一个数据,像线程的调度和它的开销应该是在 100 微秒左右,如果你的算法能够优化到 10 微秒的级别,线程调度是一个非常可怕的开销。还有数据竞争,会导致 Job 之间的依赖关系设置得非常不合理,本来能够都并行在一起,但是因为有一些数据设计得不好,只能让所有人去等那个最慢的,会出现刚才上面那张图的情况,所以也要稍微注意。
给大家的建议,所有大于 1 毫秒的任务,大家再考虑放到 worker 里面去做,否则从开销角度来讲,你尽量在主线程里面写一个 Burst 的静态函数。从开销的角度来讲,一个 IJobFor.Schedule 的调用,会大于 IJobFor.Run,.Run 是在主线程的调用,也会大于一个 Burst 静态函数的开销。
Burst 静态函数其实很简单,写一个 static 函数,它所有的传入、传出都是以引用的方式去做,在这里面你加上一个 BurstCompile 标签,这应该比写一个 Job 要来得简单,这是大家应该最先做的事情。做完这个如果发现它还超过 1 毫秒,那你再去尝试给它 Job 化,这是我给大家一个非常重要的建议。
如果大家非得要使用多线程的话,一定要先合理去组织数据,把无关的数据用 SOA 的方式组织起来,把这些无关的数据分离;还有合理设置 Job 的依赖关系,要对你的业务有非常清楚的分析。如果说一个 Job 执行的时间非常短,在微秒级的话,更好的一个策略是把这些小 Job 合成一个大 Job,这样可能反而执行得更快。
总结
总结一下,ECS 是面向 CPU 进行优化的程序架构,它和面向对象不一样,大家在使用 ECS 的时候可能会感觉到一些不习惯,但是认识到它是对数据进行优化,按照数据进行思考,和人类大脑正常的思考方式不太一样,只要适应了就都还好。
第二,尽量使用 SOA 的方式去组织代码,组织数据,这样 Burst 编译器才能够生成更高效的代码。尽量使用像 float2、float4 这种高效的数据结构。要使用 Mathmatics 库里面的函数,这样至少能够做到非常快。
第三,根据任务复杂度来确定是否使用多线程。如果说你是一个很快的,就不需要追求任何事情都是多线程的,反而把它变成一个 Burst 优化过的程序可能会是更好的选择,先 Burst,然后再 Job。
这是我今天给大家带来的所有分享,谢谢!