第6章 并行编程模型及环境

并行编程模型大多数以数据和任务(过程化的操作)为中心来命名。

一个具体的并行应用往往使用了多种并行编程模型。

并行编程模型是并行算法的基础,并行算法的具体实现依赖于软硬件支持的并行编程模型。

6.1 并行编程模型

一方面,并行 编程模型是建立在硬件体系结构模型之上的并行程序实现逻辑的抽象,它定义了并行算法设计与其实现之间的隐形的协议,为并行算法的设计者提供了一个简洁而清晰的并行软硬件系统结构的概念模型。从另一方面来说,并行编程模型是指并行算法设计时对模块间通信方式的抽象。

6.1.1 指令级并行

如果多条指令之间即没有数据和控制依赖,也没有结构化依赖,那么它们可以同时在处理器的多个处理器的多个流水线上同时执行,这称为指令级并行。

超标量和VLIW处理器通常拥有多个执行单元,这些单元可能是相同的,一可能是不同的。这些执行单元可以以并行或流水线方式执行指令。

通常处理器的指令级并行技术要求能够动态地进行指令依赖分析、顺序或乱序发射以及顺序或乱序执行,编译器也承担了部分相关的工作。

指令级并行优化粒度太细,需要深入了解处理器的流水线延迟和吞吐量以及编译器的能力,因此不易于使用。

笔者认为,执行指令级优化首先是处理器的责任,其次是优化编译器的责任,最后才是代码优化人员的责任。

进行指令级并行优化比较好的实践模式是修改源代码以便让编译器生成需要的指令系列,这通常比直接使用汇编和编译器内置函数要易于使用,代码也易于维护。

但是编译器可能不支持处理器提供的而一些最新特性,或者支持但是存在缺陷,这个时候汇编可能是最好的选择。

指令级并行的重点是去除指令间的依赖关系。代码如下所示:

sum = 0;
for (int i = 0; i < len; i+= 4)
{
    sum += a[i];
    sum += a[i + 1];
    sum += a[i + 2];
    sum += a[i + 3];
}

// 去除依赖后
sum = sum1 = sum2 = sum3 = 0;
for (int i = 0; i < len; i+= 4)
{
    sum += a[i];
    sum1 += a[i + 1];
    sum2 += a[i + 2];
    sum3 += a[i + 3];
}

sum += sum1;
sum += sum2;
sum += sum3;

对于上述优化后的代码而言,4条语句不存在任何依赖,因此无论是加法运算,还是访存都可以并行操作。

在需要使用汇编语言进行指令级并行优化时,笔者建议读者先尝试使用内置函数生成想要的代码,如果不能,然后再在内置函数版本产生的汇编代码上修改。

6.1.2 向量化并行

向量化并行是一种细粒度的并行计算,通常是对不同的数据执行一条同样的指令,或者说一个指令作用于一个数组/向量。向量化并行是数据并行的一个细粒度子集,在某些情况下,可认为向量化并行是指令级的数据并行。

主流处理器都支持向量集,比如X86处理器的SSE和AVX指令,ARM的NEON指令。

编译器通常提供编译制导(通过给编译器一些指示,让编译器生产向量指令,如OpenMP4.0)和内置函数(对汇编指令进行简单的C封装)的方式对这些向量指令提供支持。

NVIDIA和CUDA和开放的OpenCL标准则通过层次化的编程模型SIMT来使用一份代码即支持多核并行又支持向量化。

float distance(int len, float* restrict a, float* restrict b)
{
    float dis = 0.0f;
    for (int i = 0; i < len; i++)
    {
        float diff = a[i] - b[i];
        dis += diff * diff;
    }
    return sqrt(dis);
}

改用NEON后,

float distance(int len, float* restrict a, float* restrict b)
{
    float32x4_t dis = vdupq_n_f32(0.0f);
    for (int i = 0; i < len; i += 4)
    {
        float32x4_t a_vec = vldlq_f32(a + i);
        float32x4_t b_vec = vldlq_f32(b + i);
        float32x4_t diff = vsubq_f32(a_vec, b_vec);
        dis = vmlaq_f32(dis, diff, diff);
    }

    return sqrt(dis[0] + dis[1] + dis[2] + dis[3]);
}

