当前位置: 首页 > article >正文

优化代码性能:利用CPU缓存原理

在计算机的世界里,有一场如同龟兔赛跑般的速度较量,主角便是 CPU 和内存 。龟兔赛跑的故事大家都耳熟能详,兔子速度飞快,乌龟则慢吞吞的。在计算机中,CPU 就如同那敏捷的兔子,拥有超高的运算速度,能够在极短的时间内完成大量复杂的计算任务;而内存则像是那步伐缓慢的乌龟,虽然它能够存储程序运行所需的数据和指令,但数据的读取和写入速度,与 CPU 相比,简直是天壤之别。

现代 CPU 的运行频率可以轻松达到数 GHz,这意味着它每秒能够执行数十亿次的操作。而内存的访问速度,虽然也在不断提升,但与 CPU 的速度相比,仍然存在着巨大的差距。这种速度上的不匹配,就好比是让一个短跑冠军和一个普通人进行接力赛跑,每次短跑冠军快速跑到终点后,都需要花费大量时间等待普通人慢悠悠地把接力棒送过来,这无疑会极大地影响整个系统的运行效率。

为了解决这个问题,计算机科学家们引入了一种名为 CPU Cache 的高速缓冲存储器,它就像是在 CPU 和内存之间搭建了一座 “高速桥梁”,让 CPU 能够更快地获取数据,从而提高计算机的整体性能 。接下来,就让我们一起深入了解一下 CPU Cache 的奥秘吧。

一、CPU Cache概述

你可能会好奇为什么有了内存,还需要 CPU Cache?根据摩尔定律,CPU 的访问速度每 18 个月就会翻倍,相当于每年增长 60% 左右,内存的速度当然也会不断增长,但是增长的速度远小于 CPU,平均每年只增长 7% 左右。于是,CPU 与内存的访问性能的差距不断拉大。

到现在,一次内存访问所需时间是 200~300 多个时钟周期,这意味着 CPU 和内存的访问速度已经相差 200~300 多倍了。为了弥补CPU和内存之间的性能差异,以便于能够真实变得把CPU的性能提升利用起来,而不是让它在那里空转,我们在现代CPU中引入了高速缓存。

从CPU Cache被加入到现有的CPU里开始,内存中的指令、数据,会被加载到L1-L3 Cache中,而不是直接从CPU访问内存中取拿。CPU从内存读取数据到CPU Cache的过程中,是一小块一小块来读取数据的,而不是按照单个数组元素来读取数据的。这样一小块一小块的数据,在CPU Cache里面,叫做Cache Line(缓存块)

在我们日常使用的 Intel 服务器或者 PC 里,Cache Line 的大小通常是 64 字节。

1.1Cache 的定义与角色

CPU Cache,即高速缓冲存储器,是位于 CPU 和内存之间的临时存储器 。它就像是一个数据 “中转站”,主要作用是为了加速 CPU 读取数据的速度 。由于 CPU 的运算速度极快,而内存的读写速度相对较慢,这就好比一个短跑冠军和一个慢跑者,两者的速度差距明显。

如果 CPU 直接从内存中读取数据,就需要花费大量时间等待,这无疑会降低整个计算机系统的运行效率 。而 CPU Cache 的出现,就很好地解决了这个问题。它的速度比内存快很多,能够提前将 CPU 可能需要的数据从内存中读取出来并存储起来,当 CPU 需要数据时,首先会在 Cache 中查找,如果找到了,就可以直接从 Cache 中快速获取,大大节省了读取数据的时间,提高了 CPU 的工作效率 。

1.2Cache 的层级结构

随着科技发展,热点数据的体积越来越大,单纯的增加一级缓存大小的性价比已经很低了;二级缓存就是一级缓存的缓冲器:一级缓存制造成本很高因此它的容量有限,二级缓存的作用就是存储那些CPU处理时需要用到、一级缓存又无法存储的数据。

CPU Cache 通常分为三级,分别是 L1 Cache(一级缓存)、L2 Cache(二级缓存)和 L3 Cache(三级缓存) 。这三级缓存就像是一个金字塔结构,从 L1 到 L3,速度逐渐变慢,容量逐渐增大 。

L1 Cache 是离 CPU 核心最近的缓存,它的速度最快,几乎可以与 CPU 的频率同步运行 。L1 Cache 又可以细分为数据缓存(L1 D - Cache)和指令缓存(L1 I - Cache),分别用于存储数据和指令 。

