深圳大学 并行计算 实验四
一、实验目的
1. 设计前缀和的并行算法;
2. 掌握for编译制导语句和数据划分方法;
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中,即b[i]=0≤k≤ia[k]
。为了验证结果正确性,将并行计算结果和串行计算结果相比较,误差在1e-4以内。
代码实现思路:
首先,我们考虑用构建两个double类型的一维数组a、b。接着,我们考虑对数组a进行随机的初始化,对此,我采用了cstdlib库中rand()函数获取一个0到RAND_MAX之间的随机整数,再将随机数乘以1.0变成double类型,再除以RAND_MAX,最终获得一个0到1之间的随机的浮点数。
然后,我考虑了将数组a分成thread_num(thread_num为进行线程数)个小数组。这里在进行分小组的操作是,采用的是计算前后指针,然后进行存储和记录,来实现的。
在将a数组分成thread_num个小数组后,我考虑使用了OpenMP库中,omp_set_num_threads(int tp)函数用于设置并行区域的线程数为tp。再用omp_get_wtime()函数,获取当前线程距离线程线程开始的时间差,赋值给double类型的变量t0,作为并行域计算的起始时间。
接着,使用“#pragma omp parallel shared(a,begin,end)”编译指导语句,构建一个并行域,在并行域中使用了OpenMP库中的omp_get _threads_num ()函数来获取当前线程的线程号,然后依据该线程号,让每个线程各自执行对应的子求前缀和的操作,实现thread_num个小数组的进行求前缀和,并将结果存储到b数组中对应的位置。
在完成并行求thread_num个小数组的进行求前缀和后,只有第一个子数组的前缀和结果是正确,而后面的thread_num-1个小数组的前缀还是不正确的。此时,还需要让这thread_num-1个小数组的前缀结果加上前一个子数组末尾的正确的前缀和,才能使得这个thread_num-1个小数组的前缀结果变成正确的结果。这个部分操作,也可以考虑并行地执行。
接着,我考虑使用了一个for循环,来分别对这thread_num-1个小数组从前往后加上前一个子数组末尾的正确的前缀和。而在这个for循环内,为这个子数组的加法操作的速度,考虑让多个线程并行执行,先用变量temp存储前一个子数组末尾正确的前缀和的值。
然后,我使用“#pragma omp parallel for shared(a,begin,end) schedule(dynamic,10000)”编译指导语句,构建一个for循环并行域,在该并行域内执行“b[i]+=temp”的操作。
接着,再次使用omp_get_wtime()函数,获取当前线程距离线程线程开始的时间差,赋值给double类型的变量t1,作为并行域计算的结束时间。继而,通过t1-t0就可以获得该并行域的运行时间。
最后,通过使用一个for循环,串行实现a数组的求前缀和的操作,并将结果存储到a数组中。同时,将串行求取的结果与b数组中的值进行比较,判断是否相等,从而来判断矩阵乘法并行化的正确性。但是,考虑到数组的数据是浮点类型,所以在判断c矩阵和d矩阵的中数值是否相等时,为了避免精度丢引起判断错误,只需保证误差的一定小的范围内1e-4,即可认为数值相等。
初步代码实现:
//初始化参数
srand(time(NULL));
for (int i = 0; i < N; i++)
a[i] = rand()*1.0/(double)RAND_MAX;//0 ~ (RAND_MAX)32767
double t0,t1,t2,t3;
int begin[thread_num],end[thread_num],num;
//分成 thread_num 个子数组
for (int i = 0; i < thread_num; i++){
if(i == 0)
begin[0] = 0;
else
begin[i] = end[i-1] + 1;
if(i == thread_num - 1)
end[i] = N - 1;
else
end[i] = (N/thread_num) * (i+1);
}
omp_set_num_threads(thread_num);
t0 = omp_get_wtime();
//并行——小数组求前缀和,对 thread_num 个子数组进行并行求前缀和
#pragma omp parallel shared(a,begin,end)
{
int nthreads = omp_get_thread_num();
b[begin[nthreads]]=a[begin[nthreads]];
for(int i=begin[nthreads]+1;i<=end[nthreads];i++){
b[i]=b[i-1]+a[i];
}
}
//并行——对后面的 thread_num-1 个子数组,加上前一个子数组的末尾值
for(int i = 1; i < thread_num; i++){
double temp = b[end[i-1]];
#pragma omp parallel for shared(begin,end,i,temp) schedule(dynamic,10000)
for(int j = begin[i]; j <= end[i]; j++){
b[j] += temp;
}
}
t1 = omp_get_wtime();
//串行验证代码
int flag=1;
for(int i = 0; i < N; i++){
if(i != 0)
a[i] += a[i-1];
if(abs(b[i]-a[i])>1e-4){
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 for shared(a,begin,end)”和“#pragma omp parallel for shared(a,begin,end) schedule(dynamic,10000)”实现的两个并行域实现并行操作。
在“#pragma omp parallel for shared(a,begin,end)”这个并行域内实现thread_num个子数组的求前缀和的操作。在“#pragma omp parallel for shared(a,begin,end) schedule(dynamic,10000)”实现对thread_num-1个子数组的加上前一个子数组末尾的前缀和的操作。
通过分析,并行求数组a的前缀和的操作中,thread_num个子数组的求前缀和,理论上需要的时间是计算 次加法的时间;对thread_num-1个子数组的加上前一个子数组末尾的前缀和的操作,理论上需要的时间是计算
次加法的时间。所以,总共并行完成数组a求前缀和操作,需要的时间是
次加法的时间。
#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];//a为初始化值、b为并行结果、a为串行验证结果
//根据传入的线程数,进行函数的调用
double achieve(int thread_num){
//初始化参数
srand(time(NULL));
for (int i = 0; i < N; i++)
a[i] = rand()*1.0/(double)RAND_MAX;//0 ~ (RAND_MAX)32767
double t0,t1,t2,t3;
int begin[thread_num],end[thread_num],num;
//分成 thread_num 个子数组
for (int i = 0; i < thread_num; i++){
if(i == 0) begin[0] = 0;
else begin[i] = end[i-1] + 1;
if(i == thread_num - 1) end[i] = N - 1;
else end[i] = (N/thread_num) * (i+1);
}
omp_set_num_threads(thread_num);
t0 = omp_get_wtime();
//并行——小数组求前缀和,对 thread_num 个子数组进行并行求前缀和
#pragma omp parallel shared(a,begin,end)
{
int nthreads = omp_get_thread_num();
b[begin[nthreads]]=a[begin[nthreads]];
for(int i=begin[nthreads]+1;i<=end[nthreads];i++){
b[i]=b[i-1]+a[i];
}
}
//并行——对后面的 thread_num-1 个子数组,加上前一个子数组的末尾值
for(int i = 1; i < thread_num; i++){
double temp = b[end[i-1]];
#pragma omp parallel for shared(begin,end,i,temp) schedule(dynamic,100000)
for(int j = begin[i]; j <= end[i]; j++){
b[j] += temp;
}
}
t1 = omp_get_wtime();
//串行验证代码
int flag=1;
for(int i = 0; i < N; i++){
if(i != 0) a[i] += a[i-1];
if(abs(b[i]-a[i])>1e-4){
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协议,将本地的a.ccp的代码文件传输到“/2021150233”的目录下。然后,打开shell窗口,使用“ssh bxjs@hpc.szu.edu.cn”连接A0服务器,但是由于A1服务器只有32个核,而A0有64个核,所以下面的实验数据在A0服务器上执行获得的。
通过cd命令打开“/202115023”文件夹,然后通过“g++ -O3 -fopenmp -o a.out a.cpp”将a.ccp文件编译为a.out可执行文件。然后通过“./a.out”运行a.out文件,最终执行的结果如下图1所示,并将执行的结果记录进表1中,同时计算出加速比(与线程数=1时的执行时间相比)。
图1 b文件的执行结果
表1 并行程序在不同线程数下的执行时间(秒)和加速比(n=100000000)
线程数 执行时间/s | 1 | 2 | 4 | 8 | 16 | 32 | 64 |
第1次 | 0.88835 | 0.99933 | 0.87383 | 0.70499 | 0.47305 | 0.34751 | 0.29341 |
第2次 | 1.33377 | 1.00125 | 0.86595 | 0.69410 | 0.45761 | 0.34307 | 0.27853 |
第3次 | 1.46752 | 1.00855 | 0.86006 | 0.69366 | 0.48072 | 0.33718 | 0.27508 |
第4次 | 1.46913 | 1.02314 | 0.85582 | 0.70078 | 0.46284 | 0.33474 | 0.28620 |
第5次 | 1.47133 | 1.02513 | 0.89273 | 0.70233 | 0.46039 | 0.34785 | 0.29076 |
平均值 | 1.32602 | 1.01148 | 0.86964 | 0.69917 | 0.46692 | 0.34207 | 0.29076 |
加速比 | 1.00000 | 1.31097 | 1.524792 | 1.89656 | 2.83399 | 3.86933 | 4.56053 |
根据上述表1的平均值和加速比数据,绘制出下面的折线图:
图2 平均值和加速比的折线图
根据对上述,表1和图2的分析可知,随着线程数的增加,求长度为n=100000000的数组的前缀和,需要的时间在逐渐下降,加速比逐渐上升。即,在CPU核心数量的范围内,通过增加线程数,分割任务进行并行运算可以实现对复杂计算的优化。
同时,观测到加速比不是简单随线程数的增加而线性增加的,两者不是简单的正比关系,加速比随着线程数的增加,但是增长越来越缓慢了。这很大程度上与前面代码描述部分分析的“并行完成数组a求前缀和操作,需要的时间是 次加法的时间”有很大关系,执行时间与线程数并不是简单的反比关系。并且随着线程数的增加,CPU对任务调度的开销也随之增加,导致加速效果也在一定程度上被削弱,最终导致两者呈现出这种关系。
六、实验结论
在准备实验的初期,我对如何使用并行算法来计算前缀和感到困惑重重。我知道前缀和是一个常见的计算任务,但是如何将它与并行化算法结合起来并不是一件简单的事情。这个问题让我感到迷茫,因为我缺乏足够的经验和理解。
然而,我并没有放弃,而是积极地寻找解决方案。我开始查阅相关资料,阅读并行算法的文献和教程,深入了解了并行计算的原理和方法。通过学习和实践,我逐渐掌握了如何将前缀和的计算任务分解成多个并行子任务,并利用多核CPU资源进行加速。
在上手写代码实现的过程中,我遇到了许多挑战和问题。例如,如何合理地划分数据,如何处理并行任务之间的数据依赖关系等等。但是,每当我遇到困难时,我都会认真思考,并寻找解决办法。通过不断地尝试和调试,我逐步解决了这些问题,最终成功地实现了前缀和的并行算法。
在实验过程中,使用了OpenMP并行编程框架,充分利用了多核CPU资源,通过并行化加快了程序的执行速度;采用了合理的数据划分方法,将数组分割成多个子数组,实现了并行计算的任务分配;通过动态调整任务分配的方式(如在第二个并行域中采用了schedule(dynamic,10000)),避免了负载不均衡带来的性能损失。
从实验结果中,得知,随着线程数的增加,求解长度为100,000,000的数组的前缀和所需的时间逐渐减少,加速比逐渐增加,说明并行化有效提高了程序性能。然而,加速比并非简单地与线程数呈线性关系,而是随着线程数增加而逐渐减弱,这与任务调度的开销以及数据划分等因素有关。在实验中,对程序进行性能分析是必不可少的,通过对不同参数下程序的运行时间和加速比进行测量和分析,可以更好地理解程序的性能特征,并针对性地进行优化和改进。
总的来说,本实验通过设计并实现前缀和的并行算法,掌握了并行编程的基本方法和技术,同时通过性能分析对并行程序进行评估,深入理解了并行计算的原理和优化策略,为进一步深入学习并行计算奠定了基础。