向量化适合处理图形图像、音频视频、游戏等应用程序。以及科学计算中分子动力学、计算金融、石油探测。

6.1.3 易并行

易并行性计算是指并行执行的多个控制流之间没有通信的并行,英文为embarrassingly parallel。

对于计算限制的算法,易并行性意味着通常可获得近线性的性能提升。

笔者认为并行算法设计的目标就是向易并行迈进。

基于进程和线程的编程环境,甚至指令级并行环境偶读可以很好地利用易并行计算的特点,决定使用哪种环境的关键通常是计算问题的规模。必要时可同时使用这三种编程环境,在进程中分配线程,在线程中使用指令级并行。

6.1.4 任务并行

任务并行是指每个控制流计算一件事或者计算多个并行任务的子任务,通常其粒度比较大且通信很少或没有。

void* f1(void* data)
{
    computineOnData1(data);
    return NULL;
}

void* f2(void* data)
{
    computineOnData2(data);
    return NULL;
}

void invoke()
{
    float* d1;
    ...
    f1(d1);
    float* d2;
    ...
    f2(d2);
}

由于函数f1和f2作用在不同的数据集上,因此这两个函数可以并行,可以使用一个控制流处理函数f1和d1,另外一个控制流处理函数f2和d2。

void* f1(void* data)
{
    computineOnData1(data);
    return NULL;
}

void* f2(void* data)
{
    computineOnData2(data);
    return NULL;
}

void invoke()
{
    pthread_t t1, t2;

    float* d1;
    ...
    pthread_create(&t1, NULL, f1, d1);
    float* d2;
    ...
    pthread_create(&t2, NULL, f2, d2);

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);    
}

任务并行的通信很少,故易于实现,但是负载不均衡的可能性非常大,经常采用任务队列的方式来解决任务并行的这个问题。

如果任务之间的计算量差别非常比较大,那么就需要为计算量大的任务分配比较高的优先级以先计算。

另一种解决任务并行的负载均衡问题的方法是尽量将任务划分的比较小,每个控制流就可以分得许多小任务,在加上任务队列的作用,负载不均衡就很小了(至多一个小任务)。

由于计算粒度比较大,基于进程的编程环境或基于线程的编程环境都适用,通常使用哪种环境取决于各个任务是否需要共享资源或同步,及整个任务的规模。如果要共享资源或同步,则线程比较好;如果任务规模比较大,超过节点内存大小时,则需要使用基于进程的编程环境。

6.1.5 数据并行

数据并行是指一条指令同时作用在多个数据上,那么可以将一个或多个数据分配给一个控制流计算,这样多个控制流就可以并行,这要求待处理的数据具有平等的特性,即几乎没有需要特殊处理的数据。

如果对每个数据或每个小数据集的处理时间基本相同,那么均匀分割数据即可;

如果处理时间不同,就要考虑负载均衡问题。

通常的做法是尽量让数据集的数目远大于控制流数目。

        (1) 数据并行的类型

                1.以输入数据为主,每个控制流处理一个或多个输入数据,对于这种规模来说,对输入数据的处理通常无须同步,但是由于有可能多个控制流会更新同一个输出数据(因为输入/输出数据存在多对一的关系),因此对输出数据可能需要同步。

                2.以输出数据为主,每个控制流处理一个或多个输出数据,对于这种模型来说,对输出数据的处理通常无须同步。通常输入数据都是只读的。

        (2) 数据并行的应用

                1.数据并行应用最广的是图形算法,因为像素渲染通常是相互独立且要做的处理是相同的,故天然适合数据并行。

                2.数据统计学,科学模拟。

数据并行对控制要求比较少,因此现代GPU利用这一特性大量减少控制单元的比列,而将空出来的单元用于计算。

