摘要:CPU内置少量的高速缓存的重要性不言而喻,在体积、成本、效率等因素下产生了当今用到的计算机的存储结构。
CPU频率太快,其处理速度远快于存储介质的读写。因此,导致CPU资源的浪费,需要有效解决IO速度和CPU运算速度之间的不匹配问题。芯片级高速缓存可大大减少之间的处理延迟。CPU制造工艺的进步使得在比以前更小的空间中安装数十亿个晶体管,如此可为缓存留出更多空间,使其尽可能地靠近核心。
CPU内置少量的高速缓存的重要性不言而喻,在体积、成本、效率等因素下产生了当今用到的计算机的存储结构。
计算机的内存具有基于速度的层次结构,CPU高速缓存位于该层次结构的顶部,是介于CPU内核和物理内存(动态内存DRAM)之间的若干块静态内存,是最快的。它也是最靠近中央处理的地方,是CPU本身的一部分,一般直接跟CPU芯片集成。
CPU计算:程序被设计为一组指令,最终由CPU运行。
装载程序和数据,先从最近的一级缓存读取,如有就直接返回,逐层读取,直至从内存及其它外部存储中加载,并将加载的数据依次放入缓存。
高速缓存中的数据写回主存并非立即执行,写回主存的时机:
1.缓存满了,采用先进先出或最久未使用的顺序写回;
2.#Lock信号,缓存一致性协议,明确要求数据计算完成后要立马同步回主存。
现代的CPU缓存结构被分为多处理、多核、多级的层次。
分为三个主要级别,即L1,L2和L3。离CPU越近的缓存,读取效率越高,存储容量越小,造价越高。
L1高速缓存是系统中存在的最快的内存。就优先级而言,L1缓存具有CPU在完成特定任务时最可能需要的数据,大小通常可达256KB,一些功能强大的CPU占用近1MB。某些服务器芯片组(如Intel高端Xeon CPU)具有1-2MB。L1缓存通常又分为指令缓存和数据缓存。指令缓存处理有关CPU必须执行的操作的信息,数据缓存则保留要在其上执行操作的数据。如此,减少了争用Cache所造成的冲突,提高了处理器效能。
L2级缓存比L1慢,但大小更大,通常在256KB到8MB之间,功能强大的CPU往往会超过此大小。L2高速缓存保存下一步可能由CPU访问的数据。大多数CPU中,L1和L2高速缓存位于CPU内核本身,每个内核都有自己的高速缓存。
L3级高速缓存是最大的高速存储单元,也是最慢的。大小从4MB到50MB以上。现代CPU在CPU裸片上具有用于L3高速缓存的专用空间,且占用了很大一部分空间。
计算机早已进入多核时代,软件也运行在多核环境。一个处理器对应一个物理插槽、包含多个核(一个核包含寄存器、L1 Cache、L2 Cache),多核间共享L3 Cache,多处理器间通过QPI总线相连。
L1 和 L2 缓存为CPU单个核心私有的缓存,L3 缓存是同插槽的所有核心都共享的缓存。
L1缓存被分成独立的32K数据缓存和32K指令缓存,L2缓存被设计为L1与共享的L3缓存之间的缓冲。大小为256K,主要作为L1和L3之间的高效内存访问队列,同时包含数据和指令。L3缓存包括了在同一个槽上的所有L1和L2缓存中的数据。这种设计消耗了空间,但可拦截对L1和L2缓存的请求,减轻了各个核心私有的L1和L2缓存的负担。
Cache存储数据以固定大小为单位,称为Cache Line/Block。给定容量和Cache Line size,则就固定了存储的条目个数。对于X86,Cache Line大小与DDR一次访存能得到的数据大小一致,即64B。旧的ARM架构的Cache Line是32B,因此经常是一次填两个Cache Line。CPU从Cache获取数据的最小单位为字节,Cache从Memory获取的最小单位是Cache Line,Memory从磁盘获取数据通常最小是4K。
Cache分成多个组,每组分成多个Cache Line行,大致如下图:
Linux系统下使用以下命令查看Cache信息,lscpu命令也可。
下面表格描述了不同存储介质的存取信息,供参考。
存取速度:寄存器 > cache(L1~L3) > RAM > Flash > 硬盘 > 网络存储
以2.2Ghz频率的CPU为例,每个时钟周期大概是0.5纳秒。
按CPU层级缓存结构,取数据的顺序是先缓存再主存。当然,如数据来自寄存器,只需直接读取返回即可。
由于数据的局部性原理,CPU往往需要在短时间内重复多次读取数据,内存的运行频率远跟不上CPU的处理速度,缓存的重要性被凸显。CPU可避开内存在缓存里读取到想要的数据,称之为命中。L1的运行速度很快,但容量很小,在L1里命中的概率大概在80%左右,L2、L3的机制也类似。这样一来,CPU需要在主存中读取的数据大概为5%-10%,其余命中全部可以在L1、L2、L3中获取,大大减少了系统的响应时间。
高速缓存旨在加快主内存和CPU之间的数据传输。从内存访问数据所需的时间称为延迟,L1具有最低延迟且最接近核心,而L3具有最高的延迟。缓存未命中时,由于CPU需从主存储器中获取数据,导致延迟会更多。
Cache里的数据是Memory中常用数据的一个拷贝,存满后再存入一个新的条目时,就需要把一个旧的条目从缓存中拿掉,这个过程称为evict。缓存管理单元通过一定的算法决定哪些数据需要从Cache里移出去,称为替换策略。最简单的策略为LRU,在CPU设计的过程中,通常会对替换策略进行改进,每一款芯片几乎都使用了不同的替换策略。
在多CPU的系统中,每个CPU都有自己的本地Cache。因此,同一个地址的数据,有可能在多个CPU的本地 Cache 里存在多份拷贝。为了保证程序执行的正确性,就必须保证同一个变量,每个CPU看到的值都是一样的。也就是说,必须要保证每个CPU的本地Cache中能够如实反映内存中的真实数据。
假设一个变量在CPU0和CPU1的本地Cache中都有一份拷贝,当CPU0修改了这个变量时,就必须以某种方式通知CPU1,以便CPU1能够及时更新自己本地Cache中的拷贝,这样才能在两个CPU之间保持数据的同步,CPU之间的这种同步有较大开销。
为保证缓存一致,现代CPU实现了非常复杂的多核、多级缓存一致性协议MESI, MESI具体的操作上会针对单个缓存行进行加锁。
MESI:Modified Exclusive Shared or Invalid
1) 协议中的状态
CPU中每个缓存行使用4种状态进行标记(使用额外的两位bit表示)
M: Modified
该缓存行只被缓存在该CPU的缓存中,且被修改过(dirty),即与主存中的数据不一致,该缓存行中的内容需在未来的某个时间点(允许其它CPU读取主存中相应内存之前)写回主存。当被写回主存之后,该缓存行的状态变成独享(exclusive)状态
E: Exclusive
该缓存行只被缓存在该CPU的缓存中,未被修改,与主存中数据一致。在任何时刻当有其它CPU读取该内存时变成shared状态。同样,当修改该缓存行中内容时,该状态可以变成Modified状态.
S: Shared
意味该缓存行可能被多个CPU缓存,各个缓存中的数据与主存数据一致,当有一个CPU修改该缓存行中,其它CPU中该缓存行可以被作废(Invalid).
I: Invalid,缓存无效(可能其它CPU修改了该缓存行)
2) 状态切换关系
由下图可看出cache是如何保证它的数据一致性的。
譬如,当前核心要读取的数据块在其核心的cache状态为Invalid,在其他核心上存在且状态为Modified的情况。可以从当前核心和其它核心两个角度观察,其它核心角度:当前状态为Modified,其它核心想读这个数据块(图中Modified到Shared的绿色虚线):先把改变后的数据写入到内存中(先于其它核心的读),并更新该cache状态为Share.当前核心角度:当前状态为Invalid,想读这个数据块(图中Invalid到Shared的绿色实线):这种情况下会从内存中重新加载,并更新该cache状态Share
以下表格从这两个角度列举了所有情况,供参考:
3) 缓存的操作描述
一个典型系统中会有几个缓存(每个核心都有)共享主存总线,每个相应的CPU会发出读写请求,而缓存的目的是为了减少CPU读写共享主存的次数。
从上面的意义来看,E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成Invalid状态,而修改E状态的缓存不需要使用总线事务。
理解计算机存储器层次结构对应用程序的性能影响。如果需要的程序在CPU寄存器中,指令执行时1个周期内就能访问到;如果在CPU Cache中,需1~30个周期;如果在主存中,需要50~200个周期;在磁盘上,大概需要万级周期。另外,Cache Line的存取也是代码设计者需要关注的部分, 以规避伪共享的执行场景。因此,充分利用缓存的结构和机制可有效提高程序的执行性能。
一旦CPU要从内存或磁盘中访问数据就会产生一个很大的时延,程序性能显著降低,为此我们不得不提高Cache命中率,也就是充分发挥局部性原理。一般来说,具有良好局部性的程序会比局部性较差的程序运行得更快,程序性能更好。
局部性机制确保在访问存储设备时,存取数据或指令都趋于聚集在一片连续的区域。一个设计优良的计算机程序通常具有很好的局部性,时间局部性和空间局部性。
1) 时间局部性
如果一个数据/信息项被访问过一次,那么很有可能它会在很短的时间内再次被访问。比如循环、递归、方法的反复调用等。
2) 空间局部性
一个Cache Line有64字节块,可以充分利用一次加载64字节的空间,把程序后续会访问的数据,一次性全部加载进来,从而提高Cache Line命中率(而非重新去寻址读取)。如果一个数据被访问,那么很有可能位于这个数据附近的其它数据也会很快被访问到。比如顺序执行的代码、连续创建的多个对象、数组等。数组就是一种把局部性原理利用到极致的数据结构。
3) 代码示例
示例1,(C语言)
//程序 array1.c 多维数组交换行列访问顺序
char array[10240][10240];
int main(int argc, char *argv[]){
int i = 0;
int j = 0;
for(i=0; i < 10240 ; i++) {
for(j=0; j < 10240 ; j++) {
array[i][j] = ‘A’; //按行进行访问
}
}
return 0;
}
//程序array2.c
红色字体的代码调整为: array[j][i] = ‘A’; //按列进行访问
编译、运行结果如下:
从测试结果看,第一个程序运行耗时0.265秒,第二个1.998秒,是第一个程序的7.5倍。
案例参考:https://www.cnblogs.com/wanghuaijun/p/12904159.html
结果分析
数组元素存储在地址连续的内存中,多维数组在内存中是按行进行存储。第一个程序按行访问某个元素时,该元素附近的一个Cache Line大小的元素都会被加载到Cache中,这样一来,在访问紧挨着的下一个元素时,就可直接访问Cache中的数据,不需再从内存中加载。也就是说,对数组按行进行访问时,具有更好的空间局部性,Cache命中率更高。
第二个程序按列访问某个元素,虽然该元素附近的一个Cache Line大小的元素也会被加载进Cache中,但接下来要访问的数据却不是紧挨着的那个元素,因此很有可能会再次产生Cache miss,而不得不从内存中加载数据。而且,虽然Cache中会尽量保存最近访问过的数据,但Cache大小有限,当Cache被占满时,就不得不把一些数据给替换掉。这也是空间局部性差的程序更容易产生Cache miss的重要原因之一。
示例2,(Java)
以下代码中长度为16的row和column数组,在Cache Line 64字节数据块上内存地址是连续的,能被一次加载到Cache Line中,在访问数组时命中率高,性能发挥到极致。
public int run(int[] row, int[] column) {
int sum = 0;
for(int i = 0; i < 16; i++ ) {
sum += row[i] * column[i];
}
return sum;
}
变量i体现了时间局部性,作为计数器被频繁操作,一直存放在寄存器中,每次从寄存器访问,而不是从缓存或主存访问。
在多处理器下,为保证各个处理器的缓存一致,会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。
当多个线程对同一个缓存行访问时,如其中一个线程锁住缓存行,然后操作,这时其它线程则无法操作该缓存行。这种情况下,我们在进行程序代码设计时是要尽量避免的。
连续紧凑的内存分配带来高性能,但并不代表它一直都行之有效,伪共享就是无声的性能杀手。所谓伪共享(False Sharing),是由于运行在不同CPU上的不同线程,同时修改处在同一个Cache Line上的数据引起。缓存行上的写竞争是运行在SMP系统中并行线程实现可伸缩性最重要的限制因素,一般来说,从代码中很难看清是否会出现伪共享。
在每个CPU来看,各自修改的是不同的变量,但由于这些变量在内存中彼此紧挨着,因此它们处于同一个Cache Line上。当一个CPU修改这个Cache Line之后,为了保证数据的一致性,必然导致另一个CPU的本地Cache的无效,因而触发Cache miss,然后从内存中重新加载变量被修改后的值。多个线程频繁的修改处于同一个Cache Line的数据,会导致大量的Cache miss,因而造成程序性能的大幅下降。
下图说明了两个不同Core的线程更新同一缓存行的不同信息项:
上图说明了伪共享的问题。在Core1上运行的线程准备更新变量X,同时Core2上的线程准备更新变量Y。然而,这两个变量在同一个缓存行中。每个线程都要去竞争缓存行的所有权来更新变量。如果Core1获得了所有权,缓存子系统将会使Core2中对应的缓存行失效。当Core2获得了所有权然后执行更新操作,Core1就要使自己对应的缓存行失效。来来回回的经过L3缓存,大大影响了性能。如果互相竞争的Core位于不同的插槽,就要额外横跨插槽连接,问题可能更加严重。
1) 规避处理方式
2) 代码示例说明
示例3,(JAVA)
从代码设计角度,要考虑清楚类结构中哪些变量是不变,哪些是经常变化,哪些变化是完全相互独立,哪些属性一起变化。假如业务场景中,下面的对象满足几个特点
public class Data{
long modifyTime;
boolean flag;
long createTime;
char key;
int value;
}
当上面的对象需要由多个线程同时访问时,从Cache角度,当我们没有加任何措施时,Data对象所有的变量极有可能被加载在L1缓存的一行Cache Line中。在高并发访问下,会出现这种问题:
如上图所示,每次value变更时,根据MESI协议,对象其他CPU上相关的Cache Line全部被设置为失效。其他的处理器想要访问未变化的数据(key和createTime)时,必须从内存中重新拉取数据,增大了数据访问的开销。
有效的Padding方式
正确方式是将该对象属性分组,将一起变化的放在一组,与其他无关的放一组,将不变的放到一组。这样当每次对象变化时,不会带动所有的属性重新加载缓存,提升了读取效率。在JDK1.8前,一般在属性间增加长整型变量来分隔每一组属性。被操作的每一组属性占的字节数加上前后填充属性所占的字节数,不小于一个cache line的字节数就可达到要求。
public class DataPadding{
long a1,a2,a3,a4,a5,a6,a7,a8;//防止与前一个对象产生伪共享
int value;
long modifyTime;
long b1,b2,b3,b4,b5,b6,b7,b8;//防止不相关变量伪共享;
boolean flag;
long c1,c2,c3,c4,c5,c6,c7,c8;//
long createTime;
char key;
long d1,d2,d3,d4,d5,d6,d7,d8;//防止与下一个对象产生伪共享
}
采取上述措施后的图示:
在Java中
Java8实现字节填充避免伪共享, JVM参数 -XX:-RestrictContended
@Contended 位于sun.misc用于注解java 属性字段,自动填充字节,防止伪共享。
示例4,(C语言)
//程序 thread1.c 多线程访问数据结构的不同字段
#include
#include
struct {
int a;
// char padding[64]; // thread2.c代码
int b;
}data;
void *thread_1(void) {
int i = 0;
for(i=0; i < 1000000000; i++){
data.a = 0;
}
}
void *thread_2(void) {
int i = 0;
for(i=0; i < 1000000000; i++){
data.b = 0;
}
}
//程序 thread2.c
Thread1.c 中的红色字体行,打开注释即为 thread2.c
main()函数很简单,创建两个线程并运行,参考代码如下:
pthread_t id1;
int ret = pthread_create(&id1,NULL, (void*)thread_1, NULL);
编译、运行结果如下:
从测试结果看,第一个程序消耗的时间是第二个程序的3倍
案例参考:https://www.cnblogs.com/wanghuaijun/p/12904159.html
结果分析
此示例涉及到Cache Line的伪共享问题。两个程序唯一的区别是,第二个程序中字段a和b中间有一个大小为64个字节的字符数组。第一个程序中,字段a和字段b处于同一个Cache Line上,当两个线程同时修改这两个字段时,会触发Cache Line伪共享问题,造成大量的Cache miss,进而导致程序性能下降。
第二个程序中,字段a和b中间加了一个64字节的数组,这样就保证了这两个字段处在不同的Cache Line上。如此一来,两个线程即便同时修改这两个字段,两个cache line也互不影响,cache命中率很高,程序性能会大幅提升。
示例5,(C语言)
在设计数据结构的时候,尽量将只读数据与读写数据分开,并具尽量将同一时间访问的数据组合在一起。这样CPU能一次将需要的数据读入。譬如,下面的数据结构就很不好。
struct __a
{
int id; // 不易变
int factor;// 易变
char name[64];// 不易变
int value;// 易变
};
在 X86 下,可以试着修改和调整它
#define CACHE_LINE_SIZE 64 //缓存行长度
struct __a
{
int id; // 不易变
char name[64];// 不易变
char __align[CACHE_LINE_SIZE – sizeof(int)+sizeof(name) * sizeof(name[0]) % CACHE_LINE_SIZE]
int factor;// 易变
int value;// 易变
char __align2[CACHE_LINE_SIZE –2* sizeof(int)%CACHE_LINE_SIZE ]
};
CACHE_LINE_SIZE–sizeof(int)+sizeof(name)*sizeof(name[0])%CACHE_LINE_SIZE看起来不和谐,CACHE_LINE_SIZE表示高速缓存行(64B大小)。__align用于显式对齐,这种方式使得结构体字节对齐的大小为缓存行的大小。
1)字节对齐
__attribute__ ((packed))告诉编译器取消结构在编译过程中的优化对齐,按照实际占用字节数进行对齐,是GCC特有的语法;
__attribute__((aligned(n)))表示所定义的变量为n字节对齐;
struct B{ char b;int a;short c;}; (默认4字节对齐)
这时候同样是总共7个字节的变量,但是sizeof(struct B)的值却是12。
字节对齐的细节和编译器实现相关,但一般而言,满足三个准则:
2)缓存行对齐
数据跨越两个cache line,意味着两次load或两次store。如果数据结构是cache line对齐的,就有可能减少一次读写。数据结构的首地址cache line对齐,意味着可能有内存浪费(特别是数组这样连续分配的数据结构),所以需要在空间和时间两方面权衡。比如现在一般的malloc()函数,返回的内存地址会已经是8字节对齐的,这就是为了能够让大部分程序有更好的性能。
在C语言中,为了避免伪共享,编译器会自动将结构体,字节补全和对齐,对齐的大小最好是缓存行的长度。总的来说,结构体实例会和它的最宽成员一样对齐。编译器这样做是因为这是保证所有成员自对齐以获得快速存取的最容易方法。
__attribute__((aligned(cache_line)))对齐实现
struct syn_str { ints_variable; };__attribute__((aligned(cache_line)));
示例6,(C语言)
struct syn_str { int s_variable; };
void *p = malloc ( sizeof (struct syn_str) + cache_line );
syn_str *align_p=(syn_str*)((((int)p)+(cache_line-1))&~(cache_line-1);
代码在内存里面是顺序排列的,可以顺序访问,有效提高缓存命中。对于分支程序来说,如果分支语句之后的代码有更大的执行几率,就可以减少跳转,一般CPU都有指令预取功能,这样可以提高指令预取命中的几率。分支预测用的就是likely/unlikely这样的宏,一般需要编译器的支持,属静态的分支预测。现在也有很多CPU支持在内部保存执行过的分支指令的结果(分支指令cache),所以静态的分支预测就没有太多的意义。
示例7,(C语言)
int testfun(int x)
{
if(__builtin_expect(x, 0)) {
^^^--- We instruct the compiler, "else" block is more probable
x = 5;
x = x * x;
} else {
x = 6;
}
return x;
}
在这个例子中,x为0的可能性更大,编译后观察汇编指令,结果如下:
Disassembly of section .text:
00000000 :
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
3: 8b 45 08 mov 0x8(%ebp),%eax
6: 85 c0 test %eax,%eax
8: 75 07 jne 11
a: b8 06 00 00 00 mov $0x6,%eax
f: c9 leave
10: c3 ret
11: b8 19 00 00 00 mov $0x19,%eax
16: eb f7 jmp f
可以看到,编译器使用的是 jne指令,且else block中的代码紧跟在后面
8: 75 07 jne 11
a: b8 06 00 00 00 mov $0x6,%eax
程序设计要追求更好的利用CPU缓存,来减少从内存读取数据的低效。在程序运行时,通常需要关注缓存命中率这个指标。
监控方法(Linux):查询CPU缓存无命中次数及缓存请求次数,计算缓存命中率
perf stat -e cache-references -e cache-misses
程序从内存获取数据时,每次不仅取回所需数据,还会根据缓存行的大小加载一段连续的内存数据到缓存中,程序设计中的优化范式参考如下。
. . . . . .
除了本章节中介绍的案例之外,在系统中CPU Cache对程序性能的影响随处可见。
CPU高速缓存可以说是CPU与内存之间的临时数据交换器,用以有效解决CPU运行处理速度与内存读写速度不匹配的矛盾。缓存设计也一直在发展,尤其是随着内存变得更便宜、更快和更密集。减少内存的延迟必定可以减少现代计算机的瓶颈。
CPU的高速缓存设计,多处理器、多核的多级缓存,带来高效处理的同时也引入了缓存数据的存取与一致性问题,现代CPU实现了非常复杂的多核、多级缓存一致性协议MESI,以保证多个CPU高速缓存之间共享数据的一致。
CPU高速缓存往往需要重复处理相同的数据、执行相同的指令,如果这部分数据、指令都能在高速缓存中找到,即可减少整机的响应时间。程序的设计需要确保充分利用高速缓存的结构与机制,需要利用处理的局部特性,规避锁竞争、伪共享、缓存的失效、彼此的牵制,权衡空间与时间,获取程序运行时的极致与高性能。
. . . . . .
参考博文
【系统性能专题一】CPU消耗及问题定位那点事
【系统性能专题二】性能测试及指标分析这点事
【系统性能专题三】CPU高速缓存与极性代码设计
点击关注,第一时间了解华为云新鲜技术~