并行算法设计与性能优化 刘文志 第7章 并行算法设计方法

软件开发人员可以利用硬件的特性来设计算法,也可以利用应用的某些特性来优化算法等。

一个好的并行算法通常具有以下特点:

        1.热点代码恰好是并行性;

        2.可扩展性好;

        3.易于实现;

为性能考虑,应当让所有的控制流尽量自由地运行。除非必要,尽可能不要对控制流的执行顺序做限制(同步与互斥)。

通常并行算法的设计部分如下:划分、通信、结果合并和负载均衡。

7.1 划分

划分的目的是将计算任务分成多个部分,以便多个控制流同时处理。通常划分的对象有两种:
        1.计算任务,比如如何划分看电视和吃饭这个计算任务,又称为任务划分;
        2.计算数据,比如如何同时收割一亩地的稻谷,可将一亩划分为十份,由十台机器同时收割,这称为数据划分。

通常这两分别对应任务划分和数据划分。

任务划分和数据划分并没有天然的鸿沟,很多任务即可以采用任务划分,也可以采用数据划分。

划分时,要注意和应用的特性相结合,这既可以降低编程难度,又能够减少通信,提高并行效率。比如通常存储位置相邻的多个数据划分给同一个控制流处理,以利用现代处理器缓存对局部性的利用。

再比如许多物理模拟算法将模拟区域划分为二维、三维网络,此时通常以分块的方式划分网格,网格可以是均匀的也可以是不均匀的,也可以是曲面的。

7.1.1 分而治之

分而治之是指将大问题分解成小问题,通过求解小问题,再加工小问题的结果组合起来以解决大问题。

串行编程中,分而治之经常导致递归算法,而递归算法通常还有优化空间。由于可使用多个控制流并行地解决小问题,因此分而治之和并行天生相符合。

许多支持并行编程的语言或库与分而治之有着天然的联系。在Linux系统编程中,fork函数会产生子进程,之后子进程和父进程共同工作,父进程调用wait函数来等待回收子进程。

7.1.2 划分原则

本节简要地说明一些划分原则:

        1. 尽量使算法映射到硬件上后,各控制流处理的数据或任务不相关,需要依据硬件条件做一些转变。比如CPU上线程在运行时会占据一个完整的执行单元,拥有自己的指令指针,因此应尽量使每个线程的任务不相关;而在GPU上执行时,由于多个线程共享一套执行单元,因此应尽量使用在一套执行单元上工作的多个线程和其他线程不相关;而在设计FPGA处理器上的算法时,要优先使用流水线并行。

        2. 减少通信消耗。通信通常使得某些执行单元等待其他执行单元的操作,这引入了串行,会带来性能损耗,因此尽量减少通信消耗。比如既可以使用临界区,也可以使用原子函数求一个键值对数组中的键相同的值的和,此时应当优先使用原子函数。

7.1.3 常见划分方法

常见的划分方法主要有: 均匀划分、递归划分、指数下降划分。

        1. 均匀划分

                均匀划分是指一次把计算任务或计算数据依据控制流数量划分成相等的几份。然后每个控制流处理一份。比如10台服务器来并行处理2万个访问请求,那么均匀划分2万个访问请求是指每台服务器都处理2千个访问请求。

                对于任务划分来说,均匀划分通常使用在计算任务消耗的时间近似相同的情况下。对于数据划分,均匀划分通常被使用每个数据的操作近似相同的情况下。

        2. 递归划分

                递归划分指每次递归地将任务划分成几个相等或不相等的子任务,多次划分直到每个子任务都可以被简单处理。通常每次将任务划分成相等的两个子任务。

                以使用10台服务器来并行处理2万个访问请求为例,先将2万个请求划分成两份,每份1万个请求,然后在递归的划分两个1万个请求的子任务,直到每份子请求都可以被1台服务器处理。实际上对于这个例子,更常见的做法是每次将任务划分成10个子任务,即第一次划分10个子任务,每个子任务有2000个请求;第二次划分后共有100个子任务,每个子任务处理200个请求;以此类推。

                递归划分的优势在于和分而治之的算法设计思想联系非常紧密,许多分而治之设计的算法都导致最终递归划分计算或数据。

        3. 指数下降划分

                指数下降划分是指后一次划分使用的任务数量都是前一次的一半,如果每一次划分的数据大小为N,那么下一次划分的数据大小为N/2,再下一次为N/4,以此类推。

                以使用10台服务器并行处理2万个访问请求为例,假设每一次每台服务器处理1024个请求的子任务,那么第二次每台服务器处理512个请求的子任务,直到任务被处理完。

