CPU高速缓存/伪共享

CPU高速缓存/伪共享

Code:

public class Main {

	static long[][] arr;

	public static void main(String[] args) {
		long sum = 0L;
		arr = new long[1024 * 1024][8];
		// 横向遍历
		long marked = System.currentTimeMillis();
		for (int i = 0; i < 1024 * 1024; i += 1) {
			for (int j = 0; j < 8; j++) {
				sum += arr[i][j];
			}
		}
		System.out.println("Loop times:" + (System.currentTimeMillis() - marked) + "ms");

		sum = 0L;
		marked = System.currentTimeMillis();
		// 纵向遍历
		for (int i = 0; i < 8; i += 1) {
			for (int j = 0; j < 1024 * 1024; j++) {
				sum += arr[j][i];
			}
		}
		System.out.println("Loop times:" + (System.currentTimeMillis() - marked) + "ms");
	}
}

Output:

Loop times:10ms
Loop times:38ms

会发现横向遍历时间远远小于纵向遍历

物理结构:

二代i5处理器:

  • CacheLine size:64 Byte //缓存行
  • L1 Data Cache:32KB //L1数据缓存
  • L1 Instruction Cache:32KB //L1指令缓存
  • L2 Cache:256KB
  • L3 Cache:4MB

Martin和Mike的QCon演讲演讲中给出了一些缓存未命中的消耗数据,也就是从CPU访问不同层数数据的时间概念:

从CPU到 大约需要的CPU时钟周期 大约需要的时间
主存 约60-80ns
QPI总线传输(套接字之间,未绘制) 约20ns的
L3缓存 约40-45周期 约15ns的
L2缓存 约10个周期 约3ns的
L1缓存 约3-4个周期 约1ns的
寄存器 1个周期

CPU <--- > 寄存器<--- > 缓存<--- >内存

可见CPU读取主存中的数据会比从L1中读取慢了近2个数量级。

L3是每个处理器里面核心共享的,处理器之间不共享

主存是多个处理器共享的

MESI 协议及 RFO 请求

每个核都有自己私有的 L1,、L2 缓存。那么多线程编程时, 另外一个核的线程想要访问当前核内 L1、L2 缓存行的数据, 该怎么办呢?

有人说可以通过第 2 个核直接访问第 1 个核的缓存行,这是当然是可行的,但这种方法不够快。跨核访问需要通过 Memory Controller(内存控制器,是计算机系统内部控制内存并且通过内存控制器使内存与 CPU 之间交换数据的重要组成部分),典型的情况是第 2 个核经常访问第 1 个核的这条数据,那么每次都有跨核的消耗.。更糟的情况是,有可能第 2 个核与第 1 个核不在一个插槽内,况且 Memory Controller 的总线带宽是有限的,扛不住这么多数据传输。所以,CPU 设计者们更偏向于另一种办法: 如果第 2 个核需要这份数据,由第 1 个核直接把数据内容发过去,数据只需要传一次。

那么什么时候会发生缓存行的传输呢?答案很简单:当一个核需要读取另外一个核的脏缓存行时发生。但是前者怎么判断后者的缓存行已经被弄脏(写)了呢?

下面将详细地解答以上问题. 首先我们需要谈到一个协议—— MESI 协议。现在主流的处理器都是用它来保证缓存的相干性和内存的相干性。M、E、S 和 I 代表使用 MESI 协议时缓存行所处的四个状态:

M(修改,Modified):本地处理器已经修改缓存行,即是脏行,它的内容与内存中的内容不一样,并且此 cache 只有本地一个拷贝(专有);
E(专有,Exclusive):缓存行内容和内存中的一样,而且其它处理器都没有这行数据;
S(共享,Shared):缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝;
I(无效,Invalid):缓存行失效, 不能使用。

下面说明这四个状态是如何转换的:

初始:一开始时,缓存行没有加载任何数据,所以它处于 I 状态。

本地写(Local Write):如果本地处理器写数据至处于 I 状态的缓存行,则缓存行的状态变成 M。

本地读(Local Read):如果本地处理器读取处于 I 状态的缓存行,很明显此缓存没有数据给它。此时分两种情况:(1)其它处理器的缓存里也没有此行数据,则从内存加载数据到此缓存行后,再将它设成 E 状态,表示只有我一家有这条数据,其它处理器都没有;(2)其它处理器的缓存有此行数据,则将此缓存行的状态设为 S 状态。(备注:如果处于M状态的缓存行,再由本地处理器写入/读出,状态是不会改变的)

远程读(Remote Read):假设我们有两个处理器 c1 和 c2,如果 c2 需要读另外一个处理器 c1 的缓存行内容,c1 需要把它缓存行的内容通过内存控制器 (Memory Controller) 发送给 c2,c2 接到后将相应的缓存行状态设为 S。在设置之前,内存也得从总线上得到这份数据并保存。

远程写(Remote Write):其实确切地说不是远程写,而是 c2 得到 c1 的数据后,不是为了读,而是为了写。也算是本地写,只是 c1 也拥有这份数据的拷贝,这该怎么办呢?c2 将发出一个 RFO (Request For Owner) 请求,它需要拥有这行数据的权限,其它处理器的相应缓存行设为 I,除了它自已,谁不能动这行数据。这保证了数据的安全,同时处理 RFO 请求以及设置I的过程将给写操作带来很大的性能消耗。

状态转换由下图做个补充:

img

写操作的代价很高,特别当需要发送 RFO 消息时。我们编写程序时,什么时候会发生 RFO 请求呢?有以下两种:

1. 线程的工作从一个处理器移到另一个处理器, 它操作的所有缓存行都需要移到新的处理器上。此后如果再写缓存行,则此缓存行在不同核上有多个拷贝,需要发送 RFO 请求了。
2. 两个不同的处理器确实都需要操作相同的缓存行