数据缓存就像是一个小型的数据仓库,专门存放 CPU 在运算过程中需要处理的数据;指令缓存则像是一个指令库,存储着 CPU 执行运算时所需要的指令 。由于 L1 Cache 与CPU 核心紧密相连,访问速度极快,但其容量相对较小,一般只有几十 KB 到几百 KB 。例如,英特尔酷睿 i7 - 13700K 处理器,其每个核心的 L1 数据缓存为 32KB,L1指令缓存也为 32KB 。

L2 Cache 位于 L1 Cache 之后,速度比 L1 Cache 稍慢,但容量比 L1 Cache 大 。它的作用是作为 L1 Cache的补充,当 L1 Cache 中没有找到 CPU 需要的数据或指令时,CPU 就会到L2 Cache 中查找 。L2 Cache 的容量一般在几百 KB 到几 MB 之间 。以刚才提到的酷睿i7 - 13700K 为例,其每个核心的L2缓存为 1MB 。

L3 Cache 是三级缓存中速度最慢但容量最大的缓存,它通常是多个 CPU 核心共享的 。当 L1 和 L2 Cache 都没有命中时,CPU 会访问 L3 Cache 。L3 Cache 的存在可以进一步提高数据的命中率,减少 CPU 访问内存的次数 。它的容量一般在几 MB 到几十 MB 之间 。酷睿 i7 - 13700K 的 L3 缓存为 30MB 。

在实际工作中,CPU 按照 L1 Cache、L2 Cache、L3 Cache 的顺序依次查找数据 。如果在某一级缓存中找到了所需的数据,就称为缓存命中;如果在三级缓存中都没有找到,才会去内存中查找,这就是缓存未命中 。缓存命中的概率越高,CPU 获取数据的速度就越快,计算机的性能也就越好 。

二、CPU Cache核心原理

2.1局部性原理

CPU Cache 能够提高数据访问性能,背后依赖的是局部性原理 。局部性原理又分为时间局部性和空间局部性 。

时间局部性,是指如果一个数据项在某个时刻被访问,那么在不久的将来它很可能再次被访问 。就好比我们看电视剧时,喜欢反复观看精彩的片段。在程序中,循环结构就是时间局部性的典型体现 。例如下面这段简单的 C 语言代码:

int sum = 0;
int arr[100] = {1, 2, 3,..., 100};
for (int i = 0; i < 100; i++) {
    sum += arr[i];
}

在这个循环中,变量sum会被多次访问,每次循环都要读取和更新它的值 。根据时间局部性原理,CPU Cache 会将sum的值缓存起来,这样在后续的循环中,CPU 就可以直接从 Cache 中快速获取sum的值,而不需要每次都从内存中读取 ,大大提高了访问效率 。

空间局部性,是指如果一个数据项被访问,那么与其相邻的数据项很可能在不久的将来也被访问 。这就像我们在书架上找书,找到一本后,往往会顺便看看它旁边的书 。在计算机中,内存中的数据通常是按顺序存储的 。比如数组,数组元素在内存中是连续存放的 。当 CPU 访问数组中的一个元素时,根据空间局部性原理,它很可能在接下来的操作中访问该元素附近的其他元素 。

还是以上面的代码为例,当 CPU 访问arr[i]时,Cache 会把arr[i]以及它附近的一些元素(比如arr[i - 1]、arr[i + 1]等,具体取决于 Cache Line 的大小,后面会详细介绍 Cache Line)一起缓存起来 。这样,当 CPU 访问下一个元素arr[i + 1]时,就有可能直接从 Cache 中获取,而无需再次访问内存 。

CPU Cache 正是利用了这两种局部性原理,提前将可能被访问的数据从内存中读取到 Cache 中 。当 CPU 需要数据时,首先在 Cache 中查找,如果命中(即找到所需数据),就可以快速获取数据,大大缩短了数据访问时间 。如果未命中,才会去内存中读取数据,并将读取到的数据及其相邻的数据块存入 Cache,以便后续访问 。通过这种方式,CPU Cache 有效地提高了数据访问性能,减少了 CPU 等待数据的时间,从而提升了整个计算机系统的运行效率 。

2.2缓存命中与未命中