7.1.4 并行性和局部性

现代处理器系统可分为计算系统和数据访问系统的两个基本部分。为了并行算法性能够好,应当使得各控制流的计算互不相关,这通常可以使得计算机达到最优,却忽略了数据访问。现代处理器大量缓存来利用数据的局部性,并行算法的设计也应当考虑数据的局部性。

比计算为中心,设计的算法可能会出现数据访问局部性不好的情况,此时不妨从数据的角度来重新设计。由于现代处理器计算的速度比数据访问速度要快很多,因此设计算法时以数据为中心通常是一种更好的选择。

数据划分时,要避免划分后,控制流相互访问彼此拥有的数据。对于一维数据划分来说,通常均匀划分即可;对于二维数据来说,可按行、列或使用区域分解划分。

并行算法设计时,要兼顾计算的并行性和数据访问的局部性,而如何兼顾着两者考验着设计者的理论和实践功底。

7.2 通信

大多数并行算法都需要在控制流之间进行通信,这通常是因为要对某些计算步骤的结果进行合并处理。

相比于串行算法来说,通信是并行算法引入的额外消耗。

常用的减少通信消耗的方法;

        1. 如果通信都具有局部性,那么应当在设计互联网络时候就考虑这一点,以减少相邻控制流之间的通信代价。

       2. 减少通信次数,可将更多的代码并行化来实现,比如我们使用OpenMP并行了一个大循环里面的几个循环,那么每个小循环之间都会存在线程的建立、回收开销,如果把大循环内部的所有代码都转移到线程内部了,那么只需要创建、回收一次线程即可。

        3. 使用异步通信算法,可以边计算边通信,即每次通信要传输尽量多的数据,以减少通信的准备时间。比如MPI程序中,可以边给其他进程传输数据边计算;

        4. 通信时需要注意粒度,通常要尽量使用大粒度的通信,即每次通信要传输尽量多的数据,以减少通信的准备时间。在某些情况下,可以通过合并多次小数据量的通信为一次大数据量的通信来减少这种损耗,小数据量的通信通常由延迟限制,而大数据量的通信由带宽限制,因为大数据量的传输,可以通过流水线并行掩盖内容传输之外的消耗(如打包、解包)。

        5. 将通信分散到计算系统的多个组件上避免某个组件成为瓶颈,也可能提升性能。如在集群环境下使用MPI编程时,使用异步数据传输且多个节点间同时通信以避免某几个节点间的带宽成为瓶颈;在使用CUDA编程时,CPU和设备间通信时使用CPU到GPU和GPU到CPU双向通信同时进行的方式。

        6. 尽量使用轻量级的通信方式。不同的通信方式的损耗不同,如原子函数的损耗就比锁和临界区小。在可以在满足应用的前提下,应优化使用损耗小的通信方式。

        7. 减少由于通信导致的执行流等待,如使用多个局部锁,这样多个锁可并行执行。

        某些看似必须通信的函数可能已经有了更好的实现版本,如并行前缀和,并行归约,此时应优先采用。

在MPI中,通信的方式就是消息传递,而在基于共享存储器的系统中,通信的方式相对比较多,常用的是:原子操作,锁,栅栏,临界区等。

本书作者将详细讨论和通信相关的操作的原子性、结果的可观性、函数的可重入性、线程安全及各种并行通信方法的优缺点。

7.2.1 操作的原子性

原子性意味着操作必须作为一个整体执行,没有中间状态。

原子是一个来自物理学概念,通常意味着它不可再分。但是在就计算机,几乎没有声明操作是不可再分的。在高级语言中,看起来很简单的操作通常都可能会被映射到硬件的多个指令系列。

int ret = a++;

// 编译后
int ret = a;
a = a + 1;

为了提供并行需要的原子性,硬件会提供一些指令,比如锁总线、同步执行等等。操作系统也会提供一些底层的原语,这些原语可能是硬件指令的封装,也可以是一些高级算法。

void* work(void* w)
{
    ((int)(*w) = pthread_self());
}

