为了挖掘硬件的性能,并行算法的实践模式还与具体的硬件有关。
模式的意义在于挖掘算法的相似性,以同样的方式解决类似的问题。
map实践模式直观的表述是:对每个数据施加同样的运算。
在应用map模式时,需要注意算法的粒度和硬件的粒度。
算法的粒度是指:某些应用在一种粒度上看是map模式,而在另一种粒度上看却不是map模式。例如对300块不同的数据排序,那么对数据块来说,是map模式。对于块内的每一个数据而言,这又不是map模式,因为对每个数据操作可能并不相同。
硬件的粒度是指:主流处理器编程都可以分为线程化和向量化两个层次,map模式既可以映射到线程上,也可以映射到向量上,但是这两种映射可能会导致不同的性能。
计算向量的2范数,要求先求向量每个元素的平方和,然后开方。
inline float map(float d)
{
return d * d;
}
void computeSqure(int len, const float* __restrict__ in, float* __restrict__ out)
{
for (int i = 0; i < len; i++)
out[i] = map(in[i]);
}
因为SIMD指令都是作用在固定长度的数据上,因此作用在数据上的map模式会很合适。
void computeSqureNEON(int len, const float* __restrict__ in, float* __restrict__ out)
{
int end = len - len % 4;
for (int i = 0; i < end; i += 4)
{
float32x4_t a = vldlq_f32(in + i);
a = vmulq_f32(a, a);
vstlq_f32(out + i, a);
}
for (int i = end; i < len; i++)
out[i] = map(in[i]);
}
在计算量比较小的情况下,map模式进一步的优化方式主要是循环展开。
比如OpenMP上;
void computeSqureNEON(int len, const float* __restrict__ in, float* __restrict__ out)
{
int end = len - len % 4;
#pragma omp parallel for
for (int i = 0; i < end; i += 4)
{
float32x4_t a = vldlq_f32(in + i);
a = vmulq_f32(a, a);
vstlq_f32(out + i, a);
}
for (int i = end; i < len; i++)
out[i] = map(in[i]);
}
inline float map(float d)
{
return d * d;
}
__kernel void computeSqureOCL(int len, const float* __restrict__ in, float* __restrict__ out)
{
int tid = get_global_id(0);
if (tid < len)
out[tid] = map(in[tid]);
}
ruduce表示从多个输入中产生一个输出,在不考虑误差的前提下,输出的输入的多个数据的顺序无关。比如求多个数据的和、最大值等。
reduce模式的一个变种是segment_reduce,表示输出的数据并非只有一个,可能是多个,比如求图像像素的直方图。
并行reduce时,因为数据的计算顺序发生改变可能会导致串行的结果和并行结果有微小差异,这主要是浮点运算不满足结合律和分配率。
使用reduce模式;来对平方的结果求和。
float computeSum(int len, const float* __restrict__ out)
{
float sum = 0.0f;
for (int i = 0; i < len; i++)
sum += out[i];
return sum;
}
float computeSumNEON(int len, const float* restrict out)
{
int end = len - len % 4;
float32x4_t sum = vdupq_n_f32(0.0f);
for (int i = 0; i < end; i += 4)
{
float32x4_t a = vldlq_f32(out + i);
sum = vaddq_f32(sum, a);
}
float ret = 0.0f;
for (int i = end; i < len; i++)
ret += out[i];
ret += (sum[0] + sum[1]) + (sum[2] + sum[3]);
return ret;
}
从定义上看,reduce的模式的作用单位是数据块,因此多核会很适合。OpenMP提供reduction来支持reduce,其他语言需要手动实现。pthread实现如下:
typedef struct
{
int len;
float* addr;
float partRet;
}ArgData;
void* computeSumPthread(void* data)
{
ArgData* arg = (ArgData*)data;
Arg->partRet = computeSumNEON(arg->len, arg->addr);
return NULL;
}
float computeSumNeonPthreadMulti(int len, const float* __restrict__ out)
{
int index[NUM_THREADS];
pthread_t t[NUM_THREADS];
ArgData data[NUM_THREADS];
// init data
...
for (int i = 0; i < NUM_THREADS; i++)
pthread_create(i + 1, NULL, computeSumPthread, data + i);
for (int i = 0; i < NUM_THREADS; i++)
pthread_join(t[i], NULL);
float sum = 0.0f;
for (int i = 0; i < NUM_THREADS; i++)
sum += data[i].partRet;
return sum;
}
由于每个线程需要知道自己要计算的数据,而pthread_create建立的线程执行的函数只能有一个void*函数,故需要 使用一个结构体打包数据,ArgData即是为了完成这一目的。
// WCS is the size of workgroup
inline void computeWorkgroup(local float* restrict out)
{
int lid = get_local_id(0);
for (int i = WCS / 2; i > 0; i = i /2)
{
if (lid < i)
out[lid] += out[i + lid];
barrier(CLK_LOCAL_MEM_FENCE);
}
}
void kernel computeOCLStage(const int len, global float* restrict out, float* restrict temp, local float* loacl_out)
{
int gid = get_global_id(0);
int globalSize = get_gobal_size(0);
int lid = get_local_id(0);
float sum = 0.0f;
for (int i = gid; i < len; i += globalSize)
sum += out[i];
local_out[lid] = sum;
barrier(CLK_LOCAL_MEM_FENCE);
computeSumWorkgroup(local);
if (0 == lid) temp[get_group_id(0)] = local_out[0];
}
如果不保存map的结果到一个数组中,而是直接用于作为reduce的输入,那么就节约了内存读写的时间。
inline float map(float d);
{
return d*d;
}
float computeSquareSum(int len, const float* restrict in)
{
float sum = 0.0f;
for (int i = 0; i < len; i++)
sum += map(in[i]);
return sum;
}
NEON支持浮点乘加指令,这可以减少循环内指令的数量。
float computeSqureSumNEON(int len, const float* restrict in)
{
int end = len - len % 4;
float32x4_t sum = vdupq_n_f32(0.0f);
for (int i = 0; i < end; i += 4)
{
float32x4_t a = vldlq_f32(in + i);
sum = vmlaq_f32(sum, a, a);
}
float ret = 0.0f;
for (int i = end; i < len; i++)
{
ret += map(in[i]);
}
ret += (sum[0] + sum[1]) + (sun[2] + sum[3]);
return ret;
}
inline void computeSqureWorkgroup(local float* restrict out)
{
int lid = get_local_id(0);
for (int i = WGS / 2; i > 0; i = i /2)
{
if (lid < i)
out[lid] += out[lid + i];
barrier(CLK_LOCAL_MEM_FENCE);
}
}
void kernel computeSqureOCLStagel(const int len, global float* restrict in, float* restrict temp, local float* local_out)
{
int gid = get_global_id(0);
int globalSize = get_global_size(0);
int lid = get_local_id(0);
float sum = 0.0f;
for (int i = gid; i < len; i += globalSize)
{
sum += in[i] * in[i];
}
local_out[lid] = sum;
barrier(CLK_LOCAL_MEM_FENCE);
computeSumWorkGroup(local_out);
if (0 == lid) temp[get_group_id(0)] = local_out[0];
}
scan模式通常也被称为前缀和。scan可以作为许多算法的基础,如排序、划分等等。
scan的并实现的访存量大约是串行实现的访存量的1.5倍,而并行实现的计算最大约是串行的实现的计算量的2倍。
void scan(int len, float* __restrict__ data)
{
float temp = data[0];
for (int i = 1; i < len; i++)
{
temp += data[i];
data[i] = temp;
}
}
在多核上实现scan,可通过3步:
第一步:每个核心计算一个或多个数据块的内容,这可使用reduce模式到达。此步需要读原始数据空间一次,并且写与线程数目相同的数据。
第二步:对每个核心计算的结果串行的做scan。由于计算量不大,这一步可以串行处理。此步需要读写线程数目相同的数据。
第三步:每个核心计算一个或多个数据块的scan,这一步可多个线程并行操作。此步需要读写原始数据空间各一次,同时需要读与线程数目相同的数据(第二步的结果)。
除了第二步,其余两步都可以并行计算。
详情参考多核:前缀和
cuda:前缀和
// warp内reduce求和
template
__device__ T reduceInWarp(int idInWarp, T data)
{
T ret = data;
for (int i = NT / 2; i > 0; i /= 2)
{
data = __shfl_down(ret, i, NT);
if (idInWarp < i) ret += data;
}
return ret;
}
// warp内计算前缀和
template
__device__ T scanInWarp(int inInWarp, T data)
{
T ret = data;
for (int i = 0; i < NT; i*= 2)
{
data = __shfl_up(ret, i, NT);
if (idInWarp >= i) ret += data;
}
return ret;
}
// warp间前缀和
template
__global__ void scanWarpReduceInBlock(int n, const T* in, T* out)
{
int id = threadIdx.x + blockIdx.x * blockDim.x;
int warpId = threadIdx.x / 32;
int idInWarp = thredIdx.x % 32;
T data = in[id];
// BS/32 is enough, use 32 to imit boundary check
__shared__ T sum[32];
T s = reduceInWarp<32>(idInWarp, data);
if (0 == idInWarp)
sum[warpId] = s;
__syncthreads();
if (0 == warpId)
{
s = scanInWarp(idInWarp, sum[idInWarp]);
if (idInWarp < BS / 32)
out[blockIdx.x * (BS / 32) + idInWarp] = s;
}
}
// 单warp计算block内前缀和
template
__global__ void scanStrideOneWarp(int n, T* data)
{
int idInWarp = threadIdx.x;
int rem = n % 32;
int end = n - rem;
__shared__ T sum;
if (0 == idInWarp)
sum = (T)0;
for (int i = idInWarp; i < end; i += 32)
{
T d = data[(i + 1) * stride - 1];
if (0 == idInWarp)
d += sum;
T v = scanInWarp<32>(idInWarp, d);
if (32 == idInWarp)
sum += v;
data[(i + 1) * stride - 1] = v;
}
if (0 != rem)
{
T d = (idInWarp < rem ? data[stride * (1 + end + idInWarp) - 1] : (T)0);
if (0 == idInWarp)
d += sum;
T v = scanInWarp<32>(idInWarp, d);
if (idInWarp < rem)
data[stride * (1 + end + idInWarp) - 1] = v;
}
}
// Warp内前缀和
template
__global__ void scanStrideFinal(int n, const T* in, const T* warpBlockScanResult, T* out)
{
int bid = blockIdx.x;
int tid = threadIdx.x;
int id = bid * blockDimx.x + tid;
int warpId = tid / 32;
int idInWarp = tid % 32;
T blockScanResult = (0 == bid ? 0 : warpBlockScanResult[stride * bid - 1]);
T warpScanResult = (0 == warpId ? 0 : warpBlockScanResult[stride * bid + warpId - 1]);
T warpScanStart = blockScanResult + warpScanResult;
T v = (id < n ? in[id] : 0);
if (0 == idInWarp)
v += warpScanStart;
T ret = scanInWarp<32>(idInWarp, v);
if (id < n)
out[id] = ret;
}
对于串行程序而言,由结构体组成的数组对缓存的利用更好,表达数据也更直观。而数组组成的结构体则更易于并行化和使用处理器支持的SIMD指令。zip模式用来将数组组成的结构体转换成结构体组成的数组,而unzip模式刚好相反。
inline float3 make_float3(float x, float y, float z)
{
float3 xyz;
xyz.x = x;
xyz.y = y;
xyz.z = z;
return xyz;
}
void zip(int len, const float* x, const float* y, const float* z, float3* xyz)
{
for (int i = 0; i < len; i++)
{
float xt = x[i];
float yt = y[i];
float zt = z[i];
xyz[i] = make_float3(xt, yt, zt);
}
}
void unzip(int len, float* x, float* y, float* z, const float3* xyz)
{
for (int i = 0; i < len; i++)
{
float3 xyzt = xyz[i];
x[i] = xyzt.x;
y[i] = xyzt.y;
z[i] = xyzt.z;
}
}
在X86 SIMD上实现时,可使用shuffle指令,而ARM NEON则提供了直接的实现。
void zipNEON(int len, const float* x, const float* y, const float* z, float3* xyz)
{
for (int i = 0; i < len; i++)
{
float32x4x3_t xyzt;
xyzt.val[0] = vldlq_f32(x + i);
xyzt.val[1] = vldlq_f32(y + i);
xyzt.val[2] = vldlq_f32(z + i);
vst3q_f32(xyz + i, xyzt);
}
}
void unzipNEON(int len, float* x, float* y, float* z, const float3* xyz)
{
for (int i = 0; i < len; i++)
{
float32x4x3_t xyzt = vld3q_f32(xyz + i);
vldlq_f32(x + i, xyzt.val[0]);
vldlq_f32(y + i, xyzt.val[1]);
vldlq_f32(z + i, xyzt.val[2]);
vst3q_f32(xyz + i, xyzt);
}
}
由于处理xyz中的每个数据都和xyz中的其他数据无关,因此多核实现时,可让每个线程多个数据。
void zipNEON(int len, const float* x, const float* y, const float* z, float3* xyz)
{
#pragma omp parallel for
for (int i = 0; i < len; i++)
{
float32x4x3_t xyzt;
xyzt.val[0] = vldlq_f32(x + i);
xyzt.val[1] = vldlq_f32(y + i);
xyzt.val[2] = vldlq_f32(z + i);
vst3q_f32(xyz + i, xyzt);
}
}
void unzipNEON(int len, float* x, float* y, float* z, const float3* xyz)
{
#pragma omp parallel for
for (int i = 0; i < len; i++)
{
float32x4x3_t xyzt = vld3q_f32(xyz + i);
vldlq_f32(x + i, xyzt.val[0]);
vldlq_f32(y + i, xyzt.val[1]);
vldlq_f32(z + i, xyzt.val[2]);
vst3q_f32(xyz + i, xyzt);
}
}
void kernel zip(const int len, global const float* x, global const float* y, global const float* z, global float3* xyz)
{
int gid = get_global_id(0);
if (gid < len)
{
float xt = x[gid];
float yt = y[gid];
float zt = z[gid];
xyz[gid] = make_float3(xt, yt, zt);
}
}
void kernel unzip(const int len, global float* x, global float* y, global float* z, global const float3* xyz)
{
int gid = get_global_id(0);
if (gid < len)
{
float3 xyzt = xyz[gid];
x[gid] = xyzt.x;
y[gid] = xyzt.y;
z[gid] = xyzt.z;
}
}
流水线与指令流水线类似,通过并行使用不同硬件资源的操作来获得高性能。
加载向量做2范数运算为例:
float sum = 0.0f;
for (int iter = 0; iter < numIter; iter++)
{
loadDataFromFile(file, iter, len, data);
sum += computeSqureSumNEON(len, data);
}
从串行版本可用看出n-1迭代时计算平方和和n次迭代时加载数据之间不存在相关性,因此可以并行来做。具体实现可使用多线程、事件机制。
loadDataFromFileAsync(file, 0, len, data0);
for (int iter = 0; iter < numIter - 1; iter++)
{
s1: dataBuff = iter % 2 ? data0 : data1;
s2: loadDataFromFileAsync(file, iter + 1, len, dataBuff);
s3: syncPreviousLoad();
s4: data = iter % 2 ? data1 : data0;
s5: computeSqureSumNEON(len, data);
}
s2处的loadDataFromFileAsync函数从文件中异步加载数据,此函数不会阻塞,发出异步IO后,控制会立刻返回。s3处的函数syncPreviousLoad会等待前一次循环的异步IO操作完成。为了使用异步IO,算法使用了两个缓冲区,s1和s4即是当前迭代选择缓冲区的逻辑。
// CUDA + 异步IO实现流水线
loadDataFromFileAsync(file, 0, len, data0);
for (int iter = 0; iter < numIter - 1; iter++)
{
s1: dataBuff = iter % 2 ? data0 : data1;
s2: loadDataFromFileAsync(file, iter + 1, len, dataBuff);
s3: syncPreviousLoad();
s4: data = iter % 2 ? data1 : data0;
s5: computeSqureSumGPU(len, data);
}
// CUDA实现流水线模式
loadDataFromFile(file, 0, len, data0);
for (int iter = 0; iter < numIter - 1; iter++)
{
data = iter % 2 ? data1 : data0;
computeSqureSumNEON(len, data);
dataBuff = iter % 2 ? data0 : data1;
loadDataFromFile(file, iter + 1, len, dataBuff);
}
因为computeSqureSumGPU函数是异步的,因此GPU不会阻塞,会立刻返回接着执行加载数据到dataBuff中,故GPU的计算和数据加载是在同时进行的。
本章介绍了如何使用SIMD向量指令、多核多线程和GPU来介绍map、reudce、scan和流水线模式。