原文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 多线程的效率

#Threads Seq Read Seq Inc Rand 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中的数据,我们可以更容易地看到这一点。

图3.22  并发的加速效果

图3.22展示了多线程和单线程的加速因子的比较结果。对于小的数据集的度量不够准确,因此我们忽略最小的工作数据集。对于在二级缓存到三级缓存的范围,我们可以看到我们几乎是获得了线性的加速效果。我们几乎获得了完美的2和4倍的加速效果。但是只要超过了三级缓存的大小,效率马上就降低到了奔溃的边缘,导致4个线程和2个线程的加速效果基本一致。这就是很难找到拥有四个以上CPU的主机板使用同一个内存控制器的一个原因。拥有更多处理器的机器必须构建不同的内存控制器(见第5章)。测试结果的数值并不通用于所有情况,即使工作集能够被最后一级缓存容纳也可能达不到线性的倍速。实际上这是一种常态因为线程通常不像这个测试程序中那样解耦。另一方面,使用大型工作集仍然可以利用两个以上的线程。不过这样做需要程序员的聪明才智。我们将在第6章中讨论一些方案。

(2)特殊用例:超线程

超线程(有时被称为对称多线程-SMT)是CPU应对单线程不能真正实现并发运行而实现的。它们共享除了寄存器组外几乎所有处理器的资源。每个核心和cpu仍然并行工作,但是在每个核心上实现的线程受到这个限制。理论上每个核上可以跑很多的线程,但是目前为止,英特尔的CPU一个核最多跑两个线程。CPU负责对线程进行分时复用,然而,单凭这一点是没有多大意义的。真正的好处是,当目前运行的超线程被延迟时(多数情况是内存访问引起的延时),CPU可以调度另一个超线程并利用可用的资源如算术逻辑单元(ALUs)。

如果两个线程在一个超线程核心上运行,那么只有当两个线程的联合运行时间低于单线程代码的运行时间时,程序才会比单线程代码更有效。连续访问两块不同的内存的等待时间可能叠加。一个简单的计算公式展示了实现一定加速所需的缓存命中率的最低要求。程序的执行时间可以用一个只有一级缓存的简单模型来近似,如下所示:

其中:

N 表示指令的数量;Fmem表示 N个指令中访问内存的比例;Ghit表示负载命中缓存的比例;Tproc表示每条指令的周期数;Tcache缓存命中的周期数;Tmiss表示缓存脱靶的周期数;Texe表示程序执行的时间;

为了使运行两个线程看起来更有意义,两线程中每个线程的执行时间最多为运行单个线程执行时间的一半。两者都有的唯一变量是缓存命中的次数。如果我们要解决最小缓存命中率相等的问题需要使我们获得的线程的执行率不少于50%或更多,如图 3.23:

图3.23  达到加速效果要求的最小缓存命中率

X轴作为输入表示单线程代码的缓存命中率Ghit。Y轴表示多线程代码的缓存命中率。多线程的命中率不可能比单线程的高。对于单线程命中率低于55%的特定情况,程序在任何情况下都可以从使用多线程中获益。由于缓存丢失CPU或多或少有足够的时间来运行第二个超线程。

绿色是目标区域,如果线程的减速小于50%,并且每个线程的工作负载减半,则合并运行时可能小于单线程运行时间。一个缓存命中率为60%的单线程程序要求在多线程的情况下命中率至少是10%。这通常是可行的。但是,如果单线程代码的命中率为95%,那么多线程代码需要至少80%的命中率,这就比较困难了。特别是对于超线程的情况,因为现在每个超线程可用的有效缓存大小(这里是L1d,实际上也是L2等等)被削减了一半。两个超级线程使用相同的缓存来加载它们的数据。如果两个线程的工作集不重叠,原来的95%命中率也可以减半,因此大大低于要求的80%。

