《What every programmer should know about memory》-CPU Caches译

Posted fanchenxinok

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了《What every programmer should know about memory》-CPU Caches译相关的知识,希望对你有一定的参考价值。

原文PDF: http://futuretech.blinkenlights.nl/misc/cpumemory.pdf

一二章参考博文:每个程序员都应该了解的内存知识【第一部分】 - OSCHINA - 中文开源技术交流社区

目的在边学习边翻译让自己理解的更加深刻。

3. CPU缓存

       如今的cpu比25年前的更加复杂。以前的cpu核的频率和内存总线是同一级别的。内存访问只比寄存器访问慢一点点。但是90年代早期这种情况发生了巨大变化,cpu设计者提高了cpu的频率但是内存总线的频率和RAM芯片的性能并没有相应的提高。并不是说不能构建出更快的RAM而是出于经济的考虑,因为和CPU核一样快的RAM比DRAM贵几个数量级。

       一个机器具有一个很小且速度很快的RAM,另一个机器具有多个相对较快的RAM,在处理超出小RAM范围的任务和考虑到访问辅助存储媒介(如硬盘驱动器)的成本时第二个机器将是更好的选择。这儿的问题是辅助存储媒介(通常是硬盘用来存储额外的工作集数据)的速度,访问这些介质比访问DRAM慢几个数量级。

        幸运的是并不需要做一个孤注一掷的抉择。计算机除了大量的DRAM也可以拥有少量的小的高速的SRAM。一个可能的方案是处理器指定某部分地址空间给SRAM,剩余的指定给DRAM。操作系统任务将会优化分配数据反问策略来使用SRAM。通常SRAM可作为处理器寄存器集的扩展。

        尽管这方案看似可以但并不可行。首先,忽略要将SRAM的物理内存地址映射到进程的虚拟地址的问题,这要求每个进程都有管理内存区域的分配。内存区域大小因进程而异。构成程序的每个模块需要共享快速内存,进程间同步将引入额外的开销。简而言之,拥有快速内存的好处将会被系统管理资源的开销所抵消掉。

        因此,SRAM是作为CPU自动使用和管理的一个资源,而不要由OS或者用户来管理。在这种模式下,SRAM作为主内存中将要被处理器使用的数据的一份临时的拷贝。这是可行的因为程序代码和数据具有时空局限性。这意味着,短时间内同样的代码和数据将有机会被重复使用。比如在一个循环中同样的代码被重复的执行,数据的访问也被限制在一个小的内存区间内。即使程序使用的地址空间不连续,短期内同样的数据被程序使用的概率很大。例如,在程序上的表现为,在一个循环中执行一个函数调用,而该函数位于地址空间的其他位置。该函数在内存中可能相差很远,但是对该函数的调用在时间上差不大。在数据上表现为,这意味着一次使用的内存总量在理想情况下是有限的,但是由于RAM的随机访问特性,所使用的内存并不是连续的。意识到局部性的存在是我们今天使用CPU缓存概念的关键。

        先用一个简单的计算来说明缓存在理论上有多高效。假设访问主存需要200个时钟周期,访问高速缓存需要15个周期。如果没有缓存,那么使用100个数据元素各100次的代码将在内存操作上花费2,000,000个周期,如果所有数据都被缓存,则仅花费168,500个周期,提高了91.5%。

        用于缓存的SRAM的大小比主存小好几倍。以作者在工作中使用CPU缓存的经验看来,缓存的大小一般是主存大小的千分之一(如今:4MB缓存和4GB主存)。如果工作区间的内存大小比缓存小就无所谓了,这本身也并不构成问题。但是系统必定有个大的主存,工作区间使用内存的大小必定会比缓存大,尤其是运行多进程的系统,工作区间使用内存的大小是每个进程和内核的总和。

        处理好缓存的局限性需要一系列好的策略来决定在任何时候什么该缓存。由于并不是工作区间使用的所有数据都在同一时刻被使用,我们可以利用技术手段将需要使用的数据临时替换缓存中的未被使用的数据。这样预取操作将减少了访问主存时的开销,因为它和相应程序的执行是异步的。所有的这些技术使得缓存的大小看起来比实际大。我们将在3.3节讨论。一旦利用这些技术,就由程序员来协助处理器了,怎么实现将在第6章讨论。

3.1 CPU 缓存概览

        在我们深入研究CPU缓存的技术实现细节之前,一些读者可能发现首先了解下缓存如何在现代计算机系统中实现的一些细节是非常有帮助的。

                                                             图3.1:最小缓存配置

          图3.1展示了最小缓存系统的布局。早期系统采用这种CPU缓存架构。CPU核不再直接和主存连接,所有的存储和读取都通过缓存进行。CPU核和缓存之间的连接是快速连接。为了简化表达,主存和缓存都连接到总线上,总线可以和其他系统组件通信。我们把系统总线称为“FSB”,这个概念沿用至今,参考第2.2节。在这一节中我们忽略北桥,假设它的存在是促进CPU和主存的通信。

        尽管过去的数十年,大多数计算机使用冯诺依曼体系结构,经验表明将指令和数据使用的缓存独立开是有优势的。因特尔从1993年开始就将指令和数据缓存分开,这样指令和数据需要的内存区域更加独立。近年来,又出现了另外一个优势:对于多数处理器指令译码的速度慢,缓存译码指令可以加快指令的执行,尤其是当管道由于错误的预测为空时。

        在引入缓存后系统更加复杂了。主存和缓存的速度有明显的区别,增加了另一级缓存,它比一级缓存大且慢。仅仅增加一级缓存的大小出于经济的考虑是不可取的。如今,有的机器通常使用三级缓存,这样的系统如图3.2。随着单CPU中核的数量的增加,未来可能出现更高层级的缓存。

        

                                                      图3.2   三级缓存处理器

        图3.2展示了三级缓存,并引入了几个将在后面的文章中使用的术语。L1d是一级数据缓存,L1i是一级指令缓存等。注意这是一个示意图,真实的情况数据流从CPU核到主存不需要经过任何高级缓存。CPU设计者在设计缓存接口的时候有很大的自由度,对于程序开发者而言这些设计是不可见的。

      另外,处理器有多核,一个核可以拥有多个线程。核和线程之间的区别是不同的核对硬件资源有单独的一份拷贝,核可以完全独立的运行除非它们使用共同的资源。线程共享几乎所有的处理器资源。英特尔对线程的实现仅仅拥有独立的寄存器但也是有限的,有些寄存器也是共享的。现代CPU模型参考图3.3。

                                                    图3.3   多处理器,多核,多线程

        图3.3中有两个处理器,每个处理器有两个核,每个核有两个线程。线程共享一级缓存。每个核拥有独立的一级缓存,所有的CPU核共享更高级的缓存。两个处理器不共享任何的缓存。这些概念比较重要尤其是对后面讨论的缓存对多处理器和多线程应用程序的影响。

