并行引入的问题和缺点常见如下:竞写(死锁、活锁、原子操作)、同步(内存栅栏)、缓存失效和伪共享。
8.1 启动结束时间
由于缓存的刷新,操作系统启动、调度和结束进程或线程都是耗时的操作,故通常不建议在CPU上频繁创建和结束进程、线程,而是一次创建,多次重复利用。
for (int iter = 0; iter < num; iter++)
#pragma omp parallel
{
doSomething();
}
// 优化后
#pragma omp parallel
for (int iter = 0; iter < num; iter++)
{
doSomething();
#pragma omp barrier
}
如果把#pragma omp parallel 移到循环外,此时就减少了线程多次创建、销毁的代价,为了正确性,还需要在每次循环完成前栅栏同步。
但是GPU的线程是非常轻量级的,其创建、销毁代价很少,通常鼓励创建大量线程。
8.2 负载均衡
通常解决负载均衡问题的方法是减少任务的粒度,然后在各控制流间动态分配任务。
#pragma omp parallel for schedule(static)
for (int i = 0 ; i < n; i++)
for (int j = 0; j < i; j++)
{
float diff = pos[i] - pos[j];
...;// computing
}
// 优化后
#pragma omp parallel for schedule(dynamic)
for (int i = 0 ; i < n; i++)
for (int j = 0; j < i; j++)
{
float diff = pos[i] - pos[j];
...;// computing
}
由于上述内层循环次数j依赖外层循环i,故不同外层循环层次i的计算量并不相同,故使用静态负载均衡策略会带来负载不均衡问题,改为动态均衡策略即可。
8.3 竞写
发生竞写时的结果和多个控制流的运行有关,决定运行的因素有:硬件和操作系统的调度策略、存储器子系统的特性等。
void* work(void* index)
{
int id = *((int*)index);
data[id]++;
}
int index = 3;
for (int i = 0; i < 5; i++)
pthread_create(t + i; NULL; work, &index);
通常解决竞写问题有两个策略:
1. 不共享数据,尽量使用控制流私有变量,对共享变量加const限制符。在OpenMP时,尽量将变量指定为private并声明局部变量;在显式的编程环境中,优先使用局部变量,不要使用全局变量。
2. 对共享数据的读写使用锁或原子操作等,如在更新data时使用锁或者原子操作加以保护。
8.4 锁
由于锁会导致控制流之间串行运行,且多个控制流竞争锁会引入更多内存访问,故会导致性能下降。
通常要求锁住的临界区要尽量小,这意味着两个方面:
1. 时间上,锁被调用的次数尽可能少,比如缓存对共享变量的更新,然后一次处理完成;
2. 空间上,锁内的指令数要少,比如只锁住对结构体的某个元素的访问而不是对整个结构体的访问加锁;
8.4.1 死锁
死锁是指多个控制流相互拥有对方请求的资源的同时,请求对方持有的资源,如果没有外力改变,这种状态将永远下去。
如果Linux系统检查到存在死锁,则会杀死进程。
死锁的发生必须存在几个条件:
1. 资源访问互斥:
2. 请求对方拥有的资源;
3. 资源持有不可剥夺:导致死锁;
4. 循环等待。
资源互斥访问和请求对方持有的资源,这两条就使得多个控制流形成环状结构,环内控制流相互请求对方的资源,等待对方不再使用资源。
// 死锁实例
void* work(void* w)
{
int flag = (int)w;
if (flag)
{
// Pthread1
pthread_mutex_lock(&mutex_1);
pthread_mutex_lock(&mutex_2);
fork2();
pthread_mutex_unlock(&mutex_2);
pthread_mutex_unlock(&mutex_1);
}
else
{
// Pthread2
pthread_mutex_lock(&mutex_2);
pthread_mutex_lock(&mutex_1);
fork2();
pthread_mutex_unlock(&mutex_1);
pthread_mutex_unlock(&mutex_2);
}
}
int main()
{
pthread_t t[2];
for (int i = 0; i < 2; i++)
pthread_create(t + i; NULL, work, i);
...
return 0;
}
上述代码可能出现两个pthread步调一致,导致各自获得mutex_?。
在串行编程中,通常鼓励开发人员封装,增加小的函数调用以增加代码可读性和使程序更易于理解、然而在并行编程中,使得死锁的检查更为困难。
实际上并没有好且简单的方法避免死锁,但有一条基本原则是保证各个线程加锁的顺序是全局一致的。
对于基于锁的并行程序设计而言,尽量减少锁的数量不但可能提高性能(减少锁的数量也有可能降低性能,如分布式锁和层次锁),也能减少死锁发生的概率。
8.4.2 活锁
void* work(void* w)
{
int flag = (int)w;
if (flag)
{
// Pthread1
int done = 0;
while (!done)
{
pthread_mutex_lock(&mutex_1);
if (pthread_mutex_trylock(&mutex_2))
{
fuck2();
pthread_mutex_unlock(&mutex_2);
done = 1;
}
pthread_mutex_unlock(&mutex_1);
}
}
else
{
// Pthread0
int done = 0;
while (!done)
{
pthread_mutex_lock(&mutex_2);
if (pthread_mutex_trylock(&mutex_1))
{
fuck1();
pthread_mutex_unlock(&mutex_1);
done = 1;
}
pthread_mutex_unlock(&mutex_2);
}
}
}
int main ()
{
pthread_t t[2];
for (int i = 0; i < 2; i++)
pthread_create(t + i, NULL, work, i);
...
return 0;
}
从上述代码可以看出,为了防止死锁的产生做了如下的处理:
当pthread1获取mutex_1后,在通过调用pthread_mutex_trylock函数获得mutex_2。如果pthread1成功获取mutex_2,则trylock加锁成功并返回true,如果失败则返回false。pthread0也使用类似的方法。
这种方法虽然防止了死锁的产生,却可能造成活锁。例如pthread1获得mutex_1后尝试获得mutex_2失败,释放mutex_1并进入下一次while循环;如果pthread0在pthread1进行pthread_mutex_trylock(&mutex_2)的同时执行pthread_mutex_trylock(&mutex1),那么pthread0也会获取mutex_1失败,并接着释放mutex_2及进入下一次循环,那么pthread0也会获取mutex_1失败,并借着释放mutex_2及进入下一次while循环;如此反复。两个线程都可能在较长时间内不停地进行“获取一把锁,尝试获取另一把锁失败、在解锁已获得的锁的循环,从而产生活锁现象”。
不过,在实际过程中,因为线程之间的调度是不确定的,最终必定会有一个线程能同时获得两把锁,从而结束活锁。但是活锁会产生性能的损耗。
8.5 饿死
饿死是指某个控制流一直得不到计算,本质上是一种负载均衡问题。
比如操作系统以优先级调度控制流,软件开发人员错误地为某个控制流分配了一个非常低的优先级,一旦系统有任务执行,这个控制流就不可能得到处理器。
另一种饿死是从任务的角度来说,即某个任务一直存在系统中,没有控制流来计算它。
8.6 伪共享
如果存放在一个缓存线上的数据被多个核心使用,那么就会出现一种情况,假设有两个核心A,B,它们访问的数据分别是a,b。数据a,b在运行时被缓存到同一条缓存线中(但是并非同一地址),那么一旦A更新了a,B核心缓存a的缓存线将会失效,如果B随后访问b,则需要重新从内存中加载数据。
如果运气不好,所有的访问都可能通过内存完成,这称为“伪共享”。伪共享是一种更严重的缓存失效。
另外,控制流的上下文切换也会导致缓存失效,上下文切换时,系统将被切换下的缓存写回内存,同时将切换上的控制流的上下文加载入缓存,有人称这为“缓存污染”。
如果因为某种原因导致频繁地进行上下文切换,那么缓存污染导致的代价非常大。
伪共享本质上是缓存一致性导致的,它使得程序的性能从缓存的性能降低到内存或共享缓存的性能。
解决伪共享通常只需要改变数据类型的大小、更改控制流分配数据的粒度或对齐以使得它们能够不被保存到同一个缓存线上。
在使用原子操作解决竞写时,由于多个控制流同时更新一个数据,原子操作会导致严重的伪共享,这是原子操作慢的原因之一。
8.7 原子操作
原子操作通常保证多个控制流同时操作的结果和每个控制流依次串行的操作的结果一致,其概念在某种意义上与CPU流水线上的一次执行一条指令的抽象相冲突。
由于频繁需要刷新流水线,原子操作的延迟通常在几百个周期,而且通常随着处理器的数目增加,代价也成倍增加。
不幸的是,原子操作通常只用于数据的单个元素。由于许多并行算法都需要在更新多个数据元素时保证正确的执行顺序,所以大多数CPU都提供了存储器栅栏。
8.8 存储器栅栏
某个共享变量在不同核心之间存在一个或多个状态。
想保证多个控制流看到的存储器地址空间上的数据是完全一致的,需要保证(syncthreads()=barrier()+thread_fence()):
1. 所有控制流都执行到相同的代码;
2. 所有控制流读存储器地址空间的更新都已经完成且对其他控制流可见;
如果某个控制流需要访问不久前另一个控制流更新后的数据,那么就需要调用存储器栅栏。
由于需要保证各个核心中的缓存数据都已经写回到内存中,因此其延迟也就几百个周期,但是如果存在负载不均衡的情况,即各个控制流并不是同时到达栅栏调用处时,则会导致某些核心等待。
存储器栅栏(包含路障)引入了两种消耗:
1. 控制流之间相互等待;
2. 减弱了缓存的作用;
8.9 缓存一致性
在多核CPU中,已被缓存的一个地址的读操作一定会返回那个地址最新的(被写入)的值。
虽然硬件实现了缓存一致性,但是这并不意味着一个控制流对某个地址的写操作会立刻被另一个控制流看到,除非程序显式地调用存储器栅栏,这样能够让编译器大胆地优化代码而无需保证多个控制流之间的缓存一致性。而在分布式网络中的缓存一致性需要软件人员之间保证。
8.10 顺序一致性
本书的顺序一致性是指多个控制流运行同一段代码可以得到唯一的结果,无论各个控制流是以任何顺序运行代码,结果应当是一致的。
8.11 volatile同步错误
如果两个线程需要同时访问一个共享变量,为了让其中两个线程每次都能读到这个变量的最新值,就把它定义为volatile。虽然volatile意味着每次读操作和写操作都是直接操作内存,但是volatile在现有的C/C++标准中不保证原子性。
volatile int x = 0;
void* work(void*)
{
x++;
}
// 改错后
volatile int x = 0;
void* work(void*)
{
atomicAdd(&x, 1);
}
8.12 本章小结
以下是并行引入的缺陷:
1. 线程启动、结束时间:如果频繁地建立、结束线程,那么线程的开销就会比较大。
2. 负载均衡:在某些情况下,静态负载均衡方法使得不同控制流的计算量并不相同,故有些线程会提前计算完成,然后等待。此时使用动态负载均衡策略通常会更好。
3. 竞写:如果多个控制流不加锁,同时更新一个内存地址,那么可能出现有些写操作没有体现在结果上,导致错误。
4. 锁:锁竞争会导致串行执行,因此降低性能。
5. 伪共享:会将程序访问数据的性能由缓存降低到内存的性能;
6. 原子操作:会刷新缓存线;
7. 存储器栅栏:如果控制流的执行步骤不一致的话,存储器栅栏会强制要求执行快的控制流等。
8. volatile:由于语义问题,使用volatile来同步的方案大多是错误的,可以配合原子操作。
并行代码开发人员主要注意,小心由于并行引入的消耗导致并行程序性能不如串行程序。
本文探讨了并行计算中遇到的问题,包括线程创建销毁的开销、负载均衡、竞写、死锁、活锁、伪共享、原子操作和内存栅栏。提出优化策略,如减少线程创建、动态任务分配、避免共享数据、使用原子操作和存储器栅栏来确保同步。同时,强调了volatile同步的误解,并总结了并行编程中的关键挑战和注意事项。
24

被折叠的 条评论
为什么被折叠?