因此,超线程仅仅适用于某些场合。单线程代码的缓存命中率必须足够低,从而在给定上述等式和减少缓存大小的情况下,新的命中率仍然满足目标。只有这样,使用超线程才有意义。实际上,结果是否更快取决于处理器是否能够充分地将一个线程中的等待时间与其他线程中的执行时间叠加。必须将并行化代码的开销添加到新的总运行时中,通常不能忽略这一额外的开销。

在第6.3.4节中,我们将看到一种技术,其中线程紧密协作,通过公共缓存实现紧密耦合实际上是有优势的。如果程序员愿意投入时间和精力来扩展他们的代码,这种技术可以适用于许多情况。

我们应该清楚的认识到如果两个超线程执行不同的代码,缓存大小确实减小了一半意味着缓存脱靶率显著增加。除非缓存足够大,否则这种操作系统调度实际上是有问题的。除非机器的工作任务由进程组成,这些进程通过它们的设计确实可以从超线程中获益,否则最好关闭计算机BIOS中的超线程。

3.3.5 其他细节

到目前为止我们讨论的地址由三个部分组成:标签,组索引,缓存行偏移。但是实际使用的地址是什么样的呢?因为现在所有相关的处理器都为进程提供虚拟地址空间,这意味着有两种不同的地址: 虚拟地址和物理地址。

虚拟地址的问题是它们并不唯一。一个虚拟地址随着时间的变化可以映射到不同的物理内存地址上。不同处理器的相同的地址也可能指向不同的物理地址。因此最好使用物理地址吗?这里的问题是在执行期间使用的虚拟地址必须在内存管理单元(MMU)的帮助下转换为物理地址。这是一个重要的操作。在执行指令的管道中,物理地址可能在稍后的阶段才可用。这意味着缓存逻辑必须非常快地确定内存地址是否被缓存了。如果可以使用虚拟地址,缓存查找可以在管道中更早地进行,并且在缓存命中的情况下内存内容变得可用。这样管道可以隐藏更多的内存访问成本。

处理器设计者目前使用虚拟地址为一级缓存添加标签。这些缓存相当的小且清除不会有太多开销。如果处理器的页表树发生了变化至少需要清除部分缓存。如果处理器有指定已经变更的虚拟地址范围的指令可能可以避免刷新整个缓存。由于L1i和L1d缓存(大概3个周期)是低延迟的,因此必须使用虚拟地址。

对于像二级,三级那样的大缓存需要物理地址作为标签。这些缓存有较大的延时,要求虚拟地址到物理地址的转换能够及时的完成。因为这些缓存更大,填充和刷新它们因为主存的延时需要更多的开销。一般来说不需要知道这些缓存中地址处理的细节,地址处理是不能改变的,影响性能的因素要么是那些应该避免的,要么是开销大的操作。溢出缓存容量是糟糕的,并且如果使用的大多数缓存行都属于同一组,那么所有缓存都会在早期遇到问题。后者可以通过虚拟寻址的缓存来避免,但是对于使用物理地址寻址的缓存,用户级进程不可能避免。唯一需要记住的细节是,如果可能的话,不要将同一个进程中的用两个或多个虚拟地址映射同一个物理地址。

另一个开发者不感兴趣的缓存实现细节是缓存替换机制。大多数缓存首先驱逐最近最少使用的(LRU)元素。这总的来说是一个不错的默认的机制。随着更大的关联性(并且关联性可能在未来几年由于更多核心的增加而进一步增长),维护LRU列表开销变得越来越大,我们可能会看到不同的策略被采用。

因为程序员不能为缓存替换机制做点什么。如果缓存使用的是物理地址标记,则无法找出虚拟地址如何与缓存组关联。可能是所有逻辑页中的缓存行都映射到相同的缓存组导致大部分缓存未被使用。操作系统能做的就是尽量避免这种情况经常发生。

随着虚拟化的到来,事情变得更加复杂。现在,甚至操作系统也不能控制物理内存的分配。虚拟机监视器(VMM,又称Hypervisor)负责分配物理内存。