int main(argc, char* argv)
{
    pthread_t t[5];
    int count = 0;

    for (int i = 0; i < 5; i++)
        pthread_create(t + i, NULLm work, &count);

    for (int i = 0; i < 5; i++)
        pthread_join(t + i, NULL);

    return 0;
}

需要强调的是,无论哪一种语言或库,对原子操作的支持都有限,通常是一些基本操作,比如整数和浮点加减,比较和转置。

7.2.2 结果的可见性

在多个控制流同时执行的系统中,一个控制流产生的结果对自己总是可见的,这由处理器的缓存一致性协议保证。缓存一致性还保证如果一个控制流操作多个空间相邻的变量,无论底层的存储器组织如何,最终的结果都会得到保证。

缓存一致性保证多个控制流操作不同的变量,其结果有保证,但是如果多个控制流操作一个或多个共享变量,那么结果的一致性都得不到保证。简单的说一个控制流写入的结果不能够保证被另一个控制流“看见”,这称为可见性。

现代处理器和并行编程语言通过“存储器屏障(memory fence)”来保证结果的可见性。只有在屏障调用后,调用屏障的控制流写入的结果才能够被其他控制流看到。

目前常见的并行编程采用的都是弱一致性,因此可以假设:一个控制流对共享变量的更改不能保证被其他控制流看到。

7.2.3 顺序一致性

顺序一致性表示无论多个控制流以何种交错顺序执行代码,最终的执行结果都是确定的。

顺序一致性包含原子性和可见性。

开发人员认为:缓存一致性是保证在利用缓存性能的前提下,保证不因为缓存的存在而产生不一致的结果;而顺序一致性保证,多个控制流交错执行的结果是一致的。

缓存一致性和顺序一致性有个本质的区别:缓存一致性由处理器保证,而顺序一致性由开发人员保证。

为了优化程序的性能,处理器和编译器完全没有必要严格按照代码规定的顺序来执行每一条指令,只要保证程序最终的结果和代码指定的一致即可,编译器使用的这种技术称为指令重排序,而处理器采用的是乱序执行技术,流水线执行等。

在多核或多机上编译和运行时,由于种种原因,处理器和编译器还会继续对串行代码进行优化,更重要的是编译器的这些优化遇到了共享变量进行保护的这一问题,编译器并没有能力完成这一个工作,因此编译器无法保证顺序一致性,从而简单地忽略共享变量的保护的移植方法,使得并行程序的实际运行结果与期待的运行结果不一致,软件开发人员需要自己保证顺序一致性。

存储器屏障可以保证一个线程写入的结果可以被其他线程看到,但是由于线程的进程可能不同,因此它也不能保证完全的顺序一致性。

void memfenceExample(void* arg)
{
    float* data = (float*)arg;
// every thread handle their own data
...
    if (threeadId == 0) {data[0] = 6; fence();}
    printf("I am %d my data[0] is %d\n", threaded, data[0]);

}

7.2.4 函数的可重入和线程安全

在编写并行程序时,需要确保所有的库函数调用都是线程安全的,线程安全是指当多个线程并发/并行反复调用该函数时,它依旧能够产生正确的结果。

对于软件开发人员来说,线程安全意味着你可以并行地调用它而无须同步机制。

通常线程安全的函数如下特征:

        1. 为共享变量的读写提供保护。通常可以使用的机制是原子函数、锁和临界区。一般使用C中static变量的函数就不是线程安全的。标准C库中的errorno变量就不是线程安全的,因为后一次函数出错的错误码会覆盖前一次的。

        2. 没有多个函数共享的状态。C标准库中的rand函数不是线程安全的,因为它需要在多次调用间保持中间状态,一旦并行调用后,某些线程就有可能读到了另一个线程的中间运算数据。

        3. 没有返回指向静态变量的指针。从并行线程中调用这类函数时,一个线程使用的结果很有可能被另一个线程悄悄覆盖。

        4. 没有调用非线程安全的函数。非线程安全函数是可以传递的,如果函数a调用了非线程安全的函数b,那么a就不是线程安全的,同样调用a的函数也不是线程安全的。

和线程安全类似的一个术语称为可重入,可重入的要求比线程安全高,它不但是线程安全的,还要求没有引用共享数据,这要求编程时不使用全局变量和static变量,没有存储器别名。

7.2.5 volatile关键字