在 CPU 访问数据的过程中,缓存命中和未命中是两个非常关键的概念,它们对 CPU 的数据读取流程有着重要的影响 。

当 CPU 需要读取某个数据时,它会首先在 Cache 中查找该数据 。如果该数据正好存在于 Cache 中,这就称为缓存命中 。缓存命中是一种非常理想的情况,因为 Cache 的速度比内存快很多,CPU 可以直接从 Cache 中快速获取数据,几乎不需要等待 。这就好比你在自己的书架上找一本书,一下子就找到了,马上就可以开始阅读 。

在缓存命中的情况下,CPU 的数据读取流程非常简单高效,直接从 Cache 中读取数据并进行后续的运算操作,大大提高了 CPU 的工作效率 。例如,在一个频繁访问数组元素的程序中,如果数组元素被缓存到 Cache 中,当 CPU 再次访问这些元素时,就会发生缓存命中,CPU 能够迅速获取数据,使得程序能够快速运行 。

然而,如果 CPU 在 Cache 中没有找到所需的数据,这就是缓存未命中 。缓存未命中的情况相对复杂,对 CPU 的性能影响也较大 。当缓存未命中时,CPU 不得不从速度较慢的内存中读取数据 。这就像你在自己的书架上没找到想要的书,只能去图书馆的书库中寻找,这无疑会花费更多的时间 。

在从内存读取数据的过程中,CPU 需要等待内存返回数据,这个等待时间可能会包含多个时钟周期 。而且,在从内存读取数据的同时,为了提高后续数据访问的命中率,CPU 会将读取到的数据以及该数据周围的一部分数据(以 Cache Line 为单位,后面会详细介绍)存入 Cache 中 。

如果此时 Cache 已满,还需要采用一定的替换算法(如最近最少使用 LRU 算法等),将 Cache 中不太常用的数据替换出去,为新的数据腾出空间 。例如,在一个处理大数据集的程序中,如果数据量超过了 Cache 的容量,就很容易出现缓存未命中的情况 。每次缓存未命中都需要 CPU 从内存读取数据,这会导致程序的运行速度明显下降,因为内存访问的延迟相对较高 。

缓存命中率是衡量 Cache 性能的重要指标,它表示缓存命中次数在总访问次数中所占的比例 。缓存命中率越高,说明 CPU 从 Cache 中获取数据的次数越多,也就意味着 CPU 等待数据的时间越短,计算机系统的性能也就越好 。因此,在计算机系统设计和程序优化中,提高缓存命中率是一个重要的目标 。通过合理地利用局部性原理、优化数据访问模式以及选择合适的 Cache 大小和替换算法等方法,可以有效地提高缓存命中率,减少缓存未命中的次数,从而提升计算机系统的整体性能 。

3.2Cache Line

Cache Line 是 CPU Cache 中的一个重要概念,它是 CPU 与内存之间进行数据传输的最小单位 。简单来说,当 CPU 需要从内存读取数据到 Cache 时,并不是以单个字节为单位进行读取,而是一次性读取一个固定大小的数据块,这个数据块就是一个 Cache Line 。

Cache Line 的大小通常是固定的,常见的 Cache Line 大小有 32 字节、64 字节等 。不同的 CPU 架构可能会有不同的 Cache Line 大小 。例如,在许多现代 x86 架构的 CPU 中,Cache Line 的大小一般为 64 字节 。Cache Line 的存在主要是为了利用空间局部性原理,提高数据读取的效率 。当 CPU 访问某个内存地址时,虽然它只需要该地址处的一个数据,但由于空间局部性,与该地址相邻的数据很可能也会被访问 。因此,将该地址所在的一整段数据(即一个 Cache Line)都读取到 Cache 中,可以减少后续数据访问时的缓存未命中次数 。

举个例子,假设有一个包含 100 个整数的数组,每个整数占 4 字节,数组在内存中是连续存储的 。如果 Cache Line 大小为 64 字节,那么一个 Cache Line 可以容纳 16 个整数(64 字节 / 4 字节 = 16) 。当 CPU 访问数组中的第 1 个元素时,内存会将包含第 1 个元素以及其后面 15 个元素的这 64 字节数据作为一个 Cache Line 读取到 Cache 中 。这样,当 CPU 接下来访问数组中的第 2 个到第 16 个元素时,就可以直接从 Cache 中获取,而不需要再次访问内存,大大提高了数据访问的效率 。