程序员所能做的最好的事情是 a)完全使用逻辑内存页,b) 使用尽可能大的有意义的页大小来尽可能多地分散物理地址。更大的页面大小也有其他好处,但这是另一个主题(参见第4节)。

3.4 指令缓存

不仅是处理器使用的数据被缓存,处理器执行的指令也需要缓存。然而,指令缓存比数据缓存的问题少是因为:

  • 执行的代码数量取决于所需代码的大小。代码的大小通常取决于问题的复杂性。问题的复杂性是固定的。
  • 虽然程序的数据处理是由程序员设计的,但程序的指令通常是由编译器生成的。编译器编写者知道良好代码生成的规则。
  • 程序流程比数据访问模式更容易预测。如今的CPU非常擅长于检测模式,这对预取有帮助。
  • 代码总是具有较好的空间局限性。

程序员应该遵循一些规则,但这些规则主要是关于如何使用工具的规则。我们将在第6节中讨论它们。这里我们只讨论指令缓存的技术细节。

自从CPU的核心频率急剧增加,缓存(甚至是一级缓存)和CPU核之间的速度差异越来越大,CPU就被设计成管道流水线化。这意味着指令的执行是分阶段进行的。首先译码一条指令,然后准备参数,最后执行。这样的管道可能相当长(英特尔Netburst架构的> 20个阶段)。长管道意味着如果管道停止运行(当指令流程中断时)需要一段时间才能恢复到原来的速度。例如,如果无法正确预测下一条指令的位置,或者加载下一条指令的时间过长(例如,必须从内存中读取指令),就会发生管道阻塞。因此,CPU设计人员在分支预测上花费了大量的时间和芯片空间,以尽可能减少管道阻塞的发生。

在CISC处理器上,译码阶段也需要一些时间,x86和x86-64处理器受到的影响尤其严重。因此,近年来这些处理器并不缓存L1i中指令的原始字节序列,而是缓存译码指令。在本例中,L1i称为跟踪缓存。跟踪缓存允许处理器在缓存命中时跳过管道的第一步,这在管道停止时尤其有效。如前所述,从L2开始的缓存是包含代码和数据的统一缓存。显然,这里的代码以字节序列形式缓存,而不是译码。为了获得最佳的性能,只有一些与指令缓存相关的规则:

  1. 生成的代码尽可能小。
  2. 协助处理器做出好的预取决策。这可以通过代码布局或显式预取来实现。

这些规则通常由编译器的代码生成器来实施。程序员可以做的事我们将在第6节中讨论。

3.4.1 自修改代码

早期计算机的内存是非常宝贵的。人们竭尽全力缩小程序的大小,为程序数据腾出更多的空间。经常使用的一个技巧是随着时间改变程序本身。这种自修改代码(SMC)偶尔还会看到,主要是用于提高性能或攻击安全漏洞。一般来说应避免SMC。虽然它通常可以正确执行的,但有一些极端情况不是这样的,如果执行不正确,就会产生性能问题。显然,更改的代码不能保存在包含译码指令的跟踪缓存中。但是,即使(由于根本没有执行代码或一段时间没有执行代码)没有使用跟踪缓存,处理器也可能会出现问题。如果一条即将执行的指令在它已经进入流水线的时候被改变了,处理器不得不扔掉大量的工作,重新开始。甚至在某些情况下,必须丢弃处理器的大部分状态。最后,由于处理器假设代码页是不可变的(因为在99.9999999%的情况下都是如此),所以L1i实现不使用MESI协议,而是使用简化的SI协议。这意味着如果检测到修改,就必须做出许多悲观的假设。

强烈建议尽量避免SMC,内存已经不再是稀缺资源。最好是编写单独的函数,而不是根据特定的需要修改一个函数。也许有一天SMC成为可选的,我们可以以这种方式检测入侵代码。如果必须使用SMC,则写操作应该绕过缓存,以免造成L1i中需要的数据在L1d中问题。有关这些说明的更多信息,请参见第6.1节。

