CPU 高速缓存与极性代码设计
摘要:CPU 内置少量的高速缓存的重要性不言而喻,在体积、成本、效率等因素下产生了当今用到的计算机的存储结构。
1. 介 绍
2. CPU 缓存的结构
3. 缓存的存取与一致
4. 代码设计的考量
5. 最 后
CPU 频率太快,其处理速度远快于存储介质的读写。因此,导致 CPU 资源的浪费,需要有效解决 IO 速度和 CPU 运算速度之间的不匹配问题。芯片级高速缓存可大大减少之间的处理延迟。CPU 制造工艺的进步使得在比以前更小的空间中安装数十亿个晶体管,如此可为缓存留出更多空间,使其尽可能地靠近核心。
CPU 内置少量的高速缓存的重要性不言而喻,在体积、成本、效率等因素下产生了当今用到的计算机的存储结构。
介 绍
计算机的内存具有基于速度的层次结构,CPU 高速缓存位于该层次结构的顶部,是介于 CPU 内核和物理内存(动态内存 DRAM)之间的若干块静态内存,是最快的。它也是最靠近中央处理的地方,是 CPU 本身的一部分,一般直接跟 CPU 芯片集成。
CPU 计算:程序被设计为一组指令,最终由 CPU 运行。
装载程序和数据,先从最近的一级缓存读取,如有就直接返回,逐层读取,直至从内存及其它外部存储中加载,并将加载的数据依次放入缓存。
高速缓存中的数据写回主存并非立即执行,写回主存的时机:
1.缓存满了,采用先进先出或最久未使用的顺序写回;
2.#Lock 信号,缓存一致性协议,明确要求数据计算完成后要立马同步回主存。
CPU 缓存的结构
现代的 CPU 缓存结构被分为多处理、多核、多级的层次。
2.1 多级缓存结构
分为三个主要级别,即 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 高速缓存的专用空间,且占用了很大一部分空间。
2.2 多处理器缓存结构
计算机早已进入多核时代,软件也运行在多核环境。一个处理器对应一个物理插槽、包含多个核(一个核包含寄存器、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 缓存的负担。
2.3 Cache Line
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 纳秒。
3.1 读取存储器数据
按 CPU 层级缓存结构,取数据的顺序是先缓存再主存。当然,如数据来自寄存器,只需直接读取返回即可。
1) 如 CPU 要读的数据在 L1 cache,锁住 cache 行,读取后解锁、返回
2) 如 CPU 要读的数据在 L2 cache,数据在 L2 里加锁,将数据复制到 L1,再执行读 L1
3) 如 CPU 要读的数据在 L3 cache,也一样,只不过先由 L3 复制到 L2,再从 L2 复制到 L1,最后从 L1 到 CPU
4) 如 CPU 需读取内存,则首先通知内存控制器占用总线带宽,后内存加锁、发起读请求、等待回应,回应数据保存至 L3,L2,L1,再从 L1 到 CPU 后解除总线锁定。
3.2 缓存命中与延迟
由于数据的局部性原理,CPU 往往需要在短时间内重复多次读取数据,内存的运行频率远跟不上 CPU 的处理速度,缓存的重要性被凸显。CPU 可避开内存在缓存里读取到想要的数据,称之为命中。L1 的运行速度很快,但容量很小,在 L1 里命中的概率大概在 80%左右,L2、L3 的机制也类似。这样一来,CPU 需要在主存中读取的数据大概为 5%-10%,其余命中全部可以在 L1、L2、L3 中获取,大大减少了系统的响应时间。
高速缓存旨在加快主内存和 CPU 之间的数据传输。从内存访问数据所需的时间称为延迟,L1 具有最低延迟且最接近核心,而 L3 具有最高的延迟。缓存未命中时,由于 CPU 需从主存储器中获取数据,导致延迟会更多。
3.3 缓存替换策略
Cache 里的数据是 Memory 中常用数据的一个拷贝,存满后再存入一个新的条目时,就需要把一个旧的条目从缓存中拿掉,这个过程称为 evict。缓存管理单元通过一定的算法决定哪些数据需要从 Cache 里移出去,称为替换策略。最简单的策略为 LRU,在 CPU 设计的过程中,通常会对替换策略进行改进,每一款芯片几乎都使用了不同的替换策略。
3.4 MESI 缓存一致性
在多 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 读写共享主存的次数。
l 一个缓存除在 Invalid 状态外都可以满足 CPU 的读请求,一个 Invalid 的缓存行必须从主存中读取(变成 S 或 E 状态)来满足该 CPU 的读请求。
l 一个写请求只有在该缓存行是 M 或 E 状态时才能被执行,如果缓存行处于 S 状态,必须先将其它缓存中该缓存行变成 Invalid(不允许不同 CPU 同时修改同一缓存行,即使修改该缓存行中不同位置的数据也不可),该操作常以广播方式来完成。
l 缓存可以随时将一个非 M 状态的缓存行作废,或变成 Invalid,而一个 M 状态的缓存行必须先被写回主存。一个处于 M 状态的缓存行必须时刻监听所有试图读该缓存行相对主存的操作,操作必须在缓存将该缓存行写回主存并将状态变成 S 状态之前被延迟执行。
l 一个处于 S 状态的缓存行需监听其它缓存使该缓存行无效或独享该缓存行的请求,并将该缓存行变成无效。
l 一个处于 E 状态的缓存行也必须监听其它读主存中该缓存行的操作,一旦有这种操作,该缓存行需变成 S 状态。
l 对于 M 和 E 状态而言总是精确的,和该缓存行的真正状态是一致的。而 S 状态可能是非一致的,如果一个缓存将处于 S 状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为 E 状态,是因为其它缓存不会广播作废掉该缓存行的通知,同样,由于缓存并没有保存该缓存行的 copy 的数量,因此也没有办法确定自己是否已经独享了该缓存行。
从上面的意义来看,E 状态是一种投机性的优化:如果一个 CPU 想修改一个处于 S 状态的缓存行,总线事务需要将所有该缓存行的 copy 变成 Invalid 状态,而修改 E 状态的缓存不需要使用总线事务。
代码设计的考量
理解计算机存储器层次结构对应用程序的性能影响。如果需要的程序在 CPU 寄存器中,指令执行时 1 个周期内就能访问到;如果在 CPU Cache 中,需 1~30 个周期;如果在主存中,需要 50~200 个周期;在磁盘上,大概需要万级周期。另外,Cache Line 的存取也是代码设计者需要关注的部分, 以规避伪共享的执行场景。因此,充分利用缓存的结构和机制可有效提高程序的执行性能。
4.1 局部性特性
一旦 CPU 要从内存或磁盘中访问数据就会产生一个很大的时延,程序性能显著降低,为此我们不得不提高 Cache 命中率,也就是充分发挥局部性原理。一般来说,具有良好局部性的程序会比局部性较差的程序运行得更快,程序性能更好。
局部性机制确保在访问存储设备时,存取数据或指令都趋于聚集在一片连续的区域。一个设计优良的计算机程序通常具有很好的局部性,时间局部性和空间局部性。
1) 时间局部性
如果一个数据/信息项被访问过一次,那么很有可能它会在很短的时间内再次被访问。比如循环、递归、方法的反复调用等。
2) 空间局部性
一个 Cache Line 有 64 字节块,可以充分利用一次加载 64 字节的空间,把程序后续会访问的数据,一次性全部加载进来,从而提高 Cache Line 命中率(而非重新去寻址读取)。如果一个数据被访问,那么很有可能位于这个数据附近的其它数据也会很快被访问到。比如顺序执行的代码、连续创建的多个对象、数组等。数组就是一种把局部性原理利用到极致的数据结构。
3) 代码示例
示例 1,(C 语言)
红色字体的代码调整为: arrayj = ‘A’; //按列进行访问
编译、运行结果如下:
从测试结果看,第一个程序运行耗时 0.265 秒,第二个 1.998 秒,是第一个程序的 7.5 倍。
案例参考:https://www.cnblogs.com/wangh...
结果分析
数组元素存储在地址连续的内存中,多维数组在内存中是按行进行存储。第一个程序按行访问某个元素时,该元素附近的一个 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 中,在访问数组时命中率高,性能发挥到极致。
变量 i 体现了时间局部性,作为计数器被频繁操作,一直存放在寄存器中,每次从寄存器访问,而不是从缓存或主存访问。
4.2 缓存行的锁竞争
在多处理器下,为保证各个处理器的缓存一致,会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。
当多个线程对同一个缓存行访问时,如其中一个线程锁住缓存行,然后操作,这时其它线程则无法操作该缓存行。这种情况下,我们在进行程序代码设计时是要尽量避免的。
4.3 伪共享的规避
连续紧凑的内存分配带来高性能,但并不代表它一直都行之有效,伪共享就是无声的性能杀手。所谓伪共享(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) 规避处理方式
l 增大数组元素的间隔使得不同线程存取的元素位于不同 cache line,空间换时间
l 在每个线程中创建全局数组各个元素的本地拷贝,然后结束后再写回全局数组
2) 代码示例说明
示例 3,(JAVA)
从代码设计角度,要考虑清楚类结构中哪些变量是不变,哪些是经常变化,哪些变化是完全相互独立,哪些属性一起变化。假如业务场景中,下面的对象满足几个特点
l 当 value 变量改变时,modifyTime 肯定会改变
l createTime 变量和 key 变量在创建后就不会再变化
l flag 也经常会变化,不过与 modifyTime 和 value 变量毫无关联
当上面的对象需要由多个线程同时访问时,从 Cache 角度,当我们没有加任何措施时,Data
对象所有的变量极有可能被加载在 L1 缓存的一行 Cache Line 中。在高并发访问下,会出现这种问题:
如上图所示,每次 value 变更时,根据 MESI 协议,对象其他 CPU 上相关的 Cache Line 全部被设置为失效。其他的处理器想要访问未变化的数据(key 和 createTime)时,必须从内存中重新拉取数据,增大了数据访问的开销。
有效的 Padding 方式
正确方式是将该对象属性分组,将一起变化的放在一组,与其他无关的放一组,将不变的放到一组。这样当每次对象变化时,不会带动所有的属性重新加载缓存,提升了读取效率。在 JDK1.8 前,一般在属性间增加长整型变量来分隔每一组属性。被操作的每一组属性占的字节数加上前后填充属性所占的字节数,不小于一个 cache line 的字节数就可达到要求。
采取上述措施后的图示:
在 Java 中
Java8 实现字节填充避免伪共享, JVM 参数 -XX:-RestrictContended
@Contended 位于 sun.misc 用于注解 java 属性字段,自动填充字节,防止伪共享。
示例 4,(C 语言)
//程序 thread2.c
Thread1.c 中的红色字体行,打开注释即为 thread2.c
main()函数很简单,创建两个线程并运行,参考代码如下:
编译、运行结果如下:
从测试结果看,第一个程序消耗的时间是第二个程序的 3 倍
案例参考:https://www.cnblogs.com/wangh...
结果分析
此示例涉及到 Cache Line 的伪共享问题。两个程序唯一的区别是,第二个程序中字段 a 和 b 中间有一个大小为 64 个字节的字符数组。第一个程序中,字段 a 和字段 b 处于同一个 Cache Line 上,当两个线程同时修改这两个字段时,会触发 Cache Line 伪共享问题,造成大量的 Cache miss,进而导致程序性能下降。
第二个程序中,字段 a 和 b 中间加了一个 64 字节的数组,这样就保证了这两个字段处在不同的 Cache Line 上。如此一来,两个线程即便同时修改这两个字段,两个 cache line 也互不影响,cache 命中率很高,程序性能会大幅提升。
示例 5,(C 语言)
在设计数据结构的时候,尽量将只读数据与读写数据分开,并具尽量将同一时间访问的数据组合在一起。这样 CPU 能一次将需要的数据读入。譬如,下面的数据结构就很不好。
在 X86 下,可以试着修改和调整它
CACHE_LINE_SIZE–sizeof(int)+sizeof(name)*sizeof(name[0])%CACHE_LINE_SIZE 看起来不和谐,CACHE_LINE_SIZE 表示高速缓存行(64B 大小)。__align 用于显式对齐,这种方式使得结构体字节对齐的大小为缓存行的大小。
4.4 缓存与内存对齐
1)字节对齐
attribute ((packed))告诉编译器取消结构在编译过程中的优化对齐,按照实际占用字节数进行对齐,是 GCC 特有的语法;
__attribute__((aligned(n)))表示所定义的变量为 n 字节对齐;
struct B{ char b;int a;short c;}; (默认 4 字节对齐)
这时候同样是总共 7 个字节的变量,但是 sizeof(struct B)的值却是 12。
字节对齐的细节和编译器实现相关,但一般而言,满足三个准则:
1)(结构体)变量的首地址能够被其(最宽)基本类型成员的大小所整除;
2)结构体每个成员相对于首地址的偏移量都是成员大小的数倍,如有需要,编译器会在成员之间加上填充字节(internal adding)
3)结构体的总大小为结构体最宽基本类型成员大小的数倍,如有需要,编译器会在最末一个成员之后加上填充字节(trailing padding)
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 语言)
4.5 CPU 分支预测
代码在内存里面是顺序排列的,可以顺序访问,有效提高缓存命中。对于分支程序来说,如果分支语句之后的代码有更大的执行几率,就可以减少跳转,一般 CPU 都有指令预取功能,这样可以提高指令预取命中的几率。分支预测用的就是 likely/unlikely 这样的宏,一般需要编译器的支持,属静态的分支预测。现在也有很多 CPU 支持在内部保存执行过的分支指令的结果(分支指令 cache),所以静态的分支预测就没有太多的意义。
示例 7,(C 语言)
在这个例子中,x 为 0 的可能性更大,编译后观察汇编指令,结果如下:
可以看到,编译器使用的是 jne 指令,且 else block 中的代码紧跟在后面
4.6 命中率的监控
程序设计要追求更好的利用 CPU 缓存,来减少从内存读取数据的低效。在程序运行时,通常需要关注缓存命中率这个指标。
监控方法(Linux):查询 CPU 缓存无命中次数及缓存请求次数,计算缓存命中率
perf stat -e cache-references -e cache-misses
4.7 小 结
程序从内存获取数据时,每次不仅取回所需数据,还会根据缓存行的大小加载一段连续的内存数据到缓存中,程序设计中的优化范式参考如下。
l 在集合遍历的场景,可使用有序数组,数组在内存中是一段连续的空间;
l 字段尽量定义为占用字节小的类型,如 int 可满足时,不使用 long。这样单次可加载更多的数据到缓存中;
l 对象或结构体大小尽可能设置为 CPU 核心缓存行的整数倍。基于 64B 大小的缓存行,如读取的数据是 50B,最坏情况下需要两次从内存加载;当为 70B 时,最坏情况需要三次内存读取,才能加载到缓存中;
l 对同一对象/结构体的多个属性,可能存在于同一缓存行中,导致伪共享问题,需为属性的不变与常变,变化的独立与关联而隔离设计,及缓存行对齐,解决多线程高并发环境下缓存失效、彼此牵制问题;
l CPU 有分支预测能力,在使用 ifelse case when 等循环判断的场景时,可以顺序访问,有效提高缓存的命中
. . . . . .
除了本章节中介绍的案例之外,在系统中 CPU Cache 对程序性能的影响随处可见。
版权声明: 本文为 InfoQ 作者【华为云开发者社区】的原创文章。
原文链接:【http://xie.infoq.cn/article/67bca0f473aca5b85989fc350】。文章转载请联系作者。
评论