Cache Line 的大小对程序性能有着重要的影响 。如果 Cache Line 过小,虽然每次读取的数据量少,读取速度可能会快一些,但由于不能充分利用空间局部性,可能会导致缓存未命中次数增加;如果 Cache Line 过大,虽然能更好地利用空间局部性,减少缓存未命中次数,但每次读取的数据量过多,可能会导致 Cache 的利用率降低,而且读取时间也可能会变长 。因此,在设计 CPU Cache 时,需要综合考虑各种因素,选择合适的 Cache Line 大小,以达到最佳的性能 。

在编写程序时,了解 Cache Line 的概念也有助于优化程序性能 。例如,在处理数组时,可以通过合理地组织数据结构和访问顺序,使得数据访问能够更好地利用 Cache Line,提高缓存命中率 。比如,按行访问二维数组通常比按列访问更能利用 Cache Line,因为二维数组在内存中是按行存储的,按行访问时相邻元素更有可能在同一个 Cache Line 中 。

三、CPU Cache的数据写入策略

当 CPU 对 Cache 进行写操作时,为了确保 Cache 和内存之间的数据一致性以及提高系统性能,会采用不同的数据写入策略,其中最常见的是直写(Write Through)和写回(Write Back)策略 。

3.1直写(Write Through)

直写,也被称为写直通或写穿透 。其操作逻辑非常直观:当 CPU 要写入数据时,数据会同时被写入 Cache 和内存 。也就是说,只要有写操作发生,Cache 和内存中的数据都会立即被更新 。这就好比你在两个笔记本上同时记录同一件事情,一个笔记本是 Cache,另一个笔记本是内存 。

直写策略的优点是简单易懂,并且能够始终保证 Cache 和内存中的数据一致性 。因为每次写操作都同步更新了内存,所以在任何时刻,内存中的数据都是最新的 。这种一致性在一些对数据一致性要求极高的场景中非常重要,比如数据库系统中的关键数据存储 。在数据库事务处理中,需要确保数据的持久性和一致性,直写策略可以保证每次数据修改都能及时保存到内存中,避免了数据丢失或不一致的问题 。

然而,直写策略也存在明显的缺点 。由于每次写操作都要访问内存,而内存的访问速度相对较慢,这就导致了写操作的性能较低 。每次写操作都需要等待内存写入完成,这会增加 CPU 的等待时间,降低了系统的整体效率 。而且,频繁地访问内存还会增加总线的负载,因为每次写操作都需要通过总线与内存进行数据传输,可能会导致总线成为系统性能的瓶颈 。例如,在一个频繁进行数据写入的实时监控系统中,大量的写操作会使 CPU 花费大量时间等待内存写入,从而影响系统对其他任务的响应速度 。

3.2写回(Write Back)

写回策略的工作机制与直写策略有很大不同 。在写回策略中,当 CPU 进行写操作时,数据首先被写入 Cache,并不会立即写入内存 。只有当被修改的 Cache Line(缓存行,是 Cache 与内存之间数据交换的最小单位)要被替换出 Cache 时(比如 Cache 已满,需要腾出空间来存放新的数据),才会将其写回到内存中 。

为了实现这种延迟写入的机制,每个 Cache Line 都有一个脏标记位(Dirty Bit) 。当数据被写入 Cache 时,脏标记位会被设置,表示该 Cache Line 中的数据已经被修改,与内存中的数据不一致 。当 Cache Line 需要被替换时,系统会检查其脏标记位 。如果脏标记位被设置,说明该 Cache Line 中的数据是最新的,需要先将其写回到内存中,然后再进行替换操作;如果脏标记位未被设置,说明该 Cache Line 中的数据与内存中的数据一致,直接进行替换即可 。

写回策略的主要优点是性能较高 。由于大部分写操作只需要在 Cache 中进行,不需要频繁地访问内存,减少了内存访问的次数,从而提高了系统的整体性能 。这种策略尤其适用于那些存在大量写操作的应用场景,比如图形渲染、视频编码等 。在图形渲染过程中,GPU 会对大量的图形数据进行处理和修改,采用写回策略可以减少数据写入内存的次数,加快渲染速度 。