在Linux上,通常很容易识别包含SMC的程序。当使用常规工具链构建时,所有的程序代码都是写保护的。程序员必须在链接时施加魔法来创建可写代码页的可执行文件。当这种情况发生时,现代的Intel x86和x86-64处理器都有专用的性能计数器,用于计算使用自修改代码的次数。在这些计数器的帮助下识别SMC程序是很容易的。

3.5 缓存脱靶的因素

我们已经看到,当内存访问在缓存中脱靶时,开销会急剧上升。有时这是不可避免的,重要的是要了解实际的开销和可以做什么来减轻这种问题。

3.5.1 缓存和内存的带宽

为了了解处理器的性能,我们测量了处理器最佳状态下的带宽。这次测量比较有意思,因为不同的处理器版本差异很大。这就是为什么这一节拥有几个不同机器的数据。测量性能的程序使用x86和x86-64处理器的SSE指令一次加载或存储16个字节。与我们的其他测试一样,工作集从1kB增加到512MB,并测量每个循环可以加载或存储多少字节。

图3.24  奔腾4带宽

图3.24展示了64位Intel Netburst处理器的性能。对于小于L1d缓存大小的工作集,处理器能够在每个周期读取完整的16个字节,即,每个循环执行一条加载指令(movaps指令一次移动16个字节)。测试对读取的数据不做任何操作,我们只测试读指令本身。一旦超出L1d缓存,性能就会急剧下降,每个周期不到6个字节。工作集为2^18字节的时候由于耗尽了DTLB缓存,这意味着每个新页面都需要额外的开销。由于是顺序读取,预取能够很好的发挥效果,对于所有大小的工作集,FSB总线可以以大约5.3字节/周期的速度传输内存内容。

比读性能更惊人的是写和复制性能。即使对于较小的工作集,写性能也不会超过每个周期4个字节。这表明,在这些Netburst 处理器中,Intel选择在L1d中使用直写模式,该性能明显受到L2速度的限制。这还意味着,从一个内存区域复制到另一个非重叠内存区域的复制测试的性能并没有显著下降。必要的读操作很快,并且可以与写操作部分重叠。关于写和复制的测试最值得注意的细节是,一旦L2缓存不再足够,就会出现性能低下的情况。性能下降到每个周期0.5字节!这意味着写操作比读操作慢10倍。这意味着优化这些操作对于程序的性能来说更加重要。

图3.25  拥有2个超线程的奔腾4带宽

在图3.25展示的结果是在同一个处理器上,两个线程在运行,每个线程分别运行在处理器的一个超线程中。该图与前一个图以相同的比例来说明差异。结果与预期一致。由于超线程共享除了寄存器之外的所有资源,每个线程只有一半的缓存和带宽可用。这意味着即使每个线程都要等待很多时间,并且可以给另一个线程执行时间,这也没有任何区别,因为另一个线程也必须等待内存。这确实展示了超线程最糟糕的用法。

图3.26  Core2带宽

图3.27 两线程Core2的带宽

与图3.24和3.25相比,图3.26和3.27中的结果看起来与Intel Core 2处理器有很大的不同。这是一个共享L2的双核处理器,二级缓存的大小是P4机器上的四倍。不过,这只解释了写入和复制性能减缓下降的原因。 还有其他更大的区别。整个工作集范围内的读取性能在每个周期内保持在最佳16字节左右。工作集为2^20字节之后读取性能的下降再次是因为工作集对于DTLB来说太大了。能获得这么大的数值意味着处理器不仅能够预取数据并及时传输数据,还意味着数据被预取到L1d中。写和复制性能也有显著的不同。处理器采用的不是直写策略; 已经写的数据存储在L1d中,只有在必要时才会被逐出。这使得写入速度接近于最佳的16字节/周期。一旦L1d不够,性能就会显著下降。与Netburst处理器一样,写性能要低得多。由于高读性能,这里的差异甚至更大。事实上,当L2不够时,速度差增加到原来的20倍! 这并不意味着Core 2处理器性能很差。相反,它们的性能总是优于Netburst核。