3.2 高级缓存操作

        为了理解使用缓存的开销和效率,我们必须将第2节中关于机器架构和RAM技术的知识与前一节中描述的缓存结构结合起来。

        默认情况下,由CPU核读取和写入的所有数据都存储在缓存中。有些内存区域不能被缓存但这是只有OS实现者才去考虑的事情,对于应用程序开发者不需要考虑。还有些指令运行程序员故意绕过某些缓存。这将在第6章讲述。

        如果CPU需要某个数据字(word)首先在缓存中搜索,很明显缓存不可能容纳整个主存中的所有内容否则也就不需要缓存了。但是由于所有的内存地址都是可缓存的,每个缓存条目在主存中都是通过数据字的地址进行标记的。通过这种方式,对地址的读写请求可以在缓存中搜索匹配的标记。这个上下文中的地址可以是虚拟地址,也可以是物理地址,随缓存实现的不同而不同。

       由于标记也需要额外的内存,所以用一个字作为缓存的粒度是低效的。对于X86机器标记一个32位的字就需要32位甚至更多位来表示。另一方面,空间局部性是缓存基本的原则之一,因此需要考虑这个问题。由于相邻的内存很可能被同时使用,因此应该将它们一起加载进缓存。记得2.2.1节我们所学的内容:RAM模式在没有新的CAS和RAS信号时以行传输更多的数据字将会更高效。所以存储在缓存的条目是以行为粒度的多个连续的数据字。在早期的缓存中,这些行是以32字节为长度,现在通常是64字节。如果内存总线是64位(8字节)宽意味着每个缓存行有8次传输,DDR对这种传输模式的支持更高效。

        当处理器需要内存中的某块内容时,整个缓存行被加载到L1d。每个缓存行的内存地址是由高速缓存行的大小和内存地址值进行掩码计算得到。对于64字节的缓存行意味着低6位被置0(2^6 = 64),这6位用来表示在一个缓存行里的偏移。剩下的字节有些情况被用来定位该行在缓存的位置和作为一个标签。在实践中,地址值被分为3个部分。对于32位的地址可能如下划分:

低O位被用来作为缓存行的行内偏移。S位用来选择缓存组(理解为一个缓存组包括很多缓存行)。现在能够理解为该缓存中有2^S个缓存组。剩下的T位构成了标签。这些标签位用来区分同一个缓存组里不同的缓存行。同一个缓存组的不同缓存行的S值是相同的所以不需要存储。

        当一条指令需要修改内存时,虽然没有一条指令同时修改一整个缓存行的内容,处理器任然需要加载一个缓存行。缓存行的内容必须在写入操作之前被加载。缓存不可能只持有部分缓存行的内容,如果一个缓存行已经被写入但是没有更新到主存中,缓存行标记为脏的,一旦写入主存就将脏的标记清除。

        为了能够加载新的数据到缓存中需要在缓存中为其开辟空间。如果一级缓存空间不足,将L1d缓存行内容向下驱逐到2级缓存,同样2级也可以向下驱逐到3级缓存最后存储到主存中。每次驱逐的代价都越来越大,这里描述的是现代AMD和VIA处理器首选的独占缓存模型。Intel实现了包容性缓存 L1d中的每条缓存行在L2中也都有。因此从L1d驱逐更快速。拥有足够大的2级缓存在两个地方存储同样内容的资源浪费的劣势将减小并且在驱逐的时候得到益处。独占式缓存可能的优势是加载一个新的缓存行仅仅需要和L1d接触,不需要和L2接触,因此会更快。

        只要为处理器架构设定的内存模型没有改变,允许CPU用它们喜欢的模式管理缓存。举例说明,处理器利用很少或没有内存总线活动的时机将脏的缓存行内容写入主存中是很不错的选择。x86和x86-64处理器之间、制造商之间、甚至同一制造商的模型内部的各种缓存体系结构都证明了内存模型抽象的强大功能。

        对称多处理器系统(SMP)中,CPU的缓存不能彼此独立的工作。所有的处理器都应该在任何时候看到相同的内容。维持这个内存的一致性称为“缓存一致性”。如果一个处理器只是查看自己的缓存,主存将看不到其他处理器的脏缓存行。提供一个处理器直接访问另一个处理器的缓存将是非常昂贵且有巨大的瓶颈。取代的方案是,处理器检测另一个处理器何时想要读或写某个缓存行。

        如果检测到一个写请求,如果处理器在它的缓存中有这个缓存行的一个干净的副本,这个缓存行被标记为无效。将来的引用需要重新加载这个缓存行。其他处理器的读操作不需要标记无效,可以有多个干净的拷贝。

        复杂的缓存机制可能发生另一种情况。假设一个缓存行在一个处理器的缓存中是脏的,第二个处理器想要读写这个缓存行,这种情况下,主存中的内容还是旧的,第二个处理器必须从第一个处理器那获取缓存行的内容。第一个处理器注意到这种情况自动将数据传输给请求的处理器。这个过程绕过了主存,尽管在一些实现机制中内存控制器应该注意到这次直接传输并更新缓存行内容到主存中。如果请求是需要写入第一个处理器的缓存,那么本地的缓存行的拷贝将会被设置为无效。

        随着时间的推移,大量的缓存一致性协议被开发出来。最重要的是MESI协议我们将在3.4小节讲述。所有的这些可以总结以下几点简单的规则:

  •  一个脏缓存行不会出现在其他处理器中。
  •  同一缓存行的干净副本可以驻留在任意多个缓存中。

        如果支持这些规则,就算是多处理系统也可以高效的使用缓存。所有的处理器需要做的是监控其他处理器的写请求并且把地址和本地的缓存进行比较。在下一节中我们将详细讲述实现细节尤其是开销细节。

        最后,我们至少应该了解下缓存命中和脱靶时的开销。以下是英特尔奔腾M的数据:

To Where周期
Register
L1d
L2
Main Memory
≤ 1
∼ 3
∼ 14
∼ 240

这是以CPU时钟周期为单位的实际访问时间。
       表中的数字看起来很大,但幸运的是,不必为每次缓存加载和脱靶付出全部的代价。一部分的开销可以被抵消。如今的处理器都使用不同长度的内部管线,在内部管线内指令被译码和预执行。如果他们被传输到寄存器部分准备工作是从内存或缓存中加载数据。如果内存加载操作能够在管线中尽早的开始,就可以和其他操作并行执行,这样整个加载开销可以忽略。这对于L1d通常是可能的;对于一些具有长的L2管道的处理器也是如此。

        提早开始内存读取有很多的障碍。这可能是由于没有足够的内存访问资源,也可能最终的加载地址是由另一条指令的执行结果决定。在这种情况下,加载的开销就不能忽略了。

        对于写操作,CPU不必等到数据被安全地存储在内存中。只要下列指令的执行似乎与数据存储在内存中的效果相同,就没有什么可以阻止CPU走捷径。它可以提前开始执行下一条指令。在影子寄存器(它可以保存对常规寄存器中不可用的值)的帮助下,可以更改要存储的不完整的写操作中的值。

                                                         图3.4  随机写的访问时间

图3.4举例说明了缓存机制带来的影响。稍后我们将讨论一个数据生成的程序,这个简单的仿真程序可以以随机的方式重复的访问一个可配置大小的内存。每个数据条目有固定的大小。元素的数量依赖于选的数据集大小。Y轴是处理一个元素平均的CPU周期;注意到Y轴是以对数形式划分的。X轴是工作数据集的大小。

        图表显示了三个不同的阶层。这并不奇怪:这个处理器有L1d和L2缓存但是没有L3。依靠经验我们可以推断一级缓存有2^13字节,二级缓存有2^20字节大小。如果整个工作数据集在一级缓存大小的范围内,每个元素的访问时间将控制在10个时钟周期以内。一旦超出了L1d的范围处理器就需要从L2二级缓存加载数据,平均时间消耗上升到28个周期左右。一旦二级缓存也满足不了时间将攀升到了480个周期甚至更多。这时候大多数操作需要从主存加载数据。更糟糕的情况是:一旦有数据被修改,脏缓存行需要写回主存。

        这个图表应该能够给我们足够的动机深入探究代码的优化来提升缓存的使用情况。我们这里不是讨论几个百分点的区别,讨论有时是几个数量级提升的区别。在第6章中我们将讨论怎么写出更好更有效的代码。下一节继续深入研究CPU缓存的设计细节。

3.3 CPU缓存实现细节

       缓存实现者面临的问题是,巨大主内存中的每个单元都可能需要缓存。如果一个程序的工作集足够大,这意味着主内存中的许多条目要争夺缓存中的位置。前面提到过,缓存与主内存大小的比例为1比1000是比较常见的。

        3.3.1 关联性

        我们可以实现一个缓存的每个缓存行保存内存中任何位置的一个数据,这就是所谓的全关联缓存。为了访问一个缓存行需要将请求的地址标签和缓存的每个缓存行的标签进行比较。标签由整个地址组成不包括缓存行的偏移,也就是说上文提到的S为0。

        有些缓存是这样实现的,但是,通过查看今天使用的L2的数目,将表明这是不切实际的。给定一个4M的缓存,拥有64字节的缓存行,缓存的条目将有65536条。为了获得足够的性能,缓存逻辑必须能够在几个周期内从所有这些条目中选择与给定标记匹配的条目,实现这个的工作量是巨大的。

                                                            图3.5  全关联型缓存

        对于每个缓存行比较器需要比较一个长标签(因为S为0)。每个比较器需要比较T个位的值,然后比中的缓存行被选中。这需要合并尽可能多的O数据行,因为有缓存桶。实现一个比较器需要大量的晶体管,因为它必须非常快。在没有迭代比较器可用的情况下,节省比较器数量的惟一方法是通过迭代比较标记来减少比较器的数量。出于同样的原因,迭代比较器也不适合:它花费的时间太长。

        全关联缓存适用于小型缓存(例如,某些Intel处理器上的TLB缓存是完全关联的),但是这些缓存非常小。我们说的最多是几十个条目。对于L1i、L1d和更高级别的缓存,需要一种不同的方法。所能做的就是限制搜索。在最极端的限制中,每个标记只映射到一个缓存条目。计算很简单:给定4MB/64B缓存和65,536个条目,我们可以使用地址的第6位到第21位(16位)直接寻址每个条目。低6位是缓存行的索引。

        

                                                          图3.6  直接映射型缓存机制

        这种直接映射型缓存很快并且相对容易实现。 只需要一个比较器一个复用器(图中用了两个因为标签和数据分开,但这并不是硬性要求),一些逻辑只选择有效的缓存行内容,比较器比较复杂,因为有速度的要求,但现在只有一个。因此要在使比较器工作的更快速上下功夫。这种方法的真正复杂性在于多路复用器,一个简单多路复用器中的晶体管数量随O(log N)增长,N是缓存行的数量。这是可以容忍的,但可能会变慢,在这种情况下,可以通过在多路复用器上增加更多的晶体管,从而并行化一些工作并提高速度。晶体管的总数可以随着缓存大小的增长而缓慢增长,这使得这个解决方案非常有吸引力。但是它有一个缺点:只有当程序用于直接映射的地址位是均匀分布的时候,它才能很好地工作。如果不是这样,通常情况下,一些缓存条目会被大量使用,会被重复地清除,而其他的则几乎不被使用或保持为空。

                                                         图3.7    组相关缓存机制

