Intel64及IA-32架构优化指南第7章——7.6 使用PREFETCH来优化存储器
7.6 使用PREFETCH来优化存储器
奔腾4处理器对于数据预取有两个机制:软件控制的预取以及一个自动的硬件预取。
7.6.1 软件控制的预取
软件所控制的预取使用由流SIMD扩展指令所引入的四种PREFETCH指令来允许。这些指令是将一条数据的Cache行带入到Cache层级中的各种等级和模式的暗示。软件控制的预取目的并不是为了预取代码。在一个多处理器系统上,当代码被共享时,使用软件预取会遭受非常严重的性能处罚。
软件预取具有以下特征:
● 可以处理不固定的访问模式,这些访问模式不触发硬件预取器。
● 可以使用比起硬件预取来更少的总线带宽;见下面
● 软件预取必须被添加到新的代码中,并且无法在已存在的应用中获利。
7.6.2 硬件预取
自动的硬件预取能将Cache行带入到统一的最后层Cache中,基于先前的数据失败。它将试图在预取流之前预取两条Cache行。硬件预取的特征有:
● 它需要数据访问模式中的固定性。
——如果一种数据访问模式具有常量跨度,那么硬件预取是有效的,如果该访问跨度少于硬件预取器的触发距离的一半(见表2-23)。
——如果访问跨度不是常量,那么自动硬件预取器可以屏蔽存储器延迟,如果两个相继Cache失败的跨度少于触发器上限距离(小跨度的存储器交通)。
——自动硬件预取器是最有效的,如果两个相继Cache失败的跨度仍然少于触发器上限距离并接近64个字节。
● 在预取器触发之前有一个启动处罚,并且也可能会有一个数组结束取数据。对于短的数组,负荷会减少有效性。
——硬件预取器在它开始操作之前需要几次取失败。
——硬件预取器产生对超越一个数组末尾的数据的请求,而这个数据不会被利用到。这个行为浪费了总线带宽。此外,这个行为导致了当取下一个数组的开头时会有一个启动处罚。软件预取可以识别并处理这些情况。
● 它将不会跨4K字节页边界来取数据。一个程序在硬件预取器开始从新页预取前不得不发起对新页的加载要求。
● 硬件预取器会消耗额外的系统带宽,如果应用的存储器交通具有伴随Cache失败的跨度比起硬件预取的触发器距离上限更大(大跨度存储器交通)的相当一部分。
● 已存在应用的有效性依赖于应用程序的存储器交通中小跨度对大跨度的比例。具有多数小跨度存储交通,并且具有良好的临时位置的一个应用,将会从自动的硬件预取器获得很大的利益。
● 在某些情况下,由大部分大跨度的Cache失败所组成的存储器交通可以通过重新安排数据访问序列来转换,以大跨度的开支替换为对小跨度的Cache失败的关注,从而充分利用自动硬件预取器。
7.6.3 用硬件预取以有效减少延迟的例子
考虑由一个常量访问跨度、环形指针追捕序列的数据所构成的一个数组的情况(见例7-2)。利用自动硬件预取机制来减少从存储器取一条Cache行的延迟的潜能可以根据访问跨度在64字节之间以及硬件预取的触发器门限距离的不同而有所不同,当构成环形指针追捕数组时。
例7-2:用常量跨度来构成环形指针追捕的一个数组
register char **p; char *next; // 用常量跨度为环形指针追捕来构成pArray p = (char**)&pArray; // p = (char**)*p; 加载一个指向下一个节点的值 for(i = 0; i < aperture; i += stride){ p = (char**)&pArray[i]; if(i + stride >= g_array_aperture){ next = &pArray[0]; } else{ next = &pArray[i + stride]; } *p = next; // 构成下一个节点的地址 }
为几种微架构实现的有效延迟减少在图7.1中展示。对于一个常量跨度的模式,自动硬件预取器的获利开始于触发器门限距离的一半而在Cache失败跨度为64字节时达到最大收益。
7.6.4 带有软件预取指令的延迟隐藏的例子
使用PREFETCH指令来实现存储器优化的最高程度需要对一个所给机器架构的理解。这节将关键的架构上的含义翻译为若干个程序员所使用的简单的准则。
图7-2和图7-3展示了一个简化的3D几何流水线的两个场景。一个3D几何流水线一般一次取一个顶点记录,然后对它执行变换和光照功能。这两个图都展示了两个独立的流水线,一个执行流水线,一个是存储器流水线(前端总线)。
从奔腾4处理器开始(类似于奔腾II和奔腾III处理器)完全结藕了执行和存储器访问的功能,这两个流水线可以并发工作。图7-2展示了执行和存储器流水线的圆顶图。当加载为访问顶点数据而发布时,执行单元处于闲置状态并等待数据返回。另一方面,存储器总线在执行单元处理顶点时处于闲置状态。这个场景严重地降低了一个结藕架构的优势。
由于糟糕的资源利用而导致性能损失能通过正确地调度PREFETCH指令来完全消除。正如图7-3所展示的那样,预取指令在两次顶点迭代之前被发布。这假定了在一次迭代中只有一个顶点获得处理,而每次迭代都需要一个新的数据Cache行。结果,当在迭代n时,顶点Vn在被处理;所请求的数据已经被带入到Cache中。与此同时,前端总线正在传输迭代n+1所需要的数据,顶点Vn+1。由于在Vn+1数据与Vn的执行之间没有依赖,所以Vn+1的数据访问能在Vn的执行背后完全隐藏。在这些情况下,流水线中就不会存在圆顶图所展示的情况,从而可能达成最佳性能。
预取对于具有密集计算,或接近计算界限与存储器带宽界限之间的边界的内部循环很有用。而对于偏重存储器带宽界限的循环就没那么有用。
当数据以及处于一级Cache时,预取会是没用的并且甚至可能降低性能,因为额外的微操作要么堵塞等待显著的存储器访问,要么可能被一起丢掉。这个行为是平台特定的并且可能在将来会有所改变。
7.6.5 软件预取使用清单
下列清单涵盖了需要被定位以及/或解决的问题,以适当地使用PREFETCH指令:
● 确定软件预取调度距离
● 使用软件预取一系列相关联的东西。
● 最小化软件预取的次数。
● 用计算指令来混合软件预取。
● 使用Cache分块技术(比如,条带挖掘)。
● 平衡单遍与多遍的执行。
● 解决存储器段冲突问题。
● 解决Cache管理问题。
后续小节将讨论上述列项。
7.6.6 软件预取调度距离
在代码中确定理想的预取安置依赖于许多架构参数,包括:要被预取的存储器的量,Cache查找延迟,系统存储器延迟,以及计算周期的估计。预取数据的理想的距离是依赖于处理器和平台的。如果距离太短,那么预取将不会隐藏在计算背后的延迟。如果预取距离太长,那么所预取的数据可能会在等到需要它的时候又被冲刷出Cache。
由于预取距离并不是一个良好定义的度量标准,对于这个讨论,我们定义了一个新的术语——预取调度距离(PSD),它用迭代次数来表示。对于大循环,预取调度距离可以被设置为1(即,调度向前一次迭代预取指令)。对于小的循环体(即带有很少计算的循环迭代),预取调度距离必须大于一次迭代。
计算PSD的一个简化的方程由数学模型而推导出来。对于一个简化的方程、完整的数学模型以及预取距离确定的方法论,见附录E。
例7-3描述了在循环体内对一个预取的使用。预取调度距离被设置为3,ESI有效地指向了一个数据行,EDX为被引用的数据的地址,而XMM1~XMM4是在计算中所用到的数据。例7-4在每次迭代使用了两个独立的数据Cache行。如果每次迭代有多余/少于两条Cache行,那么PSD将需要增减/减少。
例7-3:预取调度距离
top_loop: prefetchnta [edx + esi + 128*3] prefetchnta [edx*4 + esi + 128*3] ..... movaps xmm1, [edx + esi] movaps xmm2, [edx*4 + esi] movaps xmm3, [edx + esi + 16] movaps xmm4, [edx*4 + esi + 16] ..... ..... add esi, 128 cmp esi, ecx jl top_loop
7.6.7 软件预取连接
当执行流水线处于最大吞吐,没有遭致任何存储器延迟处罚时,可以达到最大性能。这可以通过在一个循环中预取要在后继迭代中所使用的数据来达成。消除存储器的流水化在执行流水线中生成冒泡。
为了要解释这个性能问题,可以用以条带格式处理3D顶点的一个3D几何流水线来作为一个例子。一个条带包含了一列顶点,其预定义的顶点次序形成了连续的三角形。可以容易地察觉到,存储器管线在条带边界处被消除流水,由于无效的预取安排。执行流水线对于每个条带会为头两次迭代而拖延。这样一来,完成一次迭代的平均延迟将会是165个(FIX)时钟。见附录E。
这种消除存储器流水在存储器流水线中以及执行流水线中都创建了低效性。这种消除流水效果可以通过应用一种称为预取连接的技术来移除。伴随这种技术,存储器访问以及执行可以被充分地流水化以及被充分地利用。
对于嵌套循环,消除存储器流水可能在一个内部循环的最后一次迭代与其相关联的外部循环的下一次迭代之间的间隔期间发生。不需要对预取插入做特别注意,从一个内部循环的第一次迭代加载会使得Cache失败并拖延执行流水线等待所要返回的数据,从而降低了性能。
在例7-4中,包含A[ii][0]的Cache行根本就没有被取,并且总是Cache失败。这假定了没有数组A[][]的足迹驻留在Cache中。这个消除存储器流水的拖延的处罚可以跨内部循环迭代来分批偿还。然而,当内部循环很短时,这会变得非常有害。此外,在最后一次PSD迭代中的最后一次预取是很浪费的,并且消耗了机器资源。在这里引入预取连接为了消除解存储器流水的性能问题。
例7-4:使用预取连接
for(ii = 0; ii < 100; ii++){ for(jj = 0; jj < 32; jj++){ prefetch a[ii][jj + 8] computation a[ii][jj] } }
预取连接可以在一个内部循环的边界与其相关联的外部循环之间桥接执行流水线冒泡。简单地通过将最后一次迭代从内部循环进行展开并指定用于下一次迭代中所要使用的数据的有效预取地址,消除存储器流水的性能损失能被完全移除。例7-5给出了重写的代码。
例7-5 连接以及展开内部循环的最后一次迭代
for(ii = 0; ii < 100; ii++){ for(jj = 0; jj < 24; jj += 8){ /* N-1次迭代 */ prefetch a[ii][jj + 8] computation a[ii][jj] } prefetch a[ii + 1][0] computation a[ii][jj] /* 最后一次迭代 */ }
该数据预取的代码片段的性能被提升了,并且只有外部循环的第一次迭代遭受了存储器访问的延迟处罚,假定计算时间大于存储器延迟。在进入嵌套循环计算之前插入一个所需要的第一个数据元素会消除或减少外部循环的头一次迭代的启动处罚。这个不太复杂的高级代码优化可以大大地提升存储器性能。
7.6.8 最小化软件预取的次数
预取指令就总线周期、机器周期和资源而言并不是完全毫无代价的,即便它们需要最少的时钟周期和存储器带宽。
过度的预取会导致性能处罚,因为机器的前端中的发布处罚以及/或存储器子系统中的资源竞争。这效果在目标循环较小的情况以及/或目标循环是发布边界的情况下会比较严重。
解决过度预取问题的一个方法是展开以及/或软件流水线进行循环以减少所需要的预取次数。图7-4呈现了一个代码例子,它实现了预取和循环展开来移除冗余的预取指令,这些冗余的预取指令预取了命中先前发布的预取指令的地址。在这个特定的例子中,在每隔一次迭代中,展开原始的循环一次就节省了六条预取指令和九条用于条件的跳转。
图7-5展示了软件预取在延迟隐藏中的有效性。
图7-5中的X轴表示了每次循环计算时钟的次数(每次迭代都是独立的)。Y轴表示每次循环以时钟测量的执行时间。次等的Y轴表示总线带宽利用百分比。这些测试根据一下参数而会有所变化:
● 加载/存储流的个数——每个加载以及存储流在每次迭代访问一个128字节的Cache行。
● 每次循环的计算量——这通过增加所执行的依赖的算术操作的次数而会有所变化。
● 每次循环的软件预取次数——比如,每16字节、32字节、64字节、128字节一次。
作为所期待的,图7-5中每个图示的最左边部分展示了当没有足够的计算来叠交存储器访问的延迟时,预取无法帮助提升性能并且该执行本质上是受存储器束缚的。这些图示也说明了多余的预取不会提升性能。
7.6.9 用计算指令混合软件预取
在一个循环体的开始或在一个循环之前将所有预取指令集中在一起似乎很方便,但这可能导致严重的性能下降。为了达成最佳可能的性能,预取指令必须与其它计算指令以指令顺序来散布,而不是集中在一起。如果可能的话,它们也应该被放在远离加载的地方。这样提升了指令级并行并减少潜在的指令资源拖延。此外,这种混合减少了存储器访问资源的压力,并且反过来减少了预取隐退时实际上没有预取数据的可能性。
图7-6描述了分布预取指令。对于一个奔腾4处理器的预取传播的一个简单而有用的启发是将一条预取指令每20到25个时钟插入。重新安排预取指令可能产生一个可观察到的代码提速,它强调了Cache资源。
注意:为了避免指令执行由于资源的过度利用而拖延,预取指令必须用计算指令来散布。
7.6.10 软件预取与Cache分块技术
Cache分块技术(诸如条带挖掘)用于提升临时位置以及Cache命中率。条带挖掘是对存储器的一维临时优化。当二维数组用在程序中时,循环分块技术(类似于条带挖掘,但是二维的)可以被用作为更好的存储器性能。
如果一个应用使用一个大数据集,它可以跨一个循环的多个遍而被重用,那么它将从条带挖掘获利。比Cache大的数据集将以分为足够小以至于能适应到Cache中的分组来处理。这允许临时数据更长地驻留在Cache中,减少了总线交通。
数据集大小和临时位置(数据特征)基本上影响了预取指令如何被应用于条带挖掘的代码中。图7-7展示了用于临时邻近的和临时非邻近的数据的两个简化的场景。
在临时邻近场景下,后续的遍使用相同的数据并且发现它们已经在第二级Cache中了。预取在一旁发布,这就是更好的情景。在临时非邻近场景中,在遍m中所使用的数据被(m+1)遍取代,需要将数据重新取到第一级Cache中以及可能到第二级Cache中,如果一个后面的遍重新使用该数据的话。如果遍3和遍4这两个数据集都适应于第二级Cache,那么在这两个遍中的加载操作将变得代价更小。
图7-8展示了预取指令以及条带挖掘如何能被应用以增加这两个情况的性能。
对于奔腾4处理器,左边的场景展示了使用PREFETCHNTA将数据只预取到第二级Cache所选择的路(SM1表示第二级Cache的条带挖掘[译者注:Strip Mine]的一路),从而最小化Cache污染的一个图形化的实现。如果数据在整个执行遍期间仅仅被触及一次,那么使用PREFETCHNTA以最小化在更高层Cache中的Cache污染。这提供了立即可用性,假定该预取在读访问被发布时,在前足够远发布。
对于右边的情况(见图7-8),将数据保持在第二级Cache的一路中并不提升Cache的本地性。因此,使用PREFETCH0来预取数据。这分批偿还了在遍1和遍2中的存储器引用延迟,并且在第二级Cache中保持了数据的一份拷贝,而这减少了存储器交通以及遍3和遍4的延迟。为了进一步减少延迟,考虑在遍3和遍4中的存储器引用之前加入额外的PREFETCHNTA指令可能也是值得的。
在例7-6中,先考虑一个3D几何引擎的数据访问模式,不具有条带挖掘,然后将条带挖掘组合进去。注意,奔腾III处理器的4宽度的SIMD指令可以在每次迭代处理4个顶点。
没有条带挖掘的话,四个顶点的所有x、y、z坐标必须在第二遍——光照循环——中从存储器重新获取。这导致了在变换循环期间所取得的Cache行的低效利用以及在光照循环中带宽的浪费。
例7-6:一个没有条带挖掘的3D几何引擎的数据访问
while(nvtx < MAX_NUM_VTX){ prefetchnta vertexi data // v = [x, y, z, nx, ny, nz, tu, tv] prefetchnta vertexi+1 data prefetchnta vertexi+2 data prefetchnta vertexi+3 data TRANSFORMATION code // 仅使用一个顶点的x, y, z, tu, tv nvtx += 4 } while(nvtx < MAX_NUM_VTX){ prefetchnta vertexi data // v = [x, y, z, nx, ny, nz, tu, tv] // x, y, z再一次被取 prefetchnta vertexi+1 data prefetchnta vertexi+2 data prefetchnta vertexi+3 data compute the light vectors // 仅使用x, y, z LOCAL LIGHTING code // 仅使用nx, ny, nz nvtx += 4 }
现在考虑例7-7中的代码,条带挖掘已经被组合进了循环。
例7-7:具有条带挖掘的一个3D几何引擎的数据访问
while(strip < NUM_STRIP){ /* 条带挖掘该循环以将数据适应到第二级的一路 */ while(nvtx < MAX_NUM_VTX_PER_STRIP){ prefetchnta vertexi data // v = [x, y, z, nx, ny, nz, tu, tv] prefetchnta vertexi+1 data prefetchnta vertexi+2 data prefetchnta vertexi+3 data TRANSFORMATION code nvtx += 4 } while(nvtx < MAX_NUM_VTX_PER_STRIP){ /* x, y, z坐标在第二级Cache中,不需要预取 */ compute the light vectors POINT LIGHTING code nvtx += 4 } }
带着条带挖掘,所有顶点数据在已被条带挖掘的变换循环期间可以被保持在Cache中(比如,第二级Cache中的一路),并且在光照循环中重用。将数据保持在Cache中同时减少了总线交通以及所使用的预取的次数。
表7-1概括了仅由软件预取与条带挖掘组成的基本使用模型的步骤。这些步骤为:
● 做条带挖掘:划分循环,使得数据集适应于第二级Cache。
● 如果数据仅被使用一次或数据集适应于32K字节(第二级Cache的一路),那么使用PREFETCHNTA。如果数据集超过32K字节,那么使用PREFETCH0。
上述步骤是平台特定的,并且提供了一个实现例子。变量NUM_STRIP以及MAX_NUM_VX_PER_STRIP可以用探索法为一个特定平台的特定应用达成峰值性能来确定。
7.6.11 硬件预取以及Cache分块技术
调整自动硬件预取机制的数据访问模式可以最小化读多遍的第一遍以及某些读一次的存储器引用的存储器访问成本。读一次的存储器引用场景的一个例子可以用一个矩阵或图形的转置来描述,从以列向量的顺序进行读然后写到以行向量的顺序,或者反过来。
例7-8展示了一个数据搬移的嵌套循环,它表征了一个典型的矩阵/图像转置问题。如果数组的维度很大,那么不仅数据集的足迹将超过最后级Cache,而且Cache失败将以跨度大而发生。如果维度问题正巧是2的幂,那么由于组相联的有限个数而引起的混叠情况将加重Cache逐出的可能性。
例7-8:使用HW预取来提升只读存储器交通
// a) 没被优化的图像转置 // 目标和源表示了二维数组 for(i = 0; i < NUMCOLS; i++){ // 内部循环读单个列 for(j = 0; j < NUMROWS; j++){ // 每次读引用导致大跨度的Cache失败 dest[i * NUMROWS + i] = src[j * NUMROWS + i]; } } // b) tilewidth = L2SizeInBytes / 2 / TileHeight / sizeof(element) for(i = 0; i < NUMCOLS; i += tilewidth){ for(j = 0; j < NUMROWS; j++){ // 在内部循环同一行访问多个元素 // 访问模式对硬件预取友好,并提升了Cache命中率 for(k = 0; k < tilewidth; k++) dest[j + (i + k) * NUMROWS] = src[i + k + j * NUMROWS]; } }
例7-8(b)展示了应用铺瓦技术,选择最优的瓦片大小以及瓦片宽度来利用硬件预取。利用铺瓦,你可以选择两个瓦片的大小来适应最后级Cache。为存储器读引用而最大化每个瓦片的宽度允许硬件预取器来发起总线请求以在代码实际引用线性地址之前读取一些Cache行。
7.6.12 单遍 VS 多遍执行
一个算法可以执行单个或多个遍执行,定义如下:
● 单遍,或非堆积的执行将一单个数据元素通过整条计算流水线进行传递。
● 多遍,或堆积的执行,对一批数据元素执行流水线的一单个阶段,然后再将这批数据元素继续传递到下一阶段。
单遍和多遍执行的一个共同特征是依赖于一个算法的实现以及对单遍或多遍执行的使用,存在一个特定的权衡。见图7-9。
当实现一个通用目的API时,多遍执行经常更容易使用。对于这类API,代码路径的选择依赖于由应用程序所选择的特征的特定组合(比如,对于3D图形,这可能包括所使用的顶点图元以及光照资源的个数和类型)。
因为有许多种排列组合的可能性,一个单遍方法就代码尺寸以及校验而言可能会更复杂些。在这些情况下,每种可能的排列组合将需要一个特定的代码序列。比如,一个具有A、B、C、D特征的对象可能具有所允许的特征的一个子集,比如,A,B,D。这个阶段将使用一条代码路径;而另一种组合将可能使用另一条代码路径。对执行每个流水线阶段作为一个独立的遍都要理解,要用条件子句来选择每个阶段内所要实现的不同的特征。通过使用条带挖掘,可以选择每个阶段所要处理的顶点个数(比如后缓存大小)来确保后缓存通过所有的遍都驻留在处理器Cache内。使用一个中间被cache的缓存将顶点后缓存从一个阶段传递到下一个阶段。
单遍执行更适合于具有有限特征个数的应用程序,这些特征在一给定的时间来使用。一个单遍的方法能减少数据拷贝量,而这可能在一个多遍引擎中会发生。见图7-9。
单遍或多遍的选择会有一些性能牵连。比如,在一个多遍流水线中,受带宽所限的阶段(输入或输出)将在整个执行时间中更多地反映出此性能限制。相比之下,对于一个单遍的方法,带宽限制可以跨其它计算密集的阶段来分布/平摊。另外,对于选择使用哪个预取暗示也受使用一个单遍还是多遍方法的影响。