ECS架构是面向数据编程的代表,面向数据编程的意义就是让我们关注数据编程,从而提高程序的性能。数据的处理一般分为数据的读取以及数据的计算。
数据读取
我们需要尽可能的使用高速缓存来提高运行效率,不仅仅针对内存,磁盘的读取在游戏开发中也是非常重要的。
数据的计算
cpu端SIMD计算,以及gpu的CUDA计算都是为了提高数据的计算能力。
无论是提高缓存的命中率还是SIMD及CUDA计算,SOA式的内存布局都要比AOS更加友好。但这也不是绝对,准确的说应该是我们尽量保证数据的局部性。
说到局部性,无论是cpu,内存,操作系统,还是上层应用,我们都应该尽量的保证数据的局部性以提高性能。这里的局部性分为空间局部性以及时间局部性。
空间局部性
空间局部性体现在cache line上,cpu加载数据不是一个字节一个字节的更新,而是根据cache line的大小进行更新。比如我的机器cache line是64个字节,那么当缓存没有命中的时候,会一次更新64个字节的数据。
如果我们能够保证空间局部性,就可以提高cache line的命中。比如我们遍历数组的时候,step为1,缓存的命中率最高。
时间一致性
简单的理解就是让高速缓存中的数据尽量留的久一点。比如for循环中的i就是时间局部性良好的代表。通常空间局部性和时间局部性是互斥的,因此只要满足一种局部性就可以了。
说实话高速缓存机制底层是如何实现的,对我来说只能了解皮毛。很难准确分析为什么这段代码效率不高。但是我们开发的时候只要尽量保证局部性,基本上就可以获得良好的性能。
我对SOA和AOS做了一个简单的测试,基本上符合预期,但是我还是很难界定,到底怎么才能获得最佳性能。
首先我对cache line做了一个测试,代码如下:
for (int i = 0; i < 1024 * 32; i++)
{
step += 1;
testSoa.c0[step]++;
}
//消耗时间0.00003710秒
for (int i = 0; i < 1024 * 32; i++)
{
step += 16;
testSoa.c0[step]++;
}
//消耗时间0.00019270秒
step为1的效率比step为16的高出了5倍,这符合我的预期,step为1的时候cache的命中率应该是未命中,命中...13次.....命中。而step为16的时候应该一直是未命中。
接下来我对soa结构和aos结构进行了对比。
struct soa
{
int* c0;
int* c1;
int* c2;
int* c3;
int* c4;
int* c5;
int* c6;
int* c7;
int* c8;
int* c9;
int* c10;
};
struct aos
{
int c0;
int c1;
int c2;
int c3;
int c4;
int c5;
int c6;
int c7;
int c8;
int c9;
int c10;
};
for (int i = 0; i < 1024 * 1024; i++)
{
testSoa.c0[i]++;
}
//消耗时间0.00099790
for (int i = 0; i < 1024 * 1024; i++)
{
testAos[i].c0++;
}
//消耗时间0.00367940
测试结果符合预期,基本上SOA比AOS快了3倍。因为c0组件是连续的,所以提高了缓存命中。为什么是3倍不是5倍,因为aos的结构体大小小于cache line,还是有一些数据会被cache命中的,我把aos结构体大小调整到大于cache line,结果性能相差5倍,这个符合预期。
这里我们可以总结一个优化点,结构体大小尽量不要超过cache line的大小。
接下来我让结构体访问更多的变量
for (int i = 0; i < 1024 * 1024; i++)
{
testSoa.c0[i]++;
testSoa.c1[i]++;
testSoa.c2[i]++;
testSoa.c3[i]++;
testSoa.c4[i]++;
testSoa.c5[i]++;
testSoa.c6[i]++;
testSoa.c7[i]++;
testSoa.c8[i]++;
testSoa.c9[i]++;
testSoa.c10[i]++;
testSoa.c11[i]++;
testSoa.c12[i]++;
testSoa.c13[i]++;
testSoa.c14[i]++;
testSoa.c15[i]++;
}
//消耗时间0.13875120
for (int i = 0; i < 1024 * 1024; i++)
{
testAos[i].c0++;
testAos[i].c1++;
testAos[i].c2++;
testAos[i].c3++;
testAos[i].c4++;
testAos[i].c5++;
testAos[i].c6++;
testAos[i].c7++;
testAos[i].c8++;
testAos[i].c9++;
testAos[i].c10++;
testAos[i].c11++;
testAos[i].c12++;
testAos[i].c13++;
testAos[i].c14++;
testAos[i].c15++;
}
//消耗时间0.01300420
结果出乎意料,aos反超soa 10倍的性能。虽然我知道访问的变量越多对soa越不利,但是没想到差距这么大。分析一下代码发现aos和soa的缓存命中率是一样的,为什么性能会差这么多呢?修改一下soa的代码。
for (int i = 0; i < 1024 * 1024; i++)
{
testSoa.c0[i]++;
}
for (int i = 0; i < 1024 * 1024; i++)
{
testSoa.c1[i]++;
}
for (int i = 0; i < 1024 * 1024; i++)
{
testSoa.c2[i]++;
}
//一共16个就不全写了
上面的代码运行后的效率竟然追平了aos,这样看来貌似是产生了缓存冲突,所谓缓存冲突就是因为缓存少,内存大,会有不同的内存地址对应同一条缓存地址,这样一旦发生冲突,缓存就会不停的被更新,最终导致cache miss。我不知道怎么分析soa的代码为什么缓存命中率这么低。这也是我困惑的地方,底层对我来说太黑盒了,而且我每写一点代码都要思考缓存是否冲突,这个就太扯了。
目前我只能通过玄学的角度去思考这个问题,物极必反。
看一下unity设计的内存布局,可以用中庸二字形容,即保证了组件的连续,也保证了entity不至于过于不连续。虽然组件在chunk中是连续的,但是不同的chunk是不连续的,很中庸吧。
System通常只关心几个组件,从上面的测试结果可以看出,这样可以大大减少缓存冲突的可能性。另外unity要求componet是pod类型,也保证了组件内存的连续以及限制了组件的大小(尽量不要超过cache line)。
我不清楚unity一个chunk的大小是怎么划分的。一个chunk里面包含多少个entity?组件间的保存顺序?以及chunk地址是否连续?
首先我个人觉得chunk的地址不需要连续,chunk连续也不能保证componet连续,而且如果chunk连续的话,就需要使用数组保存,数组不够用的时候还得重新申请内存空间,然后进行内存搬移。
另外Chunk如果不连续就不需要保证Chunk字节对齐,就好比结构体字节对齐是为了保证做为数组的时候每个元素也是字节对齐的。但我隐隐有种感觉要保证chunk尺寸是偶数。看了一下unity的源码chunk的大小是16k,有时间再研究一下unity的源码。
最后补充一些基本概念,高速缓存是SRAM,内存是DRAM,两则的区别是SRAM在通电的情况下状态是稳定的,但是需要更多的晶体管,集成的成本更高,花费更多,而DRAM需要不停的刷新保证数据的持续性。
高速缓存的地址是由内存地址转换的,其中有效位标志改行是否存储了数据,组索引决定了数据在哪个组里面,标记位来判读该行是否配,块偏移代表缓存数据的起始位置。
如果组里面只有一行缓存,就是直接映射高速缓存,这种缓存实现成本低,因为不需要考虑行的选择逻辑,但是容易造成缓存冲突。
如果组里面有多行缓存,就是组相联缓存,这种缓存实现成本中庸,能够有效避免缓存冲突。是我们常见的缓存形式,比如我的cpu L1是32K 16-way 64bytes line,就是组相联模式。这个缓存大小是32k,每组有16个cache line,每个cach line是64bytes,一共有32组。
32组需要5位2bit,64bytes需要6位offset,如果是32位地址那么t就是32 - 5- 6 = 21位。
如果一个缓存只有一个组,那么就是全相联高速缓存,全相联高速缓存省去了组相关的逻辑,但是增加了标记位匹配的搜索。构造一个又大又块的全相联高速缓存很困难,而且昂贵。通常这种缓存都很小,比如虚拟内存中的翻译备用缓存(TLB)。
通常高速缓存分为多个层次,像我的cpu L1层是独立的每个cpu核都有一个数据缓存和代码缓存。L2层也是核独立的,但是代码和数据共享。L3层是所有核,代码和数据共享。
因为多核,有的时候线程会在不同的核中切换,这就会造成cache miss。因为缓存在核1中,你去访问核2肯定miss。因此有的时候需要锁定一个线程访问哪个核来提高缓存效率。
另外cpu读取数据通常都是在缓存中读取,如果缓存不命中就会更新cache line,然后再从cache line中读取数据。这就引出数据一致性的问题。比如线程1已经修改了内存数据,线程2在另外一个核的高速缓存中读取数据,这就造成数据不一致,为了解决这个问题,引入了voliate关键字。告诉cpu每次读取都要刷新cache line。
读缓存很简单,写缓存就比较复杂了。最简单的办法就是直写,直写就是立即将高速缓存写回内存,但是这样会增加总线流量。另一种方式叫做写回,就是尽可能的延迟更新,只有替换算法要驱逐这个更新过的块时才写回内存。由于局部性写回可以显著减少总线的流量,但是它的缺点是增加了复杂性,高速缓存必须维护一个额外的修改位,标识这个行是否被修改过。
如果处理写不命中,一种方法称为写分配,加载相应低一层中的块到高速缓存,然后更新这个高速缓存块。写分配试图利用写的空间局部性,但是缺点是每次不命中都会导致一个块从低一层传送到高速缓存。另一种方法是非写分配,就是避开高速缓存,直接写到低层次的缓存中。其实底层优化的手段通常都比较简单,因为复杂的逻辑就代表了更多的成本,但是底层因为调用的次数多,所以很简单的优化就会带来很大的性能提升。这就是一个阴阳的世界,计算机也跳不出大道。