Intel64及IA-32架构优化指南第7章——7.7 使用非临时存储的存储器优化
7.7 使用非临时存储的存储器优化
非临时存储也可以用于管理Cache中的数据记忆。对非临时存储的使用包括:
● 不干扰Cache层级联结许多写
● 管理哪些数据结果仍然保留在Cache中,哪些是短暂的。
这些使用模型的详细的实现涵盖在以下小节中。
7.7.1 非临时存储与软件写联合
在数据以以下两种情况被存储时使用非临时存储:
● 写一次(非临时的)
● 写的数据太大以至于导致Cache完全被破坏
非临时存储并不调用一个Cache行分配,这意味着它们不是写分配的。这样,Cache不会遭受污染也不会产生脏数据写回使得与有用的数据带宽相互竞争。不使用非临时存储,总线带宽会在Cache开始受到破坏时遭到损耗,由于脏数据的写回。
在流SIMD扩展实现中,当非临时存储被写入回写或写联合的存储器区域时,这些存储是弱次序的,并且将会在存储器的写联合缓存内部联合在一起,然后以一条行串迸发事务写出到存储器。为了实现可能的最佳性能,建议将数据对齐为Cache行边界,并且在使用非临时存储时以一个Cache行大小连续地写这些数据。如果连续的写受于程序限制而无法达成,那么软件写联合(SWWC)缓存可以用来允许行串迸发事务。
你可以在你的应用程序中声明小的SWWC缓存(为每个缓存的一条Cache行)来允许显式的写联合操作。不要立即写到非临时存储空间,程序先将数据写到SWWC缓存,然后将这些数据组合到这些缓存内。当缓存被充满时,即达到一条Cache行的大小(奔腾4处理器为128字节),程序只要将一个SWWC缓存使用非临时存储写出。尽管SWWC方法需要显式的指令来执行临时的写和读,但这确保了在前端总线上的事务引发行事务而不是几次部分的事务。应用程序性能从这个技术的实现获得了相当大的收益。这些SWWC缓存可以被维持在第二级Cache中,并且通过该程序来重新使用。
7.7.2 Cache管理
流指令(PREFETCH以及STORE)可以用来管理数据并最小化对保持在处理器Cache内的临时数据的干扰。
此外,奔腾4处理器利用了Intel C++编译器对C++语言层面的流SIMD扩展的支持。流SIMD扩展以及MMX技术指令提供了内建指令允许你优化对Cache的利用。这样的Intel编译器内建指令有_MM_PREFETCH,_MM_STREAM,_MM_LOAD,_MM_SFENCE。要获得详细信息,请参考Intel C++编译器用户指南文档。
以下在视频编码器和解码器操作以及在简单的8字节存储器拷贝中使用预取指令的例子,描述了从使用预取指令而做高效的Cache管理来获得性能收益。
7.7.2.1 视频编码器
在一个视频编码器中,在编码过程中所使用的某些数据被保持在处理器的第二级Cache中。通过做到这点来最小化必须从系统存储器重新读的参考流的次数。为了确保其它写不干扰第二级Cache中的数据,流存储(MOVNTQ)用来绕开所有处理器Cache来写。
为视频编码器而实现的Cache预取管理减少了存储器交通。第二级Cache污染减少通过防止使用一次的视频帧数据进入第二级Cache来确保。使用一个非临时预取(PREFETCHNTA)指令将数据仅带入到第二级Cache中的一路,从而减少了第二级Cache的污染。
如果被直接带入到第二级Cache的数据没被重新使用,那么从非临时预取相对于临时预取会有一个性能增益。编码器使用非临时预取避免了第二级Cache的污染,增加了第二级Cache的命中率以及减少了由于受污染而要写回到存储器的次数。性能增益得益于对第二级Cache的更高效的使用,而不仅仅是预取本身。
7.7.2.2 视频解码器
在视频解码器例子中,完成的帧数据被写到图形卡的本地存储器,该存储器被映射为WC(写联合)存储器类型。参考数据的一个拷贝在稍后时间被处理器存储到WB存储器为了生成后面的数据。假定参考数据的尺寸太大以至于无法使用到处理器的Cache中。一个流存储用来绕开Cache写数据,以避免显露保存在Cache中的其它临时数据。之后,处理器使用PREFETCHNTA重新读取数据,这确保了最大带宽,而且通过使用非临时(NTA)版本的预取最小化对其它已被cache的临时数据的干扰。
7.7.2.3 从视频编码器和解码器实现得出的结论
这两个例子暗示了,通过使用非临时预取与非临时存储的一个适当的组合,一个应用程序可以设计成通过防止第二级Cache污染,将有用的数据保持在第二级Cache来减少存储器事务的负荷,并减少代价高昂的写回事务。即便一个应用程序没有从让数据由预取而准备好而获得性能增益,这仍然能从对第二级Cache和存储器的更高效的使用来提升。这样的设计减少了编码器对诸如存储器总线这种临界资源的需求。这使得系统更平衡,导致更高的性能。
7.7.2.4 优化存储器拷贝例程
为大量数据创建存储器拷贝例程在软件优化中是一个普遍的任务。例7-9呈现了对一个简单的存储器拷贝的一个基本算法。
例7-9:一个简单的存储器拷贝的基本算法
#define N 512000 double a[N], b[N]; for(i = 0; i < N; i++){ b[i] = a[i]; }
这个任务可以使用各种编程技术来优化。一种技术使用了软件预取以及流存储指令。这在以下篇幅中讨论,而一个代码例子在例7-10中展示。
存储器拷贝算法可以使用流SIMD扩展来优化,考虑以下情况:
● 数据对齐
● 存储器中适当的页布局
● Cache大小
● 带有存储器访问的事务后备缓存(TLB)的交互
● 结合预取与流存储指令。
在本章所讨论的准则在这个简单的例子中起作用。奔腾4处理器需要填装TLB,就跟奔腾III处理器一样,由于软件预取指令在这两款处理器上不会启动页表遍历。
例7-10:使用软件预取的一个存储器拷贝例程
#define PAGESIZE 4096; #define NUMBERPAGE 512 // 适应于一个页的元素个数 double a[N], b[N], temp; for(kk = 0; kk < N; kk += NUMBERPAGE){ temp = a[kk + NUMBERPAGE]; // 填装TLB // 使用块大小 = 页大小 // 预取整个块,每次循环取一个Cache行 for(j = kk+16; j < kk + NUMBERPAGE; j += 16){ _mm_prefetch((char*)&a[j], _MM_HINT_NTA); } // 每次循环拷贝128字节 for(j = kk; j < kk + NUMBERPAGE; j += 16){ _mm_stream_ps((float*)&b[j], _mm_load_ps((float*)&a[j])); _mm_stream_ps((float*)&b[j+2], _mm_load_ps((float*)&a[j+2])); _mm_stream_ps((float*)&b[j+4], _mm_load_ps((float*)&a[j+4])); _mm_stream_ps((float*)&b[j+6], _mm_load_ps((float*)&a[j+6])); _mm_stream_ps((float*)&b[j+8], _mm_load_ps((float*)&a[j+8])); _mm_stream_ps((float*)&b[j+10], _mm_load_ps((float*)&a[j+10])); _mm_stream_ps((float*)&b[j+12], _mm_load_ps((float*)&a[j+12])); _mm_stream_ps((float*)&b[j+14], _mm_load_ps((float*)&a[j+14])); } // 结束拷贝一个块 // 结束拷贝N个元素 } _mm_sfence();
7.7.2.5 填装TLB
TLB是一个快速存储器缓存,用于通过提供对页表条目的快速访问来提升虚拟存储地址到一个物理存储地址转换的性能。如果存储器页被访问,并且页表条目不驻留在TLB中,那么会产生一个TLB失败,并且页表必须从存储器读取。
TLB失败导致性能下降,由于必须执行另一次存储器访问(假定该转换没有出现在处理器Cache中)来更新TLB。TLB可以通过访问(或触碰)在那个页中的一个地址为下一次所想要的页来预加载页表条目。这个与预取类似,但在它使用前所加载的不是一个数据Cache行,而是页表条目。这帮助确保页表条目驻留在TLB中,而且在后续请求时发生预取。
7.7.2.6 使用8字节的流存储与软件预取
例7-10展现了使用第二级Cache的拷贝算法。该算法执行以下步骤:
1、使用分块技术将8字节的数据使用_MM_PREFETCH内建函数从存储器传输到第二级Cache,一次用128字节来填充一个块。一个块的大小应该小于第二级Cache大小的一半,但要足够大以平摊循环的成本。
2、使用_MM_LOAD_PS内建函数将数据加载到一个XMM寄存器中。
3、将8字节数据通过_MM_STREAM内建函数传输到一个不同的存储器位置,旁通Cache。对于这种操作,确保为存储器而预取的页表条目被预加载进TLB是很重要的。
在例7-10中,使用八个_MM_LOAD_PS与_MMSTREAM_PS内建函数,使得所有被预取的数据(一条128字节的Cache行)被写回。预取和流存储在独立的循环中执行以最小化数据的读与写之间的切换。这很大地提升了存储器访问带宽。
TEMP = A[KK+CACHESIZE]指令用于确保使用了数组的页表条目,并且A在预取之前进入了TLB。这对于一次预取本身是有必要的,由于用这条指令从那个存储器位置填充了一条Cache行。因而,预取从这个循环中的KK+4开始。
这个例子假定拷贝的目的非临时地邻近于代码。如果要拷贝的数据注定要在不久的将来重新使用,那么流存储指令应该用固定的128位存储(_MM_STORE_PS)来代替。这是必要的,因为奔腾4处理器上的流存储直接将数据写到存储器,维护了Cache一致性。
7.7.2.7 使用16字节流存储与硬件预取
用于优化一个大区域的存储器拷贝的一个可替换的技术是利用硬件预取器,16字节的流存储,并用一个分段的方法来分开总线读与写事务。见3.6.12小节。
该技术引入了两个阶段。在第一个阶段中,一个数据块从存储器写到Cache子系统。在第二个阶段,被cache的数据使用流存储写到它们的目的位置。
例7-11:使用硬件预取的存储器拷贝以及总线分段
void block_prefetch(void *dst, void *src) { _asm{ mov edi, dst mov esi, src mov edx, SIZE align 16 main_loop: xor ecx, ecx align 16 prefetch_loop: movaps xmm0, [esi+ecx] movaps xmm0, [esi+ecx+64] add ecx, 128 cmp ecx, BLOCK_SIZE jne prefetch_loop xor ecx, ecx align 16 cpy_loop: movdqa xmm0, [esi+ecx] movdqa xmm1, [esi+ecx+16] movdqa xmm2, [esi+ecx+32] movdqa xmm3, [esi+ecx+48] movdqa xmm4, [esi+ecx+64] movdqa xmm5, [esi+ecx+80] movdqa xmm6, [esi+ecx+96] movdqa xmm7, [esi+ecx+112] movntdq [edi+ecx], xmm0 movntdq [edi+ecx+16], xmm1 movntdq [edi+ecx+32], xmm2 movntdq [edi+ecx+48], xmm3 movntdq [edi+ecx+64], xmm4 movntdq [edi+ecx+80], xmm5 movntdq [edi+ecx+96], xmm6 movntdq [edi+ecx+112], xmm7 add ecx, 128 cmp ecx, BLOCK_SIZE jne cpy_loop add esi, ecx add edi, ecx sub edx, ecx jnz main_loop sfence } }
7.7.2.8 存储器拷贝例程的性能比较
对于一个大区域的吞吐,存储器拷贝例程依赖于以下几个因素:
● 实现存储器拷贝任务的编程技术
● 系统总线的特征(速度、峰值带宽、读/写事务协议的负荷)
● 处理器的微架构
上述所讨论的两种编码技术与两个非优化的技术的一个比较在表7-2中展示。
这个性能比较的基准是在第一代奔腾M处理器(CPUID代号为0x69n),带有400MHz的系统总线,在8MB区域的存储器拷贝的吞吐,使用类似于在例7-9中所展示的字节串行技术。相对于性能基准的提升程度,对于最近的处理器以及具有更高总线速度的平台,使用不同的编程技术进行比较。
第二种编程技术使用REP字符串指令以4字节为粒度搬移数据。第三列比较了在例7-10中所列出的编程技术的性能。第四列比较了一次取4K字节(使用硬件预取来聚集总线读事务)的吞吐的性能以及通过16字节流存储写入到存储器的性能。
总线速度的增加是对吞吐提升的主要贡献者。在例7-11中所展示的技术将可能在平台中更有效地利用更快的总线速度。此外,在第二级Cache内保持总的工作集的同时将块大小增加到4K字节的倍数能稍微提升吞吐。在表7-2中所展示的相对性能图对一个处理器内的清晰的架构条件是具有代表性的(比如,循环简单的代码许多遍)。将一个特定的存储器拷贝例程集成到一个应用的纯粹收益(完整的应用程序区域创建许多复杂的微架构条件)对于每个应用程序将会有所不同。
7.7.3 确定性的Cache参数
如果CPUID支持确定性的参数枝叶,那么软件可以使用该枝叶来查询Cache层级的每个层级。每个Cache层级的枚举是在ECX寄存器中通过指定一个索引值(从0开始)。
参数列表在表7-3中展示。
确定性的Cache参数枝叶提供了一个方法,通过枚举Cache参数来实现软件向前兼容的程度。确定性的Cache参数可以被用于几种场景,包括:
● 确定Cache层级的大小。
● 将Cache分块参数适应于跨超线程技术、多核以及单核处理器的一个Cache层级的不同分享拓扑。
● 确定一个MP系统中的多线程资源拓扑(见第7章)。
● 确定使用多核处理器的一个平台上的Cache层级拓扑。
● 管理线程,以及处理器的亲密性。
7.7.3.1 使用确定性的Cache参数的Cache共享
提升Cache位置性是软件优化的一个重要部分。比如,一个Cache分块算法可以被设计为在运行时优化块大小,为单处理器实现以及为各种不同的多处理器执行环境(包括支持HT技术的处理器,或多核处理器)。
基本的技术是将块大小的上限设置为小于目标Cache层级的大小除以目标Cache层级所服务的逻辑处理器的个数。这个技术可应用于多线程应用编程。该技术也可从作为一个多任务负载的一部分的单线程应用获利。
7.7.3.2 在单核或多核中的Cache共享
确定Cache参数用于管理在多线程应用中所共享的Cache层级,对于更精密复杂的情况下。一个给定的Cache层级在一个处理器中可以被多个逻辑处理器共享,也可以实现为被在一个物理处理器包中的几个逻辑处理器共享。
使用确定性的Cache参数枝叶以及与平台中每个逻辑处理器相关联的初始的APIC_ID,软件可以萃取共享一个Cache层级的逻辑处理器的拓扑关系和数量。
7.7.3.3 确定预取跨度
预取跨度(见CPUID.01H.EBX的描述)提供了处理器用PREFETCHh指令(PREFETCHT0、PREFETCHT1、PREFETCHT2以及PREFETCHNTA)将预取的那个区域的长度。当预取进入到Cache层级的某一特定层中时——进入到哪个层由所使用的指令来标识——软件将使用与跨度一样的长度。预取大小与数据Cache(1)以及统一Cache(3)的Cache类型相关;对于其它Cache类型应该被忽略。软件不应该假定一致性行大小就是预取大小。
如果预取跨度域是零,那么软件应该假定一个默认的64字节作为预取跨度。软件应该使用以下算法来确定使用什么预取大小依赖于确定性的Cache参数机制是否支持古老遗留的机制:
● 如果一个处理器支持确定性的Cache参数并提供了一个非零的预取大小,那么使用那个预取大小。
● 如果一个处理器支持确定性的Cache参数且不提供一个预取大小,那么对Cache层级的每个层的默认大小都是64字节。
● 如果一个处理器不支持确定性Cache参数,但提供了一个遗留的预取大小描述符(0xF0——64字节,0xF1——128字节),那么这将作为Cache层级的所有层的预取大小。
● 如果一个处理器不支持确定性Cache参数并且不提供一个遗留的预取大小描述符,那么32字节作为Cache层级所有层的默认大小。