大型计算机科学

作者:Joey Bernard

几个月前,我写了一篇文章,介绍了如何使用 MPI 在多台联网机器上运行并行程序。但是,越来越常见的是,您的普通桌面电脑拥有不止一个 CPU。如何才能最好地利用您指尖的强大功能?当您在单台机器上运行并行程序时,这称为共享内存并行编程。在进行共享内存编程时,有多种选择。最常见的是 pthreads 和 openMP。本月,我将介绍 openMP 以及如何使用它来充分利用您的计算机。

openMP 是一种规范,这意味着您最终会使用一个实现。它作为编译器的扩展来实现。因此,为了在您的代码中使用它,您只需添加一个编译器标志即可。无需链接外部库。openMP 指令作为特殊注释添加到您的程序中。这意味着如果您尝试使用不理解 openMP 的编译器编译您的程序,它应该可以正常编译。openMP 指令将像任何其他注释一样显示,您最终将得到一个单线程程序。openMP 的实现可用于 C/C++ 和 FORTRAN。

openMP 中最基本的概念是,只有部分代码以并行方式运行,并且在大多数情况下,这些部分都运行相同的代码。在这些部分之外,您的程序将以单线程方式运行。最基本的并行部分由以下内容定义:


#pragma omp parallel

在 C/C++ 中,或


!OMP PARALLEL

在 FORTRAN 中。这称为并行 openMP 编译指示。您可能使用的几乎所有其他编译指示都基于此构建。

您将看到的最常见的编译指示是并行循环。在 C/C++ 中,这指的是 for 循环。在 FORTRAN 中,这是 do 循环。(在本文的其余部分,我坚持使用 C/C++ 作为示例。您可以在规范文档中找到等效的 FORTRAN 语句。)一个 C/C++ 循环可以使用以下方式并行化:


<![CDATA[
#pragma omp parallel for
for (i=0; i<max; i++) {
   do_something();
   area += i;
   do_something_else();
}
]]>

该编译指示告诉 openMP 子系统,您要创建一个由 for 循环定义的并行部分。发生的情况是,创建了定义的线程数,并且循环的工作在这些线程之间分配。因此,例如,如果您有一个四核 CPU,并且必须在此 for 循环中执行 100 次迭代,则每个 CPU 核心将获得 25 次循环迭代来执行。因此,此 for 循环应花费大约正常时间的四分之一。

这适用于所有 for 循环吗?不,不一定。为了使 openMP 子系统能够划分 for 循环,它需要知道涉及多少次迭代。这意味着您不能使用任何会更改 for 循环周围迭代次数的命令,包括 C/C++ 中的“break”或“return”之类的命令。这两者都会在 for 循环完成所有迭代之前将您退出 for 循环。但是,您可以使用“continue”语句。它所做的只是跳过此迭代中的剩余代码,并将您放在下一次迭代的开头。因为这保留了迭代计数,所以可以安全使用。

默认情况下,程序中的所有变量都具有全局作用域。因此,当您进入并行部分(如上面的并行 for 循环)时,您最终可以访问程序中存在的所有变量。虽然这非常方便,但它也非常非常危险。如果您回顾一下我的简短示例,工作是由以下行完成的:


area += i;

您可以看到变量 area 正在被读取和写入。如果您有多个线程,并且都试图同时执行此操作,会发生什么情况?这不是很漂亮——想想高速公路上的连环撞车事故。假设变量 area 以零值开头。然后,您的程序启动带有五个线程的并行 for 循环,并且它们都读取初始值零。然后,它们各自添加它们的 i 值并将其保存回内存。这意味着这五个值中只有一个实际上会被保存,而其余的基本上会丢失。那么,您能做什么呢?在 openMP 中,有一个临界区的概念。临界区是代码的一部分,受到保护,以便一次只有一个线程可以执行它。要解决此问题,您可以将区域递增放置在临界区内。它看起来像这样:


<![CDATA[
#pragma omp parallel for
for (i=0; i<max; i++) {
   do_something();
#pragma omp critical
   area += i;
   do_something_else();
}
]]>

请记住,在 C 中,代码块由单行或用大括号括起来的一系列行定义。因此,在上面的示例中,临界区适用于单行 area += i;。如果您希望它应用于多行代码,它将如下所示:


<![CDATA[
#pragma omp parallel for
for (i=0; i<max; i++) {
   do_something();
#pragma omp critical
   {
   area += i;
   do_something_else();
   }
}
]]>

这使我们了解到多个线程滥用全局变量的一种更微妙的方式。如果您有一个嵌套的 for 循环,并且想要并行化外部循环怎么办?然后:


<![CDATA[
#pragma omp parallel for
for (i=0; i<max1; i++) {
   for (j=0; j<max2; j++) {
      do_something();
   }
}
]]>

在这种情况下,每个线程都将有权访问全局变量 j。它们都将在完全随机的时间读取和写入它,并且您最终将获得超过 max2 次迭代或少于 max2 次迭代。您真正希望看到发生的是,每个线程在外部循环的每次迭代中都完成所有操作。解决方案是什么?幸运的是,openMP 规范具有私有变量的概念。私有变量是指每个线程都获得自己的私有副本来使用的变量。要使变量私有化,您只需添加到并行 for 编译指示中:


#pragma omp parallel for private(j)

如果您有多个需要私有化的变量,您可以将它们添加到同一个 private() 选项中,以逗号分隔。默认情况下,这些新的私有副本的行为就像 Linux 上 C 代码中的常规变量一样。这意味着它们的初始值将是这些内存位置中的任何垃圾。如果您想确保每个副本都以进入并行部分时存在的原始值的值开始,您可以添加选项 firstprivate()。同样,您以逗号分隔列表输入您希望以这种方式处理的变量。作为一个实际上没有任何用处的示例,它看起来像这样:


<![CDATA[
a = 10;
#pragma omp parallel for private(a,j) firstprivate(a)
for (i=0; i<max1; i++) {
   for (j=0; j<max2; j++) {
      a += i;
      do_something(a*j);
   }
}
]]>

那么,您有一个程序。现在怎么办?第一步是编译它。因为它本身是编译器的扩展,所以您需要在编译命令中添加一个选项。对于 gcc,它将只是 -fopenmp。您确实需要注意您正在使用的编译器版本以及它支持的版本。openMP 规范现在已达到 3.0 版,gcc 版本之间的支持各不相同。如果您想详细了解支持,请查看 gcc 主页 http://gcc.gnu.org。最新版本开始包含对 openMP 3.0 版的支持。

编译完成后,您需要运行它。如果您只是在命令行中运行它,而不做任何其他操作,您的程序将检查您的机器并查看您有多少个 CPU(如果您想知道,双核处理器看起来像两个 CPU)。然后,它将继续使用该数字作为要在任何并行部分中使用的线程数。如果您想显式设置应使用的线程数,可以使用环境变量进行设置。在 bash 中,您可以使用此命令设置四个线程:


export OMP_NUM_THREADS=4

您可以设置比您拥有的 CPU 更多的线程。因为它们是实际的执行线程,所以 Linux 可以毫无问题地在可用的 CPU 上调度它们。只需记住,如果您拥有的线程多于可用的 CPU,您会看到代码的执行速度变慢,因为它会在 CPU 上与自身交换。

你为什么要这样做?好吧,当您测试一段新代码时,您可能会遇到一些错误,这些错误在您达到一定数量的线程之前不会出现。因此,在测试场景中,使用大量线程和小输入数据集运行可能是有意义的。理想的情况是成为机器上唯一运行的进程,并且每个 CPU 运行一个线程。这样,您可以最大化使用率并最小化交换。

所有这些都只是最简单的介绍。我还没有介绍通用的并行部分、函数式并行性、循环调度或任何其他更高级的主题。规范位于 http://www.openmp.org,其中包含大量教程和其他示例的链接。希望这个介绍为您提供了一些尝试的想法,并让您初步了解了可能实现的目标。我将给您最后一个提示。如果您想开始使用并行程序而无需考虑太多,请添加选项 -ftree-parallelize-loops。这将尝试分析您的代码,看看是否可以并行化任何部分。它无法捕获所有可以并行化的部分,因为它无法理解代码的上下文以及它试图做什么。但是,对于添加选项、重新编译和测试时间所花费的时间来说,这绝对是值得的。

加载 Disqus 评论