在 Unite Shanghai 2024 及 Unity Open Day 厦门站中,团结引擎 DOTS Team 带来两场技术分享,分别解读了团结引擎 ECS 架构的详细实现及其高性能的真相。本文为首场演讲实录。
大家下午好!今天由我们团结引擎的 DOTS Team 带来团结引擎高性能 ECS 架构分享。
![](https://i-blog.csdnimg.cn/img_convert/67f99014a82708e94fc4890a313fa4b2.jpeg)
我们看一下市面上有哪些主流的 ECS 实现方式?其中一种是以 EnTT 为代表的基于 Sparse Set 的实现,还有一种是以 Unity Entities 为代表的基于 Archetype 的实现。
![](https://i-blog.csdnimg.cn/img_convert/806f2d59547c334c6886a250b077af74.png)
我们先看一下 Sparse Set 的实现。它的核心思想是用 Set 去存储所有的 component 数据。它的好处是它所有 component 数据是以连续并且紧密的方式存储在一起的。但是它会有一个非常大的副作用,就是它的查询复杂度会非常高。对于每一个 entity 来说,它都需要去判断这个 entity 是否存在这个 component。也就是说它的查询复杂度是 O(E) 的。大家可以想象一下,对于 1 亿个 entity 来说,我们都要判断 component 是否存在,是一个非常恐怖的性能开销。
![](https://i-blog.csdnimg.cn/img_convert/1635582888d70ca4861ce0673d4adbea.png)
我们再来看一下基于 Archetype 的实现。Archetype 代表的是一组不重复的数据类型的集合。Archetype 的好处是它可以极大地降低查询的复杂度,我们只需要对所有的 Archetype 进行遍历,就可以查询到我们想要的数据。它的缺点是 Component 数据的存储相对分散。所以说在内存的利用率上会有一些痛点。
既然 Archetype 有这么多的好处,那么作为 Archetype 实现的代表,Unity 的Entities 有没有什么缺点呢?接下来我就把话筒交给周同学,来给大家进一步分析。
![](https://i-blog.csdnimg.cn/img_convert/17af219e5cbf620778e75fcc666b86a3.jpeg)
周琛:今天我会和大家分享一下 Unity ECS 的性能痛点,我们在团结中是如何改进最小存储块的,又是如何管理这些海量的最小存储块,如何加速数据查询,最后如何加速 EntryCommandBuffer 的执行。
Unity ECS 的性能痛点
我先为大家介绍现在 Unity ECS 的一些性能痛点。
![](https://i-blog.csdnimg.cn/img_convert/1099b3d744628ad0716ac00eb188f8d5.png)
StructuralChange 是 Entities 中常被提及的话题,很多项目中的性能瓶颈即来源于此。那么什么是 StructuralChange 呢?简单总结一下就是,会导致 Entity 数据在内存上的位置发生变化的操作。比如 Entity 的创建、删除,以及对 Entity 添加/删除 Component,或者是对 Entity 设置 SharedComponent 的值。这些操作都会导致内存结构的变化,因此被称作 Structural Change。
StructuralChange 有哪些害处呢?由于会改变内存结构,那么必须等到所有读写操作都全部完成才能开始,因此会加入线程间的同步点,破坏掉多线程的 job 执行。其次,这类操作都只能单线程执行,无法利用 CPU 多核能力,执行效率会比较低。最后,由于导致 Entity 到内存的映射发生变化,也需要重新维护 Entity 到内存的映射表,这又带来另外一笔开销。
![](https://i-blog.csdnimg.cn/img_convert/5828983530ebb8d529936064c5e9c62b.png)
我们知道,Archetype 表示一组不重复的 Component 类型的组合,这些 Component 数据以 SOA 形式存储在一些固定大小的内存块中,这些内存块大小为 16K,这些内存块被称作 Chunk。同一 Archetype 下的 Chunk 内存结构相同,对于一个有 position、rotation、color 的 Archetype,Chunk 内部的内存布局统一都是先存储 position 数组、然后是 rotation 数组、最后是 color 数组。
那么 Unity chunk 设计上有什么缺陷吗?
首先,每个 Chunk 对于 Entity 个数最大的容量限制为 128,当一个 Archetype 所占字节数较小时,对 chunk 空间的整体浪费较为严重。举一个例子,当 Archetype 仅有一个 float 类型的 Component,它的有效数据只有 512 byte,但它仍然会用 16K 空间去存储,那么该 Archetype 产生的空间浪费会高达 90% 以上。
其次,对 Chunk 整体改变 Archetype 的操作,产生的开销较大。
![](https://i-blog.csdnimg.cn/img_convert/38dc07f587276aa59d6da99f01cf4a45.png)
例如图中对 Archetype1,添加一个 time 的 component,转变为 Archetype2。由于要添加一些数据,因此 chunk 下能存放的 Entity 个数更少,因此对于 Archetype1 的每个 chunk 要执行的操作为首先将 position 、rotation、color 数据分别拷贝出一部分到 Archetype2 下有空闲的 chunk,再将 time 数据在两个 chunk 中分别创建。对于图中的示例,仅一个 chunk 就会发生至少三段的数据拷贝,当 Archetype 下的 component 更多时,则会带来更多的拷贝开销。
另外,由于一些 Entity 在内存上的位置发生了变化,因此也要重新维护这部分 Entity 到内存的映射,这又带来了另一笔开销。
下面介绍我们对 Chunk 结构的一些改进。
连续内存块 Tile
![](https://i-blog.csdnimg.cn/img_convert/ac4d50b9def066207ff8c7b46afc1854.png)
团结 ECS 是使用 tile 来存储 component 数据,和 chunk 固定大小不同的是,我们是对于长度进行固定,每个 tile 的长度都为 128。我们在每个 tile 上只存储相同类型的 component 数据,将同一个 entity 的数据分散存储在不同的 tile 上,因此也可以认为 tile 是将 chunk 不同类型的数据分开存储的。
由于 tile 上可能存储不同字节数的 component,由此每个 tile 具备不同的大小,我们的 Allocator 需要分配多种大小的内存。
那么这样的设计带来什么好处呢?
首先就是减少了空间的浪费。chunk 上由于限制了 entity 最大个数为 128,因此即使有效数据只有 1K 或者 2K 的时候,也会占据 16KB 的内存;而 tile 的内存则是完全按需分配,大小等于 128 乘以类型字节数,例如要存储 float 类型的 component,tile 的大小是 512 字节,要存储 float4 类型的 component,tile 的大小是 2KB。
![](https://i-blog.csdnimg.cn/img_convert/ace2b76f7087186876efb4c933d7d4f2.png)
另一个好处在于,对 Tile 整体改变 Archetype 开销低。这个要如何理解呢?我们还是以前面对 Archetype1 添加一个 component 的操作为例,如果使用 tile 的设计,由于不同的 component 都存储在不同 tile 上,因此不必改变原有 tile 的内容,也不需拷贝 tile 的数据,为转移原有数据,只需拷贝 tile 的指针。对新添加的 component,也只需将它的 tile 指针拷贝给目标 Archetype。
因此,对于具有 N 个 component 的 Archetype,对其添加 component 的操作, chunk 需要 N 段 component 的拷贝,而 tile 则是需要 N 次指针的拷贝,tile 需要拷贝的数据量明显小于 chunk。
![](https://i-blog.csdnimg.cn/img_convert/7fdc40ec5617484fca6cc2113e884ca9.png)
我们设计了一个实验,实验方法为对 Archetype 分别添加和删除一个 component,测试 100 次函数调用的平均耗时。我们使用 Unity 的 performance test runner 工具,以及最新的 1.2.3 版本 Unity entities package,图中深紫色的为团结引擎的耗时,浅紫色的为 Unity 的耗时。从图中可以看到,在 entity 数量为 1 万及以下量级,团结和 Unity 实现的耗时相差不大,两者均在 0.1 毫秒以下,团结略微领先;但当 entity 的个数来到 10 万时,团结的性能最高可以达到 Unity 性能的 9 倍左右,这个性能提升相当可观。
层级内存管理结构 SparseTable
面对整个场景中可能多达上万个的 tile,我们应该如何去管理呢?下面我将介绍的是整个团结 ECS 架构的核心,层级内存管理结构 SparseTable。
![](https://i-blog.csdnimg.cn/img_convert/ec4a95efdbc4afdf4743a31845aefa2a.png)
这里先放一张团结引擎和 Unity 在存储结构上的宏观对比图,大家先观察两者的不同。
首先第一个不同,chunk 长度不固定,团结的 Tile 每个块长度都是 128;
第二个不同,Unity 在 archetype 比较小的情况下会产生空间的浪费,比如左下角;团结 Tile 的大小都是根据 component 类型量身定制的,不会产生浪费;
第三个不同,chunk 都是连续存储的,团结看起来会分散一些;
第四个不同,Unity 有三个分层,团结是有四个分层的,这个 AL 层是什么含义,这个 P 层又是什么含义,下面我会为大家解答这些疑问。
![](https://i-blog.csdnimg.cn/img_convert/c5a0e4eade20f63872c8c2b402e4acce.png)
![](https://i-blog.csdnimg.cn/img_convert/e93ddd6795315d9a5a71716eb87cf6fd.png)
我们先从整个层级结构最底层的 page 和 entry 说起。
我们将同一个 Archetype 下的所有对象分为 128 个一组,将这每一组对象称作一个 page,page 里的每个对象称作一个 entry,将每个 entry 不同的 component 分别存储在各个 tile 上,因此一个 page 会包含多个 tile。
如果要类比到 Unity ECS 中的概念,page 和 chunk 的概念最为接近,但与 chunk 又不完全一样,chunk 是一整块连续存储,而一个 page 由多个 tile 分散存储。
Entry 类似 entity 的概念,但又不完全相同,具体有什么区别呢?
![](https://i-blog.csdnimg.cn/img_convert/8d9fbc88a8b58b84f69a378e498118ff.png)
在 Unity 的实现中,Entity 本身包含一个全局唯一 ID,拥有这个 ID 我们就可以持久引用到这个对象的数据,即使在内存位置变来变去。代价就是,在各种会引起结构变化的操作中,都需要维护这个全局 ID 到内存位置的映射,无疑是开销非常大的。但这个 ID 一定是必要的吗?
我想从我们使用团结引擎 ECS 框架重写粒子系统的过程中遇到的需求说起。
在粒子系统中,我们会需要区分粒子是否带有拖尾、是否是这帧新生成的、是否带有灯光,来自哪个粒子系统等等,我们并不在意所有粒子中,第十个粒子的位置或速度,也不在意第一百个粒子什么时候被销毁的,而我们真正在意的需求使用 Tag 或 SharedComponent 就能满足。如果不需要用到这个唯一 ID,而仍需要付出大量的维护成本,明显是不划算的。
再进一步延伸到 entity 本身的设计上来,作为面向数据的程序设计框架,ECS 架构本身就适合于处理大量相似对象的逻辑,而在这样一群对象中,我们通常不需要具体引用其中的某一个。有了这个信息,我们可以尝试做一些优化。
首先对于那些不需要外部引用的数据,我们完全可以忽略对它们全局唯一 ID 的维护,就使用 Archetype 对它们进行管理,这样的数据,我们称其为 Entry。
![](https://i-blog.csdnimg.cn/img_convert/dad759201397dc6712eb99e9cedcb834.png)
Entry 和 Entity 的区别呢?
首先,Entry 只和内存上的位置相关,Entry 不变则指向的位置不变,即使该位置的对象可能发生变化,就类似门牌号一样;Entity 则持久地表示一个对象,Entity 不变则对象不变,即使该对象的位置可能发生变化,类似身份证号。
Entity 能持久引用一个对象,但需要多维护一个中间层进行映射,性能较低;Entry 少了对中间层的维护,性能较高,不需要维护中间层的成本,但不能提供对外的持久引用。这两者有好有坏,我们该如何取舍呢?
在团结的 ECS 架构中,两者均会提供,我们会默认使用 entry,对于用户添加了 entity tag 的 archetype,其下属的所有 entry 均会转为 entity 而获得持久引用,这样达到性能和功能兼顾。
![](https://i-blog.csdnimg.cn/img_convert/df44c55637ec2c3f864b52626f571c1e.png)
我们又设计了一个实验,实验方法为创建一定数量的 Entry 或 Entity。我们对两种 Archetype 分别进行了测试。从图中可以看到,由于减少了 entity 映射的维护,创建的开销立马大幅下降,这部分性能提升还是不错的。团结的性能最高可以达到 Unity 的两倍左右。
![](https://i-blog.csdnimg.cn/img_convert/2432d4d0fdea84f36e48960820f73a6c.png)
接下来介绍 SparseTable 的第三层 ArchetypeLine 层。
![](https://i-blog.csdnimg.cn/img_convert/ac7058ee609fe6d69b30badfcad91644.png)
在正式介绍第三层 ArchetypeLine 之前我需要先介绍一个概念:SharedComponent。Shared component 和其他的 component 一样,可以被加到 entity 上。它之所以叫 shared,是因为它的值在 entity 之间是可以共享的。在 Unity 的实现中,shared component 被存储在 chunk 上,避免了在 Entity 上重复存储,但 Unity 却没有利用这个性质对 chunk 进行进一步分组,导致不同 Chunk 可能存在重复的 shared component,带来空间上的浪费。其次查询指定的 shared component 时,需遍历所有 Chunk,复杂比较高。
而 Unity ECS 中创建 entity 的 API 无法指定 component 值,所以 entity 创建后才要设置值,这也意味着如果 entity 包含 shared component,创建 entity 带来一次 structural change,又设置值导致 entity 更换 chunk 就会带来第二次 structural change,无疑是浪费的。
![](https://i-blog.csdnimg.cn/img_convert/8d823cd3df7693d12c134fa0f0b2bc62.png)
如果说 Unity 中 shared component 是在 chunk 级别进行数据分组,我们的设计中,则是将 shared component 向上提升了一级,增加名为 ArchetypeLine 的分层,在介于 Archetype 和 page 之间进行数据分组。
是否具备一个 shared component,会影响它成为不同的 archetype,而 shared component 的值不同则影响它成为不同的 ArchetypeLine。
这样设计有什么好处呢?首先,避免了 Page 对 shared component 的重复存储;其次,通过 ArchetypeLine 创建 entity 时,即天然包含了 SharedComponent 值,减少了一次 Structural Change;最后,查询只需要匹配到 ArchetypeLine 就行,不需要再逐 page 去匹配了。
![](https://i-blog.csdnimg.cn/img_convert/d80e80c3cc334c71ac083e252446df7c.png)
直观看一下这个好处,我们又设计了一个实验,实验方法为创建一批带有某个 shared component 的 entity 或 entry。
在 Unity 最高效的做法是,首先创建一个 entity,并为其设置 shared component,然后通过 instantiate 接口将该 entity 实例化出 N 个,但是在实例化的步骤中,虽然效率比较高,还是免不了对每个 chunk 分别设置 shared component。
团结引擎的做法是,首先创建一个 ArchetypeLine,只需设置 1 次 shared component,然后调用 ArchetypeLine 的接口创建出 N 个 entry,就都是天生自带这个 shared component 值的了。
我们分别对两种 archetype 进行了测试。从图中可以看到,团结采取的将 shared component 单独设置一层的设计带来的性能提升还是很大的,最高可以达到 Unity 的 6 倍左右。
![](https://i-blog.csdnimg.cn/img_convert/387cabe0acfa0f6a12a6246dc203bc15.png)
在 Sparse Table 第四层的 archetype,我们通过 component 组合去区分数据,在这一层的设计上我们与 Unity 的思路是一致的。
![](https://i-blog.csdnimg.cn/img_convert/b523301bd747dd338401c23412c87e21.png)
这就是我们整个团结 ECS 架构的核心,层级内存管理结构 Sparse Table 的全貌了。从整体来看,这是一张稀疏的二维表,右侧花花绿绿的每一列都是一个 sized-component,我们称为 column。
从横向看,每个最小的不可再分的一行就表示一个 entry 的数据,每组连续 128 个 entry 作为一个 page,具有相同 shared component 的 page 存储在同一个 ArchetypeLine 下,具有相同 component 类型的 ArchetypeLine 存储在同一个 Archetype 下。
每个 Page 和 column 的交界处就是一个 tile,tile 内部的数据是连续存储的,而 tile 之间都是非连续的,因此远看这张图似乎被割开了一样,这就是整个结构名字里 sparse 的来源。
整个大表是一个有行有列的二维表,锁定一个数据也需要同时提供行号和列号,就像一个 table 一样,sparse table 也就因此得名。
![](https://i-blog.csdnimg.cn/img_convert/95065bf5721c2bfc5cab82c6fc69b08e.png)
前面提到,entry 表示一个 sparse table 上一行,那么行号具体是如何表示呢?
我们借鉴了操作系统内存分页的机制,对 entry 进行了编码,我们使用一个 ulong 表示 entry,在 ulong 的 64 位中,分别为 archetype,ArchetypeLine,page 以及 entry 预留了一定的位数表示每一层的编码。通过每一层的编码,即可以在不同的层上找到对应的位置,一层层深入下来,最终锁定最底层的 entry。这样的编码方式使得 sparse table 最多可以支持 6 万多个 Archetype,每个 Archetype 下 6 万多个 ArchetypeLine,每个 ArhcetypeLine 下 100 多万 page,每个 page 下 128 个 entry,这个数量级几乎足以应对任意场景了。
![](https://i-blog.csdnimg.cn/img_convert/cd343505a6776dce0c3e376f6f07550a.png)
总结一下我们和 Unity 在概念上的不同地方。
首先 Unity 是三层结构,团结是四层结构。在第一层,Unity 的 entity 和团结的 entry 或 entity 概念相对应,我们通过绕开数据的持久引用,避免了映射维护的开销;第二层,Unity 的 chunk 对应团结的 page,我们将不同 component 数据分散存储在 tile 上,减少了添加删除 component 导致的数据拷贝开销;第三层团结多了一层 ArchetypeLine,我们将 shared component 的值存储在这一层,节省了设置值的开销,也加速了数据查询;第四层,Unity 的 archetype 和团结的一致。
![](https://i-blog.csdnimg.cn/img_convert/3dff3e7346037ab2e371431378ce2459.png)
回过头再来看看这张对比图,大家可能会担心,tile 的存储会不会导致内存碎片化?答案也是不会的,因为我们的 tile 大小可能多种多样,比如 1K,2K,3K 等等,但这些 tile 都是按照 64 为最小分配单元去申请内存的,这样就保证了我们的内存申请在绝大多数平台都是页对齐的,避免了内存碎片的问题。
数据查询 Query
前面我们介绍的内容主要解决了数据怎么存的问题,下面给大家介绍下数据的查询操作 query,query 主要解决的是选取哪些数据进行计算的问题。
![](https://i-blog.csdnimg.cn/img_convert/8318e424d59c20f6aff2139ec4182626.png)
ECS 中的 query 和数据库中的 query 有些类似,都表示符合一定查询条件的数据集合。这里为大家举了个使用团结 ECS 的 query 的例子,首先创建一个 query builder,并指定 sparse table 以声明查询的范围,通过 with all,with any 等语句对查询加上一些限定条件,还可以再通过添加团结独有的 with shared 语句来指定数据的 shared component 值。
![](https://i-blog.csdnimg.cn/img_convert/05397422026e538d8d682b8baa557051.png)
Query 的查询过程采取的是两级的匹配策略,首先根据 query 描述中的查询条件的 component 类型,查找出符合条件的 archetype,比如这里的 query 要求目标 archetype 包含 position、rotation,以及 instanceID 三个 component,因此就只匹配到了 Archetype0,而无视了 Archetype1。
下一步就是根据 SharedComponent 的值对 archetype 下的 ArchetypeLine 进行匹配。比如这里的 query 由于指定了 instanceID 值为 1,因此只匹配到了 ArchetypeLine1 而无视了 ArchetypeLine0,并将其存储在 query中,到这里便执行完了所有的查询步骤。
具体 query 怎么使用呢?在对 query 执行 job 时,直接从 query 存储的 ArchetypeLine1 下获取所有 page 数据进行计算就行了,不需要逐一 Page 进行匹配。
![](https://i-blog.csdnimg.cn/img_convert/2fcd26186abaec6bd73e5ab2135a8f5c.png)
我们再直观感受一下这样设计的好处。我们又设计了一个实验,实验方法为将 entity 分别设置了不同的 shared component 值,并通过 query 查询其中的一种,返回 query 匹配到的所有 chunk 或 page。
我们分别对两种 archetype 进行了测试。从图中可以看到,由于减少了遍历 page 的开销,而使得查询操作得以提前返回,团结整体的性能最高能提升到 Unity 的约两倍,这个性能提升也是相对不错的。
下面由我们组的曹岩给大家分享团结 ECB 的实现。
Entry Command Buffer
![](https://i-blog.csdnimg.cn/img_convert/98df283bfd29940d38a8d0be9483c9c3.jpeg)
曹岩:大家好,我是来自团结 DOTS Team 的曹岩,感谢前面周琛对于我们团结 ECS 内存管理的介绍,拥有了这些管理好的数据,我们就可以在自定义的 Job 中,批量地访问并处理它们。在其中,有一些特殊的指令会触发 Structural change 的问题,导致需要回到主线程再执行。在 Unity 当中,它们这一类指令被称为 Entity Command Buffer,这些指令往往使用频率较高且耗时很大。因此在团结 ECS 中,我们提供了一套类似的基于 Entry 的指令计算来提高效率,我们将其称为 Entry Command Buffer,后面我也将它们都统称为 ECB。
![](https://i-blog.csdnimg.cn/img_convert/98ffb8fa2b18341305a2d0a978817ed1.png)
首先简单介绍一下 ECB 的作用。
在使用 ECS 的 Job 进行并行计算时,我们可能会有一些涉及 Structural Change 的操作,例如 Create/Remove Entity, 为一个 Entity Add/Remove Component。这些操作需要在主线程上才能真正被执行,但这些指令本身可能是在 Job 中产生的,我们需要将它们先记录下来,记录的 buffer 被称为 EntityCommandBuffer,等到 Job 完成回到主线程后,通过调用 ECB.PlayBack 来真正执行它们。
![](https://i-blog.csdnimg.cn/img_convert/a25bbb1755fa4ae29046e116d1c7562e.png)
那么,如何产生这些指令,同时它们又是怎么被存储的呢?
让我们先来看看 Unity 的实现,在单个线程或多个线程中,我们都能向 ECB 添加指令,单线程直接使用 EntityCommandBuffer,而多线程情况,使用对应的 ParallelWriter。
在主线程中,我们的所有 ECB 指令会被存在一条 ecbChain 的链表当中,后续的执行也相对简单,所有指令按加入链表的顺序,依次执行;而在多线程的情况下,每个线程则会拥有一条自己的链表,它们又是怎么保障执行顺序的呢?
![](https://i-blog.csdnimg.cn/img_convert/af374bef95cc3e280c0730f555432bc4.png)
在 Unity 当中,它会要求在多线程添加指令时,必须给定指令的 SortKey 来表达指令的执行顺序,这些指令以及它们的 SortKey 就会组成左图的结构。例如在线程 1中,1 3 3 7,2 4,3 4 5 代表的是指令添加的顺序,数字代表用户给定的 SortKey。它们会被划分为一个个 Node 节点,每个节点中的指令都是非递减的,如果出现一个小于前面的指令,就会划分到一个新的 Node 节点当中。之后回到主线程,执行 PlayBack 的时候,就会循环每个线程的链表,将里面的每一个节点取出来,构成一个小根堆,后面利用这个小根堆逐个获取节点并依次执行。
![](https://i-blog.csdnimg.cn/img_convert/2c73db1a6b90eb87e3e9fcc9f996a398.png)
通过这一系列操作,Unity 达到的主要目标就是,在严格遵循 SortKey 情况下,按照用户 Add 顺序执行了 Command。非常清晰同时也很准确,但在我们看来,这可能会有以下问题:
首先这些指令基本是 Per Entity 的,这也意味着它的数量可能非常大,但同时我们的指令却又无法合批。目前看来,Unity 中只会对连续创建相同的 Entity 可以合批,类似创建 AAABBB 合为创建 3 个 A 和 3 个 B,但创建 ABABAB 就无法合批。而这些大量的,无法合批的指令又必须在主线程才能运行,block 住主线程,并且有的指令又是比较耗时的。这就会带来较大的性能开销。
其次是 SortKey 的使用不够明确,通过查看 Unity 论坛,我们发现它给大家带来了较多的疑惑。首先 SortKey 的值是不太明确的,在不同线程之间,使用什么值作为 SortKey,是线程号?还是 chunkIndex?暂时并没有一个准确的建议。其次 SoryKey 本身也不符合写这么一个多线程 job 的初衷,本身是一些并行跑的 job,却又强行让指令之间根据 SortKey 排出一个线性的执行过程,并且要在回到主线程之后依次执行。
![](https://i-blog.csdnimg.cn/img_convert/5cd74cb1f2ab0b9533c3d87065c949e3.png)
正因为以上的问题,我们设计了一套新的 ECB 实现。
首先,我们舍弃了 SortKey 的选项,并且我们会将用户添加进的指令进行一定程度的乱序来更好合批,但保证结果符合用户的预期。
其次,我们已经知道,在我们设计的内存结构中,Entry 保留了丰富的编码信息,像是 Archetype, ArchetypeLine, Page,我们能利用这些信息来更好归类 Command。我们设计的核心思想就是,利用编码信息将命令尽可能多地合批,加速整个 ECB 的计算。
![](https://i-blog.csdnimg.cn/img_convert/525da5ae6f9a5d452c19964bbdf15331.png)
首先,让我们看看 ECB 中需要支持的几种指令类型。一种是创建或删除一个 Entry,一种是修改 entry 某一个 Component 的值,还有一种是 entry 添加或删除某个 Component。这会让 entry 成为另一种 ArchetypeLine 下的数据,我们需要先在新的位置创建 Entry,Copy 原来的数据,然后删除原来的 Entry,或是把新的 Component 的值,修改为 API 设定的值。
![](https://i-blog.csdnimg.cn/img_convert/05bb9d592fc8e4e94c61ffe8299a3fff.png)
整理之后可以看到,对于一个 Entry 而言,主要就会有以下这几类指令操作:创建,创建并 copy,修改,删除。
![](https://i-blog.csdnimg.cn/img_convert/e37f61863c08169182631fb462f15f77.png)
而这四类指令,在我们的实现当中,将会被分别汇总,进行合批,并按照预定义的顺序,依次执行。例如上图这个样例,在 2 个线程中,我们分别添加了一些会触发 structural change 的指令,像是创建 Entry,修改 Entry,添加 Component,删除 Entry。实际上在这过程中,它们将根据指令类型分别被放入右侧这四类指令集合当中,而这四个集合,将在主线程真正 PlayBack 时,按照创建、创建并拷贝、修改、删除的顺序,依次执行。
通过调整顺序,能为我们带来哪些优势呢?让我们依次来看看修改后,各指令对应的实现。
![](https://i-blog.csdnimg.cn/img_convert/9f30750dd8cd3b3c40b703d848d15b04.png)
首先是创建,需要先了解一下的是,在 Unity 和团结的 ECB 实现当中,都是要等到 PlayBack 才会真正在内存上去创建一个 Entry。但用户在 PlayBack 之前,可能就想往这个 Entry 里写值了,例如说新生成了一个敌人,会初始化一些参数。因此在 Job 的 ECB.CreateEntry 指令,实际上会先返回一个假 Entry,它会带有一个全局唯一的 ID,以此标记这个 Entry,并将后续的 ECB 指令和它关联起来。这些创建和修改操作,如右图所示,都需要等 ECB.PlayBack 后才真正生效。
![](https://i-blog.csdnimg.cn/img_convert/e2e2b9c4c38e243ec7659ff95d8de488.png)
在此基础上,来看看我们的 Fake Entry ID 结构,每个 Entry 由 64 位组成,之前提到了后面共 59 位的内容,包括 ArchetypeLine ID,pageIndex 和 EntryIndex。但实际上前面还有 5 位数据,第 1 位将用于标记是否是 Fake Entry,还有 4 位则会作为预留数据位,留作未来使用。对于 ECB 创建来说,只有 ArchetypeLine 是确定的,实际上在内存的哪个位置创建数据反映在 PageIndex 和 EntryIndex,都需要等真正创建后才能确定。因此,我们在使用 Fake Entry ID 时,后面 27 位都可以任意使用,对于每个 ArchetypeLine 都可以创建 2 的 27 次方个 Fake Entry,足以满足大部分使用。
其次,我们还可以利用 ArchetypeLine 信息将创建的 Entry 归类,在 PlayBack 时,将所有同类型的 Entry,批量创建。例如前面 Unity 无法支持的 ABABAB 创建顺序,在这里就会被合批为创建 3 个 A,3 个 B。
让我们来看看通过这些优化,在该方案下我们能达到怎样的性能效果。
![](https://i-blog.csdnimg.cn/img_convert/7febeb162004cabff797a2564056929d.png)
如图所示为创建 10000 个 A 类型,10000 个 B 类型的 Entity/Entry,Unity 和我们方案的开销对比。每一块最上面白色部分代表 Tuanjie ECB 耗时;淡紫色是 Unity 普通的 ECB 指令,也就是在主线程的耗时;深紫色则是 Unity ECB 带有 SortKey 的情况,也就是在多线程时候的耗时。可以发现,对于类似于 AABB 的创建形式,也就是能够进行合批的情况下,我们和 Unity 不带 SortKey 的方案耗时基本相同,而添加了 SortKey,也就是在多线程中使用,Unity 方案就从 0.4ms 增加到约 3ms 左右。而我们则保持不变,相比就能有 7.6 倍的性能提升。
对于类似于 ABAB 的创建形式,Unity 就会由于无法合批,在任何情况下耗时都增加很多,达到了接近 6ms,团结则增加不多,整体来看,在该情况下也依旧是能达到大约 7 倍的加速。
![](https://i-blog.csdnimg.cn/img_convert/9380f2b559b5db7efc8430d611208656.png)
对于创建并 Copy 类型的指令,也就是 Add/Remove Component 这些,我们则会存储一个两重的 HashMap,记录下 ArchetypeLine 下被修改 Entry 的 Component 加减操作,并进行简化。例如对于 Entry0,我们先添加了 Component0,Component1,又删除了 Component0。那么在最后,我们判断它只需要添加 Component1 即可。通过该方案,在 ecbPlayBack 中,就能在每个 Entry 层级,省下多次无效的 Add/Remove Component 开销。
![](https://i-blog.csdnimg.cn/img_convert/8bc523efa3f6b4f8c94cd0284269d9cc.png)
对于修改类型的指令来说,让我们来看看和 Unity 的区别。对于 Unity 来说,由于使用的是 Entity,我们可以在多个线程中修改同一块内存数据,这就导致必须有 SortKey,来决定谁先修改,谁后修改。对于我们来说,我们也有 Entity 来保证访问同块内存。但更多时候,我们只是希望并行地处理不同内存数据,加快计算。
因此在我们 Entry 的方案中,我们实际上会控制在每个线程中,通过给定的 Entry 接口,访问各不相同的内存区域,例如不同 Page,这时候就能不再需要 SortKey,并且能够并行地去修改。
![](https://i-blog.csdnimg.cn/img_convert/9d7068a680159af752156edf4580b63a.png)
具体到一个线程内的修改操作,由于它们本身的性能开销并不大,也并不会导致 StruturalChange,我们只需要按顺序去修改对应内存值即可。唯一比较特别的是,我们会判断是否存在不合理的指令顺序并进行报错。例如是否在删除指令后仍尝试访问同一个 Entry,我们会给到用户 error 信息。因为对于我们来说,会调整指令顺序,将删除放到最后执行,所以需要在调整前进行判断。
![](https://i-blog.csdnimg.cn/img_convert/05483cfb610a8ed35ea179a4727020c7.png)
最后再来看看删除,同创建一样,我们也会根据 Entry 的编码信息,合并同 ArchetypeLine 的删除指令。但还会进一步进行 PerPage 级别的合批,这是因为在每个 Page 中删除 Entry,会有 SwapBack 操作,删除数据后会将末尾的数据填充过来,就会导致其余的 Entry 失效,因此我们会对涉及删除的 page,记录一个 validity,也就是统计这 128 个数据中,有哪些被删除了,最后再做统一的删除和 SwapBack,既能提高效率,也能避免 SwapBack 问题。
![](https://i-blog.csdnimg.cn/img_convert/b7796e689a20269a235e05661348b2f7.png)
对于删除,让我们再来看看和 Unity 的性能对比。如图是测试不同数量 Archetype 或者 ArchetypeLine,每种包含 10000 个 Entry,将它们全部删除的耗时开销。可以发现,对于我们和 Unity Entities 两种方案来说,删除耗时都随着删除种类及数量线性增加,由于 Unity 的删除无法合批,带不带 SortKey 对其删除性能几乎没影响,随着数量的增加耗时也显著增加。而团结的方案即使是 3 万个 Entry 的删除,也基本控制在了 2ms 以下,总体来看,实现了将近 14 倍的性能提升。
![](https://i-blog.csdnimg.cn/img_convert/71f44f0eb7a6db13087301a0b1c21304.png)
那以上就是本次分享的全部内容,分别向大家介绍了我们团结引擎自己实现的这套 ECS 中,连续内存块、层级内存管理结构、数据查询、ECB 计算优化的相关内容,谢谢大家!