高级 OpenMP
由于八月刊的主题是编程,我认为我应该介绍一些 OpenMP 中更高级的功能。在之前的几期中,我介绍了使用 OpenMP 的基础知识,因此您可能需要回顾一下那篇文章。在科学编程中,基础知识往往是人们使用 OpenMP 的极限,但还有更多可用的功能——而且,这些其他功能不仅仅对科学计算有用。因此,在本文中,我深入研究了在查看 OpenMP 编程时似乎从未涵盖的其他旁支。谁知道呢,您甚至可以用 OpenMP 替换 POSIX 线程。
首先,让我快速回顾一下 OpenMP 的一些基础知识。以下所有示例均以 C 语言完成。如果您还记得,OpenMP 被定义为一组编译器的指令。这意味着您需要一个支持 OpenMP 的编译器。编译器的指令通过 pragmas 给出。这些 pragmas 被定义为对于不支持 OpenMP 的编译器,它们显示为注释。
最典型的构造是使用 for 循环。假设您要创建一个从 1 到某个最大值的整数的正弦数组。它看起来像这样
#pragma omp parallel for
for (i=0; i<max; i++) {
a[i] = sin(i);
}
然后,您将使用带有 -fopenmp
标志的 GCC 编译它。虽然这对于自然形成围绕 for 循环的算法的问题非常有效,但这远非大多数解决方案方案。在大多数情况下,您需要在程序设计中更加灵活,以处理更复杂的并行算法。为了在 OpenMP 中做到这一点,请输入 sections 和 tasks 的构造。有了这些,您应该能够完成几乎所有您可以使用 POSIX 线程完成的事情。
首先,让我们看看 sections。在 OpenMP 规范中,sections 被定义为可以并行运行的顺序代码块。您可以使用 pragma 语句的嵌套结构来定义它们。最外层是 pragma
#pragma omp parallel sections
{
...commands...
}
请记住,pragmas 仅适用于 C 语言中的下一个代码块。最简单地说,这意味着下一行代码。如果您需要使用多行代码,则需要像上面所示的那样将它们包装在花括号中。此 pragma 分叉出许多新线程来处理并行代码。创建的线程数取决于您在环境变量 OMP_NUM_THREADS
中设置的值。因此,如果您想使用四个线程,您将在命令行中执行以下操作,然后再运行程序
export OMP_NUM_THREADS=4
在 sections 区域内,您需要定义一系列单独的 section 区域。每个区域都由以下内容定义
#pragma omp section
{
...commands...
}
对于任何使用过 MPI 的人来说,这应该看起来很熟悉。您最终得到的是一系列可以并行运行的独立代码块。假设您定义了四个线程用于您的程序。这意味着您最多可以并行运行四个 section 区域。如果您在代码中定义了超过四个,OpenMP 将管理尽快运行它们,并将剩余的 section 区域分配给正在运行的线程,一旦它们空闲。
作为一个更完整的示例,假设您有一个数字数组,并且您想找到存储在那里的值的正弦、余弦和正切。您可以创建三个 section 区域来并行执行所有三个步骤
#pragma omp parallel sections
{
#pragma omp section
for (i=0; i<max, i++) {
sines[i] = sin(A[i]);
}
#pragma omp section
for (j=0; j<max; j++) {
cosines[j] = cos(A[j]);
}
#pragma omp section
for (k=0; k<max; k++) {
tangents[k] = tan(A[k]);
}
}
在这种情况下,每个 section 区域都有一个由 for 循环定义的单个代码块。因此,您不需要将它们包装在花括号中。您还应该注意到,每个 for 循环都使用单独的循环索引变量。请记住,OpenMP 是一种共享内存并行编程模型,因此所有线程都可以查看和写入所有全局变量。因此,如果您使用在并行区域外部创建的变量,则需要避免多个线程写入同一变量。如果这种情况发生,则称为竞争条件。它也可能被称为并行程序员的祸根。
我想在本文中介绍的第二个构造是 task。OpenMP 中的 tasks 比 sections 更非结构化。Section 区域需要组合成单个 sections 区域,并且整个区域被并行化。对于 tasks,它们被转储到队列中,准备尽快运行。定义 task 很简单
#pragma omp task
{
...commands...
}
在您的代码中,您将使用 pragma 创建一个通用并行区域
#pragma omp parallel
此 pragma 分叉出您在 OMP_NUM_THREADS
环境变量中设置的线程数。这些线程形成一个池,可供其他并行构造使用。
现在,当您创建一个新 task 时,可能会发生以下三种情况之一。第一种情况是线程池中有一个空闲线程。在这种情况下,OpenMP 将让该空闲线程运行 task 构造中的代码。第二种和第三种情况是没有可用的空闲线程。在这些情况下,task 最终可能会被原始线程调度运行,或者最终可能会排队等待线程空闲后立即运行。
因此,假设您有一个函数(称为 func),您想使用五个不同的参数调用它,以便它们是独立的,并且您希望它们并行运行。您可以使用以下方法执行此操作
#pragma omp parallel
{
for (i=1; i<6; i++) {
#pragma omp task
func(i);
}
}
这将创建一个线程池,然后循环遍历 for 循环并创建五个 tasks 以分配到线程池。关于 tasks 的一件很酷的事情是,您可以更好地控制它们的调度方式。如果您在 task 中到达一个可以休眠一段时间的点,您实际上可以告诉 OpenMP 这样做。您可以使用 pragma
#pragma omp taskyield
当当前正在运行的线程到达代码中的这一点时,它将停止并检查 task 队列,看看是否有任何等待运行的任务。如果有,它将继续启动其中一个任务,并将您当前的 task 置于休眠状态。当新 task 完成时,挂起的 task 将被拾取并在其停止的位置恢复。
希望看到一些不太常见的构造已经启发您去检查一下您可能在您的技能中遗漏了哪些其他技术。大多数并行框架都允许您执行大多数技术。但是,由于历史原因,每个框架都倾向于仅用于技术的一个子集,即使存在几乎从未使用过的构造。对于共享内存编程,我在此处介绍的构造允许您执行许多您可以使用 POSIX 线程执行的操作,而无需编程开销。您只需要权衡一下您使用 POSIX 线程获得的某些灵活性。