volatile的意思是“易变的”,C语言中的volaile效果是让编译器不要对这个变量的读写操作做任何优化,即不将其保存在缓存中,每次读的时候都直接去该变量的内存地址中读,每次写的时候都直接写到该变量的内存地址中去,不做任何缓存优化。其典型的应用有:

        1. 避免寄存器或缓存对内存读写的优化。编译器通常把频繁的读写的变量保存到寄存器或缓存中,不用时或被替换出寄存器或缓存时,保存到内存中。如果某个内存地址中的值可能被另一个线程或另一个设备读写,此时需要关键字volatile关键字来保证读写到最新值;

        2. 同一物理内存地址可能有两个或多个不同的引用(存储器别名问题,如*x=malloc(size); y=x;)。

实际上,volatile是一种非常鸡肋的技术。另一种鸡肋的技术是memory fence,但是这两者有一个共同的特点,那就是顶级的并行开发人员才能够使用它们开发出许多高效的算法。

valatile int counter = 0;
void access()
{
    couter++;
    if (counter >= threadhold) counter = 0;
}

7.2.6 锁

锁是指对持有它的线程有数量限制的对象。通常使用锁来实现互斥性的操作,主要有互斥锁和读写锁。互斥锁是指只允许一个线程持有它,互斥锁不允许多个线程同时执行同一段代码。读写锁是指只允许一个线程持有写锁而允许多个线程同时持有读锁的对象,如果有一个线程持有写锁,那么其他线程即不允许持有写锁也不允许持有读锁。

由于互斥锁一次只允许一个控制流执行代码,因此在多核或多核处理器上可能会导致某些处理器或核心空闲,使得性能下降。解决这个问题的方法通常有错开多个控制流加锁的时间,保证不会有多个控制流同时争夺锁,另一种方法是减少锁内代码。

优化读写锁的一个有效方法是复制资源以分开读写,然后在必要的时候合并读写结果,如RCU等。RCU是Read Copy Update的缩写,它并不对资源的使用加锁,而是写线程拷贝一份,然后在适当的时候将原来的资源替换成写线程的拥有的那个版本。

RCU在科学计算中使用并不多,但是其读写分离,适时合并的思想非常流行。

锁能够使得原来不能并行的程序可以并行,但是锁的使用也有缺陷,如死锁,活锁及由锁竞争引起的性能瓶颈。

        1. 用锁防止竞写

                常常使用锁来保护共享变量,以防止多个线程同时对该变量进行更新时产生数据竞写。

void* work(void* w)
{
    ((int)(*w))++;
}


int main()
{
    pthread_t t[5];
    int count = 0;
    for (int  i = 0; i < 5; i++)
        pthread_create(t + i, NULL, work, &count);
    
    for (int i = 0; i < 5; i++)
        pthread_join(t + i, NULL);

    return 0;
}

为了防止竞写现象,可以使用锁来保证每个线程对++操作的原子性。代码如下;

pthread_mutex_t mutex;

void* work(void* w)
{
    pthread_mutex_lock(&mutex);
    ((int)(*w))++;
    pthread_mutex_unlock(&mutex);
}

int main()
{
    pthread_t t[5];
    int count = 0;
    pthread_mutex_init(&mutex, NULL);
    for (int  i = 0; i < 5; i++)
        pthread_create(t + i, NULL, work, &count);
    
    for (int i = 0; i < 5; i++)
        pthread_join(t + i, NULL);
    
    pthread_mutex_destory(&mutex);
    return 0;
}