与直写策略相比,写回策略在性能上有明显的优势 。直写策略每次写操作都要访问内存,而写回策略只有在 Cache Line 被替换时才会访问内存,大大减少了内存访问的频率 。但是,写回策略的实现复杂度较高,因为它需要额外维护脏标记位,并且在 Cache Line 替换时需要进行更多的判断和操作 。

同时,由于数据不是立即写入内存,在系统突然断电或崩溃的情况下,可能会导致 Cache 中已修改但未写回内存的数据丢失,从而出现数据不一致的问题 。为了应对这种风险,一些系统会采用写缓冲区(Write Buffer)或日志记录(Logging)等机制来保证数据的一致性 。写缓冲区可以在数据写回内存之前临时存储数据,即使系统崩溃,也可以从写缓冲区中恢复数据;日志记录则可以记录数据的修改操作,以便在系统恢复时进行数据的一致性恢复 。

四、多核时代的挑战:缓存一致性问题

当 CPU 对 Cache 进行写操作时,为了确保 Cache 和内存之间的数据一致性以及提高系统性能,会采用不同的数据写入策略,其中最常见的是直写(Write Through)和写回(Write Back)策略 。

4.1缓存一致性问题的产生

在多核 CPU 的时代,每个 CPU 核心都拥有自己独立的缓存,这虽然进一步提高了数据访问的速度,但也带来了一个新的棘手问题 —— 缓存一致性问题 。

想象一下,有一个多核心的 CPU,其中核心 A 和核心 B 都需要访问内存中的同一个数据 X 。一开始,数据 X 被加载到核心 A 和核心 B 各自的缓存中 。当核心 A 对缓存中的数据 X 进行修改时,此时核心 A 缓存中的数据 X 已经更新,但核心 B 缓存中的数据 X 仍然是旧的 。如果在这个时候,核心 B 读取自己缓存中的数据 X,就会得到一个错误的、过时的值,这就导致了数据不一致的情况 。

以一个简单的银行转账程序为例,假设有两个线程分别在不同的 CPU 核心上执行转账操作 。这两个线程都需要读取账户余额,然后进行相应的加减操作 。如果存在缓存一致性问题,就可能出现这样的情况:第一个线程读取了账户余额为 1000 元,然后在自己的缓存中进行了减 100 元的操作,但还没有将更新后的数据写回内存 。

此时,第二个线程从自己的缓存中读取账户余额,由于其缓存中的数据没有更新,仍然是 1000 元,然后也进行了减 100 元的操作 。最后,两个线程都将各自缓存中的数据写回内存,结果账户余额就变成了 800 元,而实际上应该是 900 元 。这种数据不一致的情况会对程序的正确性产生严重影响,尤其是在涉及到金融、数据库等对数据准确性要求极高的领域 。

4.2解决缓存一致性的方案

要解决缓存一致性问题,首先要解决的是多个 CPU 核心之间的数据传播问题。最常见的一种解决方案呢,叫作总线嗅探。

⑴总线嗅探Bus Snooping

总线嗅探是一种解决缓存一致性问题的基本机制 。在这种机制下,每个 CPU 核心都通过总线与内存相连,并且每个核心都可以 “嗅探” 总线上的通信 。当一个 CPU 核心对自己缓存中的数据进行写操作时,它会向总线发送一个写请求,这个请求包含了被修改数据的地址等信息 。

总线上的其他 CPU 核心会监听这个请求,当它们发现请求中的地址与自己缓存中某数据的地址相同时,就会根据请求的类型(例如是写操作还是使缓存失效的操作),对自己缓存中的数据进行相应的处理 。如果是写操作,其他核心可能会选择将自己缓存中的该数据标记为无效,这样下次访问时就会从内存中重新读取最新的数据;如果是使缓存失效的操作,直接将对应缓存数据标记为无效即可 。

总线嗅探的优点是实现相对简单,不需要复杂的目录结构来记录缓存状态 。它能够快速地检测到其他核心对共享数据的修改,从而及时更新自己的缓存,保证数据的一致性 。然而,它也存在一些明显的缺点 。随着 CPU 核心数量的增加,总线上的通信量会大幅增加,因为每次写操作都要通过总线广播通知其他核心,这会导致总线带宽成为瓶颈,降低系统的整体性能 。而且,总线嗅探机制在处理复杂的多处理器系统时,可能会出现一些一致性问题,例如在多个核心同时进行读写操作时,可能会出现数据更新顺序不一致的情况 。