这个问题可以使用组相关缓存机制得到解决。这种机制结合了全相关缓存机制和直接映射缓存机制的优势大大的避免了这些设计的缺陷。图3.7展示了组相关缓存机制的设计。标签和数据的存储被分为组,一组由多个缓存行组成。这类似于直接映射缓存。但是,缓存中的每个设定值只有一个元素,而缓存中的少量值用于相同的设定值。所有组成员的标签都是并行比较的,这与完全关联缓存的功能类似。

        其结果是缓存不容易被具有相同缓存行的组错误的或故意选择的地址击败,同时缓存的大小不受比较器数量的限制。如果缓存增长,它只是(在图中)增加的列数,而不是行数。只有当缓存的关联度增加时,行数(以及比较器)才会增加。现在的处理器对L2或更高的缓存使用最多24级的结合度。L1缓存通常需要8组数据。

                                            表3.1  缓存大小的影响,关联性,和行大小 

给定4MB和64位,8路组相关缓存,有8192个组标签中仅仅13位用于组地址寻找。决定缓存组中的哪个条目包含寻址的缓存行,必须比较8个标签。这可以在很短的时间内完成。

        表3.1展示了二级缓存缓存失效数量随着缓存大小,缓存行的大小和关联组大小的改变而变化。在7.2节我们将介绍一个工具来模拟测试中需要的缓存。这些值的关系是:缓存大小 = 缓存行大小 x 关联(一组多少个缓存行) x 组数量

O = log2 cache line size
S = log2 number of sets

                                                  图3.8  缓存大小 VS 关联性 (CL = 32) 

图3.8 使得表3.1的数据更直观。缓存行的大小固定为32字节,查看给定缓存大小的数字,我们可以看到,关联性确实有助于显著减少缓存脱靶的数量。对于8MB的缓存,从直接映射到2路组关联缓存几乎可以减少44%的缓存脱靶数量。与直接映射缓存相比,使用组关联缓存的处理器可以在缓存中保留更多的工作集。

        在文献中,我们偶尔会读到引入关联性与将缓存大小加倍具有相同的效果。在某些极端情况下确实如此,从4MB到8MB缓存的跳变就可以看出这一点,但对于关联性的进一步倍增,肯定不是这样的,正如我们从数据中看到的那样,连续的上涨脱靶率变化很小。然而,我们不应该完全忽视其影响。在示例程序中,峰值内存使用为5.6M。因此,对于一个8MB的缓存,对于相同的缓存集不太可能有很多(多于两个)的使用。对于一个较大的工作集,节省的空间可能会更大,这可以从较小的缓存大小带来的更大的相联性好处中看出。

        通常,将缓存的关联度提高到8以上对单线程工作负载的影响似乎很小。随着超线程处理器的引入,第一级缓存是共享的,多核处理器使用共享的L2缓存,情况发生了变化。现在,您基本上有两个程序在相同的缓存上,这导致了在实践中的关联性减半(或四核处理器的四分之一)。因此,可以预期,随着内核数量的增加,共享缓存的结合性应该会增加。一旦这种方法不可行(16组关联性已经很难了),处理器设计者就必须开始使用共享的L3缓存,而L2缓存则可能由核心的一个子集共享。

        图3.8中我们还可以研究缓存大小的增加如何影响性能。这些数据不能在不清楚工作集大小的情况下解读。很显然,与主存同样大小的缓存比小的缓存能获得更好的结果,所以缓存的大小通常是没有限制的。

        之前提到的工作集的最高峰是5.6M,这并不能告诉我们最大的有效的缓存的绝对大小,但是允许我们做个估计。问题是并不是所有的使用的内存都是连续的,因此即使是16M的缓存和5.6M的工作集也是有冲突的。但是可以肯定的是,在相同的工作负载下,32MB缓存的好处可以忽略不计。但谁说工作环境必须保持不变呢?工作负载随着时间的推移而增长,缓存的大小也应如此。在购买机器时,如果需要选择缓存的大小,有必要先衡量下工作集大小。

                                                              图3.9  测试内存分布

跑两种类型的测试。第一种测试按顺序处理元素。 测试程序跟随指针n但是数组元素是链接起来的所以他们在内存中是按照顺序遍历的,如图3.9的下半部分。第二种测试是随机遍历,如3.9图的上半部分。两种测试数组元素都是循环链表构成的。

3.3.1  缓存效果的衡量

        所有的图表都是通过测试一个可以模拟任意大小的工作集、读写访问、顺序访问或随机访问的程序来创建的。我们已经在图3.4看到一些结果了。程序创建的数组相应的工作集元素的类型如下:

struct l 
    struct l *n;
    long int pad[NPAD];
;

所有节点使用n元素链接成一个循环链表,或者是随机的或者是顺序的。 从一个节点前进到下一个节点总是使用指针,即使元素是按顺序排列的。pad元素是有效的载体可以设置的很大。在一些测试中修改数据,另一些测试仅仅只是读数据。

      关于性能的度量我们一直在讨论工作集的大小,工作集是由struct l 元素构成的数组,一个2^N字节的工作集包含

2^N / sizeof(struct l)个元素。显然,sizeof(struct l)的大小由NPAD的大小决定。对于32位的系统,NPAD=7意味着每个元素是32字节,而对于64位的系统则是64字节。

(1)单线程顺序访问

        最简单的测试用例是遍历链表的所有元素,链表元素是按顺序排列,密集排列,向前还是向后的顺序处理都无所谓。所有的一系列测试我们度量的是处理单个链表元素需要多长时间,时间单位是处理器周期。图3.10展示了测试结果。除非特别的指出,所有的测试都是在64位奔腾处理器4上做的,意味着当NPAD=0时结构体l大小为8个字节。

                                                               图3.10 顺序读取,NPAD=0