实际上锁不但保证了每次只允许一个控制流执行锁内代码,还保证了控制流离开锁后,其更新的内容会立刻写回内存并使各个处理器对其的缓存失效,这样其他线程也能够读到更新后的结果。其他如临界区、原子函数等都具有这个特性。

        2. 锁竞争

                在多线程程序中,锁竞争是最主要的性能瓶颈之一。通过使用锁来保护共享变量能防止数据竞写,保证同一时刻只能由一个线程访问代码区。

                由锁竞争引起的串行执行正是串行执行的主要原因之一,因此如何减少锁竞争就很有意义。

        3. 避免使用锁

                为了提高程序的并行性,最好的办法是不使用锁,从设计角度来讲,锁的使用是为了保护共享资源。如果可以避免使用共享资源,那么自然避免了锁竞争造成的性能损失。

                下面列出一些常见的用于避免、优化排它锁使用的方法:

                        1. 使用读写锁或RCU。如果对共享资源的访问多数为读,少数为写操作,而且写操作的时间非常短,可以考虑使用读写锁来减少锁竞争。读写锁的基本原则是同一时刻多个读线程可以同时拥有读锁并进行读操作;另一方面,同一时刻只有一个写线程可以拥有写锁进行写操作;但是并不允许读写同时进行。

                        2. 读锁和写锁各自维护一份等待队列。当拥有写锁的线程释放写锁时,所有正处于读锁等待队列里的读线程全部被唤醒,并拥有读锁以进行读操作;当这些线程完成读操作并释放读锁时,写锁的等待队列中的某个写线程被唤醒,并拥有写锁以进行写操作,如此循环重复。换句话说,多个线程和一个写进线程将交替拥有读写锁以完成相应操作。特别是要注意写操作虽然很少但很耗时的情况,此时有可能不适合读写锁,而适合RCU。

                        4. 减少锁内操作数量。在实际程序中,有不少程序员在使用锁时把一些不必要的操作放在锁中,完全可以将有些操作移到锁外单独执行,以减少串行代码比例,增加并行度。

lock();
int index = ...;// compute index of array
a[index]++;
unlock();

                        5. 使用原子操作代替锁。某些操作完全可以使用更轻量级的原子操作来实现,根本不需要使用锁。(如简单的加减可使用原子操作,求和可使用归约等)。

int index = ...// compute index of array;
atomicAdd(a + index, 1);

                        6. 使用无锁算法、数据结构。本书作者并不推荐大家去实现无锁算法,建议直接使用语言或库自带的无锁算法(如OpenMP的归约算法),因为高性能的无锁算法的实现实在是太难了。

7.2.7 临界区

临界区是指只允许单个控制流执行的代码段。在临界区的开始到结束之间的代码不允许多个线程并行执行,这就提供了一种类似互斥锁的机制。

临界区的大小会影响程序的性能,因此必要的时候应当尽量减少临界区的代码数量。

临界区和互斥锁非常相似,通常可用互斥锁实现临界区,但是临界区远远没有锁灵活和功能强大。比如,使用锁可用多个变量,而临界区却无法实现这点。由于临界区的功能可被排它锁取代,因此很多基于多线程的库/语言并没有实现这个概念。

void* work(void* w)
{
    ((int)(*w))++;
}

int main()
{
    pthread_t t[5];
    int count = 0;
#pragma omp parallel for
    for (int i = 0; i < 5; i++)
    {
#pragma omp critical
        {
            work(&count);
        }

    }
    return 0;
}

7.2.8 原子操作

原子操作允许多线程并行地操作某个地址上的数据,且能够保证每个线程的操作都被原子执行,且结果对其他线程可见,由于代价比锁和临界区都要小,故经常被用来实现一些同步机制。

在不同的处理器上、不同的语言中,原子操作的定义并不相同,如在CPU上,一些基本的内存读写操作本身已经由硬件提供了原子性保证。而在GPU上,连赋值这类操作也不能保证原子性。由于在不同的处理器上能够原子读写的数据类型不固定,因此即使是简单的对共享变量的读写,也应当使用memory fence和原子操作。

需要说明的是,如果需要原子读写结构体内的某个元素,那么就无须原子操作整个结构体,只需要原子读写这个原子即可。

一般情况下软件开发人员无需跟CPU提供的原子操作汇编指令直接打交道,只需要选择语言或者平台提供的原子函数API即可。

int main()
{
    pthread_t t[5];
    int count = 0;
#pragma omp parallel for
    for (int i = 0; i < 5; i++)
    #pragma omp atomic
    {
        count++;
    }

    return 0;
}

7.2.9 栅栏

通常使用栅栏保证各控制流执行到同一代码处,以确定所有控制流都已经实现了一些操作,或者保证各控制流看到的某些存储器内容一致。

从行为上看,除非指定的控制流内的所有控制流都已经到达栅栏调用处,否则执行到此处的控制流必须等待其他控制流,直到所有的控制流都已经执行到调用处,然后所有的控制流会被唤醒以向前执行。

从可见性上看,所有的线程都已经到达存储器栅栏但是还没有向前执行时,所有线程看到的存储器内容是相同的。

如果各控制流的负载均衡不好,那么就有可能有的控制流早就到达栅栏,而有的控制流还有很多任务等待执行,这会显著减弱性能。

MPI中通过MPI_Barrier()函数调用栅栏;