⑵MESI 协议

MESI 协议是一种广泛应用的缓存一致性协议,它是对总线嗅探机制的进一步优化和完善 。MESI 代表了缓存行的四种状态:Modified(已修改)、Exclusive(独占)、Shared(共享)和 Invalid(无效) 。

  • Modified(已修改,M):当一个 CPU 核心对缓存中的数据进行修改后,该数据所在的缓存行状态变为 M 。此时,缓存中的数据与内存中的数据不一致,并且只有这个核心拥有该数据的最新副本 。在该缓存行被写回内存之前,其他核心如果要访问这个数据,必须先等待该核心将数据写回内存 。

  • Exclusive(独占,E):当一个 CPU 核心从内存中读取数据到缓存时,如果其他核心的缓存中没有该数据的副本,那么该缓存行处于 E 状态 。此时,缓存中的数据与内存中的数据一致,并且这个核心独占该数据 。如果有其他核心也读取这个数据,该缓存行状态会变为 S 。

  • Shared(共享,S):当多个 CPU 核心的缓存中都存在同一个数据的副本时,这些缓存行都处于 S 状态 。此时,缓存中的数据与内存中的数据一致,各个核心都可以同时读取该数据,但如果有一个核心要对数据进行写操作,就需要先将其他核心缓存中该数据的副本失效,然后才能进行修改,修改后缓存行状态变为 M 。

  • Invalid(无效,I):当一个缓存行中的数据被其他核心修改,或者该缓存行被替换出缓存时,它的状态就变为 I 。处于 I 状态的缓存行中的数据是无效的,不能被使用 。

MESI协议中的运行机制

假设有三个CPU A、B、C,对应三个缓存分别是cache a、b、 c。在主内存中定义了x的引用值为0。

图片

①单核读取,那么执行流程是:CPU A发出了一条指令,从主内存中读取x。从主内存通过bus读取到缓存中(远端读取Remote read),这是该Cache line修改为E状态(独享)。

图片

②双核读取,那么执行流程是:

  1. CPU A发出了一条指令,从主内存中读取x。

  2. CPU A从主内存通过bus读取到 cache a中并将该cache line 设置为E状态。

  3. CPU B发出了一条指令,从主内存中读取x。

  4. CPU B试图从主内存中读取x时,CPU A检测到了地址冲突。这时CPU A对相关数据做出响应。此时x 存储于cache a和cache b中,x在chche a和cache b中都被设置为S状态(共享)。

图片

③修改数据,那么执行流程是:

  1. CPU A 计算完成后发指令需要修改x.

  2. CPU A 将x设置为M状态(修改)并通知缓存了x的CPU B, CPU B将本地cache b中的x设置为I状态(无效)

  3. CPU A 对x进行赋值。

图片

④同步数据,那么执行流程是:

  1. CPU B 发出了要读取x的指令。

  2. CPU B 通知CPU A,CPU A将修改后的数据同步到主内存时cache a 修改为E(独享)

  3. CPU A同步CPU B的x,将cache a和同步后cache b中的x设置为S状态(共享)。

图片

MESI 协议通过状态转换机制来保证缓存一致性 。例如,当一个处于 S 状态的缓存行接收到一个写请求时,拥有该缓存行的核心会向总线发送一个 Invalidate 消息,通知其他核心将它们缓存中该数据的副本失效 。其他核心收到这个消息后,将自己缓存中对应的缓存行状态变为 I,并返回一个 Invalidate Acknowledge 消息 。当发起写请求的核心收到所有其他核心的确认消息后,它就可以将自己缓存中的数据修改,并将缓存行状态变为 M 。

MESI 协议的优势在于它能够有效地减少总线带宽的占用 。通过状态转换机制,只有在必要时才会进行总线通信,例如当一个核心要修改共享数据时才会通知其他核心使缓存失效,而不是像总线嗅探那样每次写操作都进行广播 。这大大提高了系统在多核环境下的性能和可扩展性 。同时,MESI 协议还能很好地保证数据的一致性,确保各个核心在任何时刻都能访问到正确的数据 。然而,MESI 协议的实现相对复杂,需要额外的硬件支持来维护缓存行的状态和进行状态转换 。而且,在一些极端情况下,例如大量核心同时进行读写操作时,MESI 协议的性能也会受到一定的影响 。