前两个测量值被噪声污染了。测量的工作负载太小,无法排除系统其余部分的影响。我们可以保守估计这些值在4个周期左右。考虑到这一点,我们可以从图中看到三个不同的层次:

  •   小于2^14字节大小
  •  从2^15字节到2^20字节大小
  •  2^21字节以上

产生这样的阶梯式的结果很容易解释: 处理器有16kB L1d和1MB L2,在从上一级到下一级缓存的转换中,我们看不到明显的边缘,因为系统的其他部分也使用缓存,所以缓存并不只对程序数据可用。具体来说,L2缓存是一个统一的缓存,也用于指令(注意:Intel使用的是包含性的缓存)。

        我们可能不能预计不同工作集大小所需要的确切时间。L1d命中的时间是可以预计的:在P4上大概是4个时钟周期。但是二级缓存的访问时间呢?一旦一级缓存没有足够空间保存数据,预计需要花费14个时钟周期甚至更多来处理每个元素,因为这是访问二级缓存的所需要的时间。但是图中的结果显示只需要9个时钟周期。这矛盾可以由处理器高级逻辑解释。在预期使用连续的内存区域时,处理器预取下一个高速缓存行。这意味着当实际使用下一行时,它已经加载了一半。因此,等待下一条高速缓存行加载所需的延迟远远小于L2访问时间。

        一旦工作集超过二级缓存的大小,预取得效果就更加显现出来了。之前我们提到主存的访问时间需要花费200+个时钟周期。只有通过有效的预取才可能将时间控制在9个周期以下。从200缩短到9这个效果是很明显的。

                                                      图3.11 不同大小元素的顺序读取 

        我们可以在预取得时候间接的观察处理器。图3.11我们可以看到结构体l大小不同时的结果,意味着我们的链表有更大或更小的节点。元素大小不同使得随着链表的增长每个元素之间的距离增大。在这四个case中每个元素之间的距离分别为0,56,120,248字节。从图中可以看出四条线在L1d级别时都很接近,因为所有的元素都在L1d缓存中命中没有预取得必要。

        对于L2缓存的命中,我们看到其中三条线高度吻合,但是它们的值都挺大的(大概28个周期),这个时间级别是访问二级缓存的时间,这意味着从二级缓存预取数据到一级缓存基本上是失效的。原因是NPAD=7时我们每次循环迭代时需要一个新的缓存行;对于NPAD=0时迭代8次才需要下一个缓存行。预取逻辑不能在每个迭代循环中加载新的缓存行。因此,在每次迭代中,我们都可以看到从L2加载的延迟。

        更有意思的是当工作集超过二级缓存的大小时,四条曲线的差别很大。元素的大小在性能中起到重大的影响。由于NPAD = 15和31的元素大小小于预取窗口(参见6.3.1节),因此处理器应该识别大步的大小,而不获取不必要的高速缓存行。元素大小阻碍预取的原因是硬件预取的限制:它不能跨越页面边界。对于每个size的增加硬件调度的效率减小了百分之五十。如果硬件预取器能够跨越页的边界并且下个页不是常驻或有效的,OS将必须参与页的定位。这意味着程序将出现一个未初始化的页错误。这是完全不能接受的因为处理器不知道某个页是否存在。后面的case中,OS将会中断处理。任何测试用例,当NPAD=7甚至更大时,对于链表中的每个元素都需要一个缓存行,硬件预取器能做的有限。根本没有时间从内存中加载数据,因为所有的处理器所做的只是读取一个字然后加载下一个元素。(这段没理解透????)

        速度下降的另一个重要原因是TLB缓存的脱靶。TLB缓存是存储虚拟地址转换为物理地址的结果,在第四章将详细讲述。TLB缓存非常小因为它必须足够快速。如果重复访问的页面数量比TLB缓存的条目多的话,那么虚拟地址到物理地址的转换必须不断的重复的进行,这是非常耗时的操作。对于较大的元素,TLB查找的成本将分摊到较少的元素上,也就是说每个链表元素计算的TLB条目的总数更高。

                                                          图3.12  TLB对顺序读的影响        

为了观察TLB的影响我们跑了另一个测试。一个测试条件是:还是按顺序排列每个元素,我们将NPAD=7这样每个元素占据一个缓存行。另一个测试条件是:将链表中的每个元素放在不同的页上,每个页的剩余内存我们保持不变,也不计算到总的工作集大小中。结果是,对于第一次测试,每次链表迭代需要一个新的缓存行,每64个元素需要一个新页。对于第二个测试,每次迭代都需要加载位于新页面上的新缓存行。

        结果展示在图3.12中和图3.11所用的机器相同。由于RAM的限制,工作集大小限制在4GB(2^24)的范围内,需要1GB的空间存放独立的页。红色曲线与图3.11 NPAD=7时的曲线是一致的。可以看出L1d和L2缓存大小的跳变。第二条曲线看起来截然不同。重要的特性是当工作集大小达到2^13字节的时候曲线陡然上升。这时候TLB缓存溢出了。元素大小为64字节时,我们可以计算出TLB缓存有64个条目。没有页错误影响程序开销,因为程序锁定内存,以防止它被换出。

       可以看到,计算物理地址并将其存储在TLB中所需要的周期非常长。图3.12展示了极端的情况,但现在应该清楚了,对于较大的NPAD值来说,降低速度的一个重要因素是TLB缓存的效率降低。由于物理地址必须在为L2或主存读取高速缓存线之前进行计算,因此地址转换会增加内存访问时间。这部分解释了为什么NPAD=31的每个链表元素的总成本高于RAM的理论访问时间。

                                                        图3.13 顺序读和写,NPAD=1     

  通过查看修改了链表元素的测试运行数据,我们可以看到更多关于预取实现的细节。图3.13有三条曲线,每个元素都是16字节。第一条线是熟悉的链表遍历作为基线。第二条标记为Inc的线简单得递增了下pad[0]成员的值再访问下一个元素。第三条标记为Addnext0的线是取得下一个元素的pad[0]成员值并加到当前元素的pad[0]成员上。

        我们可能会天真的认为“Addnext0”的测试更慢因为需要做更多事,在前进到下一个元素前下个元素的值就需要被加载了。这就是为什么看到实际运行结果时会感觉惊讶:对于有的工作集大小比“Inc”测试更快。对此的解释是,来自下一个链表元素的加载基本上是强制预取。每当程序前进到下一个链表元素时,我们可以确定该元素已经在L1d缓存中。结果我们看到,只要工作集大小在L2缓存大小范围内,Addnext0的性能与简单的Follow测试一样好。

        不过Addnext0测试离开二级缓存的速度比Inc测试快,因为它需要从主存加载更多数据。这就是为什么对于工作集大小为2^21字节大小时,Addnext0测试需要28个周期的原因。28个周期是同样工作集Follow测试所需14个周期的2倍。由于其他两个测试修改内存,L2缓存不能通过简单地丢弃数据为新的缓存行腾出空间。相反,它必须被写到内存中。这意味着FSB上的可用带宽减少了一半,因此将数据从主存传输到L2的时间增加了一倍。

                                                 图3.14  更大的二级、三级缓存的优势