OpenMP中通过#pragma omp barrier伪指令调用栅栏;

pthread通过pthread_barrier_t支持栅栏(实际上是计数栅栏);

CUDA支持线程块内栅栏;

OpenCL和CUDA类似,支持工作组内栅栏。

#pragma omp parallel for
for (int i = 0; i < n; i++)
{
    int myId = omp_get_thread_num();
    myTemp = ...// computing
    temData[myId] = myTemp;
#pragma omp barrier
    result += temp_data[myId - 1] + myTemp + tempData[myId + 1];
}

7.3 结果归并

对于非易并行计算来说,每个控制流计算后得到的结果可能并不是最终需要的,不同的控制流计算的结果之间可能存在重叠、依赖等等。

通常并行计算会存在一个数据本地化过程,即数据划分。

数据归并可被视为控制流之间的通信,和通信一样,结果归并相比串行代码,也是额外的部分。结果归并的代码占用时间越少,性能就越向线性扩展迈进,可扩展性就越好。

7.4 负载均衡

负载均衡是指通过调整计算在各个处理器上的分配,以充分发挥系统内处理器的计算能力,通常这意味着各个处理器近似同时结束计算。

一般而言,随着处理器的增多,处理器之间出现负载不均衡的可能性就越大,负载均衡也越来越重要。

负载均衡也会消耗时间。如果随着处理器的增多,负载均衡耗时也增多,此时就要考虑处理器的数目和问题的规模,而不是处理器数量越多越好。

        负载均衡算法主要分两种:

                1. 静态负载均衡算法,是指在程序运行前,软件开发人员已经将计算资源分隔为多个部分并保证能够均匀地把各部分计算分配给各个控制流运行,通常在作用在各个数据上的操作或处理任务的时间近似相等时采用。

                2. 动态负载均衡,是指程序运行过程中,显式地重新调整任务的分布以达到负载均衡的目的。

由于动态负载均衡和静态负载均衡具有其自身优点。任务队列作为一种常用的动态负载均衡算法得到了广泛了使用,因此本节单独予以说明。

动态负载均衡要解决的核心问题有两个:

        1) 是何时进行计算迁移,这其实是一个系统负载不均衡评价的问题;

        2) 是怎样进行任务迁移,即确定哪些控制流传递负载及如何传递。

与负载均衡联系在一起的是终止检测,它确定何时程序可以结束计算。

7.4.1 静态负载均衡

如果负载分配策略在程序运行前就已经确定了,这称为静态负载均衡。静态负载均衡通过估计程序分段的执行时间来安排计算。

相比动态负载均衡,静态负载均衡往往耗时比较少,在负载不均衡的情况下其效果会差一些。静态负载均衡减少了负载均衡导致的同步等开销。另外静态负载均衡算法易于做时间复杂度估计。目前常用的静态负载均衡算法有下面几种分配:

        1. 循环算法:是指按照控制流索引顺序,依次将计算任务分配给各个控制流,当所有控制流都分配任务后再将任务分配给第一个控制流。

        2. 随机算法:对于每个控制流随机地选择执行任务。实践中这种算法很少用,

        3. 递归二分:通过递归将问题划分为两个子问题,将子问题交给控制流,并且尽量减少通信。递归二分算法在区域分解并行中应用非常广泛。

7.4.2 动态负载均衡

在运行时才确定负载分配的称为动态负载均衡,它是一种在应用程序运行期间依据前一段时间内各控制流对资源的使用状况,对各控制流间负载进行平衡的调度算法。通过实时分析并行系统的负载信息,动态地将任务在各处理机之间进行分配合调整,以消除系统中负载分布的不均匀性。

虽然动态负载均衡会在执行期间产生额外的通信开销,某个控制流作为任务分配中心,其他控制流从任务分配中心获得计算任务,具有清晰的主从结构。

而分散式负载均衡中,控制流之间没有主次之分,一个控制流可以从其余任何一个控制流获得任务,也可以将任务发送给其他的任何一个控制流。

在集中式负载均衡中,主控制流拥有要被执行的任务集。当从控制流完成一个任务,向主控制流请求另一个任务时,任务就由主控制流发送从控制流。

