Unity手游实战:从0开始SLG——ECS战斗(五)浅谈CPU缓存命中

ECS在游戏里的运用,最初是用来解决预测和回放的问题。但是由于面向数据的编程结构,天然符合了现代CPU的编程思想,所以目前UnityECS主要还是推动展现性能方面的优势。那么ECS是如何提升程序性能的呢?最重要的其实就是CPU的缓存命中。讲CPU命中之前,我们先说说CPU的一些基础知识。

CPU架构

我们现在经常听到的关于CPU的描述里会有高通骁龙,华为麒麟,英特尔奔腾,酷睿I3、I5、I7或者X86,ARM,PowerPC,又或者32位64位等等。那么它们之间的关系是什么?

首先我们说区别CPU最核心的叫做指令集,指令集决定CPU的工作方式,所以指令集就决定了一个CPU的架构。CPU发展到现在,衍生了很多不同的架构方式,到目前为止最接触最多的就是X86和ARM架构。

X86的复杂指令集(CISC)的代表,ARM是精简指令集(RISC)的代表,这二者之间最核心的区别就在于指令的复杂程度,指令的复杂程度又决定了硬件的设计工艺和工作时的调度方式。

比如,我们把大象放进冰箱,一共需要三步:第一步把冰箱门打开,第二步把大象放进去,第三步把冰箱门关上。X86(复杂指令集)推崇的就是分成三步执行,这样以后我想把长颈鹿也放进去的时候,我只要替换第二步就可以了。但是ARM推崇的就是,我要训练一个机器人(硬件加速),告诉它怎么把大象放进去,然后我以后就只要对它说:“把大象放冰箱”,就能完成工作。这两种方式有什么优劣呢?

首先复杂指令集在面对多种相似或者相近的需求时,只要更换部分的执行顺序就能完成工作,但是前提是需要消耗额外的时钟周期,如果是一个任务还好,想想现在软件的复杂程度,那多出的执行周期不是一点两点。而ARM的方式虽然更优,但是灵活度不足,当遇到没有训练过的部分还是需要走基础步骤去解决。

当然除了复杂程度上来说,二者还有哪些区别呢?

首先X86帮助Intel坐稳了CPU产业龙头的位置,直到现在仍然是无可替代的架构。它穿插在各种PC、家用机、服务器中占据现在主流台式设备的核心位置。但是当移动领域开启之后,它仍然试图用同一套架构方式硬塞进手持设备中,这就导致了ARM这种从设计之初就面向性能的架构得到了发挥的余地。要知道一个ARM在峰值的时候功耗也就在3W左右,是I7的15分之一。更低的功耗意味着在移动设备上电量支撑的越久,这是智能移动设备初期非常大的优势所在。

而Intel要在功耗上追上ARM那么就需要在工艺上精进,所以一个28nm制造工艺的ARM架构,X86就要优化到22nm才能追赶上(麒麟处理器是7nm)。工艺的精进成本很高,7nm已经是2019的极限。我们知道制作集成电路都是需要在晶圆表面操作的,光刻机意味着是使用光来雕刻晶圆,光又是有波长的,波长越大越容易发生衍射(这也是5G信号不好的原因,5G技术最大的难点就是用来解决信号覆盖,5G快是因为波长短,携带的信息更多,但是就不容易衍射过障碍物),所以要光刻的话,必须挑选指定的光源。

常见光源分为:

可见光:g线:436nm

紫外光(UV),i线:365nm

深紫外光(DUV),KrF 准分子激光:248 nm, ArF 准分子激光:193 nm

极紫外光(EUV),10 ~ 15 nm

所以你看,极紫外光的波长都超过7nm的1-2倍了。

二者之间的差别还体现在另外一个方面,乱序执行。X86是面对桌面和多任务用户的,所以在设计上对于CPU指令的乱序处理非常好,这也就意味着要做更多更复杂的预测和设计才能保证乱序的执行不出问题,虽然我们只是简单的一句描述,但是在设计上是非常复杂的。而ARM是面对移动的,所以它本质上就没打算支持乱序,并且移动芯片现在都是片上系统(SoC)架构,图像、音频等各种硬件都在一起,调度顺序也是可以可以预测和控制的,所以这里的耗电量和功率上又进一步拉开距离。

所以这两者的设计本身其实不太具有可比性,设计的目的不一样,专攻的领域也不一样。

CPU工作原理

不管架构如何不同,最终进行工作的时候都一样。CPU主要是由运算器,控制器和寄存器组成,顾名思义,就是控制器取指令,交给运算器计算然后将结果存储在寄存器里。

 

图片来自网络(侵删)

