C++

深入理解内存屏障

"深入理解内存屏障"

Posted by Simon on September 14, 2020

“Better code, better life. ”

从cache miss说起

现在CPU基本都可以做到一个周期内执行十数条指令,但是往往一个读内存的操作就超过了十个CPU周期,所以,现代CPU的主要矛盾已经编程CPU速度太快而现在内存速度太慢的矛盾。

自然而然,CPU增加了自己的缓存。数据需要在CPU中计算的话需要先从内存中读取到cache才能被CPU访问,然后cache又分L1和L2/3级cache,L1小,一般32KB,L3大,能达到32MB。

CPU从cache中读取比较简单,64位CPU 就每次从cache中最多访问64bits数据块,内存中载入数据到内存就比较麻烦了,载入的单位是一个cache line,cache line大小一般是64bytes,也就是64*8 bits,那么很有意思的就是当CPU 需要访问的地址是一个64bits的数据块,而读入到缓存的却只能按照512bits来缓存,那么从内存中读取的时候其他512-64bits 是哪些数据呢?答案是这个数据块周围的数据。比如一个程序是对一个char ss[5120];进行访问,每次访问的是每隔512bits ss[0],ss[512],ss[1024]……,ss[5120] 访问,实际上是这个数组中所有的数据都曾经被载入到cache中了,只是有些载入到cache却没有被访问。

cache 是按照LRU 替换数据的,如果一个cache line 被替换,所有的512bits 都被替换。

那么实际上程序员针对上面所能做的最重要一点就是:增强数据访问的局部性。增强数据访问的局部性也就是减少cache miss 的最直接方法,局部性的数据很可能被载入到cache了,频繁访问局部性数据也会使得对应的cache 数据不被频繁替换出cache。(简单的来说,老师罚抄写古诗100遍,增强数据访问局部性就是诗中每个字依次抄写100遍,而不是整个首诗超100遍。前者每个字访问的命中率就会高。)

编程中需要注意以下几点

  1. cache line 对齐。编程时数据结构尽量cache line 对齐,可以自己添加pad,也可以用gcc提供的__attribute__。尽量都适用局部变量,在64位机器上,局部变量会优先放到寄存器中。在些多核程序时,尽量绑定线程到一个cpu上,不同cpu之间L1, L2 cache是独立的。
  2. 避免false sharing。定义数据结构的时候不能这么搞: int num[CPU_NUMS],这样在for循环中对num[i]++的时候就会造成false sharing。这也是为什么结构体定义要cache line对齐。
  3. cpu affinity。线程创建后立即绑定到具体的core上,然后再 进行分配内存,保证内存分配在自己的领土这边,如果内存分配超过总内存的一半,那么效率会大幅度降低,因为有一部分内存已经到了另一个cpu上了。
  4. 可以使用prefetch指令。prefetch,gcc也提供了数据的预取指令,不过这个要对程序的执行流程和时间有非常好的把握,要不然预取到cache里用不到就没意思了。

TODO 内存屏障