最后一方面,缓存的大小影响缓存的效率。这是比较明显的,图3.14展示了以每个元素128字节为基准的时间开销。这次度量了三种不同的机器,第一二个是P4系列,最后一个是Core2处理器。第一和第二台机器的区别是缓存大小的不同。第一个处理器有32K的L1d缓存,1M的二级缓存。第二台处理器有16K的L1d缓存,512K的二级缓存和2M的三级缓存。第三台处理器有32K的L1d缓存和4M的二级缓存。

        图表中最有意思的不是Core2处理器比其他两个的性能如何,这里的主要兴趣点是工作集的大小对于各自的最后一级缓存来说太大,而主内存会大量参与其中的区域。正如预期的那样,最后一级缓存越大,曲线在L2访问成本对应的低一级停留的时间就越长,需要注意的是它提供的性能优势。第二个处理器在工作集是2^20字节大小的时候性能是第一个处理器的两倍。这一切都归功于最后一级缓存大小的增加。拥有4M L2的Core2处理器性能更好。

        对于随机工作负载,这可能没有多大意义。但是,如果工作负载可以根据最后一级缓存的大小进行调整,则程序性能可以显著提高。这就是为什么有时值得为拥有更大缓存的处理器花费额外的钱。

(2)单线程随机访问

我们已经知道处理器可以通过预取缓存行内容到二级和一级缓存而隐藏了访问主存和二级缓存的延时。这种机制仅对于内存可以预取起作用。

                                                  图3.15 顺序 VS 随机读取, NPAD=0

如果访问模式不可以预取或者是随机访问那么情况就大不相同了。图3.15展示了顺序读取链表中的元素的开销和随机读取的比较结果。顺序由随机化的链表决定。处理器无法可靠地预取数据。这只能在元素在内存中彼此相邻的情况下偶然起作用。图3.15有两点需要注意:第一,随着工作集的增加时间周期大量增加,机器访问主存可能需要200个时间周期但这里达到了450个甚至更多。我们以前见过这种现象(比较图3.11)。自动预取在这里实际上是没有优势的。第二,在不同的平台上,曲线并不像在顺序访问情况下那样平坦。曲线不断上升。为了解释这一点,我们可以测量程序对不同工作集大小的L2访问。结果如图3.16和表3.2所示。

                              表3.2  顺序访问和随机访问二级缓存的命中和脱靶,NPAD=0 

                                                         图3.16   二级缓存的脱靶率 

不断增加的脱靶率就可以解释一些开销的原因,但还有其他因素。从表3.2中我们可以看出,在L2/#Iter列中,每次程序迭代使用L2的总数在增长。每次迭代的工作集都是上一次的两倍,如果没有缓存的话,内存的访问次数也将是上一次的两倍。在按顺序访问时,由于缓存的帮助及完美的预见性,对L2使用的增长比较平缓,完全取决于工作集的增长速度。

                                                  图3.17   page-wise随机化,NPAD=7

        对于随机访问,每个元素的访问时间在工作集大小每增加一倍时增加一倍以上。背后的原因是TLB脱靶率增加了。图3.17可以看到NPAD=7时随机访问的开销,只是这次修改了随机化。正常情况下整个随机链表视为一个块(图中无穷符号标记的),其他11条曲线在较小区域内进行的随机化。标记‘60’的曲线表示在60页内进行独立的随机化。这意味着在转到下一个块中的元素之前遍历块中的所有链表元素。这导致在任何时间使用的TLB条目的数量是有限的。

        对于NPAD=7时元素的大小是64字节,正好和缓存行的大小一致。由于链表元素顺序的随机化,硬件预取器不太可能有比较好的效果。这意味着L2缓存失误率与在一个块中随机化整个列表没有显著差异。对于单块随机化,随着块大小的增加,测试性能逐渐接近一个块随机化的曲线。这意味着后面测试用例的性能受到TLB miss的显著影响。如果能够降低TLB的脱靶量,则可以显著提高性能。

3.3.3 写入时的行为

在查看多个执行上下文(线程或进程)使用相同内存时的缓存行为之前,我们必须研究缓存实现的细节。缓存应该是一致的,这种一致性对于用户级代码应该是完全透明的。内核代码则是另一回事;它偶尔需要对缓存进行刷新。这意味着,如果修改了高速缓存行,则系统在此时间点之后的结果与根本没有缓存并且修改了主内存位置本身一样。这可以通过两种方式或策略实现:

  •  直写式缓存实现
  •  回写式缓存实现