youTube:上有课程可以查看整个CPU的执行顺序,地址在这:https://www.youtube.com/watch?v=FZGugFqdr60&list=PL8dPuuaLjXtNlUrzyH5r6jN9ulIgZBpdo&index=9&t=0s

我们截取其中一帧来说明一下:

 

一条指令包含操作码和地址,操作码可以理解为CPU的支持的指令集,这个例子里,前面4位二进制标识操作码,后面4位地址:

所以我们从RAM的指令开始遍历,看看CPU怎么做事。

第一步先看地址0,00101110拆解为0010和1110两个部分,前面的部分通过查询CPU指令得知是LOAD_A,就是把某个地址存放的值加载到A寄存器里,1110是14也就是把00000011加载到寄存器A。

第二步到地址1,按照同样的解析把00001110加载到B寄存器。

第三步到地址2,就是把两个数相加,然后存储到A寄存器里。看下图1000查指令得知是相加,0100表示A寄存器:

经过计算之后,寄存器A的值已经改变了。如下图:

那么接下来第四步,地址3 01001101,拆分之后就是0100和1101,前面操作码查表告知是存储,后面1101是13,也就是要把A寄存器的值存储到13位置里。

CPU的计算就完成了,所以结合上一个部分说的指令集来理解,一个指令集就代表CPU执行的一个操作。比如5X3,Intel可能会使用5+5+5的方式,而ARM可能直接用硬件完成乘法,所以对于Intel需要3个周期完成,而ARM只要1个。

缓存作用

介绍了CPU工作原理之后,我们再返回来看现在主机的结构,除了CPU之外还有内存、显卡等等。那么我们的程序指令集首先要从硬盘里读取,加载到内存,然后通过总线传入给CPU执行,CPU的工作频率都是2.xG、3.xG,,而一个机械硬盘的速度是7200转/分钟。。。

所以我们需要内存来帮助加快指令读取和传递。即使内存速度高出硬盘N多(CPU是GHZ为单位,内存是MHZ为单位),和现在CPU的频率相比仍然远远不够。从CPU的设计上也可以看到,CPU唯一的存储设备就是寄存器,一共就那么几个,不可能存储大量数据。所以每次计算完都需要找内存要东西,大量的时间就会在等待内存数据的传递中。想想一下马斯克用火箭运载货物,结果每次运完都要等一个小孩给他搬货……

那么改善的方法呢?当然是提高运货效率,这个方式就叫缓存。

 

图片来自网络(侵删)

越小的缓存离CPU的距离越近,设计上速度也也快,相对来说容量也越小。(CPU一共就那么点大,你想要怎样。。)缓存级数也不是越多越好,因为CPU查找指令的时候是逐级查找,如果找到了倒也罢了,如果找不到就浪费了多次检索过程。

CPU在执行某个程序片段的时候,它会安排缓存帮他预测下次要查找的数据,然后逐级上报,如果下次查找的数据正好在缓存里,那么就叫缓存命中,如果不在就叫miss,如果Miss了,CPU就不得不亲自去内存里找,那么速度可想而知。

所以现在知道ECS面对数据编程,对于缓存命中的重要性有多高。之前一个同事,因为场景的部分性能不好,自己花了一大堆时间弄了四叉树优化,却发现根本没有提升性能,原因就是硬遍历往往能得到较好的内存分布,命中率更高。(光线追踪的管线里有两次重要的排序,都是为了排列数据提高效率。)

缓存类型

先说一个概念,局部性。也就是程序在执行的过程中,无论是存取数据,还是读取指令集往往会呈现一定程度的局部性。

  • 时间局部性(temporal locality):当前用到的一个存储器位置,在不久的将来还会被用到。

  • 空间局部性(spatial locality):当前用到的一个存储器位置,它临近的几个位置也会被用到。

那么在CPU的层面,这两个局部性的特性就会被被cache执行,即将对拥有良好局部性的位置和指令进行缓存。来看一个具有时间局部性的例子:

这是一个简单的求数组和的函数,这里的sum和i都具有时间局部性。那么它们就会被Cache管理,被CPU取值命中。

再看一个空间局部性的例子,我们将这个一维数组改为二维。

我们知道一个二维数组组在内存里的排列是按行顺序排列的,大概是这样:

ay[0,0], ay[0,1], ay[0,2],ay[1,0], ay[1,1], ay[1,2]……

所以SumCache的写法会完全命中ay在内存里的排布,而sumMiss的写法则会MISS,二者的函数执行效率差距在几十倍。

所以ECS的架构,就是对缓存命中最大的提升,也是ECS性能倍增的原因。下一节稍微讲一下Unity日后主推的面对数据栈技术编程即DOTS。