Cache Line对数据读写性能的影响

    科技2022-08-08  99

    文章目录

    多级缓存-填补内存读写速度与CPU计算速度的鸿沟局部性原理与Cache Line伪共享对齐填充@Contended备注 尾巴

    对于一个程序来说,几乎所有的计算任务都不可能仅通过CPU的计算就可以完成,它至少要和内存打交道:读取运算数据、写入运算结果。

    现代CPU的算力已经十分强大,相比之下存储设备的IO读写速度却发展的十分缓慢。通常情况下,内存每完成一次读写操作,CPU已经可以进行上百次的运算,为了填补两者速度上的鸿沟,现代CPU不得不加入一层或多层读写速度接近于CPU处理速度的高速缓存Cache。CPU会将计算需要的数据读取到缓存中,让计算可以快速进行,当计算结束后,再将计算结果统一写回到主存中,这样CPU就不需要缓慢的等待内存读写了。

    多级缓存-填补内存读写速度与CPU计算速度的鸿沟

    笔者画了一个简图,大概表示现代CPU的缓存架构,如下: 各级缓存的读写速度如下:

    缓存时钟周期(大约)时间(大约)主存-80nsL34015nsL2103nsL13~41ns寄存器1-

    越靠近CPU的缓存读写速度越快,相应的 容量越小、且成本越高。 当CPU需要读取数据时,首先从最近的缓存开始找,找不到就逐层往上寻找,如果命中缓存,就无需再从主存中去读取,直接拿来计算。反之从主存中加载数据并依次写入多级缓存中,下次就可以直接从缓存中读数据了。

    局部性原理与Cache Line

    CPU访问存储器时,无论是存取指令还是存取数据,所访问的存储单元都趋于聚集在一个较小的连续区域中。

    当我们需要从内存中读取一个int变量i的值时,CPU真的只会仅仅将这个4字节的i加载到缓存吗? 答案是:NO!!!

    当我们去读取一个4字节的int变量时,计算机认为程序接下来很大概率会访问相邻的数据,于是会把相邻的数据给一块儿加载到缓存中,下次再读取时,就不用访问主存了,直接从缓存中读取就可以了,减少了CPU访问主存的次数,提高缓存的命中率。

    说白了,CPU读取数据时,总是会一个块一个块的读,哪怕你需要的仅仅是1个字节的数据,这个【块】就被称为【Cache Line】。 不同的CPU,Cache Line大小是不一样的,Intel的CPU大部分都是64字节。

    多级缓存就是由若干个Cache Line组成的,CPU每次从主存中拉取数据时,会把相邻的数据也存入同一个Cache Line。

    如下测试代码,各自读取一千万次数据,Cache Line失效的耗时11ms,能很好的利用Cache Line特性的只需要3ms。

    public class CacheLine { static int length = 8 * 10000000; static long[] arr = new long[length]; public static void main(String[] args) { long temp;// 无特殊含义,读取出来的数据赋值 // 1.每次读取都跳8个,下次读取的数据一定不在上次读取的Cache Line中,缓存全部未命中 long start = System.currentTimeMillis(); for (int i = 0; i < length; i += 8) { temp = arr[i]; } long end = System.currentTimeMillis(); System.out.println(end - start);// 11ms // 2.顺序读取,Cache Line生效,只读前8分之1的数据 start = System.currentTimeMillis(); for (int i = 0; i < length / 8; i++) { temp = arr[i]; } end = System.currentTimeMillis(); System.out.println(end - start);// 3ms } }

    伪共享

    当多个线程去同时读写共享变量时,由于缓存一致性协议,只要Cache Line中任一数据失效,整个Cache Line就会被置为失效。这就会导致本来相互不影响的数据,由于被分配在同一个Cache Line中,双方在写数据时,导致对方的Cache Line不断失效,无法利用Cache Line缓存特性的现象就被称为【伪共享】。

    如下代码,启动两个线程,分别修改共享变量a和b,由于a个b一共占用16字节,可以被分配进同一个Cache Line中,本来互相不影响的两个线程修改数据,但是由于a个b被分配到同一个Cache Line中,导致对方的Cache Line不断失效,不断的重新发起load指令重主存中重新加载数据,降低程序的性能。

    public class FalseShare { static volatile long a; static volatile long b; public static void main(String[] args) throws InterruptedException { CountDownLatch cdl = new CountDownLatch(2); long t1 = System.currentTimeMillis(); new Thread(()->{ for (long i = 0; i < 1_0000_0000L; i++) { // 线程只改a FalseShare.a = i; } cdl.countDown(); }).start(); new Thread(()->{ for (long i = 0; i < 1_0000_0000L; i++) { // 线程只改b FalseShare.b = i; } cdl.countDown(); }).start(); cdl.await(); long t2 = System.currentTimeMillis(); System.err.println(t2 - t1); } }

    程序运行结果:耗时2782ms。

    对齐填充

    要想解决上面的伪共享问题也很简单,既然一个Cache Line存放64字节的数据,只要在a和b变量之间填充7个无意义的Long变量,占满64字节,这样a和b就无法被分配进同一个Cache Line中,线程之间修改数据互不影响,就没有上面的问题了。

    解决方法如下: 程序运行结果:耗时752ms。

    @Contended

    Cache Line对齐其实是一种比较low的解决办法,因为你无法判断你写的程序会被放到哪种CPU上运行,不同的CPU它的Cache Line大小是不一样的,如果超过了64字节,填充7个long变量就没有效果了,还有没有更好的解决办法呢???

    JDK8引入了一个新的注解@Contended,被它修饰的变量,会被存放到一个单独的Cache Line中,不会和其他变量共享Cache Line。

    修改后如下: 程序运行结果:耗时744ms,Cache Line是生效的。

    备注

    JDK8的老版本中@Contended默认是禁用的,需要手动开启:-XX:-RestrictContended。笔者的JDK版本为【1.8.0_191】,-XX:-RestrictContended参数已经没有用了,默认都会开启对@Contended注解的支持。

    尾巴

    理解硬件设计对编写高性能程序是有必要的!!!

    Processed: 0.009, SQL: 8