直写式缓存实现是实现缓存一致性最简单的方式。如果缓存行被写了处理器马上把缓存行写入内存。这就保证了在任何时间缓存和主存的内容保持同步。只要缓存行内容被替换就可以简单的丢弃。这种缓存机制简单但不高效。举例说明,一个程序重复的修改一个局部变量将会造成FSB总线的拥堵,尽管这些数据可能不会在其他任何地方使用,而且可能是短期存在的。

        回写式缓存机制更加复杂。处理器不会立即将被修改的缓存行写到主存中,而是将缓存行标记为脏缓存。当缓存行在将来的某个时候从缓存中删除时,脏位将指示处理器在那时将数据写回,而不是仅仅丢弃内容。回写缓存有机会获得更好的性能,这就是为什么在一个拥有良好处理器的系统中,大多数内存都是这样缓存的。处理器可以利用FSB空闲的带宽在缓存行被丢弃之前将其内容写到主存中。当需要缓存腾出空间时,这时候脏的标记将会被清除,处理器丢弃缓存行。

      但在回写缓存机制的实现上还存在一个重大的问题。当不只一个处理器想要访问同一块内存单元时,必须保证所有的处理器看到同一块内存单元的内容是一致的。如果缓存行在一个处理器上是脏缓存(这时候还没写回到主存),而第二个处理器想要读取这块内存的内容,读操作不能从主存中读取,而应该从第一个处理器的缓存中读取。下一节我们将看到目前是如何实现这一机制的。

        在我们往下进行前,需要提及两个缓存策略:写合并和不可缓存。这两个策略都用于地址空间中不受实际RAM支持的特殊区域。内核为地址范围设置这些策略(在x86处理器上使用内存类型范围寄存器,MTRRs),其余的自动发生。MTRR还可以用来在写进和写回策略之间进行选择。

        写合并缓存优化机制有局限性,通常拥有显卡设备。由于设备的传输成本比本地RAM访问要高得多,因此更重要的是要避免进行太多的传输。如果下个操作修改了下个字,仅仅因为一个字的改写而转移一整个缓存行是浪费的。很容易想象这是一种常见的现象,屏幕上水平相邻像素的内存在大多数情况下也是相邻的。写合并顾名思义就是在缓存行被写到主存前将多次写访问合并。理想的情况下整个缓存行被一个字一个字的修改,仅在最后一个字写完后,整个缓存行写入设备。这样就能够在设备上加速访问RAM。

         最后是不可缓存的内存。通常意味着内存位置不被RAM支持。它可能是一个硬编码的特殊地址,以便在CPU外部实现某些功能。对于普通硬件来说,最常见的情况是内存映射地址范围转换成对连接到总线(PCIe等)上的卡片和设备的访问。在嵌入式电路板上,人们有时会发现这样一个内存地址,可以用来打开和关闭一个LED。缓存这样的地址显然不是一个好主意。在这种情况下,LEDs用于调试或状态报告,希望尽快看到这一点。PCIe卡上的内存可以在没有CPU交互的情况下改变,因此不应该缓存这些内存。

3.3.4 支持多处理器

在上一节中我们已经提到了多处理器会面临的问题。 即使是不共享缓存的多核处理器也有同样的问题。从一个处理器提供对另一处理器缓存的访问是不切实际的。首先,连接也是不够快的。可替代的方案是传输缓存内容到另一个处理器,这也同样适用于在同一处理器不共享的缓存。

        问题是何时传输缓存内容?这个问题相当容易解答:当一个处理器需要读或写缓存行的内容在另一个处理器上是脏数据时。

但是又有一个问题,处理器怎么知道缓存行在另外的处理器上是不是脏缓存呢?通常大多数的内存访问都是读操作,缓存行不是脏数据。处理器对于缓存行的操作是非常频繁,这意味着在每次写访问之后广播缓存行被更改的信息是不切实际的。

       多年来发展起来的是MESI缓存一致性协议(可修改的、独占的、共享的、无效的)。该协议是根据使用MESI协议时缓存行可能处于的四种状态命名的:

  •  变更过的(Modified): 本地处理器已经修改了缓存行的内容。
  •  独占的(Exclusive):  缓存行没有被修改,但是知道没有被加载到任何其他处理器的缓存中。
  •  共享的(Shared):缓存行没有被修改,在其他处理器中可能存在。
  •  无效的(Invaild):缓存行无效。

多年来,这个协议从比较简单的版本发展而来,这些版本比较简单,但是也比较低效。使用这四种状态可以有效地实现写回缓存,同时还支持在不同的处理器上并发地使用只读数据。

                                                          图3.18 MESI传输协议

 通过处理器监听或窥探其他处理器,无需太多的工作就可以完成状态更改。处理器执行的某些操作反应在外部引脚上,这样使得处理器的缓存操作对外部是可见的。在接下来的对于状态和状态迁移的描述中我们将指出涉及到的总线。

        初始状态所有的缓存行都是空的无效的数据。如果数据被加载进缓存行用于写缓存,缓存就处于变更的状态。如果数据被加载用来读,新的状态取决于其他处理器是否也加载了这个缓存行。如果是则新的状态为可共享的,否则为独占的。

         如果一个变更的缓存行由自己的处理器读写操作将不改变其状态。如果第二个处理器想要读取这个缓存行的内容第一个处理器必须将其传输给第二个处理器,然后把自己缓存行的状态改变为共享的。传输到第二个处理器上的数据是由内存控制器接收并处理后存储到内存中,如果这个过程没有发生,则不能被标记为共享的。如果第二个处理器想要修改第一个处理器传输过来的缓存行内容,标记第一个处理器的缓存行为无效状态。这就是臭名昭著的“请求所有权”ROF操作。在最后一级缓存中执行像I状态到M状态的操作代价是很大的。对于直写缓存,我们还必须增加将新缓存行内容写入下一个更高级别缓存或主内存的时间,从而进一步增加成本。

        如果一个缓存行处于可共享的状态,本地处理器读取内容不需要改变其状态,可以从缓存中完成读操作。如果缓存行被自己的处理器写入并且是可用的则状态修改为变更的状态,必须请求其他处理器的副本标记为无效。因此,写入操作必须通过RFO消息通知其他处理器。如果第二个处理器请求缓存行进行读取,则什么也不会发生。主内存包含当前数据,并且已经共享了本地状态。如果第二个处理器想要向缓存行(RFO)写入数据,那么缓存行将被简单地标记为无效。不需要总线操作。

        独占状态与共享状态基本相同但有一个关键性的区别:本地处理器的写操作不需要通知总线。本地缓存是唯一持有该缓存行的缓存。这可以产生巨大的优势,所以处理器将尽可能的让更多的缓存行处于独占状态而不是共享状态。后者是在信息无法获得时的后备选择。独占状态也可以完全忽略,而不会引起功能问题。只是性能将受到影响,因为从E->M的转换比S->M的转换快多了。从对状态转换的描述中,应该可以清楚地看到多处理器操作的具体开销。是的,填充缓存仍然开销很大,但是现在我们还必须注意RFO消息。每当需要发送这样的消息时,速度就会变慢。以下有两个场景需要用RFO消息:

  •  一个线程从一个处理器迁移到另一个处理器,所有的缓存行必须迁移到新的处理器。
  •  一个缓存行被两处理器使用

