并行编程速成课程
过去几个月我一直在介绍各种科学程序,但有时很难找到满足您需求的软件包。在这些情况下,您需要继续编写自己的代码。当您从事繁重的科学计算时,通常需要进行并行计算,以便将运行时间缩短到合理的范围。本月,我将提供一个并行编程速成课程,以便您可以了解其中涉及的内容。
并行程序主要分为两大类:共享内存和消息传递。您可能会在各种科学领域看到这两种类型的使用。共享内存编程是指您使用的所有处理器都在单个机器上。这限制了您的问题规模。当您使用消息传递时,您可以将您可以通过某种互连网络访问的尽可能多的机器连接在一起。
让我们首先看看消息传递并行编程。当今最常用的版本是 MPI(消息传递接口)。MPI 实际上是一个规范,因此有许多不同的实现可用,包括 Open MPI、MPICH 和 LAM 等。这些实现适用于 C、C++ 和 FORTRAN。也有适用于 Python、OCaml 和 .NET 的实现。
MPI 程序由在单个或多个机器上运行的多个进程(称为槽)组成。这些进程中的每一个都可以与其他所有进程通信。本质上,它们位于完全连接的网络中。每个进程都运行程序的完整副本作为其可执行内容,并独立于其他进程运行。当这些进程开始相互发送消息时,并行性就发挥作用了。
假设您已经有一些 MPI 代码,使用它的第一步是编译它。MPI 实现包含一组包装器脚本,用于处理您的所有编译器和链接器选项。它们分别称为 mpicc、mpiCC、mpif77 和 mpif90,分别用于 C、C++、FORTRAN 77 和 FORTRAN 90。您可以为编译器添加额外的选项作为包装器脚本的选项。一个非常有用的选项是 -showme。此选项只是打印出将用于调用编译器的完整命令行。如果您系统上有多个编译器和/或库,并且您需要验证包装器是否在做正确的事情,这将非常有用。
编译代码后,您需要运行它。您实际上并不直接运行您的程序。一个名为 mpirun 的支持程序负责设置系统并运行您的代码。您需要告诉 mpirun 您想要运行多少个处理器以及它们的位置。如果您在一台机器上运行,您可以使用选项 -np X 提交处理器数量。如果您在多台机器上运行,您可以在命令行或文本文件中提交主机名列表。如果此主机名列表有重复,mpirun 假设您要为每个重复启动一个进程。
现在您已经知道如何编译和运行代码,那么您实际上如何编写 MPI 程序呢?第一步需要初始化 MPI 子系统。有一个函数可以做到这一点,在 C 语言中是这样的:
int MPI_Init(&argc, &argv);
在您调用此函数之前,您的程序正在运行单线程执行。此外,在此之前您不能调用任何其他 MPI 函数,除了 MPI_Initialized。一旦您运行 MPI_Init,MPI 就会启动所有并行进程并设置通信网络。在此初始化工作完成后,您将以并行方式运行,每个进程都运行代码的副本。
当您完成所有工作后,您需要干净地关闭所有这些基础设施。执行此操作的函数是:
int MPI_Finalize();
一旦完成,您将回到运行单线程执行。调用此函数后,您唯一可以调用的 MPI 函数是 MPI_Get_version、MPI_Initialized 和 MPI_Finalized。
请记住,一旦您的代码并行运行,每个处理器都在运行代码的副本。如果是这样,每个副本如何知道它应该做什么?为了让每个进程做一些独特的事情,您需要某种方法来识别不同的进程。这可以通过以下函数完成:
int MPI_Comm_rank(MPI_Comm comm, int *rank);
此函数将给出调用它的进程的唯一标识符,称为 rank(等级)。等级只是整数,从 0 到 N–1,其中 N 是并行进程的数量。
您可能还需要知道有多少进程正在运行。要获得这个信息,您需要调用函数:
int MPI_Comm_size(MPI_Comm comm, int *size);
现在,您已经初始化了 MPI 子系统,并了解了您的身份以及正在运行多少个进程。接下来您可能需要做的是发送和接收消息。发送消息最基本的方法是:
int MPI_Send(void *buf, int count, MPI_Datatype type, ↪int dest, int tag, MPI_Comm comm);
在这种情况下,您需要一个缓冲区 (buf),其中包含 count 个 type 类型的元素。参数 dest 是您要将消息发送到的进程的等级。您还可以使用参数 tag 标记消息。您的代码可以根据您设置的标记值决定执行不同的操作。最后一个参数是通信器,我稍后会介绍。
int MPI_Recv(void *buf, int count, MPI_Datatype type, ↪int source, int tag, MPI_Comm comm, MPI_Status *status);
在接收端,您需要调用:
status->MPI_source status->MPI_tag status->MPI_ERROR
当您接收消息时,您可能不一定关心是谁发送的或标记值是什么。在这些情况下,您可以将这些参数设置为特殊值 MPI_ANY_SOURCE 和 MPI_ANY_TAG。然后,您可以通过查看状态结构来检查事后实际值。状态包含以下值:
这两个函数都是阻塞的。这意味着当您发送消息时,您最终会被阻塞,直到消息发送完成。或者,如果您尝试接收消息,您将被阻塞,直到消息完全接收完毕。由于这些调用会阻塞直到它们完成,因此很容易导致死锁,例如,两个进程都在等待消息到达,然后才能发送任何消息。它们最终会永远等待。因此,如果您的代码有问题,这些调用通常是首先要查看的地方。
int MPI_Bcast(void *buf, int count, MPI_Datatype type, ↪int root, MPI_Comm comm);
这些函数是点对点调用。但是,如果您想与一组其他进程对话怎么办?MPI 有一个广播函数:
int MPI_Scatter(void *send, int sendcnt, MPI_Datatype type, void *recv, int recvcnt, MPI_Datatype type, int root, MPI_Comm comm);
此函数接收包含 count 个 type 类型元素的缓冲区,并广播到所有处理器,包括根进程。根进程(来自参数 root)是实际拥有数据的进程。所有其他进程都接收数据。它们都调用 MPI_Bcast,MPI 子系统负责整理谁拥有数据以及谁在接收。此调用还将缓冲区的全部内容发送给所有进程,但有时您希望每个进程处理数据块。在这些情况下,将整个数据缓冲区发送给所有进程是没有意义的。MPI 有一个函数来处理这个问题:
int MPI_Gather(void *send, int sendcnt, MPI_Datatype type, void *recv, int recvcnt, MPI_Datatype type, int root, MPI_Comm comm);
在这种情况下,它们都调用相同的函数,MPI 子系统负责整理哪个是根(拥有数据的进程),哪个是接收数据的进程。然后,MPI 将发送缓冲区分成大小均匀的块,并将其发送给所有进程,包括根进程。然后,每个进程都可以处理其数据块。当它们完成后,您可以使用以下命令收集所有结果:
这是 MPI_Scatter 的完全逆转。在这种情况下,所有进程都发送它们的小块,根进程收集所有小块并将它们放入其接收缓冲区中。
#include <mpi.h> // Any other include files int main(int argc, char **argv){ int id,size; // all of your serial code would // go here MPI_Init(&argc, &argv); MPI_Comm_rank(MPI_COMM_WORLD, &id); MPI_Comm_size(MPI_COMM_WORLD, &size); // all of your parallel code would // go here MPI_Finalize(); // any single-threaded cleanup code // goes here exit(0); }
综合以上所有信息,您可以组合出一个基本的样板示例: