引
- 在上一篇中,我们介绍了thread level parallelism的基本概念和多核CPU架构以及超线程,现在我们将落实到具体的编程实践中,看看如何使用并行化来加速程序的运行。
- 当前,众多编程语言支持并行化编程,比如 Go、C、Java、CUDA 等,它们针对不同领域进行了特别优化。本文将以 C 语言为例,介绍一些常用的并行化编程工具和库,比如 OpenMP 和 Pthreads,帮助我们更方便地实现并行计算。
值得注意的是,为什么在 C 中我们需要使用 intrinsics 来嵌入 SIMD 指令呢? 这是因为 GCC 等编译器目前仍难以在通用场景下高效地将普通 C 代码自动并行化,所以我们需要手动告诉编译器哪些部分可以并行、哪些指令可以用 SIMD 加速。
不过,Inter等公司正在努力升级编译器,将会有更多的并行化优化。20+ years to translate C into good (fast!) assembly, How long to translate C into good (fast!) parallel assembly ? 呃呃,想要做到通用并行化确实很困难,所以各大语言基本上都是针对特定场景进行优化(比如,Scienctific computing/Machine learning, Webserver, Input/Output). 如果你有志为并行化编译器做贡献,大有前途啊小伙子!
OpenMP
Parallel Loops
- 下面是一个使用 OpenMP 来并行化 for 循环的示例代码:
| |
你会发现线程0处理了i=0,1,2 , 线程1处理了i=3,4,5 , 线程2处理了i=6,7 , 线程3处理了i=8,9. 这就是OpenMP的默认调度策略,将一块大的共享内存分成若干个不同的区间,每个线程负责处理其中一个区间。并且OpenMP支持动态分配,比如这里10并不是4的倍数,它会自动分配成3,3,2,2.
需要注意,如果你多次运行./for.out, 你会发现每次各个线程输出顺序是不一样的,甚至有错位的情况,这就是并行化的非确定性。如果你的程序依赖于某些特定的输出顺序,请务必使用适当的同步机制来保证正确性!
请慎用time()或clock()来测量并行程序的时间!! OpenMP提供了专门的函数omp_get_wtime()来测量并行程序的运行时间!
Computing π
- 下面我们将尝试使用OpenMP来并行计算π的近似值。问题来了,如何计算π呢?可以想一想,π在哪里出现过?比如圆的面积公式,周长公式,弧度制等。于是,有以下两种经典的方法:
- 对于一个单位圆,面积是π, 对于第一象限的四分之一部分,面积是$\frac{π}{4}$, $y = \sqrt{1-x^2}$, 因此我们可以用积分的方法来逼近这个面积,从而得到π的近似值。即 $$π = 4 \int_0^1 \sqrt{1-x^2} dx$$
- 另一个方法是弧度制,已知$\tan(\frac{π}{4}) = 1$, $\tan(0) = 0$, $\arctan(x)$的导数为$\frac{1}{1+x^2}$, 于是,利用函数积分: $$ \int_0^1 \frac{1}{1+x^2} dx = \arctan(1) - \arctan(0) = \frac{π}{4} $$ 故: $$ π = 4 \int_0^1 \frac{1}{1+x^2} dx $$
- 以上两种方法都可以通过数值积分来近似计算π的值,不过考虑到$\sqrt{1-x^2}$的计算比较慢,所以下面我们的代码采用方法二。
| |
| |
- 解决办法:由于问题产生原因是多个线程对同一个共享变量sum进行修改,那么我们就为每一个线程分配一个独立的sum变量,最后再将它们加起来。
| |
对于可以开的最大Thread数量,你可以使用lscpu去查看你的CPU有多少个核心(上一篇中已经详细介绍),或者使用OpenMP提供的函数omp_get_max_threads()来获取当前系统支持的最大线程数。比如,我偷偷用社团的开发机同时开128个线程核心,在num_steps=1000000000的情况下,能从1.5s优化到0.5s!
当然,num_steps越大,最终π的估计越精准。
可能有人疑惑了,如果能开无限多个线程,是不是运行时间趋近于0 ? 对于这个问题,答案是否定的,因为最后我们还得将sum[i]进行求和,而这个过程是竞争态的,必须串行!如果没有锁的保护,那么两个线程同时读当前sum值,并且一前一后写回,那么先写回的sum[i]就被忽略了!!!因此,在并行编程中,竞争态是一个非常常见的问题,必须通过适当的同步机制来解决!
| |
前面提到最后的求和过程必须串行,如果我们硬要在并行块内进行求和,那么会得到奇怪的π值。一个程序最后的优化瓶颈其实是它的串行部分,无论你开多少线程,串行部分的时间都不会减少,所以并行化的效果是有限的,这就是所谓的Amdahl’s Law。
需要注意的是,在设置THREAD_NUM=100时,num_steps设为10000或更少一点才能看到明显的错误,因为如果num_steps太大,那么不同线程迭代次数很多,完成迭代的时间差可能会比较大,导致竞争态的概率降低,反而得到正确结果🤣
Synchronization
在前面的代码中,我们在尝试对sum进行并行计算时出现了竞争态问题。在生活中,一些公共资源同样会出现类似的问题,比如一份共享文档,如果没有适当的权限控制,那么多个用户同时编辑同一处内容时,会出现冲突,导致文档内容混乱。为了解决这个问题,一个很自然的想法是take turns! 当我在编辑文档时,其他人?等着!当我编辑完了,其他人才开始编辑。这种思想在计算机中被称为"Lock"。
计算机使用Locks来控制对共享资源的访问,通常使用一个 int lock 变量来实现, 即 0 for unlocked, 1 for locked.
| |
- 上面是一个lock的示例,很简单也看似正确。然而,如果有两个线程时会发生什么呢?假设线程A和线程B同时执行到while (lock != 0)这行代码,此时lock=0,两个线程都会跳出循环,接着令lock=1,两个线程都认为自己获得了锁,接着访问共享资源,导致竞争态问题!因此,这个简单的lock实现是错误的。
而且,这个问题在C语言层面是无法解决的。我们需要保证一个线程在发现lock=0并且将lock设置为1的过程中,其他线程无法访问lock变量,这这这,用C的语法肯定做不到呀!我们需要一些更底层的“原子操作”来解决这个问题!
下一篇我们将深入探讨如何解决这个问题!