由于易于编程和控制,目前大多数系统采用集中的负载均衡策略,而具体实现时,主控制流可以执行计算任务,也可以不执行计算任务。

        1. 任务队列

                1. 任务队列是指一个转载这计算任务的队列,系统内的控制流从任务队列中获得计算任务。
                2. 在计算任务运行时间差别比较大时,各控制流根据负载均衡的要求,以不同的粒度获得计算任务。
               3. 由于可能有多个控制流同时请求任务,因此需加锁。实现中通常使用原子操作替代。
                4. 任务分发时,需要注意任务的粒度,如果粒度太小,通信时间可能成为瓶颈。如果粒度太大,可能因为没有足够的任务而导致负载不均衡。
                5. 有时候计算任务比其他任务更重要,此时就需要使用优先级队列。

                由于所有控制流都向任务队列取得任务,因此任务队列极易称为瓶颈,分布式队列部分解决了这个问题。

        2. 任务偷取/分布式队列

                每个控制流维护自身的一个任务队列,通常从自己的队列中查找任务,当自己任务完成时,便从其他控制流的任务队列中获取任务,这称为任务偷取,也称为分布式任务队列。

7.4.3 动态负载均衡算法的一般步骤

动态负载均衡算法的实现一般包括负载信息收集、重分配和负载迁移三个基本步骤。

        1. 负载信息收集

                (a)负载信息涉及的一个问题是如何表达各控制流的负载,比如一个粒子模拟系统的负载可以用粒子数表示,也可以用粒子之间的作用数目表示,主要有以下几种策略:

                        1. 周期性收集策略:每隔一定的时间周期(具体时间间隔通常由应用的具体情况决定),系统就收集各控制流的负载。

                        2. 命令驱动策略:当某个控制流发现需要了解其他控制流的负载状况以决定是否需要调用负载均衡算法时,它就会向负载控制中心发送一条命令,由负载控制中心负责收集系统内的负载信息。

                周期性收集策略对系统状态变化的适应能力很差,适合负载不可能大幅度改变的系统;而命令驱动策略在负载可能大幅度改变的状况下效果更好。

        2. 负载的重分配

                对于负载会发生变化的控制流而言,一个重要的问题是缺少负载的控制流需要从其他那些控制流中获得负载,这是一个应用相关的问题。此时通常要综合考虑各控制流目前有用的负载,以尽量减少负载在各控制流之间迁移导致的通信损耗。

                通常负载重分配的实践方式是:从节点将负载信息交给主节点,由主节点重新分配,然后把重分配结果交给各个从节点。

        3. 负载的迁移

                任务迁移是指依据负载的重分配结果在节点间任务进行传递。负载平衡中的任务迁移,一般分为抢占式和非抢占式两种。

                1. 抢占式迁移是指一个已部分执行的计算迁移到其他控制流上去。这要求提供待迁移任务目前的状态信息,以便接受任务的控制流能够接着执行。通常的信息包括进程的虚拟映像、进程控制块、代处理消息、IO缓冲区、文件指针等等。由于收集的进程状态信息通常是比较困难的,因此这种迁移要很大的系统开销,故实际中并不多见。

                2. 非抢占式迁移则只迁移还没有开始运行的计算。目前大多数的系统实现的都是这种负载迁移,因为它的代价要小,效率高,且易于编程实现:

                在实现任务迁移时,要注意几点问题:

                        1. 如果迁移到新节点的计算执行时必须频繁访问原节点的资源,那么就不应当迁移以减少迁移代价;

                        2. 要尽可能避免迁移的抖动问题,抖动问题是指前一次从A迁移到B的计算和下一次从B迁移到A的计算是一样的。

7.5 本章小结

本章详细介绍了并行算法设计的基本步骤:划分、通信、结果归并和负载均衡。

通常使用任务划分来划分计算任务,而使用数据划分来划分数据,它们分别对应着任务并行和数据并行。

常见的划分方式有均匀划分、递归划分和指数下降划分。

在划分时,需要特别注意算法的并行性和数据访问的局部性。

在设计并行算法时,需要注意几个重要的概念:操作的原子性、结果的可见性、函数的可重入性和顺序一致性。

常见的并行程序通信的方式有:锁、临界区、原子操作、栅栏和volatile关键字。

由于并行算法设计引入了划分,故并行程序各控制流的计算结果可能并非最终结果,因此需要对各个控制流的结果进行归并处理。

常用的负载均衡方式有:静态和动态负载均衡。

你可能感兴趣的:(并行算法设计与性能优化,性能优化,并行计算,c++)