在图3.27中,测试运行两个线程,分别在Core 2处理器的两个核心上运行。两个线程访问相同的内存,但不一定完全同步。读取性能的结果与单线程情况没有什么不同。在任何多线程测试用例中都可以看到更多的抖动。有趣的一点是在L1d缓存区的工作集的写和复制性能。从图中可以看出,性能与从主内存中读取数据一样。两个线程竞争相同的内存位置,必须发送用于缓存行的RFO消息。问题是即使两个核共享缓存,这些请求没有以L2缓存的速度处理。一旦L1d缓存不再足够,修改的条目将从每个核的L1d缓存刷新到共享的L2缓存中。此时性能显著提高,因为L2缓存满足了L1d缓存未命中的,而且只有在数据尚未刷新时才需要RFO消息。这就是为什么我们看到在这些工作集上速度降低了50%。渐近行为在预料之中: 因为两个核共享相同的FSB每个核得到一半的FSB带宽这意味着大型工作集的每个线程性能大约是单线程时的一半。

图3.28  AMD家族10h Opteron带宽

因为即使同一个供应商的处理器版本之间也有显著的差异,所以研究其他供应商处理器的性能是值得的。图3.28展示了AMD家族10h Opteron处理器的性能。这个处理器有64kB L1d、512kB L2和2MB L3。L3缓存在处理器的所有核心之间共享。性能测试结果如图3.28所示。

第一个需要注意的细节是,如果L1d缓存足够,处理器可以在每个周期中处理两条指令。读性能每周期超过32字节,甚至写性能也很高,每周期为18.7字节。不过读取曲线很快下降变平了,而且每周期2.3字节的速度是非常低的。此测试的处理器不预取任何数据或没有有效的预取。另一方面,写曲线根据不同缓存的大小变化,整个L1d区间达到了最佳性能,L2降低到每个周期6字节,L3降低到每个周期2.8字节,如果L3不能容纳所有数据,则最后降低到每个周期5字节。L1d缓存的性能超过了(旧的)Core 2处理器,L2的访问速度也一样快(Core 2的缓存更大),而L3和主存的访问速度更慢。复制性能不会比读或写性能强,这就是为什么我们看到曲线最初由读性能决定,后来由写性能决定。

图3.29    两线程AMD家族10h Opteron带宽

Opteron处理器的多线程性能如图3.29所示。读取性能基本上不受影响。每个线程的L1d和L2和以前一样工作,L3缓存在这种情况下预取的不是很好。这两个线程并没有过分强调L3。这个测试的主要问题是写性能。所有线程共享的数据都必须经过L3缓存。这种共享似乎效率很低,因为即使L3缓存大小足以容纳整个工作集,其开销也远远高于L3访问。将此图与图3.27进行比较,我们可以看到Core 2处理器的两个线程以共享L2缓存的速度运行,以获得适当的工作集大小范围。对于Opteron处理器,只有在非常小的工作集大小范围内才能达到这种水平的性能,即使这样,它也只能达到比Core 2的L2慢的L3的速度。

3.5.2 关键字负载

内存以比缓存行大小更小的块为单位从主内存转移到缓存中。如今一次传输64位,缓存行的大小是64或128字节。这意味着每条缓存行需要8到16次传输。DRAM芯片可以在突发模式下传输64字节的数据块。这样就可以填满缓存线,而不需要来自内存控制器的任何其他命令而产生相应的延迟。更好的方式是处理器预取缓存行。

如果程序对数据或指令的高速缓存访问失败(这意味着强制的高速缓存失败,因为数据是第一次使用,或者容量高速缓存失败,因为有限的高速缓存大小需要清除缓存行)的情况就不同了。程序继续运行所需的缓存行中的字数据可能不是缓存行中的第一个字。即使在突发模式下,使用双数据速率传输,各个64位数据块到达的时间也明显不同。每个块比前一个块晚4个或更多CPU周期。如果程序继续运行需要的字数据是缓存行中的第8个字,则程序必须在第一个字到达后再等待30个周期或更多。

