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