6.1.6 循环并行化

如果某次循环必须等待前面的循环完成才能够执行,这称为串行循环。如果各次循环不存在依赖,可并行该循环。

某些看起来不能并行的串行循环已经有很好的并行算法,如stream compaction、reduction和scan。

一些看起来是串行循环,但是经过循环拆分可变成并行循环。假设不存在存储器别名,如下面代码:

for (int i = 0; i < n; i++)
{
    a[i + 1] = b[i] * 2;
    c[i] = a[i] / 3;
}

// 优化后
for (int i = 0; i < n; i++)
{
    a[i + 1] = b[i] * 2;
    c[i] = b[i - 1] * 2 / 3;
}

// 继续优化后
for (int i = 0; i < n; i++)
{
    a[i + 1] = b[i] * 2;
}

for (int i = 0; i < n; i++)
{
    c[i] = a[i] / 3;
}

对于并行循环来说,向量化、线程级并行和多机器并行都可能适用。

6.1.7 流水线并行

流水线能够让不同的硬件单元同时运行时提高计算能力,流水线并行就利用了这一特性。

常用队列来保存某一个阶段可以并行执行的任务,由于队列中的任务可能是前一个阶段生成的,因此队列可能必须支持并发操作。对于一个四级流水线来说,可由4个队列来保存每一级流水线上操作的数据。

和指令级流水线并行类似,应用编程通常使用流水线并行以同时使用多个功能硬件,如异步编程同时计算和读取/存储数据。

aio_write(&acb);
for (int i  = 0; i < n; i++)
{
    count += rand() % 255;
}

struct aoocb* list[1];
list[0] = &acb;
if (-1 == aio_suspend(list, 1, NULL))
{
    perror("aio suspend failed");
}

6.1.8 区域分解并行

如果能够将大的计算区域划分成多个小的区域,然后由一个控制流计算一个小的区域,那么计算大区域的所有控制流便可同时进行,这称为区域分解并行。

区域分解通常和网格联系在一起,将多维相邻的多个网格分配给某个控制流计算。由于相邻控制流负责计算的网格也相邻,故多维相邻控制流之间的通信和控制流内的缓存利用都可以比较高效地实现。

自适应网格适用于收敛速度不同的区域。

多层次网格不断将网格细分,并插值新产生的网格来加速计算。

区域分解并行计算算法通常要求相邻区域的控制流交换某些信息,此时需要考虑控制流的组织和硬件处理器的拓扑结构,以更好地利用存储器结构和网络互连。

区域分解并行通常和具体的物理现象联系在一起,但是一些其他的运算也和区域分解类似,比如分块矩阵转置和分块矩阵乘法。

6.1.9 隐式和显式并行化

如果编写显式的并行代码的工作由开发者承担,这称为显式并行。显式并行通常使用线程或进程库以简化工作。例如使用pthread库。

隐式并行通常是指开发人员通过给编译器添加某种标记,指定程序的并行性,而实际的并行代码由编译器生成。如OpenMP和OpenACC。

相对来说,显式并行易于控制,易于获得高效率,但移植性不好,工作量大,且难以调试。而隐式并行正好相反。现在的编程环境通常基本上都允许这两种方式混合使用。可以首先使用隐式并行,在必要的地方使用显式并行。

6.1.10 SPMD(Single Program, Multiple Data)

是MIMD的子类,是指将多个控制流的计算编写在同一个源代码文件的一个编程方式。由于不需要为各个控制流单独编写程序,这会节约开发时间。SPMD本质上是因为同一应用的不同控制流之间通常要有许多共同的/相似的操作。

6.1.11 共享存储器并行

共享存储器并行是指所有控制流都能够访问一个共同的全局存储器,通过这个存储器来交换数据,比如OpenMP和Pthread。

由于所有控制流共享同一个地址空间,对共享数据的访问协调需要开发人员显式处理。

由于多核和多路处理器的核心都运行在同一个操作系统,访问一个统一的物理地址空间,通信代价相对较小,因此他们天然适合共享存储器并行的编程环境。

