收藏了coolshell 皓子写的程序员应该知道的cpu知识好久了,最近终于翻完了,顺便搜了几篇关于CPU缓存、伪共享的文章,在此记录一下。
cpu缓存时cpu核心处理器与内存之间的一个缓冲/缓存,一般的CPU有3级缓存,也就是L1、L2、L3,L1最快也最小,L3最大也最慢。
缓存行、8-way
cpu读取缓存的最下单位不是字节,而是缓存行cacheline,一个缓存行是64个字节(不同处理器有区别,这里是指主流CPU)
即使是有了缓存行,读取速度也不快,为了提高缓存命中率(快速判断内存地址的数据是否在缓存中),有了一个way的概念,一般cpu是8-way,也就是L1会被8等分,每一路是有64条cacheline。
为了方便索引内存地址,
- Tag:每条 Cache Line 前都会有一个独立分配的 24 bits来存的 tag,其就是内存地址的前24bits
- Index:内存地址后续的6个bits则是在这一Way的是Cache Line 索引,2^6 = 64 刚好可以索引64条Cache Line
- Offset:再往后的6bits用于表示在Cache Line 里的偏移量
查找的时候先按照index查找,如果查找到了,那现在就查询范围就缩小到了每一个way里一个固定索引位置的数据,然后再遍历这个8个cacheline就行了。
cache miss的三种原因
1. 第一次访问数据, 在cache中根本不存在这条数据, 所以cache miss, 可以通过prefetch解决.
2. cache冲突, 需要通过补齐来解决.
3. 就是我示例的这种, cache满, 一般情况下我们需要减少操作的数据大小, 尽量按数据的物理顺序访问数据.
MESI协议
Modify 修改
exclusive 独占
share 共享
invalid 失效
缓存数据的四种状态,根据四种不同的状态在使用数据的时候做不同的操作,实际也很好理解。modify 说明数据被修改了,需要拿最新的数据。exclusive 是独占,说明没有其他的核在使用这个数据是可以放心使用的。invalid 是失效,说明这个数据已经是脏数据了。不能再用。
当前操作 | CPU0 | CPU1 | Memory | 说明 |
1) CPU0 read(x) | x=1 (E) | x=1 | 只有一个CPU有 x 变量, | |
2) CPU1 read(x) | x=1 (S) | x=1(S) | x=1 | 有两个CPU都读取 x 变量, |
3) CPU0 write(x,9) | x=9 (M) | x=1(I) | x=1 | 变量改变,在CPU0中状态 |
4) 变量 x 写回内存 | x=9 (M) | X=1(I) | x=9 | 目前的状态不变 |
5) CPU1 read(x) | x=9 (S) | x=9(S) | x=9 | 变量同步到所有的Cache中, |
MOESI协议
MOESI协议,Owner(宿主)-> 从宿主cpu读取最新数据,不需要从内存中读数据,从内存中读取更慢
伪共享
我们再来看一下另外一段代码:我们想统计一下一个数组中的奇数个数,但是这个数组太大了,我们希望可以用多线程来完成这个统计。下面的代码中,我们为每一个线程传入一个 id ,然后通过这个 id 来完成对应数组段的统计任务。这样可以加快整个处理速度。
int total_size = 16 * 1024 * 1024; //数组长度
int* test_data = new test_data[total_size]; //数组
int nthread = 6; //线程数(因为我的机器是6核的)
int result[nthread]; //收集结果的数组
void thread_func (int id) {
result[id] = 0;
int chunk_size = total_size / nthread + 1;
int start = id * chunk_size;
int end = min(start + chunk_size, total_size);
for ( int i = start; i < end; ++i ) {
if (test_data[i] % 2 != 0 ) ++result[id];
}
}
然而,在执行过程中,你会发现,6个线程居然跑不过1个线程。因为根据上面的例子你知道 result[]
这个数组中的数据在一个Cache Line中,所以,所有的线程都会对这个 Cache Line 进行写操作,导致所有的线程都在不断地重新同步 result[]
所在的 Cache Line,所以,导致 6 个线程还跑不过一个线程的结果。这叫 False Sharing。
优化也很简单,使用一个线程内的变量。
void thread_func (int id) {
result[id] = 0;
int chunk_size = total_size / nthread + 1;
int start = id * chunk_size;
int end = min(start + chunk_size, total_size);
int c = 0; //使用临时变量,没有cache line的同步了
for ( int i = start; i < end; ++i ) {
if (test_data[i] % 2 != 0 ) ++c;
}
result[id] = c;
}
我们把两个程序分别在 1 到 32 个线程上跑一下,得出的结果画一张图如下所示(横轴是线程数,纵轴是完成统的时间,单位是微秒):
上图中,我们可以看到,灰色的曲线就是第一种方法,橙色的就是第二种(用局部变量的)方法。当只有一个线程的时候,两个方法相当,基本没有什么差别,但是在线程数增加的时候的时候,你会发现,第二种方法的性能提高的非常快。直到到达6个线程的时候,开始变得稳定(前面说过,我的CPU是6核的)。而第一种方法无论加多少线程也没有办法超过第二种方法。因为第一种方法不是CPU Cache 友好的。也就是说,第二种方法,只要我的CPU核数足够多,就可以做到线性的性能扩展,让每一个CPU核都跑起来,而第一种则不能。
重点:使用临时变量避免cacheline共享问题
参考链接:
https://coolshell.cn/articles/20793.html