9.2.5.1 什么是向量化
Intel 的编译器支持向量化(Vectorization) ,向量化是使用向量处理单元进行批量计算的方 法, 可以把循环计算部分使用 MMX、 SSE、 SSE2、 SSE3、 SSSE3、 AVX、 Knights Corner Instructions 等扩展指令集进行向量化,从而大大提高计算速度。
9
Chapter
MIC 处理器支持 512 位宽的 Knights Corner 指令,支持 16*32bit 或 8*64bit 处理模式,即 向量化宽度为 8 或 16。512 位相当于 16 个单精度浮点型数据的长度,单精度浮点数据向量化 的操作过程如图 99 所示,例如向量加操作 C[0~15]=A[0~15]+B[0~15](A、B、C 均为 float 型数据),没有使用向量化时这个操作需要 16 次加运算,而向量化之后,把 A、B、C 放到向 量寄存器中,进行一次向量加操作即可完成原来的 16 次加操作,因此,向量化可以大大提高 计算速度。
图 99 单精度浮点数据向量化示意图
9.2.5.2 MIC 向量化优化策略
MIC 向量化优化主要有两种方式:自动向量化和 SIMD 指令优化,向量化优化步骤一般为:
(1)插入引语自动向量化:不改变原程序结构,只需要插入预编译指令(引语)即可自 动向量化
。
(2)调整程序循环结构并插入引语自动向量化:对原程序做一些结构调整,如嵌套循环 交换次序等,然后插入引语可以自动向量化。
(3)编写 SIMD 指令:SIMD 指令可以比自动向量化获得更好的性能,但针对不同的硬 件平台编写的 SIMD 指令也不同,并且 SIMD 指令易读性较差,所以 SIMD 指令可以选择性 的使用。
9.2.5.3 自动向量化
自动向量化是英特尔编译器提供的一个可以自动地使用 SIMD 指令的功能。在处理数据 时,编译器自动选择 MMX、Intel Streaming SIMD 扩展(Intel SSE、SSE2、SSE3、SSE4、AVX 和 Knights Corner Instructions)等指令集,对数据进行并行的处理。使用编译器提供的自动向 量化功能是提高程序性能的一个非常有效的手段。自动向量化在 IA32 和 x8664 的平台上均 提供很好的支持。自动向量化能最大程度地解放程序员,既为程序员屏蔽底层 CPU/MIC 的细 节,又能通过底层 CPU/MIC SIMD 并行获得有效的性能提升,MIC 处理器拥有 512 位宽的向
9 Chapter
量化处理器,因此自动向量化对 MIC 程序优化意义更为重大。
1.自动向量化的好处
(1)提高性能:向量化处理,实现了单指令周期同时处理多批数据
。
(2)编写单一版本的代码,减少使用汇编使编码工作简化:较少的汇编意味着会大大减 少为特定的系统编程的工作, 程序将很容易升级并使用于最新的主流系统而不必重新编写那些 汇编代码
。
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=1; i<1000; i++) {
a[i] = a[i1] * b[i];
}
无论如何,这个循环是不能被向量化的,因为 a[i]在每次迭代中都读取了前一次迭代中的 a[i1]。我们称这是一个交叉迭代的数据依赖或者“flow dependence” ,这样的循环不能被编译 器向量化。
(2)向量化只能作用在最内层的循环:在一个嵌套的循环中,向量器只能尝试向量化最 内层的循环, 查看向量器的输出信息可以知道循环是否能被向量化以及原因, 如果影响性能的 关键循环没有向量化,你可能需要做一些更深层的打算,比如调整嵌套循环的顺序。
(3)向量化处理的数据类型尽量一致:需要向量化处理的语句,其包含的变量数据类型 尽可能一致。如尽量避免在同一表达式中同时出现单精度和双精度变量。
9
Chapter
3.编译器自动向量化方法
(1)编译器向量化选项:对于 MIC 程序,默认向量化编译选项为vec,即默认情况下向 量化是打开的,若关闭向量化可以在编译选项中添加novec。向量化编译器可以生成自己的 向量化报告,通过vecreport 开关开启这一功能,具体选项功能如表 91 所示。
表 91 向量化报告
vecreport[n] 含义
n=0 不显示诊断信息
(2)#pragma ivdep 和 restrict 的使用
为了向量化一个包含或可能包含依赖关系的循环, 加上#pragma ivdep (ivdep, ignore vector dependencies) 。
例如:
1 void foo(int k) 2 {
3 #pragma ivdep
4 for(int j=0; j<1000; j++)
5 {
6 a[j] = b[j+k] * c[j];
7 b[j] = (a[j] + b[j])/2;
8 b[j] = b[j] * c[j];
9 }
10 }
当向量化这个循环时,编译器会认为数组 b 依赖了交叉迭代,原因就是使用了变量 k,如 果我们知道 k 不会造成数据依赖,加上#pragma ivdep 编译指导语句忽略数据的依赖关系并尝 试进行向量化。程序员必须知道这个依赖是怎么产生的,并确信它们没有数据依赖性。
使用“#pragma vector always”编译指导语句,指定循环向量化的方式可以避免一些没有 内存对齐的操作没有被向量化。甚至可以使用“#pragma simd”语句,同时在编译选项中加入
simd,强制向量化最内层循环,如果这么做的话,需要程序员保证程序的正确性。最后一条 语句与前面两条不同的是,前面两条对编译器来说只是建议,最终是不是被向量化,是由编译 器决定的,但“#pragma simd”指令对编译器来说是强制的,如果编译器坚持认为这段代码无 法向量化,将会产生一个编译器错误。
9 Chapter
在 MIC 平台上,可以采用“#pragma vector aligned”进行向量化对齐,但必须保证内存分 配以 64B 对齐,即以 align(64)声明变量。
使用指针的循环可能造成依赖性,如果为了向量化这样的循环,可以使用 restrict 关键字。
1 void foo(float*restrict a, float*restrict b, float*restrict c) 2 {
3 for(int j=0; j<1000; j++)
4 {
5 a[j] = b[j] * c[j];
6 b[j] = (a[j] + b[j])/2;
7 b[j] = b[j] * c[j];
8 }
9 }
注意,使用了 restrict 关键字需要使用restrict 编译选项。如果不使用 restrict 关键字,编译 器会认为数组的引用可能有交叉迭代的依赖性。 这是因为指针在循环中用来访问数据, 编译器
1 for(i=0; i<N; i++) 2 {
3 rand();
9
1 for(i=0; i<N; i++) 2 {
3 rand();
4 }
5 for(i=0; i<N; i++) //自动向量化 6 {
7 …
8 …
9 } 示例二:
1 float s;
2 for(i=0; i<N; i++) 3 {
4 …
5 s=…;
6 for(j=0; j<M; j++)//自动向量化
7 {
8 if(s>0)
9 {
10 …
11 }
12 }
13 }
假设上面的代码中两层循环均无数据依赖,除了内层循环 for(j=0; j<M; j++)比较耗时,对 于 s 的求解也很耗时,然而求解 s 的部分是无法自动向量化的。我们可以通过拆分外层的循环 做到更好的自动向量化效果,修改后的伪代码如下:
1 float s[16];
2 for(i=0; i<N; i+=16) 3 {
4 T=min(N1,16);
5 for(k=0; k<T; k++) //自动向量化
6 {
7 …
9 Chapter
8 s[k]=…;
9 }
10 for(k=0; k<T; k++)
11 {
12 for(j=0; j<M; j++) //自动向量化
13 {
14 if(s[k]>0)
15 {
1 for(i=0;i<100;i++) 2 {
3 for(j=0;j<1024;j++)
4 {
5 …
6 }
7 }
假设上面的代码两层 for 均无数据依赖, MIC 并行的最佳线程数为 200,上面的代码内 层 for 可以采用自动向量化的方法,外层 for 作为 MIC 并行时仅有 100 次循环,并行度较低,
不利于发挥 MIC 的最佳性能,对于这种情况,我们可以采用拆分内层 for 的方式让并行程序 既可以满足并行度也可以满足自动向量化,一种拆分方法如下:
1 for(i1=0;i1<200;i1++) 2 {
3 for(j1=0; j1<512; j1++)
4 {
5 i = i1/2;
6 j = (i1%2)*512+j1;
7 …
8 }
9 }
9
Chapter
上面的方法可以满足 MIC 的并行度,同时满足自动向量化,上面的拆分也可以把外层 for 循环次数变得更大,但要保证内层 for 循环次数>16 次以保证向量化的效果。
向量化性能提升明显,但在某些应用的情景下,会出现精度的损失。因为向量化使用的是 向量单元,比如以前一个浮点数计算的时候,处理器给它分配了 1 个校验位,现在 16 个浮点 数同时操作,但校验位仍然只有 1 个(硬件限制),导致精度会出现误差。当然,在绝大多数 情况下,这点误差都是在可以接受的范围内的。
9.2.5.4 SIMD 指令优化
SIMD(Single Instruction Multiple Data)指令可以在程序执行中复制多个操作数,并把它 们直接打包在向量寄存器中。 显而易见, SIMD 指令在性能上有较大的优势, 可以以同步方式,
在同一时间内对多个数据执行同一条指令。
向量化的层次如图 910 所示,越往上的级别,使用的语言越低级,编程越复杂,但可以 控制的部分也越多,理论上性能也越高。相反的,越往下的级别,编程越容易,但性能可能未 必如此理想。
图 910 向量化层次
第一代 Intel MIC 产品为 KNC(Knights Corner) ,Knights Corner Instructions 是 KNC 支持 的 SIMD 指令的总称。可以看作是类似于 SSE、AVX 等的指令集。通过使用 Knights Corner 指令,可以细粒度地控制向量化运算。
9 Chapter
Knights Corner Instructions 分类:
(1)Knights Corner 指令(Knights Corner Instruction)是指具体的 SIMD 指令,是汇编指 令集中关于 SIMD 操作的子集。
(2) 内建 Knights Corner (Intrinsics of Knights Corner) 是对 Knights Corner 指令的封装 (几 乎涉及到所有指令),可以认为这些函数和数据类型是 C/C++的内建类型。
(3)Knights Corner 类库(Knights Corner Class Libraries)是为了方便使用 Knights Corner 指令而做的封装,可以让程序员尽量简单地使用 SIMD 指令,介于引语方式和 SIMD 代码之 间。其支持整型和浮点型数据。
下面通过单精度浮点向量加的例子说明三者的区别,见表 92。
表 92 Knights Corner 实现向量加
Knights Corner 指令 内建 Knights Corner Knights Corner 类库
__m512 a,b,c; 看成变量),进行向量化改造。而直接使用内建 Knights Corner 则更接近常规的思维方式,将 两个数组通过向量化函数进行运算,当然,其代码要比使用类库方式复杂一些,但由于减少了 封装和调用,因此性能也会略有提高。而内联汇编则是最难阅读的,由于最贴近底层,因而执 行效率也最高,只是编程的成本也是最高的。在实际的 SIMD 指令编写中,我们一般采用内 建 Knights Corner 的方式。
下面我们通过一个向量加的示例说明 SIMD 指令的使用方法。
1 #include <immintrin.h>
2 void foo(float *A, float *B, float *C, int N) 3 {
4 #ifdef __MIC__
5 __M512 _A,_B,_C;
6 for(int i=0; i<N; i+=16)
7 {
8 _A =_mm512_loadunpacklo_ps (_A, (void*)(&A[i]) );
9 _A = _mm512_loadunpackhi_ps (_A, (void*)(&A[i +16]) );
10 _B = _mm512_loadunpacklo_ps (_B, (void*)(&B[i]) );
11 _B = _mm512_loadunpackhi_ps (_B, (void*)(&B[i +16]) );
12 _C = _mm512_add_ps(_A,_B);
13 _mm512_packstorelo_ps ((void*)(&C[i]) , _C );