缓存行Cache Line

缓存行(Cache Line) 便是CPU Cache 中的最小单位,CPU Cache 由若干缓存行组成,一个缓存行的大小通常是64 字节(这取决于CPU),并且它有效地引用主内存中的一块地址。一个Java 的long 类型是8 字节,因此在一个缓存行中可以存8 个long 类型的变量。

试想一下你正在遍历一个长度为16 的long 数组data[16],每一个long类型为8字节,原始数据自然存在于主内存中,访问过程描述如下

  • 访问data[0],CPU core 尝试访问CPU Cache,未命中。

  • 尝试访问主内存,操作系统一次访问的单位是一个Cache Line 的大小— 64 字节,这意味着:既从主内存中获取到了data[0] 的值,同时将data[0] ~ data[7 ] 加入到了CPU Cache 之中,for free~

  • 访问data[1]~data[7],CPU core 尝试访问CPU Cache,命中直接返回。

  • 访问data[8],CPU core 尝试访问CPU Cache,未命中。

  • 尝试访问主内存。重复步骤 2

CPU 缓存在顺序访问连续内存数据时挥发出了最大的优势

CPU 缓存(Cache Memory)是位于 CPU 与内存之间的临时存储器,它的容量比内存小的多但是交换速度却比内存要快得多。
高速缓存的出现主要是为了解决 CPU 运算速度与内存读写速度不匹配的矛盾,因为 CPU 运算速度要比内存读写速度快很多,这样会使 CPU 花费很长时间等待数据到来或把数据写入内存。
在缓存中的数据是内存中的一小部分,但这一小部分是短时间内 CPU 即将访问的,当 CPU 调用大量数据时,就可避开内存直接从缓存中调用,从而加快读取速度。

伪共享:

概念:

通常提到缓存行,大多数文章都会提到伪共享问题(正如提到CAS 便会提到ABA 问题一般)。

伪共享指的是多个线程同时读写同一个缓存行的不同变量时导致的CPU 缓存失效。尽管这些变量之间没有任何关系,但由于在主内存中邻近,存在于同一个缓存行之中,它们的相互覆盖会导致频繁的缓存未命中,引发性能下降。伪共享问题难以被定位,如果系统设计者不理解CPU 缓存架构,甚至永远无法发现— 原来我的进程还可以更快。

如果多个线程的变量共享了同一个CacheLine,任意一方的修改操作都会使得整个CacheLine 失效(因为CacheLineCPU 缓存的最小单位),也就意味着,频繁的多线程操作, CPU 缓存将会彻底失效,降级为CPU core 和主内存的直接交互

解决办法:

处理伪共享的两种方式:

  1. 字节填充:增大元素的间隔,使得不同线程访问的元素位于不同的缓存线上,典型的空间换时间。
  2. 在每个线程中创建对应元素的本地拷贝,结束后再写回全局数组,相当于线程各自操作自己的数据

字节填充:

Java8中:

@Retention(RetentionPolicy.RUNTIME) 
@Target({ElementType.FIELD, ElementType.TYPE}) 
public @interface Contended { 
    String value() default ""; 
} 

Java8 中提供了字节填充的官方实现,这使得CPU Cache 更加可控了,无需担心jdk 的无效字段优化,无需担心Cache Line 在不同CPU 下的大小究竟是不是64 字节。使用@Contended 注解可以完美的避免伪共享问题。

但是这个功能暂时还是实验性功能,暂时还没到默认普及给用户代码用的程度。要在用户代码(非引导类加载器或扩展类加载器所加载的类)中使用@Contended注解的话,需要使用 - XX:-RestrictContended参数。

比如在JDK 8的ConcurrentHashMap源码中,使用 [@sun.misc.Contended]对静态内部类CounterCell进行了修饰。

/* ---------------- Counter support -------------- */

/**
 * A padded cell for distributing counts.  Adapted from LongAdder
 * and Striped64.  See their internal docs for explanation.
 */
@sun.misc.Contended 
static final class CounterCell {
        volatile long value;
        CounterCell(long x) { value = x; }
}

Thread中:

/** The current seed for a ThreadLocalRandom */
@sun.misc.Contended("tlr")
long threadLocalRandomSeed;

/** Probe hash value; nonzero if threadLocalRandomSeed initialized */
@sun.misc.Contended("tlr")
int threadLocalRandomProbe;

/** Secondary seed isolated from public ThreadLocalRandom sequence */
@sun.misc.Contended("tlr")
int threadLocalRandomSecondarySeed;

内存屏障:

内存屏障提供了3个功能:确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;

强制将对缓存的修改操作立即写入主存;

如果是写操作,它会导致其他CPU中对应的缓存行无效。

屏障的策略:

在每个volatile写操作前面插入storestore屏障;

在每个volatile写操作后面插入storeload屏障;

在每个volatile读操作后面插入loadload屏障;

在每个volatile读操作后面插入loadstore屏障;

其中loadload和loadstore对应的是方法acquire,storestore对应的是方法release,storeload对应的是方法fence。

理解:

工作内存可以看成是CPU高速缓存、寄存器的抽象

主内存可以看成就是物理硬件中主内存的抽象

参考:

Java併發編程-volatile

CPU Cache與緩存行

JAVA 拾遺 — CPU Cache 與緩存行

聊聊高并发(三十四)Java内存模型那些事(二)理解CPU高速缓存的工作原理

细说Cache-L1/L2/L3/TLB

CPU与内存的那些事

探讨缓存行与伪共享

伪共享(false sharing),并发编程无声的性能杀手

关于java中的伪共享的认识和解决

Java面试终极概念之「伪共享」

posted @ 2019-01-16 18:50  hongdada  阅读(723)  评论(0编辑  收藏  举报