山,刺破青天锷未残。
天欲堕,赖以拄其间!
这是毛主席《十六字令三首》的第三首,描述了巍峨的大山如利剑一般屹立在天地之间,又仿佛柱子一般支撑着青天,使其不会崩塌。
在计算机系统中,缓存就是内存存取性能的支柱,针对缓存组织的性能优化,也在很大程度上决定了编写的程序的性能。
在上期,我们留下了一个问题:
为什么存储器山在与X轴平行的方向,Stride=3和Stride=4之间出现了性能的跌落,也就是程序读写内存的时候,每次跨越8*2^2 = 32字节,和每次跨越8*2^3 = 64字节相比,后者性能显著下降呢?
这和缓存的组织有关。
缓存(Cache)的最小单位为缓存行 (Cacheline)。不同处理器的缓存行大小也是不一样的,如下表:
处理器名称 | 架构 | 字长 | Cache Line大小 |
---|---|---|---|
Intel Xeon Scalable III | x86, Sunny Cove | 64bit | 64Byte, 8 Words |
AMD Milan 7763 | x86, Zen-3 | 64bit | 64Byte, 8 Words |
Kunpeng 920-6426 | ARM 8.2 | 64bit | 128Byte, 16 Words |
IBM Power 780 | Power 7 | 64bit | 128Byte, 16 Words |
龙芯3A 3000 | 类MIPS | 64bit | 64Byte, 8 Words |
当内存内容被读入缓存的时候,是以缓存行为单位的。如下图:
图中是一台安装了512GB内存的Intel Xeon Scalable V7服务器,其内存物理地址从0x0000 0000 0000 0000 到 0x0000 007f ffff ffff 。
在Cache中,有3条cacheline,分别映射到内存地址:
0x00000053 1072B340
0x00000011 2F4A3800
0x0000000B 910061C0
注意到Intel Xeon Scalable V7的Cacheline大小为64Byte,对应地,映射的内存地址也应当可以被64Byte (0x40)整除。这种可以被Cacheline大小整除的内存地址,一般称为cacheline对齐 (cacheline alignment)。在编写程序时,使用cacheline对齐的地址可以实现性能的优化。
看到这里,我们已经接近在本篇的开头提出的问题的答案了。
CPU在读取内存的时候,会以cacheline大小为单位,将内存中指令或数据存放到cache。如上图中,程序只要读取了地址0x00000053 1072B340的内容,从0x00000053 1072B340到0x00000053 1072B37F的64个字节都可以直接从cache中读取。
也就是说,如果我们将读取内存的步长设定为1,那么,在读取了第一个字(8 Byte)后,随后的7个字,可以从Cache中读取(术语曰:cache line hit,缓存行命中)的概率是100%,如下图所示:
类似地,当步长为2时每次跨越2个字,随后3次读取的内容都可以保证在缓存中找到:
步长为3时,每次跨越4个字:
此时,每次读取DRAM后,下一次还可以从该cache line中读取;
但是,在步长从3增长到4以后,我们发现,由于下一次读取调过了2^3 = 8个字,也就是64字节,必须从另一个cacheline中读取,大大降低了程序运行的整体缓存命中率,也就导致了我们在寄存器山图像中看到的,在Stride=3和Stride=4之间,出现了性能的明显下降。
因此,如果我们期望程序有较好的缓存友好性,能够尽量利用CPU的缓存提升性能,就要尽量读取连续的内存块。
除了数据缓存(d-cache)外,处理器还会将指令也放入缓存中,这种缓存叫做指令缓存(i-cache)。与数据缓存类似地,指令缓存也有时间局部性和空间局部性。当CPU执行跳转指令的时候,会让pc指针不再连续增长,而是跳转到另一个指令地址进行执行,此时,就有可能造成i-cache miss,从而影响程序执行的性能。
如何避免这种情况呢?
以C/C 语言为例,编译器内置的头文件预置了两个宏:likely和unlikely,合理运用之,能够让编译器产生缓存友好,尽量避免跳转的机器指令,如下面的程序代码:
代码语言:javascript复制if (unlikely(fd < 0))
{
/* output error message */
}
上面是一段异常处理代码,如果打开文件产生的fd<0 (出错了),则进行错误处理。显然,在程序正常执行的时候不应当走到这个分支,因此,运用unlikely这个宏,能够让编译器产生的指令避免在这个地方发生跳转。
前文讲了,缓存与内存存在一定的映射关系。实际上,缓存行和内存地址之间的映射关系,并不是可以完全自主配置的。
假设某台计算机A,它的内存大小为1GB,其有效地址为
0x00000000-0x3FFFFFFF。
而缓存大小为1MB,缓存行大小为16Byte,总共有16384个缓存行,缓存行的编号从0x0000到0xFFFF。
如果采用全相联映射,内存的每16个Byte都可以映射到任意的缓存行,我们就需要为内存实现这样的映射电路:
其映射关系通过TLB(Translation Lookaside Buffer)实现。
TLB为内存到缓存的映射表,CPU在访问内存的时候,先到TLB里面看这块内存是否映射到了缓存,映射是否有效,如果答案为是,再去缓存中读取内容。显然,通过硬件电路实现全相联缓存的TLB,其成本会非常高昂。
另一种思路为,把内存地址固定映射到一个cacheline。还是以这台计算机A为例:
它的物理内存有效地址为0x00000000-0x3FFFFFFF,总共1GB,而缓存行大小为16B,物理内存容量相当于1GB/16B = 64M个缓存行,而缓存中有64K个缓存行。
我们将32位的内存地址进行拆分:
其中,bit19-bit4总共16bit,可以一一映射到缓存行ID的16bit。bit3-bit0对应缓存行的16Byte。而bit31-bit20的12bit,与缓存的映射关系无关。
这样一来,内存与缓存之间就建立了多对一的固定映射关系:
如图,只要是内存地址bit19-bit4为0x3A80的数据块,都会映射到编号为A380的缓存行上。
显然,此种实现方式的成本较低,但带来的另一个问题就是:
如果程序访问的内存块步长,刚好与缓存大小相等,一定会造成多块内存都映射到同一条缓存行,也就是造成所谓的cache line ping-pong,不同地址的内存频繁换入和换出缓存,造成缓存命中率实质上为0。
因此,出现了全相联缓存和直接映射缓存的折中——组相联缓存。
组相联缓存实现的是,把内存块分割为大小与缓存行一样,每块可以映射到N个缓存行。如果一个缓存行已经被占用,可以映射到另一行,直到N被用完。
这种方式叫做N路组相联缓存,如4路,8路等。这样,既避免了全相联缓存的高成本,又避免了直接映射缓存容易出现的缓存冲突及cache line ping-pong,提升了缓存命中率。
随着CPU从单核向多核的演进,工程师们还需要面对一个新的问题——缓存一致性。
请看下回分解。