情况不一定要这样。内存控制器可以自由地以不同的顺序请求缓存行的字。处理器可以告知程序正在等待哪个字数据-关键字,内存控制器可以先请求这个字数据。一旦字数据到达,程序可以继续,而其余的缓存行未到达且缓存还不是在一个一致的状态。这种技术称为关键字优先&早期重启。

现在的处理器实现了这种技术,但是在某些情况下是不行的,比如处理器预取数据而关键字是未知的。处理器在预取操作进行期间请求缓存行,它将不得不等待,直到关键字到达,而不能影响顺序。

图3.30  关键字在缓存行末

即使有了这些优化,关键字在缓存行上的位置仍然很重要。图3.30显示了顺序访问和随机访问的Follow测试。图中展示的关键字在缓存行的行末比在行首的慢的比例。元素大小为64字节与缓存行大小一样。这些数字非常嘈杂,但可以看出,当L2不足以保存工作集数据时,关键字在行尾的性能比在行首的性能低0.7%左右。顺序访问似乎受到更多的影响。这与前面提到的预取下一条缓存行的问题是一样的。

3.5.3 缓存的放置

缓存放在哪里与超线程、核和处理器的关系不受程序员控制。但是程序员可以确定线程在哪里执行,然后缓存与使用的cpu之间的关系就变得非常重要。在这里,我们不会详细讨论什么时候选择哪个核运行哪个线程。我们将只描述架构细节,程序员在设置线程关联性时必须考虑这些细节。根据定义,超线程除了寄存器集之外共享所有内容,包括一级缓存。这里没什么好说的了,乐趣始于处理器的各个核。每个核至少有自己的一级缓存。

  • 早期的多核处理器根本没有共享缓存
  • 后来的英特尔模型为双核处理器共享L2缓存。对于四核处理器,每一对双核处理器有单独的L2缓存。没有更高级别的缓存。
  • AMD的家族10h处理器有单独的L2缓存和统一的L3缓存。

处理器厂商的宣传材料中写了很多关于他们各自型号的优势。如果核处理的工作集不重叠,则没有共享缓存是有好处的。这对于单线程程序很有效。这种方案表现的不是太糟糕这在今天仍然是现实。但总会有一些重叠,缓存都包含公共运行库中最活跃的部分,这意味着一些缓存空间被浪费了。

完全共享除了一级缓存的所有缓存,就像Intel的dualcore处理器所做的那样,会有很大的优势。如果在两个核心上工作的线程的工作集明显重叠,那么可用的缓存内存总量就会增加,并且工作集可以更大而不会降低性能。如果工作集不重叠英特尔的先进智能缓存管理应该防止任何一个核独占整个缓存。

但是,如果两个核都为各自的工作集使用了大约一半的缓存,就会产生一些冲突。缓存必须不断权衡两个核对缓存的使用,而作为重新平衡的逐出操作可能会被错误地执行。为了看待这个问题,我们看看另一个测试程序的结果。

测试程序有一个进程不断地读写,使用SSE指令和一个2MB的内存块。内存块选择2MB大小是因为它是Core 2处理器二级缓存大小的一半。进程固定在一个核上,而第二个进程固定在另一个核上。第二个进程读写可变大小的内存区域。该图显示了每个周期中读取或写入的字节数。这里显示了四个不同的图,分别表示读写过程的每个组合。读/写曲线是后台进程,后台进程总是使用一个2MB的工作集来写,而被测量的进程则使用一个可变的工作集来读。图中有趣的部分是2^20到2^23字节之间的部分。如果两个核的L2缓存是完全独立的,这意味着一旦L2缓存被耗尽, 我们可以预期所有四个测试的性能将下降2^21到2^22字节之间。我们可以看到在图3.31中情况并非如此。对于后台进程正在写入的情况,这是最明显的。在工作集大小达到1MB之前,性能就开始下降。这两个进程不共享内存,因此不会生成RFO消息。这些都是纯粹的缓存逐出问题。智能缓存处理有它的问题,即每个核的缓存大小接近1MB,而不是可用的2MB。人们只能希望,如果核之间共享缓存仍然是将来处理器的一个特性,那么用于智能缓存处理的算法是固定的。

