缓存内存的工作原理是胡萝卜加大棒。卡罗是地方性原则,棍子是阿姆达尔';这是法律。局部性原则认为程序倾向于将它们的内存引用聚集在一起。一次引用的内存位置可能会再次被引用:时间位置。参考位置附近的记忆位置很可能很快就会被引用:空间性。阿姆达尔';s Law表示,使用速度更快的组件所获得的性能改善受到使用速度更快组件的时间的限制。在这种情况下,CPU和缓存是快速组件,而内存则是缓慢的。
如果您的程序遵循局部性原则,它将受益于快速缓存并以处理器速度运行。如果没有';t、 它对阿姆达尔和#39负责;s定律并以内存速度运行。命中率必须非常高,比如说98%,然后处理器速度才能显著增加。
阿姆达尔和#39;s定律对多处理器有特殊情况惩罚[Schimmel94]。在多处理器上进行抖动会降低所有处理器的速度。它们各自等待对方等待内存,而多处理器提供的内存则相反。遵守多处理器的局部性原则,但不遵守软件共享的原则,不是吗';这不仅是一件好事,也是一种必需品。
缓存编程风格的目标是增加本地性。了解缓存的结构和行为很重要,但更重要的是了解要利用的基本属性和要避免的最坏情况。本文将详细介绍,摘要将提供指导。
作为一个运行示例,我将研究Linux[Maxwell99],尤其是调度程序。其想法是稍微修改数据结构和代码,尝试更有效地使用缓存。希望我能实现两个目标:一个实用的缓存教程和一些Linux性能改进。
我将主要使用circa 1998 350 MHz Deschutes Pentium II系统作为一个具体的例子,而不是一般性地讨论高速缓存系统。它有以下特点:
存储大小延迟注释-------------------------------------------------------------------寄存器32字节3ns寄存器重命名文件L1 32K 6ns片上,半奔腾II时钟速率L2 256K 57 ns片外,在[Intel99a]封装内存64 MB 162 ns 100 MHz SDRAM、单银行磁盘10GB 9ms DMA IDE网络上,56K PPP
这些数字可能会发生变化。CPU性能每年提高约55%,内存每年提高约7%。内存大、成本低、速度慢,而缓存小、速度快、成本高。双数据速率SDRAM和Rambus(如果可用)将改善内存带宽,但不会改善延迟。这些改进将有助于多媒体等更具可预测性的应用程序,但不会影响Linux等可预测性较差的程序。
首先,简单介绍一下缓存。缓存在大小和速度方面都与存储层次结构相匹配。缓存线未命中、页面错误和HTTP请求在这个层次结构的不同级别上是相同的。当Squid代理没有';如果缓存中没有对象,它会将HTTP请求转发到原始服务器。当CPU请求的地址不是';t在内存中,出现页面错误,从磁盘读取页面。当CPU请求的地址不是';t在缓存中,包含的缓存线从内存中读取。LRU、工作集、关联性、一致性、哈希、预取都是存储层次结构的每一层中使用的技术和术语。
在每种情况下,层次结构中一个较小的较快级别都有另一个较大的较慢级别作为后盾。如果性能受到过度使用较慢级别的限制,则根据Amdahl和#39;根据s定律,只需加快速度,就可以实现微小的改进。
关于缓存[Handy98],最重要的是要理解缓存线。通常,缓存线的长度为32字节,并与32字节的偏移量对齐。首先,将内存块amemory line加载到缓存线中。这个代价是缓存丢失,即内存延迟。然后,加载后,只要缓存线中的字节仍在缓存中,就可以引用它,而不会受到惩罚。如果缓存线不是';当需要加载另一个内存行时,它最终会被删除。如果修改了缓存线,则需要先写入缓存线,然后才能删除缓存线。
这是最简单也是最重要的缓存视图。它的教训有两个:将尽可能多的数据打包到缓存线中,并使用尽可能少的缓存线。未来内存带宽的增加(DDR和Rambus)将奖励这种做法。缓存更复杂的特性,即结构和行为,对于理解和避免最坏情况下的缓存行为:抖动非常重要。
竞争和共享缓存线是一件好事,直到apoint成为一件坏事。理想情况下,快速缓存将具有较高的缓存命中率,并且性能不受内存速度的限制。但是,当缓存线太少而竞争太激烈时,就会发生一件非常糟糕的事情,即抖动。这种情况发生在数据结构的最坏情况下。不幸的是,当前的分析工具关注的是指令而不是数据。这意味着程序员必须了解数据结构的最坏情况,并避免它们。查找热点的有用工具是cacheprof[Seward]。
奔腾II[Shanley97]32K一级缓存由1024条32字节缓存线组成,这些缓存线被划分为512行缓存的指令和数据库。它使用颜色位5-11索引成一组缓存线。并行地,它比较索引集中每个缓存线的标记位12-31(12-35与Pentium III物理地址扩展)。L1使用4路集合关联映射,将512行划分为128组4条缓存线。
这些集合中的每一个都是最近使用最少的(LRU)列表。如果有匹配项,则使用匹配的缓存线,并将其移动到列表的前面。如果没有';如果匹配,则从二级获取数据,替换列表末尾的缓存线,并将新条目放在列表的前面。
同一颜色的两条内存线竞争同一组4条缓存线。如果它们的颜色位(5-11)相同,则它们的颜色不相同。或者,如果它们的衣服相差4096:2^(7个颜色位+5个偏移位)的倍数,则它们的颜色相同。例如,地址64和12352的差值为12288,即3*4096。因此,64和12352竞争总共4条一级缓存线。但是64和12384的差值是12320,不是4096的倍数,所以它们不';t对相同的一级缓存线进行补偿。
指令也被缓存。奔腾II一级缓存是哈佛(Harvard)或分割指令/数据缓存。这意味着指令和数据永远不会竞争相同的一级缓存线。L2是一个统一的缓存。统一意味着只有一个缓存库,指令和数据竞争缓存线。
L2与L1相似,只是更大、速度更慢。我的奔腾II上的联机包256K二级缓存有8192条缓存线。它也是4路集关联的,但是统一的。有奔腾II';使用512K ofL2将集合大小增加到8。还有PIII';具有高达2MB的L2的s。如果二级缓存线未命中,将从内存中提取缓存线。如果两条内存线相差64K:2^(11个缓存色位+5个偏移位)的倍数,则它们将竞争相同的L2cache线。
我们将从简单的事情开始。最好是把每件事都和一个长单词的边界对齐。Linux是用gcc编程语言编写的,并且仔细研究了gcc标准文档";使用和移植GNU CC";[Stallman 00]因此是必要的:没有人像RichardStallman那样拥抱和延伸。gcc对智能自动对齐的结构字段对齐特别有用。ANSI C标准允许根据实施情况进行包装或填充。
gcc自动将d_Recen与长边界对齐。这适用于无符号short,但对于x86上的short,编译器必须插入符号扩展指令。如果使用的是SaveSaveStEdStudio,请考虑使用无符号短。例如,在<;linux/mm。h>;将vm_avl_height字段更改为unsignedshort可以为典型构建节省32字节的指令。它也可以是一个int。
字符串也应该对齐。例如,如果源和目标都是长字对齐的,则strncmp()可以一次比较两个长字,即廉价的SIMD。egcs2的x86代码生成器。95.2有一个不错的小bug,它不';t ALL对齐短字符串并将长字符串与缓存线对齐:
char*short_string=";短串";;字符*长字符串=";a_long_long_long_long_long_long_long_long_long_long_long_long_long_long_long_long_圣戒指"。LC0:。字符串";一根短线";//未对齐的字符串。。。 .对齐32。LC1://与缓存线对齐。字符串";a_long_long_long_long_long_long_long_long_long_long_long_圣戒指";
这里需要的是将两个字符串与长单词对齐。对齐4。这样使用的空间更少,对齐效果更好。在非典型Linux构建中,这节省了大约8K。
数组和结构列表提供了缓存大量数据的机会。如果频繁访问的字段被收集到单个缓存线中,则可以通过单内存访问加载它们。这可以减少延迟和缓存占用。然而,如果访问大量数据,它也会增加缓存占用空间。在这种情况下,包装效率和污染更为重要。
因此,对于阵列,阵列的底部应该与缓存对齐。结构的大小必须是缓存线大小的整数倍或整数除数。如果这些条件保持不变,那么通过归纳,缓存线阵列的每个元素都将对齐或压缩。连接结构在对齐方面类似,但don';有尺寸限制。
mem_map_t类型的结构数组被pageallocator用作软件页表:
/**尝试将最常访问的字段保存在单个缓存线*中(16字节或更大)。这种排序在32位处理器上应该特别有益类型定义结构页{//from linux-2.4.0-test2 structlist_head list;//2,4 struct address_space*映射;//1,2 unsignedlong index;//1,2 structpage*next_hash;//1,2 atomic_t计数;//1,1+1个无符号长标志;//1,2结构列表\u头lru;//2,4等待队列头等待;//5,10结构页**pprev_散列;//1,2 struct buffer_head*buffers;//1,2个无符号长虚拟;//1,2 struct zone_struct*zone;//1,2}mem_map_t;//18*4==72 x86//36*4==144 Alpha
在32位奔腾上,mem_map_t的大小为72字节。在2.2.16中是40字节。由于阵列分配代码使用SSIZEOF(mem_map___t)来对齐阵列,因此基座对齐不正确。在任何情况下,MAP_ALIGN()都可以替换为L1_CACHE_ALIGN(),它使用更简单的代码:
#定义映射对齐(x)(((x)%sizeof(mem_MAP_t))=0)\?(x) :((x)+sizeof(mem_map_t)-(x)%sizeof(mem_map_t)))lmem_map=(结构页面*)(页面偏移+贴图对齐((无符号长)lmem_贴图-页面偏移)#定义一级缓存对齐(x)((x)+(一级缓存字节-1))\&~(一级缓存字节-1))lmem_映射=(结构页*)一级缓存对齐((无符号长)lmem_映射);
在64位Alpha上,long是8字节,带有8字节对齐,sizeof(mem_map__t)是144字节。flags字段没有';t不需要是长的,它应该是一个int。因为原子_t也是一个int,而且两个字段相邻,所以它们会组合成一个长单词。Page wait队列头过去是一个指针。将其更改回原来的版本将节省足够的内存,以允许缓存对齐32位和64位版本。
可以针对特定的处理器进行有条件的编译。Linux有一个包含文件<;asm-i386/高速缓存。h>;,定义x86体系结构系列的一级缓存线大小,即一级缓存字节。slab分配器[Bonwick94]从内存页分配小对象,当客户机请求带有Lab_HWCACHE_ALIGN标志的缓存对齐对象时,它使用1_CACHE_字节。
/**包括/asm-i386/缓存。h*/#如果CPU==586 | CPU==686#定义一级缓存字节32#否则#定义一级缓存字节16#endif#endif#
如果有人得到了一个以486为目标的、经过保守编译的Red Hat内核,那么它就假定有16字节的缓存线。这对雅典人来说也是一件好事。在2.4中,通过在<;linux/autoconf。h>;。
如果在设计用于便携软件的结构的fieldsinside时必须假定一个缓存线大小,请使用32字节缓存线。例如,mem_map_t可以使用这个。请注意,32字节对齐的缓存线也是16字节对齐的。PowerPC 601名义上有一个64字节的缓存线,但实际上有两个连接的32字节缓存线。Sparc64有一个32字节的L1和一个64字节的L2缓存线。将所有系统都视为具有32个字节缓存线并枚举异常(如果有)要容易得多。Alpha和Sparc64有32字节的缓存线,但Athlon和安腾(证明这一规则的例外)有64字节的缓存线。IBM S/390 G6有一个256K的一级缓存,带有128字节的缓存线。
在绝大多数处理器上,32字节缓存线是正确的选择。最重要的是,如果在32字节的情况下解决并避免了足迹和最坏情况下的颠簸场景,那么在其他情况下就可以避免它们。
Linux用分配了两个4K页面的task_结构表示每个进程。任务列表是任务结构的列表';sof所有现有流程。runqueue是任务结构的列表';这是所有可运行进程的一部分。每次调度程序需要查找另一个要运行的进程时,它都会在整个运行队列中搜索最值得运行的进程。
IBM[Bryant00]的一些人注意到,若有几个线程,调度占用了相当大的可用CPU时间。在一台具有数百个本地Java线程的单处理器机器上,仅调度程序就占用了超过25%的可用CPU。这在sharedmemory SMP机器上变得更糟,因为内存总线争用增加。这不是';t刻度。
事实证明,调度器中的goodness()例程引用了task_结构中的几个不同缓存线。在重新组织任务结构之后,goodness()现在只引用一条缓存线,CPU周期计数从179个周期减少到115个周期。这仍然很多。
下面是重要的缓存线、Linux调度循环和thegoodness()例程。调度器循环遍历entirerunqueue,用goodness()计算每个进程,并找到下一个要运行的最佳进程。
结构任务{…long counter;//关键2cd缓存线长优先级;无符号长策略;结构mm_struct*mm,*active_mm;int有_cpu;int处理器;结构列表_headrun_lisT//只有第一个长单词…};tmp=运行队列头。下一个while(tmp!=&;runqueue_head){p=list_entry(tmp,struct task_struct,run_list);if(can_schedule(p)){//在另一个CPU上运行int weight=goodness(p,this_CPU,prev->;active_mm);if(weight>;c)c=weight,next=p;}tmp=tmp->;下一步;}#定义进程更改惩罚15//处理器关联静态内联int goodness(struct task_struct*p,int this_cpu,struct mm_struct*this_mm){int weight;如果(p->;policy!=SCHED_OTHER){weight=1000+p->;rt_priority;//实时进程退出;}重量=p->;柜台如果(!重量)掉出来;//没有剩余的量子#ifdef _usmp _; if(p->;处理器==此cpu)权重+=程序更改U惩罚;//处理器关联性#endif(p->;mm==this_-mm)//相同线程类权重+=1;//电流?重量+=p->;优先事项out:返回权重;}
即使对于负载很重的服务器,长运行队列也肯定不是常见的情况。这是因为事件驱动的程序使用poll()进行自我调度。相比之下,Java、Apache和TUX更喜欢线程化风格。讽刺的是,poll()也存在可伸缩性问题,在其他Unix系统上也是如此[Honeyman 99]。此外,Linux 2.4 x86内核将最大线程数增加到4000个以上。
在SMP机器上,进程与它们运行的最后一个CPU具有调度关联。这个想法是,一些工作集仍然在本地缓存中。但是调度程序有一个微妙的SMP错误。当一个CPU在运行队列上没有进程时,调度程序将把它分配给一个与另一个CPU有亲缘关系的可命名进程。明智的做法是首先向运行队列上的进程分配更多量子,也许是那些与CPU有关联的进程。即使这样,空闲也可能更好,尤其是在运行队列较短的情况下。
现代CPU积极地预取指令,但数据呢?CPU不';t预取数据缓存线,但矢量化编译器和程序可以。根据CPU对缓存线的处理量,可能需要提前预取多条缓存线。如果预回迁提前了足够长的时间,它就赢了';t判断缓存线是否在内存中,而不是L2[Intel99a]。
通常,预取用于多媒体内核和矩阵操作,其中预取地址可以轻松计算。在数据结构上运行的算法也可以使用预取。除了预取地址将遵循alink而不是地址计算之外,其他方法也适用。数据结构的预取非常重要,因为内存带宽的增加比延迟的减少更快。遍历数据结构更容易出现延迟问题。通常只使用结构中的几个字段,而使用多媒体时,通常会检查每个字段。
如果一条预取指令可以在使用缓存线之前调度20-25次或SOI指令,则取数可以完全重叠指令执行。精确的预取调度距离是处理器和内存的一个特征。超标量处理器一次执行多条指令。
如果算法正在遍历可能是inL2的数据结构,并且可以在使用缓存线之前安排预取6-10条指令,则取数可以完全与指令执行重叠。
Linux调度程序循环是从二级缓存线预取的一个很好的候选者,因为goodness()很短,在IBMpatch之后,它只触及一条缓存线。
这是调度程序的预取版本。在goodness()的执行过程中,它与二级缓存线的预取重叠。
tmp=运行队列头。下一个while(tmp!=&;runqueue_head){p=list_entry(tmp,struct task_struct,run_list);tmp=tmp->;next;CacheLine_Prefetch(tmp->;next)//movl xx(%ebx),%eax if(can_schedule(p)){int weight=goods(p,this_cpu,prev->;active_mm);if(weight>;c)c=重量,next=p; } }
华盛顿
......