多线程或多进程程序总是需要考虑同步问题;这种同步是依赖内存实现的。所以有一些RFO消息是合理的,但它们必须尽可能避免频繁被使用。不过,还有其他的RFO消息来源。在第6节中,我们将解释这些场景。缓存一致性协议消息分布在系统的各个处理器之间。MESI状态变迁无法执行直到系统中的所有处理器都有机会回复消息。这意味着一个应答可能花费的最长时间决定了一致性协议的速度。总线发生冲突是有可能的,NUMA系统的延迟可能很高,当然,庞大的通信量会降低速度。因此把注意力放在避免不必要的总线阻塞上是应该的。

        还有一个与使用多个处理器相关的问题。这些影响是机器特有的,但原则上问题总是存在:FSB是共享资源。大多数机器所有的处理器通过一条单一总线和内存控制器连接(参见图2.1)。如果一个处理器就可以占满总线带宽,那么两个或四个处理器共享同条总线将进一步限制每个处理器的可用带宽。

        尽管每个处理器有单独的总线和内存控制器连接,如图2.2所示,但内存控制器到内存模块的总线通常只有一条。并发的访问同一个内存模块将限制总线的带宽。在AMD模型中也是一样,每个处理器都可以有本地内存。所有处理器确实可以同时快速访问它们的本地内存,特别是使用集成的内存控制器。但是多线程和多进程程序至少在某些时候必须访问相同的内存区域来进行同步。

        对实现同步来说,并发将受到有限可用带宽的严重限制。程序需要精心设计,减少不同处理器和不同核对相同内存位置的访问。下面的测量将展示这点,还展示了其他与多线程代码相关的缓存效果。

(1)多线程访问

为了帮助理解不同的处理器并发的使用相同的缓存行所带来问题的严重性,我们将使用和之前相同的程序来呈现更多的图表。这次同时有多个线程运行,所度量的是任何线程的最快运行时。这意味着当所有线程都完成时,完成一次完整运行的时间会更长。机器有四个处理器,测试最多跑四个线程。所有的处理器共享一条到内存控制器的总线,而且只有一条到内存模块的总线。

                                                             图3.19  多线程顺序访问

图3.19展示了多线程顺序访问一个元素128字节的性能。对于一个线程的曲线,我们期望类似于图3.11的曲线。因为这次测量是针对不同的机器,所以实际的数字是不同的。这次重点当然是关注多线程的行为。注意到在遍历链表时不会修改内存也不试图去保持线程同步。尽管RFO消息是需要的并且所有的缓存行是可共享的,我们可以看到两个线程的性能和只有一个线程的性能相比损失达到18%,四个线程时达到34%。由于没有缓存行在处理器之间传输,因此性能下降仅由一个或两个瓶颈造成的:从处理器到内存控制器的共享总线和内存控制器到内存模块的总线。一旦工作集比三级缓存容量大所有的三个线程都将预取新的链表元素。即使有两个线程,可用带宽也不足以线性扩展。

                                                            图3.20  多线程顺序递增        

当我们修改内存时事情变得更加棘手。图3.20Y轴使用的是以对数的形式标记的,所有不要被表面看上去差别不是很大所迷惑。当两个线程时任然有18%的性能损失,当四个线程时损失达到了93%之多。这意味着当四个线程时,预取和写回的操作使总线的带宽达到了饱和状态。

        我们可以看到,一旦多于一个线程访问,L1d缓存基本是起不到作用的。只有L1d不满足工作集时单个线程的访问才会超过20个时钟周期。而多线程访问时,即使是很小的工作集,访问时间也能达到这个水平。

        这里没有揭示问题的另一方面。因为用这个特定的测试程序很难测量。尽管测试修改了内存,但当使用多个线程时,我们本该看到RFO消息的影响,但我们没有在二级缓存看到更高的开销。要看到RFO消息的影响,程序必须使用大量的内存,并且所有线程必须并行地访问相同的内存。如果没有大量的同步,这很难实现,因为同步会占满执行时间。

                                   图3.21   多线程操作随机加下一元素的最后一个成员值    

最后在图3.21展示了惊人的数字,极端的情况需要花费大概1500个时钟周期处理单个链表元素。表3.3总结了多线程的效率:

                                                          表3.3 多线程的效率

#ThreadsSeq ReadSeq IncRand Add
2
4
1.69
2.98
1.69
2.07
1.54
1.65

该表显示了图3.19,3.20,3.21中最大工作集时多线程的工作效率。表中的数值表示在最大工作集时可能的最大加速因子。对于两个线程的情况理论上加速极限可以达到2,对于4个线程时是4(理论上几个线程就是几倍的效率)。实际情况是两个线程时结果不是特别糟糕,但是四个线程时,对于最后一项测试显示了两个线程以上就不值得加速了。如果我们换一种不同的方式表示图3.21中的数据,我们可以更容易地看到这一点。

                 

以上是关于《What every programmer should know about memory》-CPU Caches译的主要内容,如果未能解决你的问题,请参考以下文章

《What every programmer should know about memory》-What Programmers Can Do译

《What every programmer should know about memory》-What Programmers Can Do译

《What every programmer should know about memory》-Virtual Memory译

《What every programmer should know about memory》-NUMA Support译

《What every programmer should know about memory》-CPU Caches译

8 Traits of an Experienced Programmer that every beginner programmer should know