五、如何利用 CPU Cache 优化代码

5.1数据对齐

在计算机中,数据对齐是指将数据存储在内存地址是其自身大小整数倍的位置上 。比如,一个 4 字节的整数(如int类型),应该存储在地址能被 4 整除的地方;一个 8 字节的双精度浮点数(如double类型),应该存储在地址能被 8 整除的位置 。

数据对齐对 CPU Cache 访问效率有着重要的影响 。现代 CPU 在访问内存时,是以 Cache Line 为单位进行数据读取和写入的,常见的 Cache Line 大小为 64 字节 。当数据对齐时,它们更有可能完整地位于同一个 Cache Line 内 。例如,有一个包含多个int类型元素的数组,每个int占 4 字节 。如果数组元素是对齐存储的,那么连续的 16 个int元素就可以刚好存放在一个 64 字节的 Cache Line 中 。当 CPU 访问这个数组的某个元素时,就可以一次性将包含该元素以及相邻 15 个元素的 Cache Line 读入 Cache,后续访问相邻元素时就很可能直接从 Cache 中命中,大大提高了访问效率 。

相反,如果数据没有对齐,就可能出现一个数据跨越两个 Cache Line 的情况 。比如,一个int类型的数据本该存储在地址为 4 的倍数的位置,但却存储在了一个非 4 倍数的地址上,这就可能导致它的一部分在一个 Cache Line 中,另一部分在另一个 Cache Line 中 。当 CPU 访问这个数据时,就需要读取两个 Cache Line,增加了内存访问次数和时间,降低了 Cache 命中率和访问效率 。

下面通过一个简单的 C 语言代码示例来展示数据对齐前后的性能差异 。我们定义一个结构体,分别测试对齐和未对齐情况下对结构体数组的访问速度 。

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <time.h>

// 未对齐的结构体
struct UnalignedStruct {
    char a;
    int b;
    char c;
};

// 对齐的结构体,使用__attribute__((aligned(4)))确保结构体按4字节对齐
struct __attribute__((aligned(4))) AlignedStruct {
    char a;
    int b;
    char c;
};

// 测试函数,计算对结构体数组的访问时间
void testAccessTime(void *arr, size_t numElements, size_t structSize) {
    clock_t start, end;
    double cpu_time_used;
    int i;

    start = clock();
    for (i = 0; i < numElements; i++) {
        // 模拟对结构体成员的访问
        char *ptr = (char *)arr + i * structSize;
        char temp = *((char *)ptr);
        temp = *((int *)(ptr + 1));
        temp = *((char *)(ptr + 5));
    }
    end = clock();

    cpu_time_used = ((double) (end - start)) / CLOCKS_PER_SEC;
    printf("Access time: %f seconds\n", cpu_time_used);
}

int main() {
    size_t numElements = 1000000;
    struct UnalignedStruct *unalignedArr = (struct UnalignedStruct *)malloc(numElements * sizeof(struct UnalignedStruct));
    struct AlignedStruct *alignedArr = (struct AlignedStruct *)malloc(numElements * sizeof(struct AlignedStruct));

    if (unalignedArr == NULL || alignedArr == NULL) {
        perror("malloc failed");
        return 1;
    }

    printf("Testing unaligned struct:\n");
    testAccessTime(unalignedArr, numElements, sizeof(struct UnalignedStruct));

    printf("Testing aligned struct:\n");
    testAccessTime(alignedArr, numElements, sizeof(struct AlignedStruct));

    free(unalignedArr);
    free(alignedArr);

    return 0;
}

在这个示例中,UnalignedStruct是未对齐的结构体,AlignedStruct是通过__attribute__((aligned(4)))进行了 4 字节对齐的结构体 。testAccessTime函数用于测试对结构体数组的访问时间 。通过运行这个程序,可以发现对齐后的结构体数组访问时间明显比未对齐的要短,这直观地展示了数据对齐对 CPU Cache 访问效率和程序性能的提升作用 。在实际编程中,尤其是在对性能要求较高的场景下,合理地进行数据对齐是优化程序性能的重要手段之一 。

5.2循环优化

循环结构在程序中非常常见,它对 CPU Cache 命中率有着显著的影响 。当循环中的数据访问模式不合理时,很容易导致 Cache 未命中次数增加,从而降低程序的执行效率 。以下是一些优化循环以提高 CPU Cache 命中率的方法和建议 。

