并行复习重点
第1讲 绪论
- 推动并行计算的原因
-
处理器能力,硬件限制
晶体管密度仍在提高;时钟频率提高速度急剧放缓;频率不是处理器发展的主角;功耗/散热的限制;
-
性能上升放缓
-
多核、众核发展趋势
- 转变“更复杂的处理器设计、更快的时钟频率” 的发展思路
- 并行架构更容易设计
- 充分利用资源
- 巨大的功耗优势
- 了解并行计算应用
-
科学仿真
使用高性能计算机系统仿真现象
实例:全局气候建模、海洋建模、星系演化、生物信息学、强子对撞机实验、医学、商业应用 Web服务为代表:大量静态、动态内容。
- 超算机
- 神威 ·太湖之光
- Summit
- E级超算
- 天河三号
- 中科曙光
- 美国“极光(Aurora)”
- 日本“后京”(Post-K)’’
4.软件技术面临的挑战
- 并行程序设计的复杂性
- 足够的并发度
- 并发粒度
- 负载均衡
- 协调和同步
- 数据移动(通信)代价很高
- 能耗挑战
- 伸缩性挑战
- 相同的程序在新一代硬件架构下仍能高效运行
- 在更大规模(更多核心)的硬件平台下仍能高效运行
- 软件面临的挑战
- 硬件技术飞速发展,软件生态环境几乎停滞
- 关注现有软件难以处理的硬件发展趋势
- 新软件技术还不成熟
- 现有代码还未准备好硬件架构的改变
- 其实软件投资的回报更大
第2讲 并行硬件和并行软件
- 基础知识
-
冯·诺依曼体系结构
CPU(运算器、控制器)、存储器、输入输出设备
瓶颈:存取数据和处理数据的速度差距大
进程、线程、多任务(单处理器运行多个程序,实际上每个程序轮流执行)
- 改进
-
缓存
概念:缓存是指可以进行高速数据交换的存储器,它先于内存与CPU交换数据,因此速率很快。
原理:缓存中存储的数据是接下来要访问的数据,一般是访问过的数据紧挨着的数据。时间和空间局部性原理。
-
多级缓存:离cpu远近 速度越快,容量越小
-
缓存命中,缓存缺失
-
写直达:缓存和主存不一致立即更新主存中的数据;写回;
-
虚拟内存:
-
ILP(指令级并行,单核):流水线和多发射
-
硬件多线程(单核):当前任务阻塞时,系统去执行其他任务
细粒度,粗粒度,超线程/同步多线程(模拟多核)
- 硬件并行
-
弗林分类法
SISD(传统的冯诺依曼体系);SIMD;MISD;MIMD;
S: single M: multiple I: instruction stream D: data stram
-
SIMD (数据并行)
多条数据执行同一条指令,每个处理器同步执行任务
缺点:1. 所有的计算单元要么执行相同的指令要么空着,2. 所有计算单元必须同步进行 3.计算单元没有指令储存功能,4. 适合大数据量
应用:向量计算;GPU(note:并不是纯粹的SIMD)
-
MIMD
每个处理器独立执行相应任务
-
共享内存系统
每一个处理器可以访问每一块内存,处理器间通过共享数据隐式通信
包含一个或者多个多核处理器
分类:
-
一致内存访问系统:
-
非一致内存访问系统
-
-
分布式内存系统
-
集群: 多台计算机相连
-
-
互联网络
-
共享内存互联网络
总线互联
交换器互联
-
分布式内存互联网络
直接互联:环形、二维环面
等分宽度:同时通信的链路数目(以最坏情况估计),衡量互联网络的连通性;
带宽:链路速度
等分带宽:带宽 * 等分宽度
环形等分宽度: 2
二维网格等分宽度: 2 P 2\sqrt{ P } 2P
全相连网络等分宽度: P 2 / 4 P^2/4 P2/4
超立方体等分宽度: P / 2 P/2 P/2
间接互联:
-
信息传递的时间: 延迟 + 总长/带宽
-
缓存一致性
- 监听缓存一致性协议
- 基于目录的缓存一致性协议
-
-
4.软件并行
-
并行算法的设计
- 进程/线程的任务分配:负载均衡和通信量尽量 少
- …同步
- …通信
-
任务分解
-
任务并行
将求解问题的计算分解为任务,分配给多个 核心
-
数据并行
将求解问题涉及的数据划分给多个核心
每个核心对不同数据进行相似的计算
-
-
数据依赖和竞争条件
原子性(atomicity):一组操作要么全部执行要么全 不执行,则称其是原子的。即,不会得到部分执行的 结果。
互斥(mutual exclusion):任何时刻都只有一个线程 在执行
执行结果依赖于两个或更多事件的时序 ,则存在竞争条件
数据依赖(data dependence)就是两个内 存操作的序,为了保证结果的正确性, 必须保持这个序
同步(synchronization)在时间上强制使 各执行进程/线程在某一点必须互相等待, 确保各进程/线程的正常顺序和对共享可 写数据的正确访问
临界区是一个更新共享资源的代码段,一次只能允许 一个线程执行该代码段。
-
同步方法:障碍
障碍(barrier)阻塞线程继续执行,在此程序点等 待,直到所有参与线程都到达障碍点才继续执行
-
并行算法的分析
-
基本指标
并行程序设计的复杂性:足够的并发度;并发粒度;局部性;负载均衡;协调和同步
并行算法额外开销:进程间通信:最大开销,大部分并行算法都 需要;进程空闲:负载不均、同步操作、不能并行 化的部分; 额外计算;
性能评价标准:
- 串行算法: T S T_S TS,算法开始到结束的时间流逝
- 并行算法: T P T_P TP,并行算法开始到最后一个进 程结束所经历时间
- 并行算法总额外开销 T 0 = P T P − T s T_0 = PT_P - T_s T0=PTP−Ts
- 加速比: S = T S / T P S = T_S/T_P S=TS/TP (Note: 使用最优串行算法时间)
一般S≤p;S=p, 则称该并行算法具有线性加速比;S>p (超线性加速比)在实践中是可能出现的
阿姆达尔定律:除非一个串行程序的执行几乎全部都 并行化,否则不论多少可以利用的核 ,通过并行化所产生的加速比都会是 受限的。S = 1 / (1 - a + a / p) a为串行程序中可被(完美)并行化的比例
效率(Efficiency):度量有效计算时间; E = S / p = TS / (p*TP )
-
可扩展性
若某并行程序核数(线程数/进程数)固定,并且输入 规模也是固定的,其效率值为E。现增加程序核数 (线程数/进程数),如果在输入规模也以相应增长率 增加的情况下,该程序的效率一直是E(不降),则称 该程序是可扩展的。
保持问题规模不变时, 效率不随着线程数的增大而降低, 则称程序是可扩展的(称为强可扩展的)
问题规模以一定速率 增大,效率不随着线程数的增大 而降低,则认为程序是可扩展的 (称为弱可扩展的)
-
第3讲 SIMD编程
-
适合应用的特点: 规律的数据访问模式,数据项在内存中连续存储;短数据类型:8、16、32位;流式数据处理,一系列处理阶段,时间局部性,数据流重用;很多常量,循环迭代短,算术运算饱和。
-
SIMD编程的额外开销
-
打包/解包数据的开销:重排数据使之连续
- 打包源运算对象——拷贝到连续内存区域
- 解包目的运算对象——拷贝回内存
-
对齐:调整数据访问,使之对齐
-
对齐的内存访问: 地址总是向量长度的倍数
-
未对齐的内存访问:
静态对齐:对未对齐的读操作,做两次相邻的对齐 读操作,然后进行合并; 有时硬件会帮你做,但仍然会产生多次内存操作
动态对齐:合并点在运行时计算
-
-
控制流可能要求执行所有路径
额外开销:永远是两个控制流路径都执行!
-
-
x86架构SIMD支持:8个128位的向量寄存器(SSE);16个256位的向量寄存器(AVX)
-
SIMD编程
矩阵乘法——SSE版本
void sse_mul(int n, float a[][maxN], float b[][maxN], float c[][maxN]){ __m128 t1, t2, sum; for (int i = 0; i < n; ++i) for (int j = 0; j < i; ++j) swap(b[i][j], b[j][i]); for (int i = 0; i < n; ++i){ for (int j = 0; j < n; ++j){ c[i][j] = 0.0; sum = _mm_setzero_ps(); for (int k = n - 4; k >= 0; k -= 4){ // sum every 4 elements t1 = _mm_loadu_ps(a[i] + k); t2 = _mm_loadu_ps(b[j] + k); t1 = _mm_mul_ps(t1, t2); sum = _mm_add_ps(sum, t1); } sum = _mm_hadd_ps(sum, sum); sum = _mm_hadd_ps(sum, sum); _mm_store_ss(c[i] + j, sum); for (int k = (n % 4) - 1; k >= 0; --k){ // handle the last n%4 elements c[i][j] += a[i][k] * b[j][k]; } } } for (int i = 0; i < n; ++i) for (int j = 0; j < i; ++j) swap(b[i][j], b[j][i]); }
第4讲 Pthread编程
-
基础API
int pthread_create(pthread_t *, const pthread_attr_t *, void * (*)(void *), void *); 调用例: errcode = pthread_create(&thread_id, &thread_attribute, &thread_fun, &fun_arg); thread_id 是个指针,线程ID或句柄(用于停止线程等) thread_attribute 各种属性,通常用空指针NULL表示标准默认属性值 thread_fun 新线程要运行的函数(参数和返回值类型都是void*) fun_arg 传递给要运行的函数thread_fun的参数 errorcode 若创建失败,返回非零值 pthread_create的效果 ❑ 主线程借助操作系统创建一个新线程 ❑ 线程执行一个特定函数thread_fun ❑ 所有创建的线程执行相同的函数,表示线程 的计算任务分解 ❑ 对于程序中不同线程执行不同任务的情况, 可用创建线程时传递的参数区分线程的“id” 以及其他线程的独特特性
int pthread_join(pthread_t , void **value_ptr) ❑ UNIX说明:“挂起调用线程,直至目标线程结束 ,除非目标线程已结束。” ❑ 第二个参数允许目标线程退出时返回信息给调用线 程(通常是NULL) ❑ 如发生错误返回非零值 void pthread_exit(void *value_ptr); ❑ 通过value_ptr返回结果给调用者 int pthread_cancel(pthread_t thread); ❑ 取消线程thread执行
-
同步
忙等待
void *pi_busywaiting(void *parm) { threadParm_t *p = (threadParm_t *) parm; int r = p->threadId; int n = p->n; int my_n = n/THREAD_NUM; int my_first = my_n*r; int my_last = my_first + my_n; double my_sum = 0.0; if (my_first % 2 == 0) factor = 1.0; else factor = -1.0; for (int i = my_first; i < my_last; i++, factor = -factor) { my_sum += factor/(2*i+1); } while (flag != r) Sleep(0); sum += my_sum; flag++; pthread_exit(nullptr); } flag指出的线程编号 才允许累加到全局和 避免过多忙等待 线程先各自求局部和
互斥量
创建mutex: #include <pthread.h> pthread_mutex_t amutex = PTHREAD_MUTEX_INITIALIZER; pthread_mutex_init(&amutex, NULL); 使用mutex: int pthread_mutex_lock(&amutex); // 加锁,若已上锁则阻塞至被解锁 int pthread_mutex_trylock(&amutex); // 加锁,若已上锁不阻塞,返回非0 int pthread_mutex_unlock(&amutex); // 解锁,可能令其他线程退出阻塞 释放mutex: int pthread_mutex_destroy(&amutex); void *pi_mutex(void *parm) { threadParm_t *p = (threadParm_t *) parm; int r = p->threadId; int n = p->n; int my_n = n/THREAD_NUM; int my_first = my_n*r; int my_last = my_first + my_n; double my_sum = 0.0; if (my_first % 2 == 0) factor = 1.0; else factor = -1.0; for (int i = my_first; i < my_last; i++, factor = -factor) { my_sum += factor/(2*i+1); } pthread_mutex_lock(&mutex); sum += my_sum; pthread_mutex_unlock(&mutex); pthread_exit(nullptr); } // 与忙等待的区别为: // 忙等待中进入临界区的顺序由程序员规定,而互斥量版本则由系统随即控制 // 忙等待的等待进入临界区的线程需要消耗资源(sleep(0));而互斥量版本则暂时挂起
信号量
初始化信号量 #include <semaphore.h> int sem_init(sem_t *sem, int pshared, unsigned value); pshared非0:进程间共享;0:进程内线程共享 value:信号量初始值 使用semaphore: int sem_wait(sem_t *sem); // 信号量值减1,若已为0则阻塞 int sem_post(sem_t *sem); // +1,若原来为0则可能唤醒阻塞线程 释放信号量: int sem_destroy(sem_t *sem); #include <stdio.h> #include <stdlib.h> #include <pthread.h> #include <semaphore.h> #define NUM_THREADS 4 typedef struct{ int threadId; } threadParm_t; sem_t sem_parent; sem_t sem_children; void *threadFunc(void *parm) { threadParm_t *p = (threadParm_t *) parm; fprintf(stdout, "I am the child thread %d.\n", p->threadId); sem_post(&sem_parent); // 唤醒主线程——我已完成 sem_wait(&sem_children); // 等待主线程唤醒我 fprintf(stdout, "Thread %d is going to exit.\n", p->threadId); pthread_exit(NULL); } int main(int argc, char *argv[]) { sem_init(&sem_parent, 0, 0); sem_init(&sem_children, 0, 0); pthread_t thread[NUM_THREADS]; threadParm_t threadParm[NUM_THREADS]; int i; for (i=0; i<NUM_THREADS; i++) { threadParm[i].threadId = i; pthread_create(&thread[i], NULL, threadFunc, (void *)&threadParm[i]); } for (i=0; i<NUM_THREADS; i++) { sem_wait(&sem_parent); // 等待所有子线程都输出 } fprintf(stdout, "All the child threads has printed.\n"); for (i=0; i<NUM_THREADS; i++) { sem_post(&sem_children); // 唤醒子线程继续 输出新内容 } for (i=0; i<NUM_THREADS; i++) { pthread_join(thread[i], NULL); } sem_destroy(&sem_parent); sem_destroy(&sem_children); return 0; }
使用barrier同步
初始化barrier的方法如下所示(本例中线程数为3): pthread_barrier_t b; pthread_barrier_init(&b,NULL,3); 第二个参数指出对象属性,NULL表示默认属性 为等待barrier,线程应执行 pthread_barrier_wait(&b); 可通过下面的宏,指定一个初始值来初始化barrier PTHREAD_BARRIER_INITIALIZER(3). #include <stdio.h> #include <stdlib.h> #include <pthread.h> #define NUM_THREADS 4 typedef struct{ int threadId; }threadParm_t; pthread_barrier_t barrier; void *threadFunc(void *parm) { threadParm_t *p = (threadParm_t *) parm; fprintf(stdout, "Thread %d has entered step 1.\n", p->threadId); pthread_barrier_wait(&barrier); fprintf(stdout, "Thread %d has entered step 2.\n", p->threadId); pthread_exit(NULL); } int main(int argc, char *argv[]) { pthread_barrier_init(&barrier, NULL, NUM_THREADS); pthread_t thread[NUM_THREADS]; threadParm_t threadParm[NUM_THREADS]; int i; for (i=0; i<NUM_THREADS; i++) { threadParm[i].threadId = i; pthread_create(&thread[i], NULL, threadFunc, (void *)&threadParm[i]); } for (i=0; i<NUM_THREADS; i++) { pthread_join(thread[i], NULL); } pthread_barrier_destroy(&barrier); system("PAUSE"); return 0; }
条件变量
mutex是简单加锁/解锁 ❑ 如需判断条件后加锁,若条件不满足则需轮询,极耗资源 ❑ 使用条件变量,条件不满足时阻塞,在这之前会自动解锁 被唤醒后自动加锁,再去检测条件 API pthread_cond_init (condition,attr) pthread_cond_destroy (condition) pthread_condattr_init (attr) pthread_condattr_destroy (attr) pthread_cond_wait (condition,mutex) // 条件不成立便阻塞,之前解锁 pthread_cond_signal (condition) // 触发条件,可能唤醒一个阻塞线程 pthread_cond_broadcast (condition) // 唤醒多个线程
读写锁:链表函数
// 1. 对整个链表加锁;粒度太粗,可能令线程不必要地等待 // 2. 结点级锁:比1还要差,节点太多造成资源大量消耗 // 3. Pthread读写锁:仅在读写时加锁
-
负载均衡和任务分配
// 1. 块划分 // 2. 动态任务划分 int next_arr = 0; pthread_mutex_t mutex_task; void *arr_sort_fine(void *parm) { threadParm_t *p = (threadParm_t *) parm; int r = p->threadId; int task = 0; long long tail; while (1) { pthread_mutex_lock(&mutex_task); task = next_arr++; pthread_mutex_unlock(&mutex_task); if (task >= ARR_NUM) break; stable_sort(arr[task].begin(), arr[task].end()); } pthread_mutex_lock(&mutex); QueryPerformanceCounter((LARGE_INTEGER *)&tail); printf("Thread %d: %lfms.\n", r, (tail - head) * 1000.0 / freq); pthread_mutex_unlock(&mutex); pthread_exit(nullptr); } // 3. 粗粒度动态划分
数据划分
- 块划分
- 二维块划分
- 高维划分减少交互
- 循环划分
- 随机块划分
第5讲 OpenMP编程
-
执行模型
执行伊始是单进程(主线程)
并行结构开始
❑ 主线程创建一组线程(工作线程)
并行结构结束
❑ 线程组同步——隐式barrier
只有主线程继续执行
实现优化
❑ 工作线程等待下一次fork
-
编程模型 – 数据共享
并行程序通常使用两种数据
❑ 共享数据,所有线程可见, 通常是具名的
❑ 私有数据,单线程可见, 通常在栈中分配
PThread
❑ 全局作用域变量是共享的
❑ 栈中分配的变量是私有的
OpenMP
❑ shared变量是共享的
❑ private变量是私有的
❑ 默认是shared
❑ 循环变量是private
-
编译指示格式
编译指示格式 #pragma omp directive_name [ clause [ clause ] ... ] 条件编译 #ifdef _OPENMP … printf(“%d avail.processors\n”,omp_get_num_procs()); #endif 大小写敏感
-
基础api
查询函数 int omp_get_num_threads(void); 返回执行当前并行区域的线程组中的线 程数 int omp_get_thread_num(void); 返回当前线程在线程组中的编号,值在0 和omp_get_num_threads()-1之间。 主线程的编号为0 临界区指令 被临界区包围的代码 ❑ 所有线程都执行,但 ❑ 每个时刻限制只有一个线程执行 #pragma omp critical [ ( name ) ] 语句块 线程在临界区开始位置等待,直至组中
-
OpenMP归约
OpenMP支持归约操作 ❑ 归约就是将相同的规约操作符重复地应用到操作数 序列来得到一个结果的计算。 sum = 0; #pragma omp parallel for reduction(+:sum) for (i=0; i < 100; i++) { sum += array[i]; } 支持的归约运算及初值: + 0 位与 & ~0 逻辑与 & 1 - 0 位或 | 0 逻辑或 | 0 * 1 位异或 ^ 0 # pragma omp parallel num_threads(thread_count) Trap(a, b, n, &global_result); global_result = 0.0; # pragma omp parallel num_threads(thread_count) { # pragma omp critical global_result += Local_trap(a, b, n); // 不同线程的全部计算被串行化了 } global_result = 0.0; # pragma omp parallel num_threads(thread_count) { double my_result = 0.0; /* private */ my_result += Local_trap(a, b, n); // 不同线程并行计算 # pragma omp critical global_result += my_result; // 只有全局求和串行 } global_result = 0.0; # pragma omp parallel num_threads(thread_count) reduction(+: global_result) global_result += Local_trap(a, b, n); // 私有变量等繁琐工作编译器代劳全局求和采用递归算法
-
并行循环
所有编译指示都以#pragma开始 编译器为每个线程计算负责的循环范围 ——根据串行源码(计算分解) 编译器还管理数据划分 同步也是自动的(barrier) 创建线程,在线程间分配循环步;要求迭代次数可预测。 ❑ 不检查依赖性! ❑ 不支持while 、do-while 、循环体包含break等 语法 #pragma omp for [ clause [ clause ] ... ] for循环 子句形式 shared(list) private(list) reduction(operator:list) schedule(type[, chunk]) nowait(C/C++:用于#pragma omp for) #pragma omp parallel private(f) { f=7; #pragma omp for for (i=0; i<20; i++) a[i] = b[i] + f * (i+1); } /* omp end parallel */
-
数据依赖
一些定义 ❑ 两个计算等价 在相同的输入上 ➢它们产生相同的输出 ➢输出按相同的顺序生成 ❑ 一个重排转换 ➢改变语句执行的顺序 ➢不增加或删除任何语句的执行 ❑ 一个重排转换保持依赖关系 ➢它保持了依赖源和目的语句的相对执行顺序 依赖关系基本定理 ❑ 任何重排转换,只要保持了程序中所有依赖关系,它就保持了程序的含义。 并行化 ❑ 同步点之间并行执行的计算可能重排执行顺序。这种重排是否安全?根据我们的定义,如果它能保持代码中的依赖关系,则它是安全的 局部性优化 ❑ 假定我们希望修改访问顺序,以便更好地利用cache。这也是一种重排转换,同样,若它保持了代码中的依赖关系,则它是安全的 归约计算 ❑ 对于使用满足交换律和结合律的运算的归约操作,对其重排是安全的
-
循环调度
schedule子句确定如何在线程间划分循环 ❑ static([chunk])静态划分 ➢ 分配给每个线程 [chunk]步迭代,所有线程都分配完后继续循环分配, 直至所有迭代步分配完毕 ➢ 默认[chunk]为ceil(#iterations/#threads) ❑ dynmaic([chunk])动态划分 ➢ 分给每个线程[chunk]步迭代,一个线程完成任务后再为其分配 [chunk]步迭代 ➢ 逻辑上形成一个任务池,包含所有迭代步 ➢ 默认[chunk]为1 ❑ guided([chunk])动态划分,但划分过程中[chunk]指数减小 ➢ 类似于DYNAMIC调度,但分块开始大,随着迭代分块越来越少, 循环区间的划分是基于类似下列公式完成的(不同的编译系统可能 不同):Sk = Rk / 2N 其中N是线程个数,Sk表示第k块的大小,Rk是剩余下未被调度的 循环迭代次数。 默认调度 sum = 0.0; # pragma omp parallel for num_threads(thread_count) reduction(+:sum) for (i = 0; i <= n; i++) sum += f(i); 等价于 sum = 0.0; # pragma omp parallel for num_threads(thread_count) reduction(+:sum) schedule(static, n/thread_count) for (i = 0; i <= n; i++) sum += f(i); RUNTIME 调度决策推迟到运行时由环境变量 指定 需提前设定环境变量OMP_SCHEDULE AUTO 委托编译器和/或运行时系统做出调度决 策 NO WAIT/nowait:线程在并行循环结束时不进 行同步 ORDERED:循环步必须串行执行 COLLAPSE:指出嵌套循环如何收缩为一个大 的循环并根据子句进行划分(收缩顺序依据原 串行执行顺序)
-
小结
优点 ❑ 简单修改串行程序即可得到并行程序 ❑ 不必关心低层映射细节 ❑ 可移植、可扩展、单处理器也正确 缺点 ❑ 从头写并行程序的话不是很自然 ❑ 表达常见并行结构并不总是可行 ❑ 局部性处理 ❑ 性能控制
第6讲 MPI编程
-
MPI基本原语
❑ MPI_Comm_size报告进程数 int MPI_Comm_size(MPI_Comm comm, int *size) ❑ MPI_Comm_rank报告识别调用进程的rank,值从0~size-1 int MPI_Comm_rank(MPI_Comm comm, int *rank) MPI_Init ❑ 令MPI进行必要的初始化工作 int MPI_Init( int* argc_p /* 输入/输出参数 */, char *** argv_p /* 输入/输出参数 */); MPI_Finalize ❑ 告诉MPI程序已结束,进行清理工作 int MPI_Finalize(void); // ... #include <mpi.h> … int main(int argc, char *argv[]) { … /* 这部分不应有MPI函数调用 */ MPI_Init(&argc, &argv); … /* MPI程序主体 */ MPI_Finalize(); /* 这部分不应有MPI函数调用 */ return 0; } int MPI_Send(void* buf, int count, MPI_Datatype datatype,int dest, int tag, MPI_Comm comm) 消息缓冲区(buf, count, datatype) 目的进程dest——目的进程在comm指定的通信域中的编号 阻塞发送——函数返回时,数据已经转给系统进行发送,缓冲区可作他用;消息可能还未送达目的进程 int MPI_Recv(void* buf, int count, MPI_Datatype datatype, int source, int tag, MPI_Comm comm, MPI_Status *status) 阻塞接收——等待,直至收到匹配的(source和tag都相同)的消息,缓冲可作他用 source可以是comm中的编号,或MPI_ANY_SOURCE tag为特定标签(需匹配)或MPI_ANY_TAG 接收的数据量比指定的少是允许的,但接收到更多数据就是错误 status包含更多信息(如接收到的消息大小) 如何组织进程 ❑ 进程可组成进程组 ❑ 每个消息都是在一个特定上下文中发送,必须在同一个上下文中接收 ❑ 进程组和上下文一起形成了通信域(communicator) ❑ 进程用它在进程组(与某个通信域关联)中的编号标识 MPI_COMM_WORLD:默认通信域,其进程组包含所有初始进程 MPI是强类型数据传输,区别于底层网络传输 消息中的数据用三元组(地址, 个数, 类型)描述 // 类型匹配 含义 ❑ 宿主语言的类型和消息所指定的类型相匹配 ➢例外:MPI_BYTE、MPI_PACKED ❑ 发送方和接收方的类型相匹配 规则 ❑ 有类型数据的通信,发送方和接收方均使用 相同的数据类型 ❑ 无类型数据的通信,发送方和接收方均以 MPI_BYTE作为数据类型 ❑ 打包数据的通信,发送方和接收方均使用 MPI_PACKED
-
阻塞通信
-
组通信
Collective(Group) Communication 一个进程组(通信域)内的所有进程同 时参加通信 所有参与进程的函数调用形式完全相同 哪些进程参加以及组通信的上下文都是 由调用时指定的通信域限定的 在组通信中不需要通信消息标志参数 三个功能:通信、同步和计算 操作是成对的——互为逆操作 广播和归约 one-to-all broadcast ❑ 一个进程向其他所有进程发送相同数据 ❑ 初始,只有源进程有一份m个字的数据广播操作后,所有进程都有一份相同数据 对应操作:all-to-one reduction ❑ 初始,每个进程都有一份m个字的数据 ❑ 归约操作后,p份数据经过计算(加、乘、...)得到一份数据(结果),传送到目的进程 应用:矩阵相乘、高斯消去、最短路径、向量内积... int MPI_Bcast(void* buffer,int count,MPI_Datatype datatype,int root, MPI_Comm comm) root: 每个调用广播操作的进程中root都相同 count:待广播的数据的个数 int MPI_Reduce(void* sendbuf, void* recvbuf, int count, PI_Datatype datatype,MPI_Op op, int root, MPI_Comm comm) 环/线性阵列 简单算法 ❑ 源进程顺序将数据发送给其它p-1个进程 ❑ 低效,源进程成为瓶颈,网络利用不充分 递归加倍法 ❑ 第一步:源进程P→进程P’ ❑ 第二步:进程P,P’→另外两个进程... 避免冲突,环的实现 ❑ 第一步:源进程P→距离最远(p/2)的进程 ❑ 第二步:两个进程→距离p/4的进程... mesh 行——线性阵列,线性阵列算法的扩展 ❑ 第一步骤,行进行广播/归约 ❑ 第二步骤,列进行广播/归约 以广播为例 1. 源进程→同一行其它 个进程广播 2. 已有数据的 个进程→同列节点广播 3维mesh广播 ❑ 每个维度p 1/3个节点,线性阵列同样方法
All-to-All广播和归约 All-to-All广播:所有进程同时发起一个广 播,每个进程向所有其他进程发送m个字 All-to-All归约 应用:矩阵相乘,矩阵向量相乘 组收集 int MPI_Allgather(void* sendbuf, int sendcount, MPI_Datatype sendtype,void* recvbuf, int recvcount, MPI_Datatype recvtype,MPI_Comm comm) MPI_Allgatherv 相当于组内每个进程都执行一次收集 All-to-All广播 int MPI_Reduce_scatter(void* sendbuf, void* recvbuf, int *recvcounts MPI_Datatype datatype, MPI_Op op, MPI_Comm comm) 归约后再执行一次散发操作 All-to-all归约
scatter:源节点向其它每个节点发送不同 数据,one-to-all个体化通信 与one-to-all广播(发送相同数据)不同 对应操作:gather,concatenation 目的节点从其他每个节点接收不同数据 不同于all-to-one归约,无合并/归约运算 one-to-all广播相同模式,但消息大小和 内容不同——折半法 ❑ 第一步:源节点将p个消息的一半→邻居 ❑ 第二步:两个节点将各自消息的一半→邻居 消息用目的节点编号加以标记 每个步骤,节点i→邻居j,两个节点编号 差异对应哪一维,所有消息就在那一维 折半——为0的分为一组,为1的为另一 组 scatter算法的逆操作 ❑ 初始,每个节点保存一个消息 ❑ 第一步:奇数节点→偶数节点,偶数节点将 两个消息连接 ❑ 第二步:不能被4整除的偶数节点→4倍数节 点,消息连接…
-
非阻塞通信
基本含义 ❑ 调用返回≠通信完成 作用 ❑ 计算和通信的重叠 形式 ❑ 非阻塞发送和非阻塞接收
当用户提供的buffer可被重用时,才表明send完成 *buf =3; MPI_Send(buf, 1, MPI_INT …) *buf = 4; /* OK, 接收方总是会收到3 */ *buf =3; MPI_Isend(buf, 1, MPI_INT …) *buf = 4; /* 不确定接收方收到3还是4*/ MPI_Wait(…); send完成并不意味着receive也完成 ❑ 消息可能被系统缓冲 ❑ 消息可能还在传输中 单一非阻塞通信的完成 int MPI_Wait(MPI_Request *request, MPI_Status *status) ❑ 等待完成并释放该对象,返回信息放在 status中 ❑ 若为发送操作,则发送完成,发送缓冲区可 以重用 ❑ 若为接收操作,则数据已经接收到,放在接 收缓冲区中 若通信尚未完成,相当于回到阻塞状态 多个非阻塞通信的完成 int MPI_Waitany(int count, MPI_Request *array_of_requests, int *index,MPI_Status *status) ❑ count:非阻塞通信对象的个数 ❑ array_of_requests:非阻塞通信完成对象数组 ❑ index:完成对象对应的句柄索引 ❑ status:返回状态 任何一个对象完成就返回 多个非阻塞通信的完成(2) int MPI_Waitall(int count, MPI_Request *array_of_requests,MPI_Status *array_of_statuses) ❑ count:非阻塞通信对象的个数 ❑ array_of_requests:非阻塞通信完成对象数组 ❑ array_of_statuses:状态数组 所有对象均完成才返回 int MPI_Waitsome(int incount,MPI_Request *array_of_request, int *outcount,int *array_of_indices, MPI_Status *array_of_statuses) ❑ incount:非阻塞通信对象的个数 ❑ array_of_request:非阻塞通信对象数组 ❑ outcount:已完成对象的数目 ❑ array_of_indices:已完成对象的下标数组 ❑ array_of_statuses:已完成对象的状态数组 只要有不为0的对象完成就返回 并不等到通信结束,立即返回状态 单个 ❑ int MPI_Test(MPI_Request*request, int *flag, MPI_Status *status) ❑ flag==true,表示指定通信操作已完成 flag==false,表示指定通信操作还未结束 多个 ❑ 与Wait相对应,只是增加了flag ❑ MPI_Testsome并无flag参数 非阻塞通信对象 识别各种通信操作,判断相应的非阻塞 操作是否完成 释放 ❑ 确认一个非阻塞通信操作完成时,可直接释 放该对象所占用的资源,而不是通过调用非 阻塞通信完成操作来间接地释放 ❑ int MPI_Request_free(MPI_Request * request) ❑ 如果与该非阻塞通信对象相联系的通信还没 有完成则该对象的资源并不会立即释放 非阻塞通信的取消 int MPI_Cancel(MPI_Request *request) ❑ 若取消操作调用时相应的非阻塞通信已经开 始则它会正常完成 ❑ 也必须调用非阻塞通信的完成操作或查询对 象的释放操作来释放查询对象 int MPI_Test_cancelled(MPI_Status status, int *flag) ❑ 如果flag=true则表明该通信已经被成功取消
-
混合编程
MPI和线程 MPI描述了进程(独立地址空间)间并行 线程并行则是提供了一个进程内的共享内存模型 多核集群的常见编程方法 ❑ 纯MPI ➢ 节点内和跨节点都采用MPI实现进程并行 ➢ 节点内MPI内部是采用共享内存来通信的 ❑ MPI + OpenMP ➢ 节点内使用OpenMP,跨节点使用MPI ❑ MPI + Pthreads ➢ 节点内使用Pthreads,跨节点使用MPI 后两种方法被称为“混合编程”
MPI四种线程安全级别 MPI定义了四级线程安全级别——这其实是应用程序向MPI做出的 承诺 ❑ MPI_THREAD_SINGLE:应用中只有一个线程 ❑ MPI_THREAD_FUNNELED:多线程,但只有主线程会进行MPI调用 (调用MPI_Init_thread的那个线程) ❑ MPI_THREAD_SERIALIZED:多线程,但同时只有一个线程会进行 MPI调用 ❑ MPI_THREAD_MULTIPLE:多线程,且任何线程任何时候都会进行 MPI调用(有一些限制避免竞争条件) 线程安全级别是递增顺序的 ❑ 如果一个应用工作在FUNNELED模式,它也工作在SERIALIZED模式 MPI定义了一个MPI_Init的替代API ❑ MPI_Init_thread(requested, provided) ➢ 应用给出它希望的级别;MPI实现返回它支持的级别 MPI_THREAD_SINGLE 系统中无其他线程,如OMP并行区域 int main(int argc, char ** argv) { int buf[100]; MPI_Init(&argc, &argv); for (i = 0; i < 100; i++) compute(buf[i]); /* Do MPI stuff */ MPI_Finalize(); return 0; } MPI_THREAD_FUNNELED 所有MPI调用都是主线程进行 ❑ 在OMP并行区域之外 ❑ 在OMP Master区域内 int main(int argc, char ** argv) { int buf[100], provided; MPI_Init_thread(&argc, &argv, MPI_THREAD_FUNNELED, &provided); if (provided < MPI_THREAD_FUNNELED) MPI_Abort(MPI_COMM_WORLD, 1); #pragma omp parallel for for (i = 0; i < 100; i++) compute(buf[i]); /* Do MPI stuff */ MPI_Finalize(); return 0; } MPI_THREAD_SERIALIZED 一个时刻只有一个线程进行MPI调用 ❑ 由OMP临界区保护 int main(int argc, char ** argv) { int buf[100], provided; MPI_Init_thread(&argc, &argv, MPI_THREAD_SERIALIZED, &provided); if (provided < MPI_THREAD_SERIALIZED) MPI_Abort(MPI_COMM_WORLD, 1); #pragma omp parallel for for (i = 0; i < 100; i++) { compute(buf[i]); #pragma omp critical /* Do MPI stuff */ } MPI_Finalize(); return 0; } MPI_THREAD_MULTIPLE 任何线程任何时候都可进行MPI调用(应用限 制条件) int main(int argc, char ** argv) { int buf[100], provided; MPI_Init_thread(&argc, &argv, MPI_THREAD_MULTIPLE, &provided); if (provided < MPI_THREAD_MULTIPLE) MPI_Abort(MPI_COMM_WORLD, 1); #pragma omp parallel for for (i = 0; i < 100; i++) { compute(buf[i]); /* Do MPI stuff */ } MPI_Finalize(); return 0; } 线程和MPI 并不要求一个MPI实现支持高于 MPI_THREAD_SINGLE的级别;即,不要求实 现是线程安全的 一个完全线程安全的实现会支持 MPI_THREAD_MULTIPLE 一个调用MPI_Init(而非MPI_Init_thread)的程 序应假定实现只支持MPI_THREAD_SINGLE 一个未调用MPI_Init_thread的多线程MPI程序 是一个错误程序(很常见的错误) MPI_THREAD_MULTIPLE规范 序:当多个线程并发调用MPI API时,结果就像以某种 (任意)顺序串行执行调用一样 ❑ 序是在每个线程内维持的 ❑ 程序员必须确保在相同通信域、窗口或文件上的组操作在线程 间正确排序 ➢ 例如,不能在相同通信域上由一个线程调用广播操作、而同时另 一个线程调用归约操作 ❑ 当一个应用内的线程进行冲突的MPI调用时,程序员应负责防 止竞争条件 ➢ 例如,一个线程访问一个info对象、而同时另一个线程在释放它 阻塞:阻塞MPI调用只会阻塞调用线程,而不会阻止其 他线程的运行、也不会阻止它们调用MPI函数 MPI实现对多线程的支持 所有MPI实现均支持MPI_THREAD_SINGLE 可能支持MPI_THREAD_FUNNELED即使不承认 ❑ 不需要线程安全的malloc ❑ 在OpenMP程序中可能是OK的 很多(但不是所有)实现支持THREAD_MULTIPLE ❑ 虽然高效实现很困难(锁粒度问题) “容易的”OpenMP程序(循环并行)只需 FUNNELED ❑ 因此很多混合程序不需要“线程安全”MPI ❑ 但要注意Amdahl定律! MPI_THREAD_MULTIPLE性能 线程安全不是免费的 MPI实现必须用互斥量或临界区保护特定数据 结构或代码片段 衡量性能影响:测试多线程 vs. 多进程的通信 性能 为何MPI_THREAD_MULTIPLE优 化困难 MPI内部要维护多种资源 由MPI语义,要求所有线程都可访问一些 数据结构 ❑ 例如,thread 1可能发起一个Irecv,而thread 2可能等待其完成 – 因此请求队列必须被两 个线程共享 ❑ 由于多线程访问此共享队列,就需要对其加 锁 – 产生显著的额外开销 混合编程:正确性要求 MPI+threads混合编程对降低多线程编程 复杂性没有什么帮助 ❑ 程序还必须是一个正确的多线程程序 ❑ 在此之上,你还必须确保正确遵循MPI语义 有很多商用调试工具支持MPI+threads混 合程序的调试(大多用于MPI+Pthreads和 MPI+OpenMP)