深圳大学 并行计算 实验五
一、实验目的
1. 设计归并的并行算法;
2. 掌握数据划分方法;
3. 对并行程序进行简单的性能分析。
二、实验环境
1. 硬件环境:64核CPU、256GB内存的共享内存并行计算平台;
2. 软件环境:Ubuntu Linux、gcc、g++(g++ -O3 -fopenmp -o a.out a.cpp);
3. 远程登录:本地PowerShell中执行ssh bxjs@hpc.szu.edu.cn;
4. 传输文件:本地PowerShell中执行scp c:\a.cpp bxjs@hpc.szu.edu.cn:/home/bxjs/或ftp://hpc.szu.edu.cn。
三、实验内容
1. 用OpenMP语言编写程序,将有序数组a和b归并到数组c中。将数组a等分为p段,并选前p-1段的最后一个元素作为划分元,将数组b也分为p段;线程i归并数组a和b的第i段。为了验证结果正确性,将并行计算结果和串行计算结果相比较。
代码实现思路:
首先,我们考虑用构建两个长度为N的double类型的一维数组a、b,同时构建两个长度为N*2的double类型的一维数组c、d,其中c数组用来存储数组a、b并行归并的结果,而d数组用来存储数组a、b串行归并的结果。
接着,我们考虑对数组a、b进行随机的初始化,同时,要保证数据有序性。对此,我采用了cstdlib库中rand()函数获取一个0到RAND_MAX之间的随机整数,再将随机数乘以1.0变成double类型,再除以RAND_MAX,最终获得一个0到1之间的随机的浮点数,然后加上一个数组元素的值,这样就可以获得两个随机的有序的double类型的一维数组。
在完成初始化后,我考虑使用了OpenMP库中,omp_set_num_threads(int tp)函数用于设置并行区域的线程数为tp。再用omp_get_wtime()函数,获取当前线程距离线程线程开始的时间差,赋值给double类型的变量t0,作为并行域计算的起始时间。
然后,我考虑了通过一个for循环将数组a分成thread_num(thread_num为进行线程数)个小数组。这里在进行分小组的操作是,采用的是计算前后指针,然后进行存储和记录,来实现的。然后,将前thread_num-1个子数组的最后一个元素作为划分元。
为了实现并行归并,还需要将数组b分成thread_num(thread_num为进行线程数)个小数组,但是这个不能简单采用前面的a数组的划分办法,因为如果采用这种划分办法对b数组进行划分,会导致最后的分段归并的结果还需要再归并才能保持有序,这样作就没有意义了。
所以这里对b数组采用的划分办法是:根据前面a数组的前thread_num-1个子数组的划分元,在b数组中查找比这个thread_num-1划分元刚好小于的最大元素,以此将b数组划分为thread_num个子数组。而对于这个查找的过程,考虑采用并行来实现,所以我使用了“#pragma omp parallel shared(a,b,a_begin,a_end,b_begin,b_end,thread_num)” 编译指导语句,构建一个并行域,在并行域中使用了OpenMP库中的omp_get _threads_num ()函数来获取当前线程的线程号,然后依据该线程号,进行对应的并行查找。同时考虑采用b数组的有序性,这里考虑采用C++标准库<algorithm>中的lower_bound函数,进行二分查找,获取相应的下标。
之后,为了保证a数组中的子数组和b数组中的子数组在进行并行归并的时候,可以将数据归并到c数组中对应的位置,我这里采用了一个for循环,计算每段归并需要存储在c数组的什么位置,采用的是计算前后指针,然后进行存储和记录。
接着,使用“#pragma omp parallel shared(a, b, c, a_begin, a_end, b_begin, b_end, c_begin, c_end)”编译指导语句,构建一个并行域,在并行域中使用了OpenMP库中的omp_get _threads_num ()函数来获取当前线程的线程号,然后依据该线程号,让每个线程将对应的a、b子数组进行归并,存储到c数组中对应的位置。这里可以直接使用C++ 标准库<algorithm>中的merge函数完成归并操作。
接着,再次使用omp_get_wtime()函数,获取当前线程距离线程线程开始的时间差,赋值给double类型的变量t1,作为并行域计算的结束时间。继而,通过t1-t0就可以获得该并行域的运行时间。
最后,直接使用merge函数将完整的a数组和b数组归并到d数组中,实现串行归并。然后,通过一个for循环进行遍历,判断c数组和d数组的结果是否一致,来判断并行运行的正确性。
初步代码实现:
const int N=100000000;//求解范围
double a[N],b[N],c[N*2],d[N*2];//a为并行结果、b为串行验证结果
//初始化参数
srand(time(NULL));
for (int i = 0; i < N; i++){
a[i] = (i ? a[i-1] : 0) + rand() * 0.1 / (double)RAND_MAX ;
b[i] = (i ? b[i-1] : 0) + rand() * 0.1 / (double)RAND_MAX ;
}
double t0,t1;
t0 = omp_get_wtime();
omp_set_num_threads(thread_num);
//begin、end为分段数组的前后指针, c_begin、c_end为存储c数组的前后指针
int a_begin[thread_num],a_end[thread_num];
int b_begin[thread_num],b_end[thread_num];
int c_begin[thread_num],c_end[thread_num];
//分成 thread_num 个子数组
for (int i = 0; i < thread_num; i++){
a_begin[i] = i ? a_end[i-1] + 1 : 0;
a_end[i] = (i == thread_num - 1 ? N - 1 : (N/thread_num) * (i+1));
}
//并行代码
#pragma omp parallel shared(a,b,a_begin,a_end,b_begin,b_end,thread_num)
{
int nthreads = omp_get_thread_num();
b_end[nthreads] = lower_bound(b,b+N,a[a_end[nthreads]]) - b - 1;
if(nthreads == thread_num-1) b_end[nthreads] = N-1 ;
}
for (int i = 0; i < thread_num; i++){
b_begin[i] = i ? b_end[i-1] + 1 : 0;
c_begin[i] = i ? c_end[i-1] + 1 : 0;
c_end[i] = c_begin[i] + b_end[i] - b_begin[i] + 1 + a_end[i] - a_begin[i];
}
#pragma omp parallel shared(a,b,c,a_begin,a_end,b_begin,b_end,c_begin,c_end)
{
int nthreads = omp_get_thread_num();
merge(a+a_begin[nthreads], a+a_end[nthreads]+1, b+b_begin[nthreads], b+b_end[nthreads]+1, c+c_begin[nthreads]);
}
t1 = omp_get_wtime();
//串行排序代码
merge(a, a+N, b, b+N, d);
//验证代码
int flag=1;
for(int i = 0; i < N*2; i++)
if(c[i] != d[i]){
flag=0;
cout << "Results are not equal!" << endl;
break;
}
if(flag)
cout << "Results are equal!" << endl;
cout << "Thread num: "<<thread_num<<", Parallel time: " << t1 - t0 <<"second"<< endl;
2. 测试并行程序在不同线程数下的执行时间和加速比(与线程数=1时的执行时间相比)。其中,n固定为100000000,线程数分别取1、2、4、8、16、32、64时,为减少误差,每项实验进行5次,取平均值作为实验结果。
为了避免代码多次修改,这里可以将对上述1中的代码进行封装成函数“double achieve(int thread_num)”,传入参数thread_num,为程序并行域的线程数,并将并行域的运行时间作为返回值进行返回。
同时,为了避免程序的多次间断的运行,导致程序运行时CPU的状态差,导致实验获取的结果误差较大,这里考虑,创建数组提前存储“1、2、4、8、16、32、64”,然后通过for循环将数值传入函数“double juzheng(int thread_num)”中,获取运行时间,并计算出对应5次实验的平均值。从而,避免了程序的多次间断的运行带来的误差。具体的代码在下列“四、代码描述”中可见。
四、代码描述
如下所示,为该实验的完整实现代码,必要的一些参数主要通过改变全局变量的参数进行设置,而主函数通过循环调用double achieve(int thread_num) 函数,获取对应thread_num下的平均的并行域的运行时间,然后打印出来。
在double achieve(int thread_num)函数中,主要通过“#pragma omp parallel shared(a, b, a_begin, a_end, b_begin, b_end, thread_num)”和“#pragma omp parallel shared(a, b, c, a_begin, a_end, b_begin, b_end, c_begin, c_end)”实现的两个并行域实现并行操作。
在“#pragma omp parallel shared(a, b, a_begin, a_end, b_begin, b_end, thread_num)”这个并行域内实现对b数组的分割。在“#pragma omp parallel shared(a, b, c, a_begin, a_end, b_begin, b_end, c_begin, c_end)”实现对thread_num个子数组对的并行归并。
通过分析,实现a数组和b数组的并行归并中,其中串行部分的对a数组的划分和确定c数组存储位置,都是通过一个for循环实现,时间复杂度是O(thread_num) ,是常数级比,基本可以忽略不记。而对b数组进行并行分割,理论上需要的时间就是一个二分查找的时间,为O(logN) ;而最后对thread_num个子数组对进行归并,理论上如果b数组的划分比较均匀的话,需要的时间是 次加法的时间。所以,总共完成将有序数组a和有序数组b进行并行归并,需要的时间是
。
#include <omp.h>
#include <bits/stdc++.h>
using namespace std;
const int N=100000000;//求解范围
const int ThreadNum_CanShu=7;//线程数组的大小
const int set_thread_num[]={1,2,4,8,16,32,64};//线程数
const int XunHuan_CanShu=5;//循环次数
double a[N],b[N],c[N*2],d[N*2];//a为并行结果、b为串行验证结果
//根据传入的线程数,进行函数的调用
double achieve(int thread_num){
//初始化参数
srand(time(NULL));
for (int i = 0; i < N; i++){
a[i] = (i ? a[i-1] : 0) + rand() / (double)RAND_MAX ;
b[i] = (i ? b[i-1] : 0) + rand() / (double)RAND_MAX ;
}
double t0,t1;
//begin、end为分段数组的前后指针, c_begin、c_end为存储c数组的前后指针
int a_begin[thread_num],a_end[thread_num];
int b_begin[thread_num],b_end[thread_num];
int c_begin[thread_num],c_end[thread_num];
//分成 thread_num 个子数组
for (int i = 0; i < thread_num; i++){
a_begin[i] = i ? a_end[i-1] + 1 : 0;
a_end[i] = (i == thread_num-1 ? N-1 : (N/thread_num)*(i+1));
}
omp_set_num_threads(thread_num);
t0 = omp_get_wtime();
//并行代码
#pragma omp parallel shared(a,b,a_begin,a_end,b_begin,b_end,thread_num)
{
int nthreads = omp_get_thread_num();
b_end[nthreads]=lower_bound(b,b+N,a[a_end[nthreads]])-b-1;
if(nthreads == thread_num-1) b_end[nthreads] = N-1 ;
}
for (int i = 0; i < thread_num; i++){
b_begin[i] = i ? b_end[i-1] + 1 : 0;
c_begin[i] = i ? c_end[i-1] + 1 : 0;
c_end[i]=c_begin[i]+b_end[i]-b_begin[i]+1+a_end[i]-a_begin[i];
}
#pragma omp parallel shared(a,b,c,a_begin,a_end,b_begin,b_end,c_begin,c_end)
{
int nthreads = omp_get_thread_num();
merge(a+a_begin[nthreads], a+a_end[nthreads]+1, b+b_begin[nthreads], b+b_end[nthreads]+1, c+c_begin[nthreads]);
}
t1 = omp_get_wtime();
//串行排序代码
merge(a, a+N, b, b+N, d);
//验证代码
int flag=1;
for(int i = 0; i < N*2; i++)
if(c[i] != d[i]){
flag=0;
cout << "Results are not equal!" << endl;
break;
}
if(flag) cout << "Results are equal!" << endl;
cout << "Thread num: "<<thread_num;
cout << ", Parallel time: "<< t1 - t0 <<"second"<< endl;
return t1-t0;
}
int main()
{
cout<<"CPU核心数: "<<omp_get_num_procs()<<endl;
cout<<"最大线程数: "<<omp_get_thread_limit()<<endl;
cout<<"----------------------------------"<<endl;
for(int i=0;i<ThreadNum_CanShu;i++){
double pingjunzhi=0,speedup_rate=0;
for(int j=0;j<XunHuan_CanShu;j++)
pingjunzhi+=achieve(set_thread_num[i]);
cout<<"omp_set_num_threads="<<set_thread_num[i];
cout<<", 平均运行时间:"<< pingjunzhi/XunHuan_CanShu;
cout<<"秒"<<endl;;
cout<<"----------------------------------"<<endl;
}
return 0;
}
五、实验结果和分析
通过在文件目录下输入“ftp://hpc.szu.edu.cn”,使用ftp协议,将本地的b.ccp的代码文件传输到“/2021150233”的目录下。然后,打开shell窗口,使用“ssh bxjs@hpc.szu.edu.cn”连接A0服务器,但是由于A1服务器只有32个核,而A0有64个核,所以下面的实验数据在A0服务器上执行获得的。
通过cd命令打开“/202115023”文件夹,然后通过“g++ -O3 -fopenmp -o b.out b.cpp”将b.ccp文件编译为b.out可执行文件。然后通过“./b.out”运行b.out文件,最终执行的结果如下图1所示,并将执行的结果记录进表1中,同时计算出加速比(与线程数=1时的执行时间相比)。
图1 b文件的执行结果
表1 并行程序在不同线程数下的执行时间(秒)和加速比(n=100000000)
线程数 执行时间/s | 1 | 2 | 4 | 8 | 16 | 32 | 64 |
第1次 | 4.70544 | 3.11984 | 1.86865 | 1.18653 | 0.69979 | 0.54144 | 0.37931 |
第2次 | 5.45614 | 2.99073 | 1.94239 | 1.10194 | 0.87540 | 0.44713 | 0.42043 |
第3次 | 5.45514 | 2.97473 | 1.72461 | 1.10098 | 0.73548 | 0.51287 | 0.32265 |
第4次 | 5.68466 | 3.03499 | 1.93830 | 1.16766 | 0.74305 | 0.52182 | 0.40450 |
第5次 | 5.68366 | 2.95000 | 1.93609 | 1.09127 | 0.80166 | 0.51306 | 0.32882 |
平均值 | 5.39701 | 3.01406 | 1.88201 | 1.12968 | 0.77108 | 0.50726 | 0.37114 |
加速比 | 1.00000 | 1.79061 | 2.86768 | 4.77747 | 6.99993 | 10.63953 | 14.54171 |
根据上述表1的平均值和加速比数据,绘制出下面的折线图:
图2 平均值和加速比的折线图
根据对上述,表1和图2的分析可知,随着线程数的增加,求长度为n=100000000的数组的前缀和,需要的时间在逐渐下降,加速比逐渐上升。即,在CPU核心数量的范围内,通过增加线程数,分割任务进行并行归并可以实现对复杂归并的优化。
同时,观测到加速比不是简单随线程数的增加而线性增加的,两者不是简单的正比关系,加速比随着线程数的增加,但是增长越来越缓慢了。这与前面代码描述部分分析的“总共完成将有序数组a和有序数组b进行并行归并,需要的时间是 O(2×Nthread_num+logN+thread_num×2) ”有关系,执行时间与线程数并不是简单的反比关系。
除此之外,在对b数组进行并行分割时,很多时候可能是并不能实现较为均匀的分割的,所以这也在很大程序上影响着程序的运行时间。并且随着线程数的增加,CPU对任务调度的开销也随之增加,导致加速效果也在一定程度上被削弱,最终导致两者呈现出这种关系。
六、实验结论
在准备实验的初期,我对如何使用并行算法来实现并行归并感到困惑重重。我知道有序数组的归并是一个常见的计算任务,但是如何将它与并行化算法结合起来并不是一件简单的事情。这个问题让我感到迷茫,因为我缺乏足够的经验和理解。
但是,在我认真上完课上对该问题的讲解后,豁然开朗,逐步掌握了如何将有序数组的归并分解成多个并行子任务,并利用多核CPU资源进行加速的思路。然后,在上手写代码实现的过程中,我也遇到了不少挑战和问题。但是,每当我遇到困难时,我都会认真思考,并寻找解决办法。通过不断地尝试和调试,我逐步解决了这些问题,最终成功地实现了前缀和的并行算法。
在本次实验中, 首先,在算法设计上,我们采用了归并排序的并行算法,并利用OpenMP语言编写了程序。通过将有序数组a和b进行归并,利用并行计算平台的多核CPU并行处理任务,从而提高了算法的效率。
其次,在数据划分方法上,我们对数组a和b进行了合理的划分,以便在并行归并过程中各个线程能够独立地处理各自的子数组。通过合理的划分方法,我们最大程度地利用了并行计算平台的资源,提高了并行算法的效率。
另外,我们进行了简单的性能分析,通过对不同线程数下程序的执行时间进行测试,并计算了加速比。实验结果显示随着线程数的增加,程序的执行时间逐渐减少,加速比逐渐增加。这表明我们设计的并行算法在一定程度上具有良好的性能表现。
通过实验,我们可以知道:利用并行算法可以显著提高排序算法的执行效率,特别是在大规模数据处理场景下;随着线程数的增加,程序的执行时间呈现出逐渐减少的趋势,但增长越来越缓慢;在并行计算中,合理的数据划分方法对程序的性能影响巨大,需要根据实际情况进行优化;考虑到并行计算中存在的任务调度开销,加速比不是简单的线性增长,而是会逐渐趋于平缓。
综上所述,本次实验通过并行算法设计与实现,充分利用了计算资源,提高了算法的执行效率,并从实验结果中得出了一些有益的结论,对进一步优化并行算法的设计与实现具有一定的指导意义。