6.1.12 分布式存储器并行

通过网络互连的多机系统、存储器分布在不同的节点机上,每个节点机运行各自的操作系统,拥有独立的物理地址空间。通过网络互连,控制流之间的通信只能通过网络数据分布实现。分布式存储并行模型通过显式的消息传递来交换数据。目前常用的分布式存储并行环境是MPI。

从编程来看,分布式存储并行程序比共享内存并行程序要易于编写,但是通过网络的消息传递的带宽和延迟通常难以预测。

6.2 常见的并行编程环境

主要从编程方式和通信方式来看:

        (1) 从编程方式分类

                大致分为隐式并行环境和显式并行环境:

                        1. 隐式并行(Implicit Parallel)环境: 用串行语言编程并使用标记指明并行性和控制流调度,编译器或运行时环境自动将其转化为并行代码。相比于显式并行环境,隐式并行环境具有语义简答、可移植性好、易于调试和可验证正确性等优点。

                        2. 显式并行(explicit Parallel)环境:编写代码显式地指明程序的控制流创建、执行、调度和退出。在实现相同的功能的前提下,使用显式并行环境开发的程序代码通常要比使用隐式并行环境开发的程序代码要长。

        (2) 以通信方式分类

                一般有共享存储器和分布式存储的消息传递并行环境:

                        1. 共享存储器(shared memory)并行环境:共享存储器并行环境支持多线程并行、使用统一虚拟地址空间通信、线程间使用函数来显式同步。其天生适合多核处理器。缺点在于线程并不知道数据分布在那些机器上,因此数据访问和迁移的路径都是隐式的,因此性能可能不好且不便优化。

                        2. 消息传递并行(message passing)环境:支持进程并行、使用独立的地址空间、支持异步数据传输、显式同步、显式负载分配、显式通信。其天生适合分布式多机环境。

两类组合起来产生了几种小分类,目前常见的并行编程环境都可以归类:

        1. 支持隐式共享存储器:OpenMP、OpenACC、SSE/AVX和NEON;

        2. 支持显式共享存储器:pthread、CUDA、OpenCL;

        3. 支持显式消息传递:MPI;

6.2.1 MPI

MPI(Message Passing Interface)是一种消息传递编程环境。消息传递是指用户必须通过显式地发送和接受来实现处理器间的数据交换。

实践表明MPI的扩展性非常好,无论是在几个节点的小集群上,还是成千上万节点的大集群上。

由于消息传递程序设计需要用户很好地分解问题,组织不同控制流间的数据交换,并行计算粒度大,特别适合大规模可扩展并行算法。

MPI最新版本4.0.

MPI只规定了标准并没有给出实现,目前主要的实现有OpenMPI、Mvapich和MPICH,MPICH相对比较稳定、而OpenMPI性能较好、Mvapich在主要是为了Infiniband而设计。

MPI即可用于功能分解,也可以用于数据分解,是一个基于CPU的并行编程模式,MPI3.0加入了异构并行计算的内容。

6.2.2 OpenMP

目前常用的GCC,ICC和Visual Studio都支持OpenMP。

OpenMP API包括以下几个部分,一套编译器伪指令、一套运行时函数、一些环境变量。

对基于数据并行的多线程程序设计,OpenMP是一个很好选择。

线程粒度和负载均衡等传统并行程序设计中的难题,OpenMP库接管了这两方面的工作。

OpenMP的设计目标是标准、简洁实用、使用方便、可移植。作为高层抽象,OpenMP并不适合需要复杂的线程间同步、互斥及对线程做精密控制的场合。

6.2.3 fork/pthread

fork是类UNIX系统的一个调用,它调用一次,返回两个进程。

pthread是一个基于线程的库,提供了创建、回收线程的函数。pthread创建的线程和父线程共享内存和指令,但有其独立的指令指针。

6.2.4 CUDA

