MIC性能优化主要包括系统级和内核级:系统级优化包括节点之间,CPU与MIC之间的负载均衡优化;MIC内存空间优化;计算与IO并行优化;IO与IO并行优化;数据传递优化;网络性能优化;硬盘性能优化等。内核级优化包括并行度优化;负载均衡优化;进程/线程的同步优化;线程扩展优化;向量化优化;cache优化;数据对齐优化;库函数的选择等。
并行度优化
MIC上的并行化主要涉及并行线程/进程的数目,并行层级,并行粒度等方面。
并行度
MIC卡上包含众多的物理核,同时每个核上可以开启4个线程。例如一块60个核的MIC卡上,我们最多可以开启240个线程,最佳线程数一般是每个核设置3个或4个线程。不是MIC卡上的线程数越多越好,线程数越多,线程开销也比较大。
并行粒度
根据并行程序尽可能使用粗粒度的病性原则,尽可能在最上层并行化代码。在外层上并行除了带来易编程的好处之外,还可以带来好的性能:增加粒度,减少线程调度和销毁的次数,也就是减少线程本身的开销所占的比例,尤其对于MIC平台上开启上百个线程,减少线程的开启对性能影响更为重要;同时,隐藏了底层的线程交互,减少了不必要的同步带来的损耗。
#pragma omp parallel for num_threads(THREAD_NUM) for(int i=0;i<M;i++){ for(int j=0;j<N;j++){ ..... } }
并不是所有的应用程序都是在外层并行效果最佳,外层循环可能引起线程之间访问的数据跨度比较大,可能会引起Cache miss,这种情况可能采取内层循环的效果更佳,同时为了减少线程的开销,可以在外层开启多线程,在内层for进行任务分发,
#pragma omp parallel num_threads(THREAD_NUM) for(int i=0;i<M;i++){ #pragma omp for for(int j=0;j<N;j++){ ..... } }
有时某一层循环无法达到并行度要求,可以考虑将两层循环合并,满足并行度要求,也可以采取嵌套并行的方法
合并循环:
#pragma omp parallel for num_threads(THREAD_NUM) for(int k=0;k<M*N;k++){ i=k/M; j=k%M; .... }
嵌套并行:
omp_set_nested(true) #pragma omp parallel for num_threads(THREAD_NUM1) for(int i=0;i<M;i++){ #pragma omp parallel for num_threads(THREAD_NUM2) for(int j=0;j<N;j++){ ..... } }
内存管理优化
内存占用
MIC上存在内存瓶颈,有两种情况,其一是任务本身要占用大量内存,由于MIC卡上内存容量较小,移植有困难,其二是,每个线程占用临时空间较大,当移植到MIC上时,由于线程较多,导致内存不足。
任务分块
把大任务划分成小任务,把一次需要占用的内存空间降下来,则可以解决几乎全部的内存空间不足的问题。
当任务本身需要占用大量内存,则通常需要将任务本身分块,一次只处理任务的一个子集
当每个线程需要较多的临时空间,通常可以选择降低并发度,减少线程数,自然可以降低总体内存占用。由于MIC卡上可以并发的线程较多,降低一定的并发度,任然有可能保持性能在可接受的范围内。
临时空间复用
临时空间可以合并或者节省的。例如:程序前半部分用到数组a,大小为100MB,后半部分用到数组b,大小为150MB,可以只开辟一个150MB的空间,用作数组a和b。
改变算法
从程序算法来看,通常粗粒度并行,即并行层次较高的循环,会占用相对较多的内存资源,而细粒度并行占用的内存资源较少。
申请次数
把开辟空间的操作放到循环外面,无论是简单的循环,还是循环中调用的子函数,都有可能根据需要开辟自己私有的空间,尤其是使用malloc等函数开辟的大块内存空间。由于MIC的时钟频率等原因,开辟空间的操作比主机端要慢。
当需要多次调用offload函数,进行一系列操作时,如果不同offload函数中有公用的数组,也可以使用nocopy等方式一次申请,多次使用。一方面减少了数据传输时间,另一方面也避免了多次申请空间的开销。
数据传输优化
数据传输对并行程序的性能有很大影响,对于单机节点来说,频繁进行的发送/接收操作将大大降低并行程序的执行性能。对于集群来说,巨大的通信开销对并发效率的影响是致命的。
一般对数据传输用到的优化方法有:nocopy,offload异步等。
nocopy
CPU与MIC之间通过PCI-E同信,PCI-E的速度较慢,因此要尽量减少CPU与MIC之间的数据同信,通过nocopy技术可以有效地减少CPU与MIC之间的通信次数。
offload中的in,out语句默认为每次offload开始时申请空间,结束时释放空间,然而在很多程序中,数据或空间是可以重复利用的,并不需要每次offload时都申请空间,释放空间。nocopy主要应用在多次调用offload语句时,可以减少CPU与MIC的通信次数,即主要应用在迭代调用MIC的程序中。
没用采用nocopy的MIC程序可以表示成下面的伪代码:
... p_c=...;//p_c在每次迭代中值不变 for(int i=0;i<steps;i++)//迭代多次 { p_in=...;//每次迭代时,p_in的值发生变化 #pragma offload target(mic) in(p_in:length(...)) in(p_c:length(...)) out(p_out:length(...)) { kernel(p_in,p_c,p_out); } } ...=p_out;//CPU端在所有迭代完成后才用到p_out的值
nocopy技术往往与alloc_if,free_if()联用
p_c=...;//p_c在每次迭代中值不变 #pragma offload target(mic) in(p_c:length(...) alloc_if(1) free_if(0)) nocopy(p_in:length(...) alloc_if(1) free_if(0)) onocopy(p_out:length(...) alloc_if(1) free_if(0)) { }//申请空间,并且不释放;传递p_c的值 for(int i=0;i<steps;i++)//迭代多次 { p_in=...;//每次迭代时,p_in的值发生变化 #pragma offload target(mic) in(p_in:length(...) alloc_if(1) free_if(0)) nocopy(p_c) nocopy(p_out)//每次迭代传递p_in的值,p_c和p_out采用nocopy { kernel(p_in,p_c,p_out); } } #pragma offload target(mic)\ nocopy(p_c:length(...) alloc_if(0) free_if(1)) nocopy(p_in:length(...) alloc_if(0) free_if(1)) out(p_out:length(...) alloc_if(0) free_if(1)) { }//回传到p_out值到CPU端,并释放MIC端申请的空间 ...=p_out;//CPU端在所有迭代完成后才用到p_out的值
offload异步传输优化
对于MIC来说,异步有两种方式,其一是数据传输与计算的异步,即MIC与主机端的数据传输与MIC卡上计算的异步,其二是计算与计算的异步,即MIC卡与CPU计算的异步。
数据传输与计算的异步
数据传输优化主要使用两个offload语句的变种:offload_transfer和offload_wait。offload_transfer作用是传输数据,并在数据传输完成时发送信号。其参数是与传统的offload语句完全一致,区别在于offload_transfer语句后面没有代码段,仅有offload语句。offload_wait的作用是暂停程序的执行,知道接收到offload_trandfer发送的信号。举例如下:
1 #pragma offload_attribute(push,target(mic)) 2 int count=25000000; 3 int iter=10; 4 float *in1,*out1; 5 float *in2,*out2; 6 #pragma offload_attribute(pop) 7 8 void do_async_in(){ 9 int i; 10 #pragma offload_transfer target(mic:0)11 in(in1:length(count) all) alloc_if(0) free_if(0) signal(in1) 12 for(int i=0;i<iter;i++) 13 { 14 if(i%2==0){ 15 #pragma offload_transfer target(mic:0) if(i!=iter-1)16 in(in2:length(count) all) alloc_if(0) free_if(0) signal(in2) 17 #pragma offload target(mic:0) nocopy(in1)18 `` wait(in1) out(out1:length(count) alloc_if(0) free_if(0)) 19 compute(in1,out1) 20 }else{ 21 if(i%2==0){ 22 #pragma offload_transfer target(mic:0) if(i!=iter-1)23 in(in1:length(count) all) alloc_if(0) free_if(0) signal(in1) 24 #pragma offload target(mic:0) nocopy(in1)25 `` wait(in2) out(out2:length(count) alloc_if(0) free_if(0)) 26 compute(in2,out2) 27 } 28 } 29 }
本例中,我们开辟了两个数组空间,并等待使用第一个空间in1,out1计算时,同时在传输第2个空间in2,out2的数据,当第2个空间数据传输完成时,且第1个空间的计算完成时,开始第2个空间的计算,并且传输第1个空间的数据,循环往复,直到全部计算完成。即在循环变量为奇数是,传输第2个空间的数据,并等待第1个空间的数据计算完成,循环变量为偶数时反之。由于二者交替进行,因此最终的时间大致等于传输和计算二者中较长的时间,而非二者时间之和。
进入循环以前,首先使用offload_transfer将第1个空间填充(10-11行),进入循环体后(12行),可以首先启动填充第2个空间的工作(15-16行),等待第1个空间传输完毕(17-18行),再对第1个空间进行计算(19行)。由于MIC卡只有一个,因此对计算来说是串行执行的,只有传输和计算之间是异步的。第1次传输时,并没有开辟空间(alloc_if(0)),这是因为在该函数外,已经在MIC卡上开辟空间了。
另外一个值得注意的地方是signal的参数。在C/C++中,参数是需要传递的一个数组的指针。另一点是signal中能且只能使用一个参数。
由于计算和传输需要两部分空间,即占用内存成倍增长,因此分块过大,很有可能造成卡上内存空间不够用。
计算和计算异步
计算异步多用于CPU与MIC协同计算的情况,让CPU和MIC个分担一部分任务。通常意义上的协同计算,是指MIC计算函数与CPU计算韩函数分属不同的线程,甚至是不同的进程,这里介绍的是二者处于同一线程,启动函数是串行的,但函数的执行时是并行的。
传统offload方式的MIC程序中,当调用MIC函数执行完成返回时,即MIC函数后,即MIC函数后,MIC函数即接管控制权,直到MIC函数执行完成之后,才将控制权交回给CPU线程,此时CPU线程才能继续执行下去。
在计算异步MIC程序中,调用offload语句后,代码段在MIC卡上启动以后,驱动即刻将控制权交还给CPU线程,CPU线程继续下面的工作,当MIC函数执行完毕后,会给CPU线程发送一个信号。
计算异步方式用到的除了传统的offload语句之外,也会使用数据传输异步中介绍的offload_wait语句等待计算完成的信号,如下:
1 int counter; 2 float *in1; 3 counter=10000; 4 __attributes__((target(mic))) mic_compute; 5 while(counter>0) 6 { 7 #pragma offload target(mic:0) signal(in1) 8 { 9 mic_compute(); 10 } 11 cpu_compute(); 12 #pragma offload_wait target(mic:0) wait(in) 13 counter--; 14 }
在调用mic_compute后程序马上返回,将控制权交还给CPU,CPU可以继续执行cpu_compute,并在offload_wait处等待,直到MIC函数计算完成后,offload_wait才返回,继续执行下面的代码。
存储器访问优化
MIC存储器访问优化
隐藏存储器访问延迟
隐藏存储器访问延迟的基本思想是在处理器进行计算时,如果出现访问存储器时发生延迟,则可以通过预先的存储器操作或者另外的计算将这些延迟和处理器的计算重叠起来,使处理器不因为等待存储器操作的结果而停顿。
多线程:多线程基于暴露线程级并行的思想隐藏访存延迟。比较典型的就是同时多线程技术,其基本思想是在一个线程的指令发生访存延迟的时候,从另一个线程中选择适当的指令执行,这样不至于让处理器发生停顿。MIC卡每个core支持最多4个线程,采用了硬件多线程技术,该技术就是通过多线程隐藏访存延迟,因此,在MIC程序设计时要尽量提高并行度,使每个核上运行3-4个线程比较理想。
预取:预取技术指在处理需要或者指令之前将其从存储器中取出,以备需要时使用。目前的预取技术可以分为硬件预取(扩展存储器子系统的体系结构)、软件预取(利用现代处理器的非阻塞预取指令)和混合预取,由MIC硬件自动完成。
利用cache优化
MIC包含L1和L2两级Cache,KNC每个核包含32KB L1指令cache和32KB L1数据Cache,L1 Cache line 为64B,采用8路关联,8个bank,L1Cache为每个核私有,访问速度快。
KNC拥有共享的L2 Cache。每个核上的L2 Cache包含L1的数据缓存和指令缓存。不仔细分析可能不清楚那些核之间是怎么组织成一个大的共享的L2Cache(达到31MB)。因为每个核包含512KB L2Cache,62个核的L2 Cache即为31MB,看起来31MB L2 Cache都是可用的。然而,如果两核或多核之间共享数据,这些共享数据在不同核的L2 Cache中是重复的。如果核之间没有共享任何数据或代码,那么片上L2的全部大小为31MB,相反如果每一个核同时共享相同的代码或数据,那么片上 的L2的全部的大小仅为512KB(每个核上L2 Cache存放相同的512KB)。
cache优化方法
代码变换
代码变换是针对程序指令进行的程序变换,不仅能改变指令之间的关系,优化指令自身的局部性,提高指令Cache的性能,而且还能够通过改变指令的执行顺序来优化程序数据的局部性。提高数据Cache的性能。
循环融合
循环融合是一种将多个循环合并成一个循环的代码变换方法。进行融合的过程中,同一个数据在多个循环中多次使用能够变成在一个循环的一次迭代中的多次使用,这样提高了这些数据的时间局部性,从而提高Cache性能。还能够扩大循环体,从而有利于进行指令调度。
//原始循环 for(i=0;i<n;i++) a[i]=b[i]+1; for(i=0;i<n;i++) c[i]=a[i]/2; //循环融合 for(i=0;i<n;i++) { a[i]=b[i]+1; c[i]=a[i]/2; }
循环分割
循环分割是一种和循环融合相逆的代码变换技术。通过循环分割能够将一个循环变换成多个循环,提高循环体中访问数据的空间局部性。如果一个循环的循环体中的数据之间存在依赖关系,循环分割将这些数据分到不同的循环体中。从而消除这些依赖关系,以便进行其他的循环变换。
//原始循环 for(i=0;i<n;i++) { a[i]=a[i]+b[i-1]; b[i]=c[i-1]*x*y; c[i]=1/b[i]; d[i]=sqrt(c[i]); } //循环分割 for(i=0;i<n;i++) { b[i]=c[i-1]*x*y; c[i]=1/b[i]; } for(i=0;i<n;i++) { a[i]=a[i]+b[i-1]; } for(i=0;i<n;i++) { d[i]=sqrt(c[i]); }
循环分块
循环分块是一种按照循环访问的数据的特性将一个循环分成多个嵌套的循环的代码变换技术。通过分块,循环尽量完成某一个数据集的处理,才开始下一个数据集的数据处理,这样提高循环访问数据的时间局部性。循环分块可以通过Cache大小确定分块的大小,充分利用Cache优化程序,提高性能。
//原始循环 for(i=0;i<n;i++) for(j=0;j<m;j++) x[i][j]=y[i]+z[j]; //循环分块 for(it=0;it<n;it++) for(jt=0;jt<m;jt++) for(i=it;i<min(it+nb,n);i++) for(j=jt;j<min(jt+mb,m);j++) x[i][j]=y[i]+z[j];
循环交换
循环交换指在嵌套循环中,改变循环嵌套的顺序,以此改变数据的访问方式,从而提高循环访问数据的空间局部性。
通过代码交换来提高Cache的性能的基本思想是改变指令执行的顺序,从而改变Cache命中的命中率。
//原始循环 for(j=0;j<m;j++) for(i=0;i<n;i++) c[i][j]=a[i][j]+b[j][i]; //循环交换 for(i=0;i<n;i++) for(j=0;j<m;j++) c[i][j]=a[i][j]+b[j][i];
数据变换
数据变换主要是改变程序中数据的布局,依据空间局部性原理提高数据的Cache性能。数据变换的基本思想是:当程序访问数据时,将经常一起访问的数据组织在一起,使其在内存中的位置相邻。这样当Cache失效的时候,每次调入相应的Cache块,紧接着访问的数据也就由于在同一Cache中而被一起调入。
数据放置
程序变量的地址在编译时或运行时才能决定,这些地址决定了程序数据在内存中的位置,也就决定了这些数据在Cache中的位置。例如在虚拟索引的Cache中,变量的地址与数据Cache大小取模后就能得到变量所在Cache的相应地址。因此,可以通过将数量放置在适当的位置来决定其对应的Cache的位置。如果将经常一起访问的变量通过重新放置使其位于同一Cache快上,则能有效提高数据的空间局部性。在实际的数据放置实现中,需要先判断数据之间的关系来选择放置在一起的数据,常见的方法有Clustering和Coloring等,这种方法对一些基于指针的数据结构效果特别明显。
数组重组
在科学计算程序中,大量的数据是以数组的形式出现,对这些数组进行重组能有效地提高Cache性能。在这样的程序中经常出现一些循环,这些循环以循环变量为下标访问多个数组,循环的每次迭代都需要到各个数组所在的内存区域读取数据,如果将这些数组重组成以结构体为元素的数组,则循环的每次迭代用到的数据都在一个结构体,读取这些数据时就只需访问内存中一个连续的区域提高了数据的空间局部性,相应的Cache失效次数就会减少。
向量化优化
什么是向量化
Intel的编译器支持向量化(Vectorzation),向量化是使用向量处理单元进行批量计算的方法,可以把循环计算部分使用MMX,SSE,SSE2,SSE3,AVX,Knights Corner Instrutions等扩展指令集进行向量化,从而大大提高计算速度。
MIC处理器支持512位宽的Knights Corner指令,支持16*32bit或8*64bit处理模式,即向量化宽度为8或16。512位相当于16个单精度浮点型数据的长度。
MIC向量化优化策略
MIC向量化优化主要有两种方式:自动向量化和SIMD指令优化,向量化优化步骤一般为:
(1)插入引语自动向量化:不改变源程序结构,只需要插入预编译指令(引语)即可自动向量化。
(2)调整程序循环结构并插入引语自动向量化:对程序做一些结构调整,如嵌套循环交换次序等,然后插入引语可以自动向量化。
(3)编写SIMD指令:SIMD指令可以比自动向量化获得更好的性能,但针对不同的硬件平台编写的SIMD指令也不同,并且SIMD指令易读性较差就,所以SIMD指令可以选择性的使用。
自动向量化
自动向量化是英特尔编译器提供的一个可以自动的使用SIMD指令的功能。在处理数据时,编译器自动选择MMX,Intel Streaming扩展(MMX,SSE,SSE2,SSE3,AVX,Knights Corner Instrutions)等指令集,对数据进行并行的处理。
自动向量化的好处
(1)提高性能:向量化处理,实现了单指令周期同时处理多批数据。
(2)编写单一版本的代码,减少使用汇编是编码工作简化:较少的汇编意味着会大大减少为特定的系统编程的工作,程序将很容易升级并使用最新的主流系统而不必重新编写那些汇编代码。
什么样的循环可以向量化
(1)对于一个循环,如果编译器认为循环内的每一个语句都没有依赖于另一个语句并且没有循环的依赖关系,那么这个循环就是可向量化的。也就是每一个语句必须能独立执行,读写数据的操作必须中立于循环的每次迭代。例如:
for(int i=0;i<1000;i++) { a[i]=b[i]*T+d[i]; b[i]=(a[i]+b[i])/2; c=c+b[i]; }
等价于:
for(int i=0;i<1000;i++) a[i]=b[i]*T+d[i]; for(int i=0;i<1000;i++) b[i]=(a[i]+b[i])/2; for(int i=0;i<1000;i++) c=c+b[i];
这个循环是可以向量化的。
看下面这个例子:
for(int i=0;i<1000;i++) { a[i]=a[i-1]*b[i]; }
无论如何这个循环是不能被向量化的,因为每次a[i]在每次迭代中都读取了前一次迭代中的a[i-1]。我们称这是一个交叉迭代的数据依赖或者“flow dependence”,这样的循环不能被编译器向量化。
(2)向量化只能作用于最内层的循环:在一个嵌套的循环中,向量器只能尝试向量化最内层的循环,查看向量器的输出信息可以知道循环是否能被向量化以及原因,如果影响性能的关键循环没有向量化,你可能需要做一些更深层的打算,比如调整嵌套循环的顺序。
(3)向量化处理的数据类型尽量一致:需要向量化处理的语句,其包含的变量数据类型尽可能一致。如尽量避免在同一表达式中同时出现单精度与双精度变量。
编译器自动向量化方法
(1)编译器向量化选项:对于MIC程序,默认向量化编译选项为-vec,即默认情况下向量化是打开的,若关闭向量化可以在编译选项中添加-no-vec。向量化编译器可以生成自己的向量化报告,通过-vec-report开关开启这一功能,具体选项功能如下
-vec-report[n] | 含义 |
n=0 | 不显示诊断信息 |
n=1 | 只显示已向量化的循环(默认值) |
n=2 | 显示向量化和未向量化的循环 |
n=3 | 显示已向量化和未向量化的循环以及数据依赖信息 |
n=4 | 只显示为向量化的循环 |
n=5 | 显示未向量化的循环以及数据依赖信息 |
(2)#pragma ivdep和restrict的使用
为了向量化一个包含或可能包含依赖关系的循环,加上#pragma ivdep(ivdep,ignore vector dependencies)
例如:
void foo(int k) { #pragma ivdep for(int j=0;j<1000;j++) { a[j]=b[j+k]*c[j]; b[j]=(a[j]+b[j])/2; b[j]=b[j]*c[j]; } }
当向量化这个循环时,编译器会认为数组b依赖了交叉迭代,原因就是使用了变量k,如果我们知道k不会造成数据依赖,加上#pragma ivde编译指导语句忽略数据的依赖关系并尝试进行向量化。程序员必须知道这个依赖是怎么样产生的,并确信它们没有数据依赖性。
使用“#pragma vector always”编译指导语句,指定循环向量化的方式可以避免一些没有内存对齐的操作没有被向量化。甚至可以使用“#pragma simd”语句,同时在编译选项中可以加入-simd,强制向量化最内层循环,如果这么做的话,需要程序员保证程序的正确性。最后一条语句与前面两条不同 的是,前两条对编译器来说只是建议,最终不是被向量化,是由编译器决定的,但“#pragma simd”指令对编译器来说是强制的,如果编译器坚持认为这段代码无法向量化,将会产生一个编译器错误。
在MIC平台上,可以采用“#pragma vector aligned”进行向量化对齐,但必须保证内存分配以64B对齐,即以align(64)声明变量。
使用指针的循环可能造成依赖性,如果为了向量化这样的循环,可以使用restrict关键字。
void foo(float *restrict a,float *restrict b,float *restrict c) { #pragma ivdep for(int j=0;j<1000;j++) { a[j]=b[j+k]*c[j]; b[j]=(a[j]+b[j])/2; b[j]=b[j]*c[j]; } }
注意,使用了restrict关键字需要使用-restrict编译选项。如果不适用restrict关键字,编译器会认为数组的引用可能有交叉迭代的依赖性。这是因为指针在循环中用来访问数据,编译器无法知道指针是否指向了相同的地址(一般就是别名),为了安全必须阻止这样程序向量化,restrict关键字告诉编译器指针指向的地址是受限的,只能通过这个指针访问,换句话说,这里没有别名。
自动向量化优化方法
调整嵌套循环的顺序
自动向量化只能对嵌套中的最内层的循环进行向量化,然而内层循环向量化效果未必最好,我们可以通过调整嵌套循环的顺序达到更好的向量化效果,如调整之后的向量化可以满足更好的连续访问。例:
for(j=0;j<N;j++) #pragma ivdep for(i=0;i<M;i++) { C[i][j]=A[i][j]+B[i][j]; } for(i=0;i<M;i++) #pragma ivdep for(j=0;j<N;j++) { C[i][j]=A[i][j]+B[i][j]; }
(2)拆分循环
在某些情况下,除了最内层的循环比较耗时外,其他不在最内层循环的代码也比较耗时,这部分代码是无法自动向量化的,为此我们可以采取拆分循环的方法实现更多的自动向量化,例:
for(i=0;i<N;i++) { rand(); ... ... }
假设上面的代码循环无数据依赖,由于rand函数无法向量化,从而导致整个循环无法向量化,我们可以把一个循环拆分成两个循环的方法达到第二个for循环(主要耗时的)实现向量化的目的
for(i=0;i<N;i++) { rand(); } for(i=0;i<N;i++)//自动向量化 { ... ... }
实例二:
float s;
for(i=0;i<N;i++) { ... s=...; for(j=0;j<M;j++)//自动向量化 { if(s>0) { ... } } }
假设上面的代码中两层循环均无数据依赖,除了内层循环for(j=0;j<M;j++)比较耗时,对以s的求解也很耗时,然而求解s的部分是无法自动向量化的。我们可以通过拆分外层的循环做到更好的自动向量化效果,修改后代码如下:
float s[16]; for(i=0;i<N;i+=16) { T=min(N-1,16); for(k=0;k<T;k++)//自动向量化 { ... s[k]=...; } for(k=0;k<T;k++) { for(j=0;j<M;j++)//自动向量化 { if(s[k]>0) { ... } } } }
通过对循环的拆分,我们可以使更多的代码自动向量化,获取更好的向量化性能。
(3)并行度与向量化
由于MIC内核可以开启数百个线程,所以要 保证足够多的线程数,然而,多数应用中,一般采取并行外层循环,内层循环采用向量化优化,如果外层循环次数较少会影响到并行度。例:
for(i=0;i<100;i++) { for(j=0;j<1024;j++) { ... } }
假设上面的代码两层for循环均无数据依赖,MIC并行的线程为200,上面的代码内层for可以采用自动向量化的方法,外层for作为MIC并行时仅有100次循环,并行度较低,不利于发挥MIC的最佳性能,对于这种情况,我们可以采用拆分内层for的方式让并行程序卡哇伊满足并行度也可以满足自动向量化,一种方法如下:
for(i1=0;i1<200;i1++) { for(j1=0;j1<512;j1++) { i=i1/2; j=(i1%2)*512+j1; ... } }
上面的方法可以满足MIC的并行度,同时满足自动向量化,上面的拆分也可以把外层for循环次数变得更大,但要保证内层for循环次数>16次可以保证向量化的效果。
向量化性能提升明显,在某些应用场景下,会出现精度的损失。因为向量化使用的是向量单元,比如以前一个浮点计算的时候,处理器给他分配了1个校验位,现在16个数同时操作,但校验位任然只有1个(硬件限制),导致精度汇出现误差。当然多数情况误差是可以接受的。
SIMD向量化
SIMD指令可以在程序执行中复制多个操作数,并把他们直接打包在向量寄存器。
向量化层次如图,越往上,使用语言越低级,编程越复杂,但可以控制的部分也越多,理论上性能也越高。相反,越往下,编程越容易,但性能未必理想。
汇编代码(sddps) |
向量化指令(mm_ad_ps()) |
SIMD类库(F32vec4 add) |
SIMD特性(#pragma simd) |
自动向量化引语(#pragma ivdep) |
编译器自动向量化 |
Knights Corner Instrutions分类:
(1)Knights Corner指令(Knights Corner Instrution)是指具体的SIMD指令,是汇编指令集中关于SIMD操作的子集。
(2)内建Knights Corner(Instrinsics of Knights Corner)是对Knights Corner指令的封装(几乎涉及到所有指令),可以认为这些函数和数据类型是C/C++的内建类型。
(3)Knights Corner类库(Knights Corner Class Libraries)是为了使用Knights Corner指令而做的封装,可以让程序员尽量简单的使用SIMD指令,介于引语方式和SIMD代码之间。
有单精度浮点数相加说明区别:
Knights Corner指令 | 内建Knights Corner | Knights Corner类库 |
__m512__ a,b,c; __asm{ vloadd v0,b vloadd v1,c vaddps v0,v1 vstored a,v0} |
#include<immintrin.h> ... __M512 a,b,c; a=_mm512_add_ps(b,c); ... |
#include<micvec.h> ... F32vec16 a,b,c;j a=b+c; ... |
通过上面的例子可以看出使用类库的方式非常简单,能够以最类似于标量的方式(把数组看成标量),进行标量化改造。而直接使用内建Knights Corner则更接近常规的思维方式,将两个数组通过向量化函数进行运算,当然,其代码要比使用类库方式更复杂一些,但由于减少了封装和调用,因此性能也会略有提高。而内联汇编则是最难阅读的,但由于最贴近底层,因而执行效率也最高,只是编程的成本也是最高的。实际的SIMD指令编写中,一般采用内建Knights Corner的方式。
实例:
#include<immintrin.h> void foo(float *A,float *C,int N) { #if __MIC__ _M512__ _A,_B,_C; for(int i=0;i<N;i+=16) { _A=_mm512_loadunpacklo_ps(_A,(void*)(&A[i])); _A=_mm512_loadunpackhi_ps(_A,(void*)(&A[i+16])); _B=_mm512_loadunpacklo_ps(_B,(void*)(&B[i])); _B=_mm512_loadunpackhi_ps(_B,(void*)(&B[i+16])); _C=_mm512_add_ps(_A,_B); _mm512_packstorelo_ps((void*)(&C[i],_C)); _mm512_packstorelo_ps((void*)(&C[i+16],_C)); } #endif }
负载均衡优化
什么是负载均衡
负载是指多个任务之间的工作量分布情况,负载均衡是指各任务之间的工作量平均分配。负载均衡在并行计算里指的是将任务平均分配到并行执行的系统中各个资源的各个计算资源上,使之充分发挥计算能力,没有空闲等待,也不存在负载过度等。
通常情况下,实现负载均衡有两种方案:静态负载均衡和动态负载均衡。静态负载均衡需要人工将工作区域分割成多个可并行的部分,并保证分割成的各个任务之间均衡地进行分配。使并行程序的加速性能最高;动态负载均衡是在程序运行过程中进行任务的动态分配以达到负载平衡的目的。实际情况中静态负载均衡解决不了问题,比如,在一个循环中,每次循环的计算量均不同,且不能事先预知。一般来说,动态负载均衡的系统总体性能比静态负载均衡要好,但代码实现上更复杂。
CPU/MIC协同计算负载均衡优化方法
CPU/MIC协同计算应用程序中包含3个层次的负载均衡:
(1)计算设备(CPU或MIC)内部各线程/进程之间的负载均衡。
(2)CPU/MIC协同计算时,一个节点内CPU设备与MIC设备之间的负载均衡。
(3)集群计算时,节点之间的负载均衡。
设备内负载均衡
设备内的负载均衡可以采用OpenMP中的三种策略。
(1)schedule(static [,chunk]):静态调度,线程每次获得chunk个迭代次数,并以轮询的方式进行。如果不指明chunk,则以平均分配的方式进行,这是默认的调度方式。
(2)schedule(dynamic [,chunk]):动态调度,动态的将迭代分配到各个线程,不使用chunk参数时将迭代的分配到各个线程,使用chunk参数,每次分配给线程的迭代次数为指定的chunk次。
(3)schedule(guided [,chunk]):导引调度是一种采用指导性的启发式自调度方法。开始时每个线程分配到较大的迭代块,之后分配到的迭代块会逐渐递减。迭代块的大小会按指数下降到指定的chunk大小,如果没有指定chunk参数,那么迭代块大小最小降到1。
CPU/MIC设备间的负载均衡
由于CPU与MIC的计算能力不等,因此CPU与MIC之间的分配的计算量也不能相同,CPU与MIC之间的负载均衡最好的方式是采用动态负载均衡的方法。
(1)任务划分:对于任务划分的应用程序,在CPU与MIC之间的负载均衡可以采用动态负载均衡的优化方法,例如有N个任务,一个节点内有2MIC卡,即三个设备(CPU和2个MIC),动态负载均衡的方法是每个设备先获取一个任务进行计算,计算之后立即取下一个任务,不需要等待其他设备,直到N个计算任务计算完成。这种方式只需要设定一个主进程,负责给各个计算任务计算进程分配任务。
(2)数据划分:由于我们需要一次性在设备上分配内存空间,因此,对于数据划分的应用程序,我们无法采用动态负载均衡,需要采用静态数据划分方法,然而静态数据划分方法使异构设备间的负载均衡变得更加困难,有时甚至无法实现。对于一些爹迭代应用程序,我们可以采用学习型的数据划分方法,如让CPU和MIC分别做一次迭代相同计算量的计算,然后通过各自的运行时间计算出CPU与MIC的计算能力比例,然后在对数据进行划分。