数据级并行
数据级并行是指处理器能够同时处理多条数据的并行方式,大部分处理器采用SIMD向量扩展作为计算加速部件,SIMD扩展部件可以将原来需要多次装载的标量数据一次性装载到向量寄存器中,通过一条向量指令实现对向量寄存器中数据元素的并行处理。
使用SIMD方法执行的代码称为向量代码,将标量代码转换成向量代码的过程即为向量化。
通常使用两种方式获得向量程序:
-
由程序设计人员编写向量代码。
-
借助于编译器的向量化编译自动生成向量代码。
向量化的本质是重写程序,以便其同时对多个数据进行相同的操作。
向量程序编写
关于SIMD的使用,可以参考SIMD相关笔记
循环的向量化
当需要计算的数据较多时,直接进行计算需要多个for循环,代码冗长且不好理解。而将循环向量化后可以将多次for循环变成一次计算,较为方便且代价小。有些循环不适合直接进行向量化,此时可以使用循环变换技术对循环进行变换
面对多层循环时,最内层循环的依赖关系更容易计算清楚,因此一般都选择最内层循环作为向量化的目标。当最内层循环存在依赖环向量化后不再满足正确性要求或优化效果不佳时,且无法使用循环交换的前提下,可以考虑对外层循环进行向量化。
基本块的向量化
面向基本块的向量化又叫直线型向量化,其要求基本块内有足够的并行性,否则会因为有大量的向量和标量之间的转换而影响向量化效果,基本块级向量化方法与循环级向量化方法不同,是指从指令级并行中挖掘数据级并行。
面向基本块的向量化方法中常常提到打包、解包的概念,包是一个同构语句的集合,即语句参数可能不同但可编译的部分是相同的,将多条同构语句组成包的过程称为打包,反之则称为拆包。
函数的向量化
不论是面向循环还是面向基本块的向量化方法都是在标量函数内的,即该函数的参数为标量。而函数级向量化是将几个相邻的计算实例合并为一个向量实例,是一种单程序多数据的程序,即函数的参数为向量,返回值也为向量。
优化前
#include <stdio.h>
#define N 8
float func1(float x, float y) {
float z = x * y;
return z;
}
int main() {
float c[N], b[N], a[N];
for (int i = 0; i < N; i++) {
b[i] = i * 2;
c[i] = i / 2;
}
for (int i = 0; i < N; i++)
a[i] = func1(b[i], c[i]);
for (int i = 0; i < N; i++)
printf("%f ", a[i]);
printf("\n");
return 0;
}
优化后
// 个人不是很明白这个函数向量化的意义,为什么不能直接用_mm_mul_ps呢?
__m128 vecfunc1(__m128 x, __m128 y) {
__m128 vz = _mm_mul_ps(x, y);
return vz;
}
int main() {
float c[N], b[N], a[N];
for (int i = 0; i < N; i++) {
b[i] = i * 2;
c[i] = i / 2;
}
for (int i = 0; i < N; i += 4) {
__m128 vb = _mm_loadu_ps(&b[i]);
__m128 vc = _mm_loadu_ps(&c[i]);
__m128 va = vecfunc1(vb, vc);
_mm_storeu_ps(&a[i], va);
}
for (int i = 0; i < N; i++)
printf("%f ", a[i]);
printf("\n");
return 0;
}
分支的向量化
控制依赖会影响向量化的开展,是向量化时需要重点考虑的依赖形式之一。
If转换是向量化控制依赖最常用的方法,可以将控制依赖转换为数据依赖,其需要借助于向量条件选择指令完成向量指令生成。
规约的向量化
归约操作是指将多个元素归并为单个元素的过程,该操作把向量中的多个元素归约为一个元素,常见的归约操作包括归约加、归约乘等。
优化前
for (j = 0; j < N; j++) {
sum = sum + a[j];
}
优化后
for (i = 0; i < N / 4; i++) {
ymm0 = _mm_load_ps(a + 4 * i);
ymm1 = _mm_set_ps(0, 0, 0, 0);
ymm2 = _mm_hadd_ps(ymm0, ymm1);
ymm3 = _mm_hadd_ps(ymm2, ymm1);
_mm_storeu_ps(s, ymm3);
sum = s[0] + sum;
}
以上面的代码段为例,将向量寄存器槽位中的4个数据相加,利用提取指令实现的上述归约需要进行三次提取,然后进行三次加法。
此外还可以利用向量移位指令实现归约加法,实现原理如图所示
合适的向量长度
当前大多数向量寄存器在使用时为一个不可拆分的整体,即向量寄存器中的每个数据都是有效的。但语句中的数据并行性不足时,需要向量寄存器的部分使用,即向量寄存器中的某些槽位为有效数据,其它槽位为无效数据。
向量寄存器有四种使用方式,分别为满载使用、一端无效的部分使用、两端无效的部分使用、不连续的部分使用。
其中寄存器满载使用的情况也可称为程序充分向量化,而部分使用的情况可称为程序不充分向量化。
不是所有程序都适合使用不充分向量化方法改写,适合使用不充分向量化方法程序可以分为两种情况。
- 一是当平台没有向量重组指令或者向量重组指令的功能较弱时,如果强制将不连续的访存数据组成向量可能导致向量化没有收益,而不充分向量化不需考虑平台是否支持向量重组指令,同样可以生成向量程序。
- 二是向量重组指令的代价过大而导致向量化没有效果,即使用充分向量化效果不如使用不充分向量化效果。
常用的不充分向量化方法分为三类,分别为掩码内存读写方法、插入/提取方法以及加宽向量访存方法。
不充分向量化方法的代码生成需要从三个方面进行考虑,首先在读内存时需要标记出有效槽位和无效槽位,然后在运算时需要将参与运算的向量寄存器槽位相对应,最后再将结果写入内存时需要避免将无效槽位的值写入内存。
向量程序优化
不对齐访存
访存对齐性是影响向量程序性能的重要因素,内存对齐访问是指内存地址A对n求余等于,其中n为访存数据的字节数。如果向量访存是不对齐的,与对齐的向量访存相比,需要额外的开销才能实现数据的存储操作。
可以使用循环剥离等操作将循环中的非对齐部分从循环中剥离出来,使主体循环变为内存访问对齐的循环。
当多维数组的最低维长度不是向量长度的整数倍时,难以判断访存的对齐性,此时一般会使用非对齐访问指令保证程序的正确性,也可以使用循环填充来解决。
对于同时访问多个数组,且对齐情况不统一,一般使用非对齐指令访问。
不连续访存
连续的向量访存不仅可以提高向量访存指令的效率,还可以提高向量寄存器中有效数据的比率。
向量化的收益不仅来自于向量运算的收益,也来自于向量访存的收益。 因为一次标量访存的节拍数和一次连续向量访存的节拍数一致,使得向量化有明显的数据处理优势。
多数计算都不是理想的连续访存情况,如复数运算、稀疏矩阵等间接数组的计算
在不借助数组重组、循环变换等程序变换的情况下,向量化不连续访存的程序需要借助目标平台提供的向量指令。在向量化不连续访存程序时,可以直接使用聚集指令gather和分散指令scatter这类指令完成向量化。
并不是所有的平台都支持聚集和分散指令,对于一些不连续访存、但是访问内存有规律的程序可以利用向量重组实现不连续访存。
向量重组是指当目标向量中的所有元素不在同一个向量中时,通过多个向量之间的重新组合得到目标向量,如图所示
向量重用
不对齐访存代码可以利用向量重用进一步优化。
向量寄存器部分值的重用:
#define N 100
int main() {
float a[N + 2];
float c[N];
for (int i = 0; i < N + 2; i++) {
a[i] = i;
}
__m128 va1 = _mm_load_ps(a);
for (int i = 0; i < N; i += 4) {
__m128 va2 = _mm_load_ps(&a[i + 4]);
// 用到va1的后两个和va2的前两个
__m128 V1 = _mm_shuffle_ps(va1, va2, _MM_SHUFFLE(1, 0, 3, 2));
_mm_store_ps(&c[i], V1);
// 直接赋值,这样可以省去一次访存操作,也就是重用
// 由于va1只用到了va2的后两个,所以是向量寄存器部分值的重用
va1 = va2;
}
for (int i = 0; i < N; i++) {
printf("%f ", c[i]);
}
return 0;
}
向量运算融合
向量运算融合是将多条向量运算指令合并为一条向量运算指令,以提高向量程序的执行性能。
也就是考虑使用积和熔加运算
循环完全展开
循环展开不仅可以提高程序的指令级并行还可以提高寄存器重用,在循环被向量化后继续对循环进行展开,相当于在发掘完程序数据级并行的基础上,进一步发掘程序的指令级并行,同时提升向量寄存器的重用。
全局不变量合并
参考
《程序性能优化理论与方法》
先进编译实验室