这篇文章将带着你从设计出发重新发现ECS。
注意:此篇为泛泛之谈,不涉及具体实现。
从Abstract说起
- 从”是”到”能”再到”有”
对对象的抽象是整理代码的要点,继承是一种比较古老并常见的抽象,其描述了一个对象"是"什么,其中包含了对象拥有的属性和对象拥有的方法,在简单情况下,继承是一种非常易用易懂的抽象,然而在更复杂的情况下,继承引入的的问题渐渐浮现出来,使得它不再那么易用。
以下列举几个例子:
- 深层次继承树(要理解一个类,需要往上翻看非常多的类)。
- 强耦合(修改基类会影响到整棵子继承树)。
- 菱形继承(祖父的数据重复,方法产生二义性)。
- 繁重的父类(子类的方法被不断提取到父类,导致父类过度膨胀,某 UE4)。
- 而这些问题又相互影响产生恶性循环,使得项目的后期开发和优化变得无比困难。
于是,大家便尝试简化模型,并描述了一种叫做接口的抽象,其描述了一个对象"能"干什么,其中包含了对象拥有的方法(不再包含数据),接口隐藏了对象的大部分细节,使得对象变成一个黑箱,且展平了类结构(不再是树状),然而接口(这里指运行时接口而非泛型)作为一种非常高层次的抽象,这种抽象层次似乎有时会过高,导致CPU更难以理解代码,这点在稍后会讨论到。
类似的,在游戏开发中,面对大量的对象种类,大家又描述了一种组件的抽象,如 UE4 中的 Actor Component 模型和 Unity 中的 Entity Component 模型,其描述了一个对象"有"什么部分,其中对象本身不再拥有代码或数据(但其实 Unity 和 UE4 之类的并没有做到这么纯粹,对象本身依然带有大量"基础"功能,这导致了代码量和内存占用的双重膨胀)。组件的方式带来了优越的动态性,对象的状态完全由其拥有的组件决定(同样,一般没这么纯粹),甚至可以动态的改变。并且这让我们可以排列组合以少量的组件组合出巨量的对象(当然,有效组合往往没那么多)。有趣的是,从展平对象结构的角度看起来和组件和接口有着微妙的相似性。不过这种抽象也带来出了一些歧义性,接下来将讨论这一点。
2. “有”和”能”和实现
在组件模型中,对象由组件组成,所以其行为也由组件主导,例如一个对象拥有[Movement] 和 [Location],则我们可以认为它能够移动,这在整体上是十分和谐自然的,但当我们仔细考量,这个"能"是由于什么呢,是因为 [Movement]吗,是因为[Location]吗,还是同时因为 [Movement] 和 [Location]?当然是同时(这里便揭示出了组件和接口的展平对象方式是正交的),那移动的逻辑放到哪呢?答案是放在这个“切片“上。但在实际项目中会看到把逻辑放在 [Movement] 上的做法,这两种方式都是可取的,后一种拥有较为简单的实现并被广泛采用,而前一种拥有更精准的语义,更好的抽象(后一种种方式中 [Movement] 去访问并修改了 [Location] 的数据,这破坏了一定的封闭性,且形成了耦合,当然这种耦合也有一定的好处,如避免只添加了 [Movement] 这种无意义的情况发生)。
从Cache说起
- Cache Miss
Cache(Cache Memory)作为储存器子系统的组成部分,存放着程序经常使用的指令和数据,是为了缓解访问慢设备的延时而预读的 Buffer,例如 CPU L1/L2/L3 Cache 作为 DDR 内存 IO 的 Cache,而 DDR 内存作为磁盘 IO 的 Cache。当计算需要读取数据的时候,通常从最快得缓存开始依次向下查找,并递归的读取。预读就是用来减少下一次读取的查找层数(每一层的延迟有数量级的差距)的技术。相应的,预读的预测失败的时候将会有非常高的代价,这种情况被称为 Cache Miss。在大部分的情况下,在现代 CPU 的频率带来的运算力下, Cache Miss 比数学运算更容易成为程序的性能瓶颈,且在代码中的表现比较隐晦。这使得一味的讨论复杂度O(n)不再适用,因为现在效率=数据+代码,最常见的例子就是在数据量小的情况下遍历数组会比 (Hash)Map 快上很多,这也是Java或C#这类语言的效率陷阱.。
2. Avoid Cache Miss
避免 Cache Miss 的方案当然就是去讨好预读。而一般预读的策略为线性预读,即我们应该尽量的保证数据读写的连续性,从逆向思维出发,则需了解会打断数据连续性的情形。简单的列举几个:遍历大结构体的数组(却只访问少数成员),操作对象引用(OOP),操作数组的顺序不够连续(比如实现得不好的 hash 表),etc。综上所述,避免Cache Miss的主要考量就是尽量使用数组,尽量分割属性(SOA),尽量连续的进行处理。(在 GPU 编程中存在大量实例)
3. More than Data
前面提到过 Cache 存放着程序经常使用的指令和数据,现代 CPU 在数据 IO 的时候并不会完全的挂起,而是会利用空闲的运算力继续执行后续的指令,且指令也是一种数据,这意味着我们不光要照顾数据的连续性,还需要考虑到指令的连续性,那么什么情况会破坏指令的连续性呢?可能是函数指针(虚函数的调用,回调等),循环超长代码块等。特别是函数指针在 IO 期间,CPU 无事可做,于是在需要高性能的情形下,应该尽量避免虚函数。
4. Allocation
对于数据而言,还有一个重要的问题就是分配内存。在应用中,不管是分配还是释放都是十分消耗性能的操作,前者可能产生碎片,而后者,(考虑 GC)可能带来停顿,(考虑 RC)带来析构血崩,(考虑手动)也可能带来危险和脑力负担,所以一般对于高频分配的部分,会预先分配大块内存用来管理(一般称作池化)。
从 Thread 说起
- Multithread
随着处理器核心的发展速度减缓,为了进一步提升处理器的性能,堆叠核心成为了新的出路,甚至现在的处理器没个四核都不好意思见人,其中堆叠核心的巅峰就是 GPU,上千个核心带来了疯狂的数字处理能力,被广泛运用于 AI 和图形领域。而这在游戏之类的高性能软件中,为了充分利用 CPU 的算力,程序设计成多线程运行也是非常必要的。
2. Race Condition 和 Data Race
不幸的是多线程很多时候不是免费的性能,并不是所有情况都像异步读文件那么简单,在开发过程中,很多地方都可能会有 Race 的发生。同步性问题非常的恶心,因为通常其不会即时造成崩溃之类的错误,而是会积累错误,等到错误爆发,缘由已经很难查询。所以编码的时候就必须要小心翼翼,其中 Race Condition 主要需要我们保证整体操作的原子性,一般的解决方案是一把大锁。Data Race 则更加复杂,触发Data Race的条件可以归纳为:
1,同一个位置的对象。
2,被两个并行的线程操作。
3,两个线程并非都是读。
4,不是原子操作。
只有当这四个条件同时成立的时候,Data Race 才会发生,所以为了避免它的发生,我们需要破坏掉其中的一个或多个条件。对于条件4,可以使用原子操作破坏,然而原子操作的复杂性颇高,实际应用中常用于实现底层库(无锁队列,线程池之类的)。而要破坏条件1、3,则是避免可变共享,完全进行拷贝(如erlang)。剩下条件2就是避免硬碰硬,在可能发生 Data Race 的时候直接放弃并行。但总得来说最重要的还是,要避免它的发生,一定要对这些条件足够敏感以预防遗漏,在这里通常封装就起了反作用,因为黑箱之内我们无法知道会发生什么。而此时相对于 OOP 的黑箱,函数式的纯粹(纯原子性)便能体现出它在并行上天生的优势,所以卡神推荐在 C++ 里也尽量使用函数式的思想来进行编码。
交汇之地 - 三相之力!
- Component 与 System
之前说到组件模式的时候,我们列举了两种方式来存放实现组件功能的代码,而使用“管理器”实现的方式,拥有更精准的语义和更好的抽象,组件之间被彻底解耦,而这个“管理器”我们称之为系统(System)。即系统负责管理特定的组件的组合,而组件则不再负责逻辑。接下来分别讨论这两个部分。
2. System
对象耦合于接口,而这里系统则耦合于对象。这意味着组件不变的情况下,系统的任何修改都不会对程序的其余部分造成影响。这给代码带来了出色的内聚性,让 culling 和 plugin 都变得更轻松,并且系统本身拥有很好的纯度,我们完全可以把系统看做是”输入上一帧的数据,输出下一帧的数据“。也就是系统本身贴合了函数式的思想,根据前面的叙述,函数式在并行上有天生的优势,这在系统上也体现了出来:系统负责管理组件的信息是透明的,于是我们对系统对组件的读写便一目了然 - 注意结构体之间没有任何依赖,系统与系统之间的冲突也一目了然。更进一步,在通常情况下,系统是一个白箱,运作系统的代码将不会经过虚函数,不管是效率还是可测试性都是极好的。甚至对于系统的执行调度也完全暴露了出来,这在实现网络同步之类的框架的时候能提供很大的便捷性。
3. Component 与 Entity
对于对象本身,其实已经不必要承载多少信息了,激进一点说,对象甚至只是一个唯一的ID,用于和其他对象区分而已,这让我们有机会去除那些"基础"功能的依赖(例如 Transform),使得内存和代码进一步压缩。而组件不包含逻辑,就只有数据,作为一个大的对象的分割的属性,通常为小结构体。对于每一种组件,我们可以使用紧密的数组来储存它,而这也意味着我们可以轻松的池化这个数组。在系统管理组件的时候,并不关心特定 Entity,而是在组件数据的切片上批量的连续的进行处理,这在理想情况下能大大的减少 Cache Miss 的情况。作为额外的好处,纯数据的组件对序列化,表格化有着极强的适应性,毕竟对象天生就是一个填着组件的表格,对网络、编辑、存档等都十分的友好。(这里也可以引入很多数据库相关的知识)
4. ECS
至此,我们重新发现了 ECS,并详细阐述了它的好处。