图3.31  两个进程的带宽

在引入更高级别的缓存之前,拥有两个L2缓存的四核处理器的设计只是一个权宜之计。与单独的套接字和双核处理器相比,这种设计没有显著的性能优势。两个核通过同一外部的FSB总线通信。没有特殊的快速数据交换。

未来多核处理器的缓存设计将在更多的层上。AMD的10h处理器系列开始了。我们是否会继续看到低级缓存被一个处理器核心的子集共享还有待观察(在2008年的第一代处理器中L2缓存是不共享的)。我们有必要引入更多级别的缓存,因为高速缓存和频繁使用的缓存不能在多个核心之间共享,性能会受到影响。我们也需要具有很高关联性的大缓存。缓存大小和关联度,都必须随着和共享缓存核心数量的增加而增加。使用大的L3缓存和大小合理的L2缓存是一种合理的选择。L3缓存速度较慢,但在理想情况下,它的使用频率不如L2缓存。对于程序员来说,所有这些不同的设计意味着做调度决策时的复杂性。为了获得最佳性能,必须了解工作负载和机器架构的细节。幸运的是,我们有确定的机器架构的支持。这些接口将在后面几节中介绍。

3.5.4 FSB影响

FSB在机器的性能中起着核心作用。缓存数据的存取速度受制于与内存连接的总线的速度。我们可以通过在两台机器上运行一个程序来说明这一点,这两台机器上的内存模块的速度各不相同。图3.32显示了在64位机器上对NPAD=7进行Addnext0测试(将pad[0]的下一个元素的内容添加到pad[0]元素中)的结果。两台机器都使用Intel Core 2处理器,第一个使用667MHz DDR2模块,第二个使用800MHz模块(增加了20%)。

图3.32  FSB速率的影响

数据显示,当FSB在大规模工作集下压力很大时,我们确实看到了大带宽的好处。该测试中测量到的最大性能增长为18.2%,接近理论最大值。这表明,更快的FSB确实能带来巨大的回报。当工作集在缓存的范围内时,FSB的速率并不重要(这些处理器有4MB L2)。必须记住,我们在这里测量的只是一个程序。系统的工作集包括所有并发运行的进程所需的内存。通过这种方式,使用更小的程序很容易超过4MB或更多的内存。

今天,一些英特尔的处理器支持的FSB速度高达1333MHZ,这将意味着另一个60%的增长。未来将会看到更高的速度。如果速度很重要,并且工作集的大小很大时花大价钱拥有快速RAM和高FSB速度肯定是值得的。不过,人们必须小心,因为即使处理器可能支持更高的FSB速度,主板/北桥可能不支持。检查规格是很重要的。

纯属个人学习翻译有翻译不准确的地方请指出。