目前CUDA提供了两种API以满足不同人群的需要:运行时API和驱动API。运行时API基于驱动API构建,应用也可以使用驱动API。驱动API通过展示低层的概念提供额外的控制,使用运行时API时,初始化,上下文和模块管理都是隐式的,因此代码比较简明。

一般一个应用只使用运行时API或者驱动API的一种,但是也可以混合使用这两种。

6.2.5 OpenCL

目前OpenCL的API参数比较多(因为不支持函数重载),因此函数相对难记。

OpenCL覆盖的领域不但包括GPU,还包括其他多种处理器芯片。

OpenCL包括两个部分:一是语言和API,二是架构。

6.2.6 OpenACC

OpenACC编译器根据C/C++/Fortan编写的编译制导语句,将并行区域的代码翻译成另一种语言的表示,如CUDA/OpenCL等。

6.2.7 NEON内置函数

NEON是ARM处理器上的SIMD指令,由于ARM在移动端得到广泛应用,目前NEON的使用也越来越普遍。

NEON支持数据并行,一个指令可同时对多个数据进行操作,同时操作的数据个数由向量处理器的长度和数据类型共同决定的。

使用NEON指令读写数据时需要对齐。

6.2.8 SSE/AVX内置函数

SSE/AVX是Intel推出的用以挖掘SIMD能力的汇编指令。由于汇编编程太难,后来Intel又给出了其内置函数版本(intrinsic)。

SSE/AVX支持数据并行,一个指令可同时对多个数据进行操作,同时操作的数据个数由向量处理器的长度和数据类型共同决定的。

使用SSE指令读写数据时需要对齐。主要是为了减少内存操作或缓存操作的次数。SSE要求16字节对齐,而AVX指令要求32字节对齐。

SSE4及之前的SSE指令不支持不对齐的读写操作,为了简化编程和扩大应用范围内,AVX指令支持不对齐的读写。

6.3 本章小结

由于分类方式并非正交,很多并行编程模型之间都有重叠的部分,一下是常见的并行编程模型:

        1.指令级并行:如果多条指令之间没有数据或控制依赖,也没有结构化的依赖,那么它们可以同时在处理器的多个流水线上同时执行。

        2.向量化并行:向量化并行是一种细粒度的并行计算,通常是对不同的数据执行一条同样的指令,或者说一个指令作用于一个数组/向量。向量化并行是数据并行的一个细粒度子集,在某些情况下,可认为向量化并行是指令级的数据并行。

        3. 易并行:是指并行执行的多个控制流之间没有通信的并行。

        4. 任务并行: 是指每个控制流计算一件事或者计算多个并行任务的一个子任务。

        5. 数据并行:一条指令同时作用在多个数据上,那么可以将一个或多个数据分配给一个控制流计算,这样多个控制流可以并行。

        6. 流水线并行:把任务分成多个阶段,各个阶段相互依赖,只能串行,而不同的任务的阶段可以并行,这非常类似于现代的指令流水线。

        7. 区域分解并行:将大的计算区域分成多个小的区域,然后由一个控制流计算一个小的区域,那么计算大的区域所有控制流便可同时进行。

        8. 共享存储器并行:所有控制流都能够访问同一个共同的全局存储器,通过这个存储器来交换数据。

        9. 分布式存储器并行:对于通过互连网络的多机系统、存储器分布在不同的节点上,每个节点机运行各自的操作系统,拥有独立的物理地址空间。

常见的并行编程化解:

        1. MPI:用户必须通过显式地发送和接受消息来实现处理器间的数据交换。

        2. OpenMP:基于共享存储器的并行环境。

        3. CUDA:06年11月由NVIDIA推出。

        4. OpenCL:尽可能支持多核CPU,GPU或其他加速器,支持数据并行。任务并行、同时内建了多GPU并行。

        5. OpenACC:OpenACC编译器依据编译制导语句,并将并行区域的代码翻译成另一种语言表示。

        6. NEON:ARM处理器上的SIMD指令。

        7. SSE/AVX:Intel推出的挖掘SIMD能力的汇编指令。

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值