并行复习重点

本文深入探讨并行计算的核心概念,包括并行架构的优势、软件技术挑战、并行硬件和软件基础、SIMD编程、Pthread编程、OpenMP编程、MPI编程等。覆盖并行计算的最新趋势,如多核、众核发展,以及并行计算在科学仿真、超算机领域的应用。
摘要由CSDN通过智能技术生成

并行复习重点

第1讲 绪论

  1. 推动并行计算的原因
  • 处理器能力,硬件限制

    晶体管密度仍在提高;时钟频率提高速度急剧放缓;频率不是处理器发展的主角;功耗/散热的限制;

  • 性能上升放缓

  • 多核、众核发展趋势

    • 转变“更复杂的处理器设计、更快的时钟频率” 的发展思路
    • 并行架构更容易设计
    • 充分利用资源
    • 巨大的功耗优势
  1. 了解并行计算应用
  • 科学仿真

    使用高性能计算机系统仿真现象

    实例:全局气候建模、海洋建模、星系演化、生物信息学、强子对撞机实验、医学、商业应用 Web服务为代表:大量静态、动态内容。

  1. 超算机
  • 神威 ·太湖之光
  • Summit
  • E级超算
    • 天河三号
    • 中科曙光
    • 美国“极光(Aurora)”
    • 日本“后京”(Post-K)’’

4.软件技术面临的挑战

  • 并行程序设计的复杂性
    • 足够的并发度
    • 并发粒度
    • 负载均衡
    • 协调和同步
  • 数据移动(通信)代价很高
  • 能耗挑战
  • 伸缩性挑战
    • 相同的程序在新一代硬件架构下仍能高效运行
    • 在更大规模(更多核心)的硬件平台下仍能高效运行
  • 软件面临的挑战
    • 硬件技术飞速发展,软件生态环境几乎停滞
    • 关注现有软件难以处理的硬件发展趋势
    • 新软件技术还不成熟
    • 现有代码还未准备好硬件架构的改变
    • 其实软件投资的回报更大

第2讲 并行硬件和并行软件

  1. 基础知识
  • 冯·诺依曼体系结构

    CPU(运算器、控制器)、存储器、输入输出设备

    瓶颈:存取数据和处理数据的速度差距大

    进程、线程、多任务(单处理器运行多个程序,实际上每个程序轮流执行)

  1. 改进
  • 缓存

    概念:缓存是指可以进行高速数据交换的存储器,它先于内存与CPU交换数据,因此速率很快。

    原理:缓存中存储的数据是接下来要访问的数据,一般是访问过的数据紧挨着的数据。时间和空间局部性原理。

  • 多级缓存:离cpu远近 速度越快,容量越小

  • 缓存命中,缓存缺失

  • 写直达:缓存和主存不一致立即更新主存中的数据;写回;

  • 虚拟内存:

  • ILP(指令级并行,单核):流水线和多发射

  • 硬件多线程(单核):当前任务阻塞时,系统去执行其他任务

    细粒度,粗粒度,超线程/同步多线程(模拟多核)

  1. 硬件并行
  • 弗林分类法

    SISD(传统的冯诺依曼体系);SIMD;MISD;MIMD;

    S: single M: multiple I: instruction stream D: data stram

  • SIMD (数据并行)

    多条数据执行同一条指令,每个处理器同步执行任务

    缺点:1. 所有的计算单元要么执行相同的指令要么空着,2. 所有计算单元必须同步进行 3.计算单元没有指令储存功能,4. 适合大数据量

    应用:向量计算;GPU(note:并不是纯粹的SIMD)

  • MIMD

    每个处理器独立执行相应任务

    • 共享内存系统

      每一个处理器可以访问每一块内存,处理器间通过共享数据隐式通信

      包含一个或者多个多核处理器

      分类:

      • 一致内存访问系统:

        image-20200824195728800
      • 非一致内存访问系统

        image-20200824194919573
    • 分布式内存系统

      • 集群: 多台计算机相连

        image-20200824200114659
    • 互联网络

      • 共享内存互联网络

        总线互联

        交换器互联

      • 分布式内存互联网络

        直接互联:环形、二维环面

        等分宽度:同时通信的链路数目(以最坏情况估计),衡量互联网络的连通性;

        带宽:链路速度

        等分带宽:带宽 * 等分宽度

        环形等分宽度: 2

        二维网格等分宽度: 2 P 2\sqrt{ P } 2P

        全相连网络等分宽度: P 2 / 4 P^2/4 P2/4

        超立方体等分宽度: P / 2 P/2 P/2

        间接互联:

      • 信息传递的时间: 延迟 + 总长/带宽

      • 缓存一致性

        • 监听缓存一致性协议
        • 基于目录的缓存一致性协议

4.软件并行

  • 并行算法的设计

    1. 进程/线程的任务分配:负载均衡和通信量尽量 少
    2. …同步
    3. …通信
  • 任务分解

    1. 任务并行

      将求解问题的计算分解为任务,分配给多个 核心

    2. 数据并行

      将求解问题涉及的数据划分给多个核心

      每个核心对不同数据进行相似的计算

  • 数据依赖和竞争条件

    原子性(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=PTPTs
      • 加速比: 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
    
  • 阻塞通信

    image-20200827180743484 image-20200827192123182 image-20200827192123182 image-20200827192515827 image-20200827192652922
  • 组通信

     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个节点,线性阵列同样方法
    
    image-20200827202431443
    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归约
    
    image-20200827201713688
     scatter:源节点向其它每个节点发送不同
    数据,one-to-all个体化通信
    与one-to-all广播(发送相同数据)不同
    对应操作:gather,concatenation
    目的节点从其他每个节点接收不同数据
    不同于all-to-one归约,无合并/归约运算
        
     one-to-all广播相同模式,但消息大小和
    内容不同——折半法
    ❑ 第一步:源节点将p个消息的一半→邻居
    ❑ 第二步:两个节点将各自消息的一半→邻居
    消息用目的节点编号加以标记
    每个步骤,节点i→邻居j,两个节点编号
    差异对应哪一维,所有消息就在那一维
    折半——为0的分为一组,为1的为另一
    组
    
     scatter算法的逆操作
    ❑ 初始,每个节点保存一个消息
    ❑ 第一步:奇数节点→偶数节点,偶数节点将
    两个消息连接
    ❑ 第二步:不能被4整除的偶数节点→4倍数节
    点,消息连接…
    
    
    image-20200827202349353
  • 非阻塞通信

    基本含义
    ❑ 调用返回≠通信完成
    作用
    ❑ 计算和通信的重叠
    形式
    ❑ 非阻塞发送和非阻塞接收
    
    image-20200827210242149
    当用户提供的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
     后两种方法被称为“混合编程”
    

    image-20200828142741444

    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)
    
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值