减少循环嵌套深度:嵌套循环会增加数据访问的复杂性,容易导致 Cache 命中率下降 。

例如,有一个双重嵌套循环:

for (int i = 0; i < N; i++) {
    for (int j = 0; j < M; j++) {
        // 访问数组a[i][j]
        data[i][j] = data[i][j] * 2;
    }
}

在这个例子中,如果N和M都比较大,那么在访问data[i][j]时,由于数组元素在内存中的存储顺序是按行优先的,当内层循环j不断变化时,可能会频繁地访问不同的 Cache Line,导致 Cache 未命中次数增多 。可以尝试将一些计算逻辑提取到外层循环,减少内层循环的复杂性,从而减少 Cache 未命中 。比如,如果内层循环中有一些与j无关的计算,可以将其移到外层循环:

// 假设存在一些与j无关的计算,这里简化为一个常量计算
int constant = someComplexCalculation();
for (int i = 0; i < N; i++) {
    // 与j无关的计算结果在每次i循环中可以复用
    int temp = someCalculationBasedOnI(i, constant);
    for (int j = 0; j < M; j++) {
        // 利用与j无关的计算结果进行内层循环操作
        data[i][j] = temp * data[i][j];
    }
}

合理安排循环顺序:根据数据在内存中的存储方式和访问的局部性原理,合理安排循环顺序可以提高 Cache 命中率 。以二维数组为例,二维数组在内存中是按行存储的 。

如果按行优先的顺序访问二维数组,更能利用空间局部性原理 。比如:

// 按行优先访问
for (int i = 0; i < ROWS; i++) {
    for (int j = 0; j < COLS; j++) {
        sum += matrix[i][j];
    }
}

这种访问方式下,当访问matrix[i][j]时,由于空间局部性,matrix[i][j + 1]很可能也在同一个 Cache Line 中,从而提高了 Cache 命中率 。而如果按列优先访问:

// 按列优先访问
for (int j = 0; j < COLS; j++) {
    for (int i = 0; i < ROWS; i++) {
        sum += matrix[i][j];
    }
}

在这种情况下,每次访问matrix[i][j]时,下一个访问的matrix[i + 1][j]很可能在不同的 Cache Line 中,导致Cache未命中次数增加,降低了访问效率 。所以,在大多数情况下,按行优先访问二维数组能更好地利用 CPU Cache,提高程序性能 。

在编写程序时,充分考虑循环结构对 CPU Cache 命中率的影响,并运用上述优化方法,可以显著提高程序的运行效率 。尤其是在处理大规模数据和对性能要求苛刻的应用场景中,循环优化是提升程序性能的关键环节之一 。


http://www.kler.cn/a/531903.html

相关文章:

  • 华为云kubernetes部署deepseek r1、ollama和open-webui(已踩过坑)
  • 手写MVVM框架-实现简单的数据代理
  • 4 前端前置技术(上):AJAX技术(前端发送请求)
  • 洛谷 P11626 题解
  • DeepSeek-R1:通过强化学习激励大型语言模型(LLMs)的推理能力
  • 优化代码性能:利用CPU缓存原理
  • 人工智能学习(五)之机器学习逻辑回归算法
  • DeepSeek-R1 本地部署教程(超简版)
  • SwiftUI 在 Xcode 预览修改视图 FetchedResults 对象的属性时为什么会崩溃?
  • DRM系列七:Drm之CREATE_DUMB
  • C++(进阶) 第8章unordered_map和unordered_set的使⽤(哈希)
  • 基于STM32景区环境监测系统的设计与实现(论文+源码)
  • 分布式数据库架构与实践:原理、设计与优化
  • 3 卷积神经网络CNN
  • ES6 入门教程:箭头函数、解构赋值及其他新特性详解
  • Gurobi求解旅行商问题的官方例程
  • 计网week3
  • 【LeetCode 刷题】回溯算法(5)-棋盘问题
  • Linux线程 —— 生产消费模型
  • 存储器知识点3
  • 优选算法的灵动之章:双指针专题(一)
  • 算法设计-0-1背包动态规划(C++)
  • 4.[ISITDTU 2019]EasyPHP
  • Nginx笔记220825
  • 机器学习day7
  • 红黑树的封装