本期为GAMES104《现代游戏引擎:从入门到实践》课程视频公开课文字实录前沿理论的第8期。GAMES104课程由GAMES(图形学与混合现实研讨会)发起,游戏引擎技术专家王希携手游戏引擎一线开发者共同研发。课程共计22个课时,介绍了现代游戏引擎所涉及的系统架构,技术点,引擎系统相关的知识。
关注公众号GAMES104,可查看往期文字实录内容。
本期编辑:Piccolo 社区编委会 曾添
引言
接下来我们要讲的是,DOP(Data oriented programming ),面向数据编程。
在正式讲 DOP 之前,先和大家简单的快速过一下各种编程范式,包括面向过程编程,面向对象编程,其中最为经典的面向对象编程(OOP)。
01「面向对象编程」
提起OOP ,我相信所有的读者用的最多的就是 OOP ,实际上,在游戏引擎开发的时候,通常我们会使用各种的编程范式。这也是我后来进入到游戏引擎这个行业的时候,面对不同的业务模型,才发现在不同的业务模型下,需要使用特定类型的编程范式。其中就包括面向过程的编程,面向对象的编程,甚至面向数据的编程。
早期的游戏引擎其实比较简单,比如说我们要做一个小乌龟的游戏的话,小乌龟的这个游戏,那其实很简单,用面向过程就好了。在这种情况下,数据的各种处理,我可以定义各种的输入和输出。但是一旦到了现代游戏引擎,大家很天然的会想到我们需要使用面向对象编程,我一旦我定义一个基类,可以派生出各种各样的东西?类似于vehicle ,飞机、武器,角色,什么东西都可以,万物皆可以继承。
这个思想其实就是面向对象的核心思想, 这个思想其实非常的了不起,因为从它诞生至今,它非常符合我们人类对这个世界的认知,所以写代码的时候,我们可以很好的用我们的思维方式去实现代码。但是 object or oriented programming 它是不是完美的呢?答案是否定的,实际上在OOP编程中也存在很多的问题。第一,它虽然理论上你会觉得很舒服,但是它有很多的二异性。
举个例子,比如一个 Actor的战斗逻辑,我们可以定义怪物为一个 Actor,那角色也可以定义为 Actor。现在这个玩家去攻击怪物,那这个业务逻辑是玩家去攻击怪物,然后 施加伤害给怪物, 还是定义为怪物被攻击了呢?所以面向对象看起来对这个流程分析得非常的清楚,但是这个动作到底是属于谁呢? 在这个案例里面,是这个攻击者还是受击者呢?其实这是有二异性的,而且不同的程序员他有不同的写法。大家如果打开一些游戏的这个逻辑的代码,比如说你们真的打开一个真正的商业级游戏的时候,你发现这种游戏它有很多的程序员一起写,你会发现有些程序员就喜欢从这个攻击者角度去写, 但是有些程序员就喜欢从受害者角度去写,所以这个时候就会存在代码一致性的问题。
面向对象的第二个问题是什么呢? 在复杂的系统里面,面向对象编程存在一个非常深的一个继承树,现在假设玩家对一个物体造成了一个魔法伤害,但是这个物体存在一个很长的继承链。所以我很难知道这个伤害究竟会施加在哪一层,是在基类做的 还是 Actor这一层,还是所有的层级都做. 而且正如上文所说的面向对象编程本身就具有二义性,不同的人的思考方式不同,可能有的人觉得应该写在基类,可能有的人觉得应该写在子类。
第三个问题在于,由于引擎需要考虑到不同业务的复杂性,因此在部分引擎的基类可能存在非常冗余的情况。比如我们看虚幻的源代码会发现很多基类里面有各种各样的方法。
第四个问题,OO的会有额外性能的开销,以至OO的性能普遍偏低。因为其实 OO 它非常符合我们人的直觉,所以一对象可能是由多个对象组合而成,所以OO的数据在内存的分布可能存在不一致性,其特征表现为部分成员变量通过指针引用到了别的内存空间。而且 object 通过派生继承,它的数据全是异构的,然后它就每一个对象的创生就会 allocate一次。同时在面向对象里面又存在虚函数的重载,由于虚函数指针的特性,每次访问虚函数的时候,会发现调用的地址不连续的问题。
最后一个问题是 OO 的可测试性。比如我们有一个非常复杂的系统里面有几百个上千个模块。如果我们要测试其中的某一个模块,比如说角色的某一个业务或者是某一个业务逻辑,类似于伤害检查。那为了测试伤害的计算公式,如果用 OO 写的系统的时候,我们需要把整个环境给创建起来,我们需要把这些这个所有的对象所有的东西全部拿起来。然后我再去测中间有一个函数。但实际上在现代软件工程里面,单元测试(unit test)最理想的情况是, 不管是你的项目有多大,同时代码可以一直更改。但是每个unit test 它只去测你的一个特定模块的数据。但在OO里面所有的数据全部被包到对象里了,所以当去测某个数据的准确性,得把整个整个环境个配置起来。
当然为了克服这个问题,也有其他方法,但本文就不展开,比如说面向函数的编程,其实也能解决这个问题。但为什么有这样的不同的编程范式的存在,其实跟这个也是有很深的关系,特别对于这种超大型的软件工程,因为一个游戏引擎真正的一个到达可以用的规模的话,一般都是几百万行以上的代码。那这个时候其实它的可测试性实际上要求也会非常的高。
02「多级缓存」
随着现代的计算机的发展,我们会发现一个特点,我们的处理器的速度越来越快,但是内存的访问速度的增长跟不上CPU 速度的发展。但是在前文我们说过CPU的发展已经到了瓶颈,为什么现在又说内存跟不上CPU呢?因为在过去的 20 年里,我们CPU的速度是以将近上千倍往上去涨的,但是我们的内存访问速度大概也就提高了大概 10 倍左右。所以这两个之间的差距已经拉开了,将近是两个数量级以上,两到三个数量级以上。所以为什么我们现在要讲面向数据编程了。
现代计算机程序有个非常复杂的机制称之为缓存(cache)。在上文讲渲染的时候,我们提到到过,由于缓存的存在我们可以简单的处理CPU和内存速度不匹配的问题,所以这个特性,也导致了面向数据的编程这个思想的产生。在现代计算机架构里面,这个缓存机制,你可以理解成就它像个水泵一样。CPU 在最上面,然后我们一次叠加缓存,最靠近 CPU 的是我们的 L 1 cache 那也是最快的,所以它的体积也比较小。接下来就是L2 Cache, 一般来讲是 10 兆左右。但是这个 L2 Cache 的速度大概比 L1 Cache 慢一个数量级左右。然后下面这层是L3 cache 它一般是在就是十几兆到上百兆左右之间。它实际上是比 L2 cache 又去慢一个数量级,然后再到主内层,内存又慢一个数量级,所以它们之间依次是 1000 倍的差距。
所以当 CPU 在进行数据处理的时候,无论是你的代码也好,还是你的数据也好,都是过这个 cache,类似于分级水泵一样,一个批次一个批次的把数据与内存加载 L3,L2,L1缓存中,然后 CPU 才能对这个数据进行处理。
所以在面向高性能编程的时候,一定要记得缓存速度匹配的问题。也就是说我们尽可能的处理相同的数据以保证数据的局部性原理。
在上文讲渲染的时候,我们提到过一些把渲染做的更好更快的方法,所以你必须要理解 GPU 的工作原理。因为在GPU 里面有存在各种缓存。在这里最简单的一个方法是 SIMD,因为它是一个非常好的硬件加速方法,现代 CPU 基本上全部实现了 smid ,这也就意味着一个包含了四个分量的Vector ,如果对它进行浮点加减乘除,可以把它看成一个Vec4在一个指令时间内完成,因为它一次性会读四个浮点内存的空间,并且一次性写也是四个空间,所以基本上你可以认为它将近四倍的加速。
所以当去打包这些数据的时候,我们尽可能把它数据成能够支持SIMD操作的格式,比如16 个 bytes 为一组的空间。当然这个方法显然不能完全满足高性能编程的需求。因为在操作系统的缓存当中还存在一个数据管理原则,称之为LRU(latest recent used)。
这也就意味着, cache 一旦满了之后,会把那些最近一直用的数据留住,把最不常用的数据,就最近最没有用的东西我把它扔掉。当然实际上还有一种思想是把数据随机的扔掉。因为数据的分布也是一个概率事件。如果cache 足够大,比如把 1k 或者 1 兆,假设每一个 tick 我随机扔掉,比如64 byte或者 128 byte,但是它相对整个这个存储空间来说,可能只是千分之一左右,这样随机扔掉数据的一个基础是基于这个数据没有被访问到,如果我们从概率的角度去讨论,你会发现这个策略也符合我们的数学期望,也就是谁最久没被用到,它就有最大的概率会被扔掉,所以其实这也是一个 cache 管理的机制,那这也是为什么我们在打包数据和代码的时候尽可能的放到一起的原因。
03「Cache Line」
首先我们来理解Cache Line,当引入CPU cache之后,CPU访问某块地址时,会首先检查L1 Cache,如果不存在,则会检查L2 cache,然后是L3 Cache、内存,如果CPU直接命中cache,则不需要再去访问内存,如果没有命中cache,则需要找到之后会把内存中的数据映射到cache中,内存映射到到cache的传输的最小单位就是 cache line,现在一般都是64字节,就算CPU只取一个字节,也会把这个字节所在的内存段64字节全部映射到cache中。这主要是根据局部性原理,访问到一个地址时,这个地址附近的内容近期也很大概率被访问到。
比如说我们看到一个我们定义一个变量,一个 integer,定义了一个数字。其在 CPU 里面,当你对它进行计算的时候,在最顶级的 L1 cache 里面有它的一个数字,有一段内存,然后在 L2 里面也有它的一段, L3 里面也有一段,在内存中也有一段。而且整个操作系统和 CPU 要保证 CPU在这四个三个 cache 和 memory 的它的数据最终是一致的。 当我们对其进行读写时候,就会按照一个 cash line 进行的,也就意味着我一次性去读 64 个 byte 的数据,我去对它进行读的操作。当这些数据发生改变了,我要必须要写回主内存的时候,我也是一次性的一级一级的写下去。
假设我们现在要去读写一个矩阵,根据上文的描述我们可以发现,逐行读的效率可能比逐列读的效率高个几百倍甚至上千倍。原因在于按列读取可能导致数据在内存空间的不连续,意味着每次映射到cache-line的时候需要花更多的时间去查找,也就大大降低了读写的效率。
所以按照上文我们所描述的观点,面向数据编程的核心就是更加高效的利用局部性原理和CPU缓存机制去提高速度,因为在游戏里面,所有的事件都可以用数据的方式进行驱动,所以当面向数据编程的时候,可以很好的降低 cache miss 。
当然 cache miss 不仅包括这个数据的 cache miss 还包括这段需要执行的代码,如果加载新的代码,新的代码也会导致 cache miss。从 profiling中我们也可以看到函数时间的替换也有 7%左右的性能损耗。所以当其面向数据的编程的时候,我们会把数据和代码看成一个整体,而且我们尽可能的要保证数据和代码都尽可能紧密的。就是说在 cache 里面在一起,注意内存中他们可能分开,但是在 cache 里面我们要尽可能在一起,这样保证就这一段代码执行完之后,这些数据刚好能够处理完,然后在进行下一批数据的处理. 我们还是继续以鞋厂为例,我们希望是当工人处理鞋的时候,这个工人应该是和他的这个要处理的鞋,原料全部放在一起。这样一来,他一次性处理完之后,他不用花时间去寻找其他的鞋和原料。