《What every programmer should know about memory》-CPU Caches译相关推荐

  1. What every programmer should know about memory 笔记

    What every programmer should know about memory, Part 1(笔记) 每个程序员都应该了解的内存知识[第一部分] 2.商用硬件现状 现在硬件的组成对于p ...

  2. NUMA全称 Non-Uniform Memory Access,译为“非一致性内存访问”,积极NUMA内存策略

    目录 NUMA的诞生背景 NUMA构架细节 上机演示 NUMA Memory Policy What is NUMA Memory Policy? Memory Policy Concepts Sco ...

  3. 每个程序员都应该了解的 CPU 高速缓存 英文原文:Memory part 2: CPU caches

    现在的CPU比25年前要精密得多了.在那个年代,CPU的频率与内存总线的频率基本在同一层面上.内存的访问速度仅比寄存器慢那么一点点.但是,这一局面在上世纪90年代被打破了.CPU的频率大大提升,但内存 ...

  4. 开发一款抓取Android系统Log的APP(logcat, kernel, Memory, cpu)

    近期项目需要一款抓取系统log的实用工具,具体的内容包括kernel中的log, cpu中的log,  memory 中的log, 以及system中的log,在Android4.1之后 认为应用读取 ...

  5. 每个程序员都应该了解的内存知识(2)-CPU caches

    [原文:http://www.cnblogs.com/mikewolf2002/archive/2013/04/13/3017855.html] 英文原帖:http://lwn.net/Article ...

  6. 多核CPU缓存一致性协议MESI

    在计算机系统中,CPU高速缓存(英语:CPU Cache)是用于减少处理器访问内存所需平均时间的部件.在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU寄存器.其容量远小于内存,但速度却可以接近 ...

  7. 多核心CPU并行编程中为什么要使用内存屏障 memory barriers / 内存栅栏 memory fence

    文章目录 前言 现代Intel® CPU架构 指令集 CISC, RICS ... Intel各个时期的CPU微架构(microarchitecture)特点 P6 Family Microarchi ...

  8. CPU,GPU,Memory调度

    CPU,GPU,Memory调度 HDD&Memory&CPU调度机制(I/O硬件性能瓶颈) 图1. HDD&Memory&CPU调度图 CPU主要就是三部分:计算单元 ...

  9. CPU与内存的那些事

    下面是网上看到的一些关于内存和CPU方面的一些很不错的文章. 整理如下: 转: CPU的等待有多久? 原文标题:What Your Computer Does While You Wait 原文地址: ...

最新文章

  1. 介绍一款贼美的Vue+Element开源后台管理UI
  2. 64.多态性实现机制—静态分派与动态分派(方法解析、静态分派、动态分派、单分派和多分派)
  3. linux服务器没网情况下手动安装软件几个方法
  4. php 写 mysql 事件_PHP日歷,包含來自MySQL數據庫的重復事件
  5. jQuery的实现,去掉传入html代码两端的空格:
  6. Java字符串与日期互转
  7. 杭电2159FATE
  8. 【极客学院出品】Cocos2d-X系列课程之六-用户交互事件处理方法
  9. 九、全面提高人民生话水平
  10. 巨蟒django之CRM2 展示客户列表分页
  11. 用html5写个炫酷的3d电子相册
  12. AIX操作系统使用心得
  13. 计算机一级幻灯片样式,PPT怎么设置单个幻灯片为背景样式4?网友:原来这么简单!...
  14. 计算机二级excel经典操作题,计算机二级office经典题库
  15. K-Java WAP浏览器
  16. 1. 神禹(shenyu)网关启动踩坑
  17. 【英文SEO】Google网站流量分析
  18. 网吧无盘服务器连接交换机,网吧为什么要使用万兆交换机
  19. jquery 自动触发a 标签的click()方法
  20. C# 编写VLC视频事件处理程序 libvlc libvlc_event_attach libvlc_event_manager libvlc_event_type ibvlc_event_e用法

热门文章

  1. 【css3】径向渐变实现任意大小背景圆点
  2. 鸿蒙系统1007鸿蒙系统,1007 燃爆 | 华为“鸿蒙”真的来了!看完这些商标来历,网友们又激动了...
  3. 群集服务器作用,使用集群服务器的必要性是什么?其优缺点是什么?
  4. chatgpt赋能python:Python绝对值符号:用法及实例
  5. Brother打印机驱动:如何下载、安装和更新
  6. MATLAB中果蝇味道浓度判定函数,果蝇优化算法的加权策略研究
  7. 百度云管家吾爱破解论坛会员专用修正安装版
  8. 基于SiamMask网络的智能视频监控实时人员跟踪系统
  9. 小学生计算机模型的制作,19届中小学电脑制作活动小学组课件模型推荐
  10. access2010与mysql_计算机文化基础(第六章数据库系统与Access2010)