并行机体系结构
并行机体系结构及通信机制
- SMP:共享内存并行机( Shared Memory Processors )。多个
处理器通过交叉开关(Crossbar)或总线与共享内存互连。- 任意处理器可直接访问任意内存地址,且访问延迟、带
宽、几率都是等价的; - 系统是对称的;
- 单地址空间 、共享存储、UMA;
- 并行编程方式: 通常采用OpenMP, 也可使用消息传递
(MPI/PVM) 。
- 任意处理器可直接访问任意内存地址,且访问延迟、带
- DSM:分布共享存贮并行机(Distributed Shared Memory),由结点(一般是SMP系统)通过高速消息传递网络互连而成。存贮系统在物理上分布、逻辑上共享。各结点有自己独立的寻址空间。
- 单地址空间 、分布共享
- NUMA( Nonuniform Memory Access )
- 与SMP的主要区别:DSM在物理上有分布在各个节点的局部内存从而形成一个共享的存储器;
- 代表: SGI Origin 2000, Cray T3D 3
并行程序
设计方法
- 隐式并行程序设计:
• 常用传统的语言编程成顺序源编码,把“并行”交给编译器
实现自动并行
• 程序的自动并行化是一个理想目标,存在难以克服的困难
• 语言容易,编译器难 - 显式并行程序设计:
• 在用户程序中出现“并行”的调度语句
• 显式并行是目前有效的并行程序设计方法。例如通过消息
传递方式或多线程等
• 语言难,编译器容易
设计模型
- 隐式并行(Implicit Parallel)
- 概况:
• 程序员用熟悉的串行语言编写相应的串行程序
• 通过编译器和运行支持系统将串行程序自动转化为并行
代码 - 特点:
• 语义简单
• 可移植性好
• 单线程,易于调试和验证正确性
• 细粒度并行
• 效率很低
- 数据并行(Data Parallel)
概况:
• SIMD的自然模型
- 特点:
• 并行操作于聚合数据结构(数组)
• 松同步
• 单一地址空间
• 隐式交互作用 - 优点:编程相对简单,串并行程序一致.
- 缺点:程序的性能在很大程度上依赖于所用的编译系统及用户对编译系统的了解. 并行粒度局限于数据级并行,粒度较小.
- 共享变量(Shared Variable)
- 概况:
SMP, DSM的自然模型 - 特点:
多线程:SPMD, MPMD
松同步
单一地址空间
显式同步
隐式数据分布
隐式通信 - 典型代表:
OpenMP
- 消息传递(Message Passing)
- 概况:
MPP、COW的自然模型 - 特点:
多进程异步并行
多地址空间
显式同步
显式数据映射和负载分配
显式通信 - 典型代表
MPI、PVM
openmp 基础
OpenMp简介
OpenMP是共享存储体系结构上的一个并行编程模型。适合于SMP共享内存多处理系统和多核处理器体系结构
由一组编译制导、运行时库函数(Run-Time routines)和环境变量组成。
OpenMP是一种用于共享内存并行系统的多线程程序设计方案,支持的编程语言包括C、C++和Fortran。OpenMP提供了对并行算法的高层抽象描述,特别适合在多核CPU机器上的并行程序设计。编译器根据程序中添加的pragma指令,自动将程序并行处理,使用OpenMP降低了并行编程的难度和复杂度。当编译器不支持OpenMP时,程序会退化成普通(串行)程序。程序中已有的OpenMP指令不会影响程序的正常编译运行
查看自己的笔记本电脑是几个核的:
在VS中启用OpenMP
- 打开vs2019, 并创建一个空项目
- 在【源文件】中添加【新建项】
- 创建.cpp文件
- 添加如下测试代码
# include "omp.h"
# include "stdio.h"
int main()
{
int id, numb;
omp_set_num_threads(5);
#pragma omp parallel private(id, numb)
{
id = omp_get_thread_num();
numb = omp_get_num_threads();
printf("I am thread %d out of %d\n",id,numb );
}
}
- 在【项目】中找到【属性】
- 【符合模式】选择【否】,【OpenMP】选择【是】
- 运行程序,出现如下结果(可以运行多次,每次的结果都不太一样)
OpenMp并行编程模型
- OpenMP是基于线程的并行编程模型。
- OpenMP采用Fork-Join并行执行方式:
- OpenMP程序开始于一个单独的主线程(Master Thread),然后主线程一直串行执行,直到遇见第一个并行域(Parallel Region),然后开始并行执行并行
区域。其过程如下:
- Fork:主线程创建一个并行线程队列,然后,并行域中的代码在不同的线程上并行执行;
- Join:当并行域执行完之后,它们或被同步或被中断,最后只有主线程在执行
- OpenMP程序开始于一个单独的主线程(Master Thread),然后主线程一直串行执行,直到遇见第一个并行域(Parallel Region),然后开始并行执行并行
OpenMp 存储模型
# include "omp.h"
# include "stdio.h"
int main()
{
int x = 2;
#pragma omp parallel num_threads(2) shared(x)
{
if (omp_get_thread_num() == 0) {
x = 5;
}
else {
printf("1: Thread# %d: x = %d\n", omp_get_thread_num(), x);
}
#pragma omp barrier
if (omp_get_thread_num() == 0) {
printf("2: Thread# %d: x = %d\n", omp_get_thread_num(), x);
}
else {
printf("3: Thread# %d: x = %d\n", omp_get_thread_num(), x);
}
}
}
在不同的线程中,x的值均为5;
支持条件编译
int main()
{
# ifdef _OPENMP
printf("Compiled by an OpenMP-compliant implementation.\n");
# endif
return 0;
}
并行化控制
OpenMP编程模型以线程为基础,通过编译制导指令制导并行化,有三种编程要素可以实现并行化控制,他们分别是编译制导、API函数集和环境变量
编译制导
OpenMP的并行化是通过使用嵌入到C/C++或
Fortran源代码中的编译制导语句来实现. 通过对串行程序添加制导语句实现并行化。
支持并行区域、工作共享、同步等。
支持数据的共享和私有化。
支持增量并行。
编译制导指令以制导标识符#pragma omp
开始,后边跟具体的功能指令,格式如:#pragma omp 指令[子句[,子句] …]
。
并行域制导
一个并行域就是一个能被多个线程并行执行的
程序段.
#pragma omp parallel [clauses]
{
BLOCK
}
在并行域结尾有一个隐式同步(barrier)。
子句(clause)用来说明并行域的附加信息。
C/C++子句间用空格分开。
功能指令
parallel
:用在一个结构块之前,表示这段代码将被多个线程并行执行;for
:用于for循环语句之前,表示将循环计算任务分配到多个线程中并行执行,以实现任务分担,必须由编程人员自己保证每次循环之间无数据相关性;parallel for
:parallel和for指令的结合,也是用在for循环语句之前,表示for循环体的代码将被多个线程并行执行,它同时具有并行域的产生和任务分担两个功能;sections
:用在可被并行执行的代码段之前,用于实现多个结构块语句的任务分担,可并行执行的代码段各自用section指令标出(注意区分sections和section);parallel sections
:parallel和sections两个语句的结合,类似于parallel for;single
:用在并行域内,表示一段只被单个线程执行的代码;critical
:用在一段代码临界区之前,保证每次只有一个OpenMP线程进入;flush
:保证各个OpenMP线程的数据影像的一致性;barrier
:用于并行域内代码的线程同步,线程执行到barrier时要停下等待,直到所有线程都执行到barrier时才继续往下执行;atomic
:用于指定一个数据操作需要原子性地完成;master
:用于指定一段代码由主线程执行;threadprivate
:用于指定一个或多个变量是线程专用,后面会解释线程专有和私有的区别。
子句
private
:指定一个或多个变量在每个线程中都有它自己的私有副本;firstprivate
:指定一个或多个变量在每个线程都有它自己的私有副本,并且私有变量要在进入并行域或任务分担域时,继承主线程中的同名变量的值作为初值;lastprivate
:是用来指定将线程中的一个或多个私有变量的值在并行处理结束后复制到主线程中的同名变量中,负责拷贝的线程是for或sections任务分担中的最后一个线程;reduction
:用来指定一个或多个变量是私有的,并且在并行处理结束后这些变量要执行指定的归约运算,并将结果返回给主线程同名变量;nowait
:指出并发线程可以忽略其他制导指令暗含的路障同步;num_threads
:指定并行域内的线程的数目;schedule
:指定for任务分担中的任务分配调度类型;shared
:指定一个或多个变量为多个线程间的共享变量;ordered
:用来指定for任务分担域内指定代码段需要按照串行循环次序执行;copyprivate
:配合single指令,将指定线程的专有变量广播到并行域内其他线程的同名变量中;copyin
:用来指定一个threadprivate类型的变量需要用主线程同名变量进行初始化;default
:用来指定并行域内的变量的使用方式,缺省是shared。
说明:
在reduction子句中,编译器为每个线程创建变量sum的私有副本。当循环完成后,将这些值加在一起并把结果放到原始的变量sum中; Reduction中的op操作必须满足算术结合律和交换律
// 计算Pi的值
# include <stdio.h>
# include <omp.h>
static long num_steps = 100000; double step;
#define NUM_THREADS 4
void main()
{
int i; double pi, sum[NUM_THREADS], start_time, end_time;
step = 1.0 / (double)num_steps;
omp_set_num_threads(NUM_THREADS);
start_time = omp_get_wtime();
#pragma omp parallel
{ int id; double x;
id = omp_get_thread_num();
for (i = id, sum[id] = 0.0; i < num_steps; i = i + NUM_THREADS) {
x = (i + 0.5) * step; sum[id] += 4.0 / (1.0 + x * x);
}
}
for (i = 0, pi = 0.0; i < NUM_THREADS; i++) pi += sum[i] * step;
end_time = omp_get_wtime();
printf("Pi = % f\n Running time: %f \n", pi, end_time - start_time);
}
API函数
omp_in_parallel
: 判断当前是否在并行域中omp_get_thread_num
: 返回线程号omp_set_num_threads
: 设置后续并行域中的线程格式omp_get_num_threads
: 返回当前并行域中的线程数omp_get_max_threads
:获得并行域中可用的最大线程数omp_get_num_procs
: 返回系统中处理器的个数omp_get_dynamic
:判断是否支持动态改变线程的数目omp_set_dynamic
: 启动或者关闭线程数目的动态改变omp_get_nested
: 判断系统是否支持并行嵌套omp_set_nested
: 启动或者关闭并行嵌套omp_init_lock
初始化一个简单锁omp_set_lock
上锁操作omp_unset_lock
解锁操作,要omp_set_lock函数配对使用。omp_destroy_lock
, omp_init_lock函数的配对操作函数,关闭一个锁
环境变量
OMP_SCHEDULE
:用于for循环并行化后的调度,它的值就是循环调度的类型;OMP_NUM_THREADS
:用于设置并行域中的线程数;OMP_DYNAMIC
:通过设定变量值,来确定是否允许动态设定并行域内的线程数;OMP_NESTED
:指出是否可以并行嵌套
编程实例与代码讲解
- parallel制导指令用来创建并行域,后边要跟一个大括号将要并行执行的代码放在一起
- 需要包含头文件<omp.h>,并且是大小写敏感的。
1. 多线程执行 parallel
#include<iostream>
#include <omp.h>
using namespace std;
void main()
{
#pragma omp parallel
{
cout << "Test" << endl;
}
system("pause");
}
我的电脑的逻辑处理器是16个(打开【任务管理器】,【性能】,【CPU】查看),所以输出16个test。
输出的结果并不是每个Test后换行,原因是每个线程都是独立运行的,在其中一个线程输出字符“Test”之后还没有来得及换行时,另一个线程直接输出了字符“Test”。而且我们可以发现空行数与每行重复输出的Test数量相等。
2.通过子句num_threads显式控制创建的线程数
设置6个进程
#include<iostream>
#include"omp.h"
using namespace std;
void main()
{
#pragma omp parallel num_threads(6)
{
cout << "Test" << endl;
}
system("pause");
}
输出6个test
3. parallel for使用
3.1 for
for
:用于for循环语句之前,表示将循环计算任务分配到多个线程中并行执行,以实现任务分担,必须由编程人员自己保证每次循环之间无数据相关性。
#include <omp.h>
#include <stdio.h>
void main()
{
omp_set_num_threads(3);
#pragma omp parallel
{
printf("The number of threads : %d seen by thread %d\n", omp_get_thread_num(), omp_get_num_threads());
#pragma omp for
for (int i = 1; i <= 5; ++i) {
printf("No. %d iteration by thread %d\n", i, omp_get_thread_num());
}
}
}
3.2 parallel for
parallel for
与parallel
的区别:使用parallel制导指令只是产生了并行域,让多个线程分别执行相同的任务,并没有实际的使用价值。parallel for用于生成一个并行域,并将计算任务在多个线程之间分配,从而加快计算运行的速度。可以让系统默认分配线程个数,也可以使用num_threads子句指定线程个数。
#include<iostream>
#include"omp.h"
using namespace std;
void main()
{
#pragma omp parallel for num_threads(6)
for (int i = 0; i < 12; i++)
{
printf("OpenMP Test, 线程编号为: %d\n", omp_get_thread_num());
}
system("pause");
}
指定了6个线程,迭代量为12,从输出可以看到每个线程都分到了12/6=2次的迭代量。
备注:如果for里面比较简单(执行时间短) ,不建议使用多线程并发, 因为 线程间的调度 也会比较耗时,是一个不小的开销。
4. OpenMP效率提升以及不同线程数效率对比
#include<iostream>
#include"omp.h"
using namespace std;
void test()
{
for (int i = 0; i < 80000; i++)
{
}
}
void main()
{
float startTime = omp_get_wtime();
//指定2个线程
#pragma omp parallel for num_threads(2)
for (int i = 0; i < 80000; i++)
{
test();
}
float endTime = omp_get_wtime();
printf("指定 2 个线程,执行时间: %f\n", endTime - startTime);
startTime = endTime;
//指定4个线程
#pragma omp parallel for num_threads(4)
for (int i = 0; i < 80000; i++)
{
test();
}
endTime = omp_get_wtime();
printf("指定 4 个线程,执行时间: %f\n", endTime - startTime);
startTime = endTime;
//指定8个线程
#pragma omp parallel for num_threads(8)
for (int i = 0; i < 80000; i++)
{
test();
}
endTime = omp_get_wtime();
printf("指定 8 个线程,执行时间: %f\n", endTime - startTime);
startTime = endTime;
//指定12个线程
#pragma omp parallel for num_threads(12)
for (int i = 0; i < 80000; i++)
{
test();
}
endTime = omp_get_wtime();
printf("指定 12 个线程,执行时间: %f\n", endTime - startTime);
startTime = endTime;
//不使用OpenMP
for (int i = 0; i < 80000; i++)
{
test();
}
endTime = omp_get_wtime();
printf("不使用OpenMP多线程,执行时间: %f\n", endTime - startTime);
startTime = endTime;
system("pause");
}
从如下的执行结果可以看出,线程使用的越多,执行所用的时间越短。
5. sections和section指令的用法
section语句是用在sections语句里用来将sections语句里的代码划分成几个不同的段,每段都并行执行。用法如下:
#pragma omp [parallel] sections [子句]
{
#pragma omp section
{
}
}
5.1 示例1
#include"omp.h"
#include "stdio.h"
void main()
{
#pragma omp parallel sections
{
#pragma omp section
{
printf("section 1 ThreadId = % d \n", omp_get_thread_num());
}
#pragma omp section
{
printf("section 2 ThreadId = % d \n", omp_get_thread_num());
}
#pragma omp section
{
printf("section 3 ThreadId = % d \n", omp_get_thread_num());
}
#pragma omp section
{
printf("section 4 ThreadId = % d \n", omp_get_thread_num());
}
}
}
由执行结果可以看出各个section里的代码都是并行执行的,并且各个section被分配到不同的线程执行。
使用section语句时,需要注意的是这种方式需要保证各个section里的代码执行时间相差不大,否则某个section执行时间比其他section过长就达不到并行执行的效果了。
5.2 示例2
#include"omp.h"
#include "stdio.h"
void main(int argc, char* argv)
{
#pragma omp parallel
{
#pragma omp sections
{
#pragma omp section
{
printf("section 1 ThreadId = %d\n", omp_get_thread_num());
}
#pragma omp section
{
printf("section 2 ThreadId = %d\n", omp_get_thread_num());
}
#pragma omp section
{
printf("section 3 ThreadId = %d\n", omp_get_thread_num());
}
#pragma omp section
{
printf("section 4 ThreadId = %d\n", omp_get_thread_num());
}
}
#pragma omp sections
{
#pragma omp section
{
printf("section 5 ThreadId = %d\n", omp_get_thread_num());
}
#pragma omp section
{
printf("section 6 ThreadId = %d\n", omp_get_thread_num());
}
#pragma omp section
{
printf("section 7 ThreadId = %d\n", omp_get_thread_num());
}
#pragma omp section
{
printf("section 8 ThreadId = %d\n", omp_get_thread_num());
}
}
}
}
这种方式和前面那种方式的区别是,两个sections语句是串行执行的,即第二个sections语句里的代码要等第一个sections语句里的代码执行完后才能执行。但是同一个section 内部的顺序可以任意改变。如下图, (1、2、3、4)总是在(5,6,7,8)的前面。
用for语句来分摊是由系统自动进行,只要每次循环间没有时间上的差距,那么分摊是很均匀的,使用section来划分线程是一种手工划分线程的方式,最终并行性的好坏得依赖于程序员。
5.3 示例3
#include <stdio.h>
#include <omp.h>
int main() {
#pragma omp parallel sections
{
#pragma omp section
for (int i = 0; i < 5; ++i) {
printf("section i : iteration % d by thread no. % d\n", i, omp_get_thread_num());
}
#pragma omp section
for (int j = 0; j < 5; ++j) {
printf("section j : iteration % d by thread no. % d\n", j, omp_get_thread_num());
}
}
}
由上图的实验结果可以看出,每个section 模块分配有一个线程,在单个section的模块中,按照次序迭代5次,但是不同的section 模块i和j中的迭代语句可以交叉,即并行运行。说明sections语句里用来将sections语句里的代码划分成几个不同的段,每段都并行执行。
6 . private的作用
// paralle Construct
# include "omp.h"
# include "stdio.h"
int main()
{
int id, numb;
omp_set_num_threads(5);
#pragma omp parallel private(id, numb)
{
id = omp_get_thread_num();
numb = omp_get_num_threads();
printf("I am thread %d out of %d\n",id,numb );
}
}
通过子句num_thread
可以显式控制创建的线程数,如上图所示,创建的线程数为5,通过private
指定id和numb在每个线程中都有它自己的私有副本。同时可以发现每次运行时线程ID出现的次序不同,说明多线程并发运行时,其速度并不是完全一样的而是随机的。
7. barrier 的作用
barrier
:用于并行域内代码的线程同步,线程执行到barrier时要停下等待,直到所有线程都执行到barrier时才继续往下执行
#include <omp.h>
#include <stdio.h>
int main(){
#pragma omp parallel
{
for (int i = 0; i < 10; i++) {
printf("loop i:iteration %d by thread no.%d\n", i, omp_get_thread_num());
}
#pragma omp barrier
for (int j = 0; j < 10; j++) {
printf("loop j:iteration %d by thread no.%d\n", j, omp_get_thread_num());
}
}
return 0;
}
由上图结果可以看出,在一个循环内,每个线程都被迭代10次,这个过程是并行的。但循环之间是按照次序进行的,即loop i 全部执行完后才执行loop j的内容。说明了线程执行到barrier时要停下等待,直到所有线程都执行到barrier时才继续往下执行。
8. critical 的作用
critical
:用在一段代码临界区之前,保证每次只有一个OpenMP线程进入。临界区指的是一个访问共用资源(例如:共用设备或是共用存储器)的程序片段,而这些共用资源又无法同时被多个线程访问的特性。当有线程进入临界区段时,其他线程或是进程必须等待
shared
:指定一个或多个变量为多个线程间的共享变量;
#include <omp.h>
#include <stdio.h>
int main() {
int x; x = 0;
#pragma omp parallel shared(x)
{
#pragma omp critical
x += 1;
}
printf("x=%d\n", x);
}
其中x被设置为多个线程之间的共享变量,由多个线程分别执行一次迭代,在每次的迭代过程中x的值加1,因此最后的x的值等于线程的个数。
9. atomic 的作用
atomic
:用于指定一个数据操作需要原子性地完成,原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束。
shared
:指定一个或多个变量为多个线程间的共享变量
#include <omp.h>
#include <stdio.h>
int main() {
int x; x = 0;
//omp_set_num_threads(4);
#pragma omp parallel shared(x)
{
#pragma omp atomic
x += 1;
}
printf("x=%d\n", x);
}
该结果与上一题中使用critical的结果一样,相当于不同线程间串行运行。其中的critical 和atomic 的区别在于:critical 可以对代码块进行临界区设置,而atomic只能对代码语句进行加持。 原子操作是要独占处理器,其他的线程必须等原子操作完了才可以运行。
10. reduction
模拟计算圆周率的数学公式如下:
π
4
=
∫
0
1
1
1
+
x
2
d
x
\frac{\pi}{4}=\int_{0}^{1} \frac{1}{1+x^{2}} d x
4π=∫011+x21dx
把0-1下面积分为n个小矩形,再在每个处理器上处理一部分面积,最后加起来。
#pragma omp parallel for
用在一个for循环的前面,表示下面的一行代码或代码块要分配到多个执行单元中并行计算。
private(local)
默认情况下定义在并行代码之外的变量为各并行的执行单元所共享,使用private限制,表示每个执行单元创建该变量的一个副本
reduction
:用来指定一个或多个变量是私有的,并且在并行处理结束后这些变量要执行指定的归约运算,并将结果返回给主线程同名变量。
10.1 parallel for 计算pi
#include <omp.h>
#include <stdio.h>
static long num_steps = 100000;
double step;
#define NUM_THREADS 2
void main() {
int i;
double x, pi, sum = 0.0;
step = 1.0 / (double)num_steps;
omp_set_num_threads(NUM_THREADS);
#pragma omp parallel for reduction(+:sum) private(x)
for (i = 1; i <= num_steps; i++) {
x = (i - 0.5) * step;
sum += 4.0 / (1.0 + x * x);
}
pi = step * sum;
printf("pi=%f\n", pi);
}
为该程序分配多个线程,其中指定x为private,即每为每个线程创建一个x的副本。reduction(+:sum)表示并行代码执行完毕后对各个执行单元中的sum进行相加操作,说明每个线程计算一个小长方形的面积,根据积分的原理,最后所有的小长方形的面积之和近似等于圆周率的值。
10.2 parallel 计算 pi
#include <omp.h>
#include <stdio.h>
static long num_steps = 100000;
double step;
#define NUM_THREADS 2
void main() {
int i,id;
double x, pi, sum = 0.0;
step = 1.0 / (double)num_steps;
omp_set_num_threads(NUM_THREADS);
#pragma omp parallel private(x,i,id) reduction(+:sum)
{
id = omp_get_thread_num();
for (i = 1+id; i <= num_steps; i=i+NUM_THREADS) {
x = (i - 0.5) * step;
sum += 4.0 / (1.0 + x * x);
}
}
pi = step * sum;
printf("pi=%f\n", pi);
}
通过for (i = 1+id; i <= num_steps; i=i+NUM_THREADS)
来分配每个处理器计算的矩形id, 然后每个线程并行计算,最后的和累加即可得到圆周率的估计值 pi=3.141593。