借助英特尔® 至强融核™ 协处理器的支持优化并运行 ISO 3DFD
简介
有限差分是一种简单且有效的数学工具,能够帮助解出微分方程。 在地震应用中,地震波即是按照这种等式扩散。
地震波的传播是一个 CPU 密集型任务。 本文中,我们将介绍如何部署并优化采用有限差分的三维各项同性内核,以便在英特尔® 至强™ 处理器 v2 产品家族和英特尔® 至强融核™ 协处理器上运行。
这种方法具备以下优势:
- 其部署非常简单(无需转换至频域)。
- 精度可以通过更改模板的尺寸来轻松调整。
地震波传播概述
各项同性声波等式如下:
计算该等式可求出:
然后,使用有限差分可求出:
其中:
此处的 c0至 cn是有限差分的系数。
然后,下一时间步可以通过当前和以前的时间步来表达:
部署和优化
接下来,我们来看一下针对英特尔至强融核协处理器所做的部署和优化。
Dev00 – 部署
我们在公式中加入有限差分方法后,进行部署将非常简单。 我们将部署空间为第 16 阶,时间为第 2 阶的传播。
有限差分代码围绕模板而构建。 模板代表更新一个单元 (single cell) 所需的所有临近单元。 图 1 展示了第 8 阶的模板。
图 1. 第 8 阶模板
有限差分从三个维度进行计算,如图 2 所示:
图 2.三维模板计算
在处理代码前,已经实现了一些优化:
XXXXXXXXXXXXXXX
- 因为
t2总是需要乘以 c2,所以此处 c[x,y,z] 在不同的时间步中是一个常量,我们可以直接预先计算 vel[x,y,z] = c2[x , y, z]
t2
- 在空间计算的有限差分中,x、y 和 z 中的示例是相同的。 由于
x2 =
y2 =
z2 我们可以一直只使用有限差分系统除以
x2。
- 该等式表示,我们需要使用三个阵列来存储 t-1、t、t+1 时的压力。 实际上只需要两个阵列。 图 3 展示了三阵列制程,并且显示 pt+1 在 pt-1 时只需要一个单元;因此,可以将这两个阵列进行合并。
图 3.三阵列制程
首先,我们部署能够解等式核心:
void iso_3dfd_it(float *ptr_next, float *ptr_prev, float *ptr_vel, float *coeff, const int n1, const int n2, const int n3, const int num_threads, const int n1_Tblock, const int n2_Tblock, const int n3_Tblock){ int dimn1n2 = n1*n2;//This value will be used later #pragma omp parallel for OMP_SCHEDULE OMP_N_THREADS collapse(2) default(shared) for(int iz=0; iz<n3; iz++) { for(int iy=0; iy<n2; iy++) { for(int ix=0; ix<n1; ix++) { if( ix>=HALF_LENGTH && ix<(n1-HALF_LENGTH) && iy>=HALF_LENGTH && iy<(n2-HALF_LENGTH) && iz>=HALF_LENGTH && iz<(n3-HALF_LENGTH) ) { int offset = iz*dimn1n2 + iy*n1 + ix; float value = 0.0; value += ptr_prev[offset]*coeff[0]; for(int ir=1; ir<=HALF_LENGTH; ir++) { value += coeff[ir] * (ptr_prev[offset + ir] + ptr_prev[offset - ir]); value += coeff[ir] * (ptr_prev[offset + ir*n1] + ptr_prev[offset - ir*n1]); value += coeff[ir] * (ptr_prev[offset + ir*dimn1n2] + ptr_prev[offset - ir*dimn1n2]); } ptr_next[offset] = 2.0f* ptr_prev[offset] - ptr_next[offset] + value*ptr_vel[offset]; }//end if }}}//3 for loops }//end function
此处,HALF_LENGTH 表示模板尺寸的一半 (8)。 我们需要确认不是在阵列的边界计算有限差分,以避免出现分段错误。 请注意,函数原型中包含尚未使用的参数。 这些参数将用于后续的一些特定优化。
鉴于我们处理的是两个阵列,我们需要在执行一次迭代后将两个阵列进行互换。 这可以通过以下代码实现:
void iso_3dfd(float *ptr_next, float *ptr_prev, float *ptr_vel, float *coeff, const int n1, const int n2, const int n3, const int num_threads, const int nreps, const int n1_Tblock, const int n2_Tblock, const int n3_Tblock){ for(int it=0; it<nreps; it+=2){ iso_3dfd_it(ptr_next, ptr_prev, ptr_vel, coeff, n1, n2, n3, num_threads, n1_Tblock, n2_Tblock, n3_Tblock); // here's where boundary conditions and halo exchanges happen // Swap previous & next between iterations iso_3dfd_it(ptr_prev, ptr_next, ptr_vel, coeff, n1, n2, n3, num_threads, n1_Tblock, n2_Tblock, n3_Tblock); } // time loop }
我们将不对主文件进行具体介绍,因为除了阵列分配之外,主文件中没有其他关键内容。
为了获得英特尔® 至强融核™ 协处理器上的平衡负载,数据需要与更新的第一个单元对齐。 由于有边界检查条件,我们使用了 HALF_LENGTH 填充对阵列进行了更新。 此处值得借鉴的是我们可以直接从该填充处理对齐数据。
为了确保每一新行都是从 HALF_LENGTH 开始,我们强制确保 x 维度是 16 (英特尔至强融核协处理器 SIMD 寄存器中的浮点数)的倍数。 数据的分配如下所示:
size_t nsize = p.n1*p.n2*p.n3; float *prev_base = (float*)_mm_malloc( (nsize+16+MASK_ALLOC_OFFSET(0 ))*sizeof(float), 64); float *next_base = (float*)_mm_malloc( (nsize+16+MASK_ALLOC_OFFSET(16))*sizeof(float), 64); float *vel_base = (float*)_mm_malloc( (nsize+16+MASK_ALLOC_OFFSET(32))*sizeof(float), 64); if( prev_base==NULL || next_base==NULL || vel_base==NULL ){ printf("couldn't allocate CPU memory prev_base=%p next=_base%p vel_base=%p\n",prev_base, next_base, vel_base); printf(" TEST FAILED!\n"); fflush(NULL); exit(-1); } // Align working vectors offsets p.prev = &prev_base[16 – HALF_LENGTH + MASK_ALLOC_OFFSET(0 )]; p.next = &next_base[16 – HALF_LENGTH + MASK_ALLOC_OFFSET(16)]; p.vel = &vel_base [16 – HALF_LENGTH + MASK_ALLOC_OFFSET(32)];
我们可能需要对这部分代码解释一下。 因为我们希望数据在 HALF_LENGTH 填充上对齐,我们偏移了指针,以便 ptr[HALF_LENGTH] 与 64 位对齐(64 位 = 16 浮点数)。 为了实现上述操作,我们加上了对齐因子 (16),并减去 HALF_LENGTH。
我们使用了宏 MASK_ALLOC_OFFSET
来添加其他对齐因子的倍数填充 (16 浮点数或 64 位)。 我们使用该技术是为了减少由于分配相似而导致的高速缓存重写,从而避免可能导致的三个阵列使用相同的高速缓存地址转换。
如果我们使用标准尺寸运行该内核,性能结果将如表 1 所示。
表 1.Dev00 结果
英特尔至强融核协处理器 | 双插槽英特尔® 至强™ 处理器 v2 系统 | |
---|---|---|
每秒浮点运算/点 | 78 | 78 |
MPoints/秒 | 41.12 | 369.51 |
GFlops/秒 | 3.2 | 28.8 |
Dev01 – 删除条件
此处,我们可以删除 if 语句,该语句可以检查我们是否在阵列边界处理数据。 我们通过修改三个 for 循环的范围可以轻松实现。
void iso_3dfd_it(float *ptr_next, float *ptr_prev, float *ptr_vel, float *coeff, const int n1, const int n2, const int n3, const int num_threads, int n1_Tblock, const int n2_Tblock, const int n3_Tblock){ int dimn1n2 = n1*n2; /*We store the initial addresses of the pointers to access directly*/ float *ptr_init_next = ptr_next; float *ptr_init_prev = ptr_prev; float *ptr_init_vel = ptr_vel; //To avoid the condition in the inner loop, we start at HALF_LENGTH and stop at dim-HALF_LENGTH #pragma omp parallel for OMP_SCHEDULE OMP_N_THREADS collapse(2) default(shared) for(int iz=HALF_LENGTH; iz<n3-HALF_LENGTH; iz++) { for(int iy=HALF_LENGTH; iy<n2-HALF_LENGTH; iy++) { for(int ix=HALF_LENGTH; ix<n1-HALF_LENGTH; ix++) { int offset = iz*dimn1n2 + iy*n1 + ix; float value = 0.0; value += ptr_prev[offset]*coeff[0]; for(int ir=1; ir<=HALF_LENGTH; ir++) { value += coeff[ir] * (ptr_prev[offset + ir] + ptr_prev[offset - ir]); value += coeff[ir] * (ptr_prev[offset + ir*n1] + ptr_prev[offset - ir*n1]); value += coeff[ir] * (ptr_prev[offset + ir*dimn1n2] + ptr_prev[offset - ir*dimn1n2]); } ptr_next[offset] = 2.0f* ptr_prev[offset] - ptr_next[offset] + value*ptr_vel[offset]; }}} }
该版本的结果如表 2 所示。
表 2.Dev01 结果
英特尔至强融核协处理器 | 双插槽英特尔® 至强™ 处理器 v2 系统 | |
---|---|---|
每秒浮点运算/点 | 78 | 78 |
MPoints/秒 | 58.69 | 378.54 |
GFlops/秒 | 4.6 | 29.5 |
通过以下参数可以获得这些数字:
- NX = 256
- NY = 256
- NZ = 256
- 我们处理了 100 次迭代
- 我们在主机处理器上运行 24 条线程,在英特尔® 至强融核™ 协处理器上运行 244 条线程
Dev02 – 高速缓存块
我们不是从头到尾对划分区域每个单元的阵列分别处理,而是创建了块来改进数据局部性。 通过在三个现有的 for 循环上添加三个 for 循环可以部署高速缓存数据块。 缺点是我们需要使用三个新参数分别为 X、Y 和 Z 维度的数据块指定尺寸。 设置这些新参数可能较困难。 通常在 Y 和 Z 维度上分块,不在 X 维度上分块效果较好。
void iso_3dfd_it(float *ptr_next_base, float *ptr_prev_base, float *ptr_vel_base, float *coeff, const int n1, const int n2, const int n3, const int num_threads, const int n1_Tblock, const int n2_Tblock, const int n3_Tblock){ int dimn1n2 = n1*n2; int n3End = n3 - HALF_LENGTH; int n2End = n2 - HALF_LENGTH; int n1End = n1 - HALF_LENGTH; #pragma omp parallel for OMP_SCHEDULE OMP_N_THREADS collapse(3) default(shared) for(int bz=HALF_LENGTH; bz<n3End; bz+=n3_Tblock){ for(int by=HALF_LENGTH; by<n2End; by+=n2_Tblock){ for(int bx=HALF_LENGTH; bx<n1End; bx+=n1_Tblock){ int izEnd = MIN(bz+n3_Tblock, n3End); int iyEnd = MIN(by+n2_Tblock, n2End); int ixEnd = MIN(n1_Tblock, n1End-bx); int ix; for(int iz=bz; iz<izEnd; iz++) { for(int iy=by; iy<iyEnd; iy++) { float* ptr_next = ptr_next_base + iz*dimn1n2 + iy*n1 + bx; float* ptr_prev = ptr_prev_base + iz*dimn1n2 + iy*n1 + bx; float* ptr_vel = ptr_vel_base + iz*dimn1n2 + iy*n1 + bx; for(int ix=0; ix<ixEnd; ix++) { float value = 0.0; value += ptr_prev[ix]*coeff[0]; for(int ir=1; ir<=HALF_LENGTH; ir++) { value += coeff[ir] * (ptr_prev[ix + ir] + ptr_prev[ix – ir]); value += coeff[ir] * (ptr_prev[ix + ir*n1] + ptr_prev[ix - ir*n1]); value += coeff[ir] * (ptr_prev[ix + ir*dimn1n2] + ptr_prev[ix - ir*dimn1n2]); } ptr_next[ix] = 2.0f* ptr_prev[ix] - ptr_next[ix] + value*ptr_vel[ix]; } }}//end of inner iterations }}}//end of cache blocking }
该版本的性能如表 3 所示。
表 3.Dev02 结果
英特尔至强融核协处理器 | 双插槽英特尔® 至强™ 处理器 v2 系统 | |
---|---|---|
每秒浮点运算/点 | 78 | 78 |
MPoints/秒 | 508.8 | 418.2 |
GFlops/秒 | 39.7 | 32.4 |
此处,由于编译器也能够对代码进行矢量化处理,因此在一定程度上有助于英特尔至强融核协处理器的性能提升。 此外,请注意在此次部署中,我们选择重新映射 x 维度,以便 ix 索引从 0 开始,在 ixEnd-1 结束。
Dev03 – 划分 omp 语句
我们可以不使用
#pragma omp parallel for OMP_SCHEDULE OMP_N_THREADS collapse(3) default(shared)
而使用两个指令,一个用于并行部分,另一个用于 for 循环。 这样做可以让我们向 OpenMP 线程声明私有变量。 在不同的迭代中可以重复使用该变量。
此处,我们并未看到配置之间的性能差别,因为目标我们的迭代所控制的变量较少。 但是如果您拥有更多的变量,优势可能会更明显。
void iso_3dfd_it(float *ptr_next_base, float *ptr_prev_base, float *ptr_vel_base, float *coeff, const int n1, const int n2, const int n3, const int num_threads, const int n1_Tblock, const int n2_Tblock, const int n3_Tblock){ int dimn1n2 = n1*n2; int n3End = n3 - HALF_LENGTH; int n2End = n2 - HALF_LENGTH; int n1End = n1 - HALF_LENGTH; #pragma omp parallel OMP_N_THREADS default(shared) { float* ptr_next; float* ptr_prev; float* ptr_vel; float value; int izEnd; int iyEnd; int ixEnd; #pragma omp for OMP_SCHEDULE collapse(3) for(int bz=HALF_LENGTH; bz<n3End; bz+=n3_Tblock){ for(int by=HALF_LENGTH; by<n2End; by+=n2_Tblock){ for(int bx=HALF_LENGTH; bx<n1End; bx+=n1_Tblock){ izEnd = MIN(bz+n3_Tblock, n3End); iyEnd = MIN(by+n2_Tblock, n2End); ixEnd = MIN(n1_Tblock, n1End-bx); for(int iz=bz; iz<izEnd; iz++) { for(int iy=by; iy<iyEnd; iy++) { ptr_next = &ptr_next_base[iz*dimn1n2 + iy*n1 + bx]; ptr_prev = &ptr_prev_base[iz*dimn1n2 + iy*n1 + bx]; ptr_vel = &ptr_vel_base[iz*dimn1n2 + iy*n1 + bx]; for(int ix=0; ix<ixEnd; ix++) { value = 0.0; value += ptr_prev[ix]*coeff[0]; for(int ir=1; ir<=HALF_LENGTH; ir++) { value += coeff[ir] * (ptr_prev[ix + ir] + ptr_prev[ix - ir]); value += coeff[ir] * (ptr_prev[ix + ir*n1] + ptr_prev[ix - ir*n1]); value += coeff[ir] * (ptr_prev[ix + ir*dimn1n2] + ptr_prev[ix - ir*dimn1n2]); } ptr_next[ix] = 2.0f* ptr_prev[ix] - ptr_next[ix] + value*ptr_vel[ix]; }//end x }}//end y and z }}}//end cache blocking }//end parallel }
该版本的性能如表 4 所示。
表 4. Dev03 结果
英特尔至强融核协处理器 | 双插槽英特尔® 至强™ 处理器 v2 系统 | |
---|---|---|
每秒浮点运算/点 | 78 | 78 |
MPoints/秒 | 520.4 | 418.8 |
GFlops/秒 | 40.6 | 32.7 |
Dev04 – 添加 pragmas
此处,我们的内核使用 –fno-alias 选项进行编译,这表示这些指针不支持使用别名。 如果您不希望使用该选项进行编译,则可以在一个特定的 for 循环中进行本地指定;将不会为指针指定别名。
这表示,指针没有别名将允许编译器对代码进行矢量化处理。 然后,编译器可以使用自己的启发法来确定矢量是否能够带来更多性能。
为了帮助编译器对代码进行矢量化处理,您可以添加其他 pragmas:
#pragma ivdep
—表示您的指针未指定别名,但是编译器将确定是否需要进行矢量化。#pragma simd
—强制执行矢量化。 在一些情况下,它可能会导致分段错误。
其他的 pragmas 可以帮助矢量化。 例如,使用 #pragma unroll
,您可以将 for 循环索引增加 2、3 或更多,而非增加 1,并且在每一步中对 2、3 或更多次迭代进行硬编码。
void iso_3dfd_it(float *ptr_next_base, float *ptr_prev_base, float *ptr_vel_base, float *coeff, const int n1, const int n2, const int n3, const int num_threads, const int n1_Tblock, const int n2_Tblock, const int n3_Tblock){ int dimn1n2 = n1*n2; int n3End = n3 - HALF_LENGTH; int n2End = n2 - HALF_LENGTH; int n1End = n1 - HALF_LENGTH; #pragma omp parallel OMP_N_THREADS default(shared) { float* ptr_next; float* ptr_prev; float* ptr_vel; float value; int izEnd; int iyEnd; int ixEnd; #pragma omp for OMP_SCHEDULE collapse(3) for(int bz=HALF_LENGTH; bz<n3End; bz+=n3_Tblock){ for(int by=HALF_LENGTH; by<n2End; by+=n2_Tblock){ for(int bx=HALF_LENGTH; bx<n1End; bx+=n1_Tblock){ izEnd = MIN(bz+n3_Tblock, n3End); iyEnd = MIN(by+n2_Tblock, n2End); ixEnd = MIN(n1_Tblock, n1End-bx); for(int iz=bz; iz<izEnd; iz++) { for(int iy=by; iy<iyEnd; iy++) { ptr_next = &ptr_next_base[iz*dimn1n2 + iy*n1 + bx]; ptr_prev = &ptr_prev_base[iz*dimn1n2 + iy*n1 + bx]; ptr_vel = &ptr_vel_base[iz*dimn1n2 + iy*n1 + bx]; #pragma ivdep for(int ix=0; ix<ixEnd; ix++) { value = 0.0; value += ptr_prev[ix]*coeff[0]; #if defined(MODEL_CPU) #pragma unroll(16) #endif #pragma ivdep for(int ir=1; ir<=HALF_LENGTH; ir++) { value += coeff[ir] * (ptr_prev[ix + ir] + ptr_prev[ix - ir]); value += coeff[ir] * (ptr_prev[ix + ir*n1] + ptr_prev[ix - ir*n1]); value += coeff[ir] * (ptr_prev[ix + ir*dimn1n2] + ptr_prev[ix - ir*dimn1n2]); } ptr_next[ix] = 2.0f* ptr_prev[ix] - ptr_next[ix] + value*ptr_vel[ix]; }//end x }}//end y and z }}}//end cache locking }//end parallel }//end function
该版本的性能结果如表 5 所示。
表 5.Dev04 结果
英特尔至强融核协处理器 | 双插槽英特尔® 至强™ 处理器 v2 系统 | |
---|---|---|
每秒浮点运算/点 | 78 | 78 |
MPoints/秒 | 515.6 | 439.8 |
GFlops/秒 | 40.2 | 34.3 |
Dev05 - __assume_aligned 和人工展开
我们在版本 dev00 中看到,数据使用 HALF_LENGTH 填充进行对齐。 该信息可以以后添加到代码中,以便编译器进一步优化。
在英特尔至强融核协处理器上,未对齐的负载会造成严重的性能缺陷。 由于文件是单独编译,所以编译器无法了解内核是否使用了对齐数据。 如要帮助编译器了解,开发人员可以添加 _assume_aligned 指令。 该指令需要 2 个参数:
- 指针
- 对齐因子
因为 dimension(x)%16 = 0 和 array[0][0][HALF_LENGTH] 是与 64 位对齐,所以我们可以确保无论 Z 和 Y 是多少,array[Z][Y][HALF_LENGTH] 都可以对齐。
此外,用来计算每个系数有限差分的最内层循环可以手动展开。 而且完全展开该循环有助于矢量化。 此处的手动展开非常简单、有趣,因为迭代较少(在我们的案例中只有 8 次)。 为了让代码的可读性更高,我们选择使用宏来添加模板的两个对称点:
#define FINITE_ADD(ix, off) ((ptr_prev[ix + off] + ptr_prev[ix - off]))
我们还预计算了在 Y 和 Z 维度访问数据所需的所有填充。 我们在 #pragma omp parallel
和 #pragma omp for
之间的每个线程上执行这些计算。
void iso_3dfd_it(float *ptr_next_base, float *ptr_prev_base, float *ptr_vel_base, float *coeff, const int n1, const int n2, const int n3, const int num_threads, const int n1_Tblock, const int n2_Tblock, const int n3_Tblock){ int dimn1n2 = n1*n2; int n3End = n3 - HALF_LENGTH; int n2End = n2 - HALF_LENGTH; int n1End = n1 - HALF_LENGTH; #pragma omp parallel OMP_N_THREADS default(shared) { float* ptr_next; float* ptr_prev; float* ptr_vel; float value; int izEnd; int iyEnd; int ixEnd; const int vertical_1 = n1, vertical_2 = n1*2, vertical_3 = n1*3, vertical_4 = n1*4; const int front_1 = dimn1n2, front_2 = dimn1n2*2, front_3 = dimn1n2*3, front_4 = dimn1n2*4; const float c0=coeff[0], c1=coeff[1], c2=coeff[2], c3=coeff[3], c4=coeff[4]; //At this point, we must handle the stencil possible sizes. #if ( HALF_LENGTH == 8 ) const int vertical_5 = n1*5, vertical_6 = n1*6, vertical_7 = n1*7, vertical_8 = n1*8; const int front_5 = dimn1n2*5, front_6 = dimn1n2*6, front_7 = dimn1n2*7, front_8 = dimn1n2*8; const float c5=coeff[5], c6=coeff[6], c7=coeff[7], c8=coeff[8]; #endif __assume_aligned((void*)vertical_1, CACHELINE_BYTES); __assume_aligned((void*)vertical_2, CACHELINE_BYTES); __assume_aligned((void*)vertical_3, CACHELINE_BYTES); __assume_aligned((void*)vertical_4, CACHELINE_BYTES); __assume_aligned((void*)front_1, CACHELINE_BYTES); __assume_aligned((void*)front_2, CACHELINE_BYTES); __assume_aligned((void*)front_3, CACHELINE_BYTES); __assume_aligned((void*)front_4, CACHELINE_BYTES); //Handle all size of stencil #if( HALF_LENGTH == 8 ) __assume_aligned((void*)vertical_5, CACHELINE_BYTES); __assume_aligned((void*)vertical_6, CACHELINE_BYTES); __assume_aligned((void*)vertical_7, CACHELINE_BYTES); __assume_aligned((void*)vertical_8, CACHELINE_BYTES); __assume_aligned((void*)front_5, CACHELINE_BYTES); __assume_aligned((void*)front_6, CACHELINE_BYTES); __assume_aligned((void*)front_7, CACHELINE_BYTES); __assume_aligned((void*)front_8, CACHELINE_BYTES); #endif __declspec(align(CACHELINE_BYTES)) float div[n1_Tblock]; #pragma omp for OMP_SCHEDULE collapse(3) for(int bz=HALF_LENGTH; bz<n3End; bz+=n3_Tblock){ for(int by=HALF_LENGTH; by<n2End; by+=n2_Tblock){ for(int bx=HALF_LENGTH; bx<n1End; bx+=n1_Tblock){ izEnd = MIN(bz+n3_Tblock, n3End); iyEnd = MIN(by+n2_Tblock, n2End); ixEnd = MIN(n1_Tblock, n1End-bx); for(int iz=bz; iz<izEnd; iz++) { for(int iy=by; iy<iyEnd; iy++) { ptr_next = &ptr_next_base[iz*dimn1n2 + iy*n1 + bx]; ptr_prev = &ptr_prev_base[iz*dimn1n2 + iy*n1 + bx]; ptr_vel = &ptr_vel_base[iz*dimn1n2 + iy*n1 + bx]; __assume_aligned(ptr_next, CACHELINE_BYTES); __assume_aligned(ptr_prev, CACHELINE_BYTES); __assume_aligned(ptr_vel, CACHELINE_BYTES); #pragma ivdep for(int ix=0; ix<ixEnd; ix++) { value = ptr_prev[ix]*c0 + c1 * FINITE_ADD(ix, 1) + c1 * FINITE_ADD(ix, vertical_1) + c1 * FINITE_ADD(ix, front_1) + c2 * FINITE_ADD(ix, 2) + c2 * FINITE_ADD(ix, vertical_2) + c2 * FINITE_ADD(ix, front_2) + c3 * FINITE_ADD(ix, 3) + c3 * FINITE_ADD(ix, vertical_3) + c3 * FINITE_ADD(ix, front_3) + c4 * FINITE_ADD(ix, 4) + c4 * FINITE_ADD(ix, vertical_4) + c4 * FINITE_ADD(ix, front_4) #if( HALF_LENGTH == 8) + c5 * FINITE_ADD(ix, 5) + c5 * FINITE_ADD(ix, vertical_5) + c5 * FINITE_ADD(ix, front_5) + c6 * FINITE_ADD(ix, 6) + c6 * FINITE_ADD(ix, vertical_6) + c6 * FINITE_ADD(ix, front_6) + c7 * FINITE_ADD(ix, 7) + c7 * FINITE_ADD(ix, vertical_7) + c7 * FINITE_ADD(ix, front_7) + c8 * FINITE_ADD(ix, 8) + c8 * FINITE_ADD(ix, vertical_8) + c8 * FINITE_ADD(ix, front_8) #endif ; ptr_next[ix] = 2.0f* ptr_prev[ix] - ptr_next[ix] + value*ptr_vel[ix]; }// end x }}//end Y and Z }}}//end of cache blocking }//end of parallel }//end of function
请注意,此处所加的数量发生了变化。 测试结果如表 6 所示。
表 6.Dev05 结果
英特尔至强融核协处理器 | 双插槽英特尔® 至强™ 处理器 v2 系统 | |
---|---|---|
每秒浮点运算/点 | 77 | 77 |
MPoints/秒 | 512.3 | 1147.7 |
GFlops/秒 | 39.4 | 88.4 |
Dev06 – 更改内核分解
上一版明确了生成其他分解的方法。 事实上,在 dev05 版本中,每个系数用于三次乘法。 另一个解决方案是,在三个维度上加上 FINITE_ADD 结果,然后用系数乘以该结果。 这将可减少乘法的数量,而不影响加法的数量。
可以在此处做一下标记。 为了尽量接近处理器的峰值性能,代码必须在加法和乘法之间实现完美平衡。 这是因为 CPU 包含两个单独的管线:一个管线用于加法,另一个管线用于乘法。 在 dev05 版本中,我们使用了 50 次加法和 27 次乘法;在 dev06 版本中,我们进一步减少了乘法的次数(50 次加法和 11 次乘法)。 在英特尔至强处理器 v2 产品家族中,由于执行无序,我们可能无法获得更高的性能。 但是在英特尔至强融核协处理器上则不同。 由于其采用顺序架构,更改加法或乘法的次数可以改变性能。
void iso_3dfd_it(float *ptr_next_base, float *ptr_prev_base, float *ptr_vel_base, float *coeff, const int n1, const int n2, const int n3, const int num_threads, const int n1_Tblock, const int n2_Tblock, const int n3_Tblock){ int dimn1n2 = n1*n2; int n3End = n3 - HALF_LENGTH; int n2End = n2 - HALF_LENGTH; int n1End = n1 - HALF_LENGTH; #pragma omp parallel OMP_N_THREADS default(shared) { float* ptr_next; float* ptr_prev; float* ptr_vel; float value; int izEnd; int iyEnd; int ixEnd; const int vertical_1 = n1, vertical_2 = n1*2, vertical_3 = n1*3, vertical_4 = n1*4; const int front_1 = dimn1n2, front_2 = dimn1n2*2, front_3 = dimn1n2*3, front_4 = dimn1n2*4; const float c0=coeff[0], c1=coeff[1], c2=coeff[2], c3=coeff[3], c4=coeff[4]; //At this point, we must handle the stencil possible sizes. #if ( HALF_LENGTH == 8 ) const int vertical_5 = n1*5, vertical_6 = n1*6, vertical_7 = n1*7, vertical_8 = n1*8; const int front_5 = dimn1n2*5, front_6 = dimn1n2*6, front_7 = dimn1n2*7, front_8 = dimn1n2*8; const float c5=coeff[5], c6=coeff[6], c7=coeff[7], c8=coeff[8]; #endif __assume_aligned((void*)vertical_1, CACHELINE_BYTES); __assume_aligned((void*)vertical_2, CACHELINE_BYTES); __assume_aligned((void*)vertical_3, CACHELINE_BYTES); __assume_aligned((void*)vertical_4, CACHELINE_BYTES); __assume_aligned((void*)front_1, CACHELINE_BYTES); __assume_aligned((void*)front_2, CACHELINE_BYTES); __assume_aligned((void*)front_3, CACHELINE_BYTES); __assume_aligned((void*)front_4, CACHELINE_BYTES); //Handle all size of stencil #if( HALF_LENGTH == 8 ) __assume_aligned((void*)vertical_5, CACHELINE_BYTES); __assume_aligned((void*)vertical_6, CACHELINE_BYTES); __assume_aligned((void*)vertical_7, CACHELINE_BYTES); __assume_aligned((void*)vertical_8, CACHELINE_BYTES); __assume_aligned((void*)front_5, CACHELINE_BYTES); __assume_aligned((void*)front_6, CACHELINE_BYTES); __assume_aligned((void*)front_7, CACHELINE_BYTES); __assume_aligned((void*)front_8, CACHELINE_BYTES); #endif __declspec(align(CACHELINE_BYTES)) float div[n1_Tblock]; #pragma omp for OMP_SCHEDULE collapse(3) for(int bz=HALF_LENGTH; bz<n3End; bz+=n3_Tblock){ for(int by=HALF_LENGTH; by<n2End; by+=n2_Tblock){ for(int bx=HALF_LENGTH; bx<n1End; bx+=n1_Tblock){ izEnd = MIN(bz+n3_Tblock, n3End); iyEnd = MIN(by+n2_Tblock, n2End); ixEnd = MIN(n1_Tblock, n1End-bx); for(int iz=bz; iz<izEnd; iz++) { for(int iy=by; iy<iyEnd; iy++) { ptr_next = &ptr_next_base[iz*dimn1n2 + iy*n1 + bx]; ptr_prev = &ptr_prev_base[iz*dimn1n2 + iy*n1 + bx]; ptr_vel = &ptr_vel_base[iz*dimn1n2 + iy*n1 + bx]; __assume_aligned(ptr_next, CACHELINE_BYTES); __assume_aligned(ptr_prev, CACHELINE_BYTES); __assume_aligned(ptr_vel, CACHELINE_BYTES); #pragma ivdep for(int ix=0; ix<ixEnd; ix++) { value = ptr_prev[ix]*c0 + c1 * (FINITE_ADD(ix, 1) + FINITE_ADD(ix, vertical_1) + FINITE_ADD(ix, front_1)) + c2 * (FINITE_ADD(ix, 2) + FINITE_ADD(ix, vertical_2) + FINITE_ADD(ix, front_2)) + c3 * (FINITE_ADD(ix, 3) + FINITE_ADD(ix, vertical_3) + FINITE_ADD(ix, front_3)) + c4 * (FINITE_ADD(ix, 4) + FINITE_ADD(ix, vertical_4) + FINITE_ADD(ix, front_4)) #if( HALF_LENGTH == 8) + c5 * (FINITE_ADD(ix, 5) + FINITE_ADD(ix, vertical_5) + FINITE_ADD(ix, front_5)) + c6 * (FINITE_ADD(ix, 6) + FINITE_ADD(ix, vertical_6) + FINITE_ADD(ix, front_6)) + c7 * (FINITE_ADD(ix, 7) + FINITE_ADD(ix, vertical_7) + FINITE_ADD(ix, front_7)) + c8 * (FINITE_ADD(ix, 8) + FINITE_ADD(ix, vertical_8) + FINITE_ADD(ix, front_8)) #endif ; ptr_next[ix] = 2.0f* ptr_prev[ix] - ptr_next[ix] + value*ptr_vel[ix]; }// end x }}//end Y and Z }}}//end of cache blocking }//end of parallel }//end of function
此处,每个浮点的 flops 数量相比上一版本有所减少。 测试结果如表 7 所示。
表 7. Dev06 结果
英特尔至强融核协处理器 | 双插槽英特尔® 至强™ 处理器 v2 系统 | |
---|---|---|
每秒浮点运算/点 | 61 | 61 |
MPoints/秒 | 543.5 | 1145.7 |
GFlops/秒 | 33.1 | 69.9 |
Dev07 – 首次触摸
该优化仅适用于 NUMA 系统(它不属于英特尔至强融核协处理器)。 在多插槽系统上,CPU 可以更快地访问其插槽本地的 DIMM 内存。 图 4 介绍了在一个双插槽设备上使用该原则的情况,其中每个插槽有 4 个 DIMM。
为了限制速度较慢的访问,必须在内存中正确映射数据。 这可以使用首次触摸技术实现。 当分配数据时(使用 malloc
、_mm_malloc
等),操作系统预留了空间,但是内存中没有实际映射数据。 首次写入数据时映射即发生。 “触摸”数据的线程将其存放在运行线程的 CPU 附近。
因此,如果数据的存放方式与稍后的访问方式相同,则我们在程序执行过程中始终能够快速访问。
在本代码中部署首次触摸技术非常简单。 我们只需要使用随后用来处理和计算波传播的 OpenMP 拆分对数据进行初始化即可。
首次触摸的部署如以下代码所示。
void first_touch(float* tab_base, long n1, long n2, long n3, long n1_Tblock, long n2_Tblock, long n3_Tblock, long num_threads){ long n3End = n3 - HALF_LENGTH; long n2End = n2 - HALF_LENGTH; long n1End = n1 - HALF_LENGTH; long dimn1n2 = n1*n2; #pragma omp parallel OMP_N_THREADS { float* tab; #pragma omp for collapse(3) for(long bz=HALF_LENGTH; bz<n3End; bz+=n3_Tblock){ for(long by=HALF_LENGTH; by<n2End; by+=n2_Tblock){ for(long bx=HALF_LENGTH; bx<n1End; bx+=n1_Tblock){ //---Cache Blocking Loop implementation End-------------- long izEnd = MIN(bz+n3_Tblock, n3End); long iyEnd = MIN(by+n2_Tblock, n2End); long ixEnd = MIN(n1_Tblock, n1End-bx); if(bz==HALF_LENGTH){ for(long i=0;i<bz; i++){ for(long iy=by; iy<iyEnd; iy++) { //Compute the addresses for next x-loops tab = &tab_base[i*dimn1n2 + iy*n1 + bx]; for(long ix=0;ix<ixEnd; ix++){ tab[ix] = 0.f; } }} } if(izEnd>=n3End){ for(long i=n3End;i<n3; i++){ for(long iy=by; iy<iyEnd; iy++) { //Compute the addresses for next x-loops tab = &tab_base[i*dimn1n2 + iy*n1 + bx]; for(long ix=0;ix<ixEnd; ix++){ tab[ix] = 0.f; } }} } if(by==HALF_LENGTH){ for(long iz=bz; iz<izEnd; iz++) { for(long i=0;i<by; i++){ //Compute the addresses for next x-loops tab = &tab_base[iz*dimn1n2 + i*n1 + bx]; for(long ix=0;ix<ixEnd; ix++){ tab[ix] = 0.f; } }} } if(iyEnd>=n2End){ for(long iz=bz; iz<izEnd; iz++) { for(long i=n2End;i<n2; i++){ //Compute the addresses for next x-loops tab = &tab_base[iz*dimn1n2 + i*n1 + bx]; for(long ix=0;ix<ixEnd; ix++){ tab[ix] = 0.f; } }} } if(bx==HALF_LENGTH){ for(long iz=bz; iz<izEnd; iz++) { for(long iy=by; iy<iyEnd; iy++) { //Compute the addresses for next x-loops tab = &tab_base[iz*dimn1n2 + iy*n1 ]; for(long ix=0;ix<HALF_LENGTH; ix++){ tab[ix] = 0.f; } }} } if(ixEnd>=n1End){ for(long iz=bz; iz<izEnd; iz++) { for(long iy=by; iy<iyEnd; iy++) { //Compute the addresses for next x-loops tab = &tab_base[iz*dimn1n2 + iy*n1 + ixEnd]; for(long ix=0;ix<HALF_LENGTH; ix++){ tab[ix] = 0.f; } }} } for(long iz=bz; iz<izEnd; iz++) { for(long iy=by; iy<iyEnd; iy++) { //Compute the addresses for next x-loops tab = &tab_base[iz*dimn1n2 + iy*n1 + bx]; for(long ix=0;ix<ixEnd; ix++){ tab[ix] = 0.f; } }} }}} } }
分配后为每个阵列调用该函数。 应使用稍后在代码中使用的 OpenMP 分解,如下所示:
#pragma omp parallel OMP_N_THREADS { float* tab; #pragma omp for collapse(3) for(long bz=HALF_LENGTH; bz<n3End; bz+=n3_Tblock){ for(long by=HALF_LENGTH; by<n2End; by+=n2_Tblock){ for(long bx=HALF_LENGTH; bx<n1End; bx+=n1_Tblock){
该版本的性能结果如表 8 所示
表 8. Dev07 结果
英特尔至强融核协处理器 | 双插槽英特尔® 至强™ 处理器 v2 系统 | |
---|---|---|
每秒浮点运算/点 | 61 | 61 |
MPoints/秒 | 540.4 | 1666.2 |
GFlops/秒 | 32.9 | 101.6 |
Dev08 – 内联函数
使用内联函数能够进一步提升英特尔至强融核协处理器上的性能。 理想情况下,我们将通过英特尔编译器中可用的明确的矢量编程来利用 SIMD 架构。 但是,在执行该任务时,英特尔编译器无法完全支持该应用中所需的模式。 为解决该问题,我们使用内联函数来演示能够实现的性能。 我们预计在以后的版本中英特尔编译器将继续提升。 由于使用了单位步长访问,在 Y 和 Z 轴上执行手动矢量化非常简单(图 5)。
图 5.矢量化
对于有限差分的每个系数,我们可以直接加载四个 SIMD 寄存器。 然后,我们将四个寄存器的数量相加,并乘以相关的系数。
在 X 轴上,情况更为复杂,因为我们需要将同一个寄存器上的值相加,如图 6 所示。
图 6.X 轴矢量化
在英特尔至强融核协处理器上,我们实际上能够以高效的方式对此进行编码。 此处使用 _mm512_alignr_epi32
非常有帮助。 该内联函数允许我们在 SIMD 寄存器之间转换浮点数。 对于空间上的 16 阶有限差分,更新一个矢量仅需我们加载 3 个矢量。 图 7 展示了如何处理系数 c0 和 c1。 该转换使用 _mm512_alignr_epi32
指令完成。
图 7. C0、C1 转换
此处,我们需要保留两个不同版本,因为针对英特尔至强融核协处理器和英特尔至强处理器 v2 产品家族的指令集是相同的。 您可在软件包中获得该代码。
该优化的结果如表 9 所示。
表 9.Dev08 结果
英特尔至强融核协处理器 | 双插槽英特尔® 至强™ 处理器 v2 系统 | |
---|---|---|
每秒浮点运算/点 | 69 | 69 |
MPoints/秒 | 603.1 | 1338.2 |
GFlops/秒 | 41.6 | 92.3 |
我们发现,编译器在基于英特尔至强处理器 v2 产品家族的 dev07 上能够更好地生成代码。
Dev09 – 使用 FMA
在该版本中,我们仍然使用指令,但是我们统一使用了 FMA。 这还意味着,英特尔至强处理器 v2 产品家族版本与 dev08 版本相同(英特尔至强处理器 v2 产品家族上没有 FMA)。
此处仅使用 FMA可以提供一个优势。 有限差分系数在同一寄存器上可以停留更长的时间,这是 dev08 版本中的情况。 加载了 6 个矢量,生成并结合了多个临时数据(图 8)。
图 8.Dev08 流程
在 dev09 版本中,流程将会更简单(图 9)。
图 9. Dev09 流程
临时结果一直保留在寄存器中,在一个 FMA 中使用完之后将用于另一个 FMA。 而且,有限差分系统也保留在寄存器中。 此外,负载重新分区也更加优化。
该优化的结果如表 10 所示。
表 10.Dev09 结果
英特尔至强融核协处理器 | 双插槽英特尔® 至强™ 处理器 v2 系统 | |
---|---|---|
每秒浮点运算/点 | 98 | 69 |
MPoints/秒 | 632.4 | 1353.4 |
GFlops/秒 | 61.9 | 93.4 |
获得更好的结果
在 HPC 中,一些分析模型(如 Roofline Model)可以帮助开发人员预测某个算法的性能上限。
如果我们在英特尔至强融核协处理器和英特人至强处理器 v2 产品家族的 dev00 版本上应用该工具,我们将会得到下面两个图标(图 10):
图 10. Dev00 上的 Roofline Model 结果
为了更加精确,这些图表应在每次优化后绘制出,因为加法和乘法的次数在每个版本中不同。 但是,使用 roofline model 可以提示我们预计最高性能是多少。
此处,roofline model 模型显示,对于英特尔至强处理器 v2 产品家族,我们应该能够达到 337.5 GFlop/秒 以上。 该值基于一次内核迭代中的加法/乘法/负荷/存储的数量。 由于 dev00 版本在每次迭代时可以处理 78 个浮点,所以我们内核的最大吞吐量应为 337.5/78 * 1000= 4327 MPoint/秒。
在英特尔至强融核协处理器上,应可提供 658.12/78 * 1000 = 8437 MPoint/秒。
目前,我们还远未达到该预期。
roofline model 假定代码高度并行,完美矢量化,而且硬件能够利用完美的高速缓存系统。 通过上面呈现的是个不同版本,我们试图改进并行性、矢量化和数据局部性,但是这似乎还不够。
在编译和执行过程中出现了一个问题。 编译器可以选择多种选项来提高性能,因此很难找到最佳设置。 此外,我们的应用采用了许多参数,如阵列尺寸、高速缓存块尺寸、线程和迭代数量等。 这些参数也可以提高性能。
为了优化这些参数,我们使用了自动调试工具,该工具对参数空间进行搜索,以查找改进的参数集来使用。
借助该工具,我们在英特尔至强融核协处理器和双插槽英特尔至强处理器 v2 系统上得出以下结果(表 11)。
表 11. 通过参数设置进一步优化
英特尔至强融核协处理器 | 双插槽英特尔® 至强™ 处理器 v2 系统 | |
---|---|---|
每秒浮点运算/点 | 98 | 69 |
MPoints/秒 | 6100 | 3500 |
GFlops/秒 | 597 | 241 |
循环顺序 | -DBLOCK_X_Z_Y | -DBLOCK_X_Z_Y |
亲和性 | 均衡 | 紧凑 |
优化标记 | -03 | -03 |
预取 | 默认值 | -opt-prefetch-distance=78,24 |
nx | 400 | 224 |
ny | 388 | 1222 |
nz | 1432 | 1405 |
cbx | 400 | 1376 |
cby | 2 | 48 |
cbz | 48 | 93 |
# 线程数 | 244 | 24 |
请注意,此处获得的 GFlops/秒是通过将 MPoints/秒和 Flops/浮点的值相乘而得出。
参数调整确实能够让性能更接近 Roofline Model 呈现的理论最大值,通过优化上述参数能够带来明显改进。
附录
配置
本文表格中提供的性能测试结果可以通过以下测试系统实现。 如欲了解更多信息,请访问:http://www.intel.com/performance。
组件 | 规格 |
---|---|
系统 | 双插槽服务器 |
主机处理器 | 英特尔® 至强™ 处理器 E5-2697 V2; 2.70 GHz |
GFlop/秒主机内核/线程 | 12/24 |
主机内存 | 65865 MB |
协处理器 | 英特尔® 至强融核™ 协处理器 7120P,61 内核,1.24 GHz |
编译器 | 英特尔® C++ 编译器版本 12.144,驱动程序 3.3-1,MPSS 3.3 |
主机操作系统 | Linux;版本 2.6.32-431.3.1.el6.x86 |
注释
在性能检测过程中涉及的软件及其性能只有在英特尔微处理器的架构下方能得到优化。 诸如 SYSmark 和 MobileMark 等性能测试均系基于使用特定计算机系统、组件、软件、操作系统及功能。 上述任何要素的变动都有可能导致测试结果的变化。 请参考其他信息及性能测试(包括结合其他产品使用时的运行性能)以对目标产品进行全面评估。
英特尔的编译器针对非英特尔微处理器的优化程度可能与英特尔微处理器相同(或不同)。 这些优化包括 SSE2,SSE3 和 SSSE3 指令集以及其它优化。 对于在非英特尔制造的微处理器上进行的优化,英特尔不对相应的可用性、功能或有效性提供担保。
此产品中依赖于处理器的优化仅适用于英特尔微处理器。 某些不是专门面向英特尔微体系结构的优化保留专供英特尔微处理器使用。 请参阅相应的产品用户和参考指南,以了解关于本通知涉及的特定指令集的更多信息。