跳到主要内容

丁致宇第三周学习报告:MPI学习

[TOC]

MPI基础

一个基本的MPI程序框架

#include <mpi.h>
#include <stdio.h>

int main(int argc, char *argv[]) {
// 初始化MPI环境
MPI_Init(&argc, &argv);

// 获取当前进程的排名
int world_rank;
MPI_Comm_rank(MPI_COMM_WORLD, &world_rank);

// 获取总进程数
int world_size;
MPI_Comm_size(MPI_COMM_WORLD, &world_size);

// 让每个进程打印出它的排名和总的进程数
printf("Hello world from rank %d out of %d processors\n", world_rank, world_size);

// 清理MPI环境
MPI_Finalize();

return 0;
}

计时框架

#include <stdio.h>
#include <mpi.h>

int main(int argc, char *argv[])
{
MPI_Init(&argc, &argv);

int rank;
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
int size;
MPI_Comm_size(MPI_COMM_WORLD, &size);

double start_time = MPI_Wtime();

// ... 在这里执行你的并行代码 ...



// ... 在这里执行你的并行代码 ...

double end_time = MPI_Wtime();
double elapsed_time = end_time - start_time;

// 在所有进程中找到最大的运行时间
double max_elapsed_time;
MPI_Reduce(&elapsed_time, &max_elapsed_time, 1, MPI_DOUBLE, MPI_MAX, 0, MPI_COMM_WORLD);

// 在主进程中打印最大运行时间
if (rank == 0)
{
printf("\ntime:\n");
printf("Max elapsed time: %f seconds\n", max_elapsed_time);
}

MPI_Finalize();
return 0;
}

编译运行

要编译和运行这个MPI程序,你需要安装MPI库并使用支持MPI的编译器,比如mpicc。编译命令可能如下所示:

mpicc -o mpi_hello_world mpi_hello_world.c

运行MPI程序时,你需要使用mpirunmpiexec命令,并指定进程数,例如:

mpirun -np 4 ./mpi_hello_world

这条命令会启动4个进程运行你的程序。每个进程都会打印出它的排名和总进程数,但是打印的顺序可能是不确定的,因为它们是并行运行的。

点对点通信

MPI(Message Passing Interface)是一个通信协议,用于编程在各个不同节点上运行的并行计算机之间的进程通信。它被设计用来在分布式内存系统上工作,这样的系统通常没有全局地址空间。在这样的系统中,进程间的通信需要通过发送和接收消息来实现。

MPI_Send 函数

MPI_Send 是 MPI 中用于发送消息的基本函数。它的原型如下:

int MPI_Send(const void *buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm)

参数解释:

  • buf:指向待发送数据的缓冲区的指针。
  • count:缓冲区中数据元素的数量。
  • datatype:发送数据元素的数据类型。
  • dest:目标进程的排名(rank)。
  • tag:消息的标签,接收方可以根据这个标签来选择性地接收消息。
  • comm:通信器(communicator),定义了一个进程组和它们之间的通信上下文。

MPI_Recv 函数

MPI_Recv 是 MPI 中用于接收消息的基础函数。它的原型如下:

int MPI_Recv(void *buf, int count, MPI_Datatype datatype, int source, int tag, MPI_Comm comm, MPI_Status *status)

参数解释:

  • buf:指向接收数据的缓冲区的指针。
  • count:缓冲区中数据元素的最大数量。
  • datatype:接收数据元素的数据类型。
  • source:源进程的排名(rank)。
  • tag:消息的标签,与发送时的标签相对应。
  • comm:通信器。
  • status:一个结构体,用于返回关于接收到的消息的信息,如源排名、标签和错误码。

示例

下面是一个简单的 MPI 程序示例,其中使用了 MPI_SendMPI_Recv 函数来在两个进程之间传递一个整数消息。

#include <mpi.h>
#include <stdio.h>

int main(int argc, char** argv) {
// 初始化 MPI 环境
MPI_Init(&argc, &argv);

// 获取总的进程数
int world_size;
MPI_Comm_size(MPI_COMM_WORLD, &world_size);

// 获取当前进程的排名
int world_rank;
MPI_Comm_rank(MPI_COMM_WORLD, &world_rank);

int number;
if (world_rank == 0) {
// 如果是排名为 0 的进程,则发送一个整数到排名为 1 的进程
number = -1;
MPI_Send(&number, 1, MPI_INT, 1, 0, MPI_COMM_WORLD);
} else if (world_rank == 1) {
// 如果是排名为 1 的进程,则从排名为 0 的进程接收一个整数
MPI_Recv(&number, 1, MPI_INT, 0, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE);
printf("Process 1 received number %d from process 0\n", number);
}

// 清理 MPI 环境
MPI_Finalize();
return 0;
}

在这个例子中,进程 0 发送一个整数(-1)给进程 1。进程 1 接收这个整数并打印出来。请注意,这段代码需要在支持 MPI 的环境中编译和运行,通常使用如下命令:

mpicc -o mpi_example mpi_example.c
mpirun -np 2 ./mpi_example

这里,mpicc 是用于编译 MPI 程序的编译器,而 mpirun 用于启动 MPI 程序,-np 2 指定了使用两个进程。

注意

Send、Recv函数中的buf参数可以不同吗?

在MPI中,MPI_SendMPI_Recv函数用于在进程间发送和接收消息。这些函数中的buf参数指的是发送和接收数据的缓冲区。在发送和接收操作中,缓冲区的数据类型和数量应该匹配,但实际的缓冲区指针(即内存地址)是可以不同的。

这里是一个简单的说明:

  • 发送方 (MPI_Send): buf 参数是指向发送缓冲区的指针,这个缓冲区包含了要发送的数据。
  • 接收方 (MPI_Recv): buf 参数是指向接收缓冲区的指针,这个缓冲区用于存储接收到的数据。

发送方和接收方的buf参数通常指向它们各自进程中的不同内存位置。这是因为在不同的进程中,内存空间是隔离的,所以即便是相同的相对地址,在不同的进程中也可能映射到不同的物理内存。

确保通信正确进行的关键在于buf参数所指向的缓冲区大小和数据类型应该与在MPI_SendMPI_Recv调用中指定的countdatatype参数相匹配。如果发送和接收操作的数据类型或数量不匹配,可能会导致数据错误或程序崩溃。

这里有一个简单的例子:

发送方:

int send_data[10]; // 发送缓冲区
MPI_Send(send_data, 10, MPI_INT, dest, tag, MPI_COMM_WORLD);

接收方:

int recv_data[10]; // 接收缓冲区
MPI_Recv(recv_data, 10, MPI_INT, source, tag, MPI_COMM_WORLD, &status);

在这个例子中,发送方有一个名为send_data的数组,接收方有一个名为recv_data的数组。虽然它们是不同的数组(可能位于不同的内存位置),但它们都是大小为10的整型数组,因此可以正确匹配。

MPI预定义的数据类型(MPI_Datatype datatype)

image-20240205200228385

MPI中的通配符

MPI(Message Passing Interface)是一个标准化且可移植的消息传递系统,设计用于由各种计算机组成的并行计算机。MPI标准定义了一系列的API用于进程间通信,这些API包括了一系列的通配符(wildcards)和特殊常量,它们用于简化编程,并提供灵活的通信模式。

以下是MPI中几个常见的通配符和特殊常量:

  1. MPI_ANY_SOURCE

    • 这个通配符用于MPI_Recv和相关函数中的源地址参数,表示接收来自任何进程的消息。
    • 使用MPI_ANY_SOURCE时,如果有多个消息都满足接收条件,MPI将根据其内部机制选择一个消息接收。
    • 由于不指定具体的发送源,因此通常需要通过MPI_Status对象来确定实际消息的发送源。
  2. MPI_STATUS_IGNORE

    • 这是一个特殊的参数,可以在需要MPI_Status对象的函数调用中使用,表示用户对状态信息不感兴趣,可以忽略。
    • 使用MPI_STATUS_IGNORE可以减少一些系统开销,因为MPI不需要去填充状态对象。
    • 如果使用了MPI_STATUS_IGNORE,就无法获取关于接收消息的详细信息,如发送者的标识、消息的标签等。
  3. MPI_ANY_TAG

    • 类似于MPI_ANY_SOURCE,这个通配符用于MPI_Recv和相关函数中的消息标签参数,表示接收任何标签的消息。
    • 使用MPI_ANY_TAG时,接收到的消息可以是任何匹配其他条件(如发送源)的标签。
  4. MPI_PROC_NULL

    • 这个常量用于表示一个“空”进程,可以用在发送和接收操作中,表示不进行实际的消息传递。
    • 当使用MPI_PROC_NULL作为目标时,发送操作会变成一个空操作,不会有任何消息被发送。
    • 当使用MPI_PROC_NULL作为源时,接收操作会立即返回,状态对象会显示消息来源为MPI_PROC_NULL

使用这些通配符和特殊常量时的注意事项:

  • 当使用MPI_ANY_SOURCEMPI_ANY_TAG时,你通常需要检查状态对象来确定消息的实际来源和标签。
  • 如果你打算忽略状态信息,可以使用MPI_STATUS_IGNORE,但这意味着你无法获取到消息的详细信息。
  • 在使用MPI_ANY_SOURCEMPI_ANY_TAG接收消息时,你需要确保你的程序逻辑可以处理来自任何源或带有任何标签的消息。
  • 使用MPI_PROC_NULL可以用于简化代码逻辑,比如在边界条件处理时,你可以使用它来避免编写特殊的边界检查代码。
  • 在并行编程中,通配符的使用可能会影响性能,因为它可能导致不确定性和额外的开销。你应该在确保正确性的前提下,尽可能优化通信模式,减少对通配符的依赖。

MPI提供了强大的抽象来处理进程间的通信,但是它也要求程序员对通信模式有深入理解,以避免死锁和性能瓶颈。在使用这些通配符时,你应该仔细设计你的通信模式,并充分测试以确保程序的正确性和效率。

状态(MPI_Status *status)

image-20240205224014258

实践:ping-pong通信

MPI(Message Passing Interface)是一种通信协议,用于编写并行计算程序。在并行计算中,"ping-pong"测试是一种简单的通信模式,用来衡量两个进程间通信的延迟。在这个测试中,一个进程(我们称之为"Ping")发送一个消息到另一个进程(我们称之为"Pong"),然后"Pong"进程接收这个消息并将其返回给"Ping"进程。

用MPI实现的ping-pong程序

#include <stdio.h>
#include <mpi.h>

int main(int argc, char *argv[]) {
int my_rank, num_procs, partner;
int ping_pong_count = 0;
int max_ping_pong_count = 10;
MPI_Status status;

// 初始化MPI环境
MPI_Init(&argc, &argv);
// 获取当前进程的排名
MPI_Comm_rank(MPI_COMM_WORLD, &my_rank);
// 获取所有进程的数量
MPI_Comm_size(MPI_COMM_WORLD, &num_procs);

if (num_procs < 2) {
fprintf(stderr, "This test requires at least 2 processes\n");
MPI_Abort(MPI_COMM_WORLD, 1);
}

// 确定通信的伙伴进程的排名
partner = (my_rank + 1) % 2;

while (ping_pong_count < max_ping_pong_count) {
if (my_rank == ping_pong_count % 2) {
// 增加ping-pong计数器并发送消息
ping_pong_count++;
MPI_Send(&ping_pong_count, 1, MPI_INT, partner, 0, MPI_COMM_WORLD);
printf("%d sent and incremented ping_pong_count %d to %d\n", my_rank, ping_pong_count, partner);
} else {
// 接收消息
MPI_Recv(&ping_pong_count, 1, MPI_INT, partner, 0, MPI_COMM_WORLD, &status);
printf("%d received ping_pong_count %d from %d\n", my_rank, ping_pong_count, partner);
}
}

// 清理MPI环境
MPI_Finalize();
return 0;
}

代码解释

  1. 引入头文件:#include <mpi.h> 是必须的,因为它包含了MPI程序所需的所有MPI函数和符号定义。

  2. 初始化MPI:MPI_Init(&argc, &argv); 初始化MPI执行环境,这个函数必须在任何其他MPI函数之前调用。

  3. 获取进程信息:MPI_Comm_rank(MPI_COMM_WORLD, &my_rank); 获取当前进程的排名(即进程号),MPI_Comm_size(MPI_COMM_WORLD, &num_procs); 获取参与运算的总进程数。

  4. 确认进程数量:这个程序至少需要两个进程来运行,如果进程数少于2,则程序会退出。

  5. 确定通信伙伴:每个进程都会找到它的通信伙伴,这里简单地通过计算排名的模2余数来确定。

  6. Ping-Pong通信循环:使用MPI_SendMPI_Recv在两个进程间发送和接收消息。MPI_Send用于发送消息,而MPI_Recv用于接收消息。MPI_Status结构体用于获取接收操作的状态信息。

  7. 打印消息:每次发送或接收消息后,进程会打印出相应的信息。

  8. 结束MPI:MPI_Finalize(); 结束MPI执行环境,这个函数必须在程序结束前调用。

注意:这个例子假设只有两个进程参与通信。在实际应用中,可能有多个进程,且通信模式可能更为复杂。此代码应该在支持MPI的环境中编译和运行,例如使用mpicc编译器和mpirunmpiexec来运行程序。

终止进程函数 MPI_Abort(MPI_COMM_WORLD, 1)

MPI_Abort(MPI_COMM_WORLD, 1) 是一个MPI函数,用于在发生错误时终止MPI程序的执行。这个函数会立即终止调用它的进程所在的所有MPI进程,并且尽可能地清理所有MPI状态。MPI_Abort通常在无法恢复的错误发生时调用,比如程序内部检测到了一个不可恢复的错误,或者用户想要在某个特定条件下提前结束程序。

函数参数解释:

  1. 第一个参数 MPI_COMM_WORLD 指定了要终止的通信器(communicator)。在这个例子中,它是 MPI_COMM_WORLD,意味着要终止所有与 MPI_COMM_WORLD 关联的进程。MPI_COMM_WORLD 是一个预定义的通信器,包含了所有的MPI进程。

  2. **第二个参数 1 是错误码,用于传递给程序外部,告知程序为什么被终止。**这个错误码可以被操作系统或其他监控软件用来确定程序终止的原因。在UNIX系统中,非零的退出码通常表示程序异常退出。

使用 MPI_Abort 函数时需要注意:

  • MPI_Abort 是一个集体操作,意味着它将影响所有在相同通信器中的进程。在这个例子中,所有的进程都会被终止,因为它们都是 MPI_COMM_WORLD 的一部分。

  • 由于 MPI_Abort 会立即终止所有相关进程,所以不会执行任何MPI进程的正常退出过程,比如不会调用 MPI_Finalize 函数。因此,可能不会释放所有资源,也可能不会完成所有输出。

  • MPI_Abort 应该只在必要的时候使用,因为它不是一个优雅退出的方法。它不保证所有的进程都能接收到终止信号,也不保证终止的顺序。

  • 通常,更偏好的做法是处理错误,尝试将程序恢复到一个稳定的状态,并让所有进程达成一致后再结束程序,而不是直接调用 MPI_Abort。只有在错误无法恢复,且必须立即停止程序时,才应该使用 MPI_Abort

阻塞通信和非阻塞通信和缓冲发送

死锁

死锁(Deadlock)是计算机科学中的一个概念,特别是在并发控制、多线程和多进程编程中,指的是一种特定的阻塞状态,其中两个或多个运行的线程或进程都在等待对方停止,或等待某些无法由这些线程或进程控制的事件,结果是它们都无法进行下去。

以下是一个经典的死锁示例:

假设有两个进程(进程A和进程B)和两个资源(资源1和资源2):

  • 进程A持有资源1并且等待资源2。
  • 同时,进程B持有资源2并且等待资源1。

在这种情况下,没有任何进程能够继续执行,因为它们都在等待对方释放资源,从而形成了死锁。

为了防止死锁,可以使用多种方法,包括资源分配策略、锁顺序、死锁检测和恢复机制等。例如,确保程序以一致的顺序请求资源可以减少死锁的可能性,而死锁检测算法可以帮助系统识别和处理死锁。如果检测到死锁,系统可以采取各种措施来解决,例如撤销某些进程或强制释放资源。

image-20240206001705919

概念

阻塞通信(Blocking Communication)

在阻塞通信中,进程在调用发送或接收操作时会被阻塞,直到某些条件得到满足。对于发送操作,阻塞可能意味着等待数据被复制到系统缓冲区,或者直到接收方接收数据。对于接收操作,阻塞意味着等待直到有数据到达并被复制到用户的缓冲区。

特点:

  • 简单性:代码易于理解和维护,因为发送和接收操作完成后,进程才会继续执行。
  • 确定性:当阻塞调用返回时,你知道数据已经发送或接收完毕。

示例:

MPI_Send(buffer, count, datatype, dest, tag, MPI_COMM_WORLD); // 发送操作
MPI_Recv(buffer, count, datatype, source, tag, MPI_COMM_WORLD, &status); // 接收操作

在这两个操作中,进程会等待,直到发送或接收完成。

缓冲(Buffering)

在MPI中,发送消息的行为可以根据其对缓冲和阻塞的处理方式来区分。这些特性定义了发送操作如何与系统的缓冲区交互,以及发送操作在消息实际传递之前会不会阻塞调用进程。

缓冲

缓冲指的是消息在被发送和接收之间的临时存储。MPI实现通常有一个内部的缓冲区系统,它可以存储发送的消息,直到接收进程准备好接收它们。这个特性允许发送进程在接收进程实际调用接收操作之前就继续执行,从而可能提高并行程序的整体性能。

  • 缓冲发送(Buffered Send):MPI提供了缓冲发送操作(例如MPI_Bsend),在这种模式下,发送的消息首先被复制到MPI的发送缓冲区中,然后发送进程就可以继续执行而不用等待接收进程开始接收。如果缓冲区空间不足以存储消息,发送操作可能会阻塞直到有足够的空间。

阻塞(Blocking)

阻塞指的是发送操作在消息被接收之前暂停(阻塞)调用进程的执行。在阻塞发送中,发送操作只有在满足特定条件后才会返回控制权给调用进程。

  • 标准阻塞发送(Standard Blocking Send)MPI_Send是一个标准的阻塞发送操作。在这种模式下,发送操作可能会阻塞调用进程,直到消息数据被复制到系统缓冲区(如果存在)或者直到接收进程开始接收操作,从而确保发送进程可以安全地重用或修改发送缓冲区中的数据。

区别

  • 缓冲区依赖性:缓冲发送依赖于MPI系统的缓冲区,而标准发送可能不需要缓冲区或者使用更少的缓冲区。
  • 阻塞行为:标准发送可能会阻塞进程直到发送操作完成(即数据被拷贝到缓冲区或者接收者开始接收),而缓冲发送会尝试立即返回,只有在没有足够缓冲区空间时才会阻塞。
  • 资源使用:缓冲发送可能会消耗更多的缓冲资源,因为它需要在内部缓冲区中存储消息的副本。
  • 程序复杂性:使用缓冲发送可能需要程序员管理缓冲区大小和可用性,增加了编程的复杂性。

在实际编程中,选择哪种发送方式取决于应用程序的需求、消息大小、通信模式和性能考量。标准发送简单直接,适用于大多数情况,但在高性能计算应用中,为了避免潜在的阻塞,可能需要更细致地控制消息的缓冲和发送方式。


缓冲发送(Buffered Send)和非阻塞通信(Non-blocking Communication)是两个不同的概念,尽管它们都旨在减少因通信导致的等待时间,但它们的工作方式有所不同。

缓冲发送

缓冲发送,如MPI_Bsend,会将数据复制到MPI的内部缓冲区。如果缓冲区有足够的空间,发送操作会立即返回,即使接收方还没有开始接收消息。这意味着发送进程可以继续执行后续代码而不必等待接收进程已经开始接收。然而,如果内部缓冲区没有足够的空间来存储发送的消息,缓冲发送可能会阻塞。

非阻塞通信

非阻塞通信,如MPI_IsendMPI_Irecv,指的是发送和接收操作启动后会立即返回,不管操作是否已经完成。这允许程序在消息实际传输的同时继续执行其他操作。非阻塞通信需要程序员在消息传输完成之前管理和测试通信请求(使用MPI_TestMPI_Wait等函数)。

区别

  • 缓冲发送:可能会阻塞,如果缓冲区不足。它依赖于MPI的内部缓冲区来存储消息副本。
  • 非阻塞通信:始终立即返回,允许进程继续执行其他任务。它要求程序员管理通信状态,并在适当的时候确保通信已经完成。

总结来说,缓冲发送是一种尝试减少发送操作中阻塞的方式,但它不是真正的非阻塞通信。而非阻塞通信是一种允许并行操作和更细粒度控制的通信方式,它确保了发送和接收调用在开始后立即返回,让程序有机会在通信完成之前执行其他操作。

非阻塞通信(Non-blocking Communication)

非阻塞通信允许进程在调用发送或接收操作后继续执行,而不必等待操作完成。这意味着进程可以在数据被发送或接收的同时执行其他操作。

特点:

  • 效率:可以在等待数据传输完成的同时执行计算,这有助于隐藏通信延迟。
  • 复杂性:代码可能更难理解和维护,因为你必须管理多个同时发生的操作,并确保在使用数据之前完成通信。

示例:

MPI_Isend(buffer, count, datatype, dest, tag, MPI_COMM_WORLD, &request); // 非阻塞发送操作
MPI_Irecv(buffer, count, datatype, source, tag, MPI_COMM_WORLD, &request); // 非阻塞接收操作
// ... 执行其他操作 ...
MPI_Wait(&request, &status); // 等待非阻塞操作完成

在这里,MPI_IsendMPI_Irecv调用后,进程可以立即继续执行代码,但必须在实际使用数据前调用MPI_Wait来确保操作已经完成。

非阻塞通信:Isend、Irecv函数

MPI_IsendMPI_Irecv 是 MPI (Message Passing Interface) 中用于非阻塞通信的两个函数。它们允许进程在不等待通信完成的情况下发起发送和接收操作,这样进程就可以继续执行其他任务,从而提高程序的并行性和效率。

MPI_Isend

MPI_Isend 用于开始一个非阻塞发送操作。它的函数原型如下:

int MPI_Isend(const void *buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm, MPI_Request *request);

参数说明:

  • buf:指向要发送数据的缓冲区的指针。
  • count:要发送的数据元素的数量。
  • datatype:发送的数据元素的类型。
  • dest:目标进程的秩(rank)。
  • tag:发送的消息标签,接收方将根据这个标签来接收消息。
  • comm:使用的通信器。
  • requestMPI_Request 变量,用于后续的 MPI_WaitMPI_Test 调用。

MPI_Irecv

MPI_Irecv 用于开始一个非阻塞接收操作。它的函数原型如下:

int MPI_Irecv(void *buf, int count, MPI_Datatype datatype, int source, int tag, MPI_Comm comm, MPI_Request *request);

参数说明与 MPI_Isend 类似,不同之处在于 source 参数指定了消息来源的进程秩。

非阻塞通信的必要等待

在使用非阻塞通信时,无论是发送还是接收操作,都可能需要在某个时刻进行等待,但原因和时机可能有所不同:

  1. 非阻塞发送:发送操作通常需要等待的原因是确保数据已经被复制到系统缓冲区或者已经发送给接收方,这样发送方才能安全地重用或修改发送缓冲区中的数据。如果你在发送后立即修改了数据或者在发送完毕前退出了程序,可能会导致数据损坏或未定义的行为。

  2. 非阻塞接收:接收操作需要等待的原因是确保接收缓冲区中的数据已经完整地到达,这样才能安全地读取和使用这些数据。

在MPI (Message Passing Interface) 中,MPI_WaitMPI_Test 是用于等待或检查非阻塞通信操作完成的函数。这些函数与非阻塞发送 (MPI_Isend) 和非阻塞接收 (MPI_Irecv) 一起使用,以允许重叠计算与通信。

MPI_Wait

MPI_Wait 函数用于等待特定的非阻塞通信操作完成。

函数原型如下:

int MPI_Wait(MPI_Request *request, MPI_Status *status);

参数说明:

  • request: 指向一个 MPI_Request 类型的变量的指针,该变量在非阻塞操作时被赋值。MPI_Wait 会等待这个请求对应的操作完成。
  • status: 指向一个 MPI_Status 结构的指针,用于存储操作完成后的状态信息。如果你对状态信息不感兴趣,可以传递 MPI_STATUS_IGNORE

MPI_Wait 会阻塞调用线程直到对应的非阻塞操作完成。完成后,request 会被设置为 MPI_REQUEST_NULL,表示请求对象可以被重用或释放。

MPI_Test

MPI_Test 函数用于检查特定的非阻塞通信操作是否已经完成,而不会阻塞调用线程。

函数原型如下:

int MPI_Test(MPI_Request *request, int *flag, MPI_Status *status);

参数说明:

  • request: 同 MPI_Wait 中的 request
  • flag: 指向一个整数的指针,该函数会将其设置为非零值,如果对应的操作已经完成;否则设置为零。
  • status: 同 MPI_Wait 中的 status

如果操作已经完成,MPI_Test 会设置 flag 为非零值,并且 request 会被设置为 MPI_REQUEST_NULL。如果操作尚未完成,flag 会被设置为零,且 request 保持不变。

示例

下面是一个简单的例子,展示了如何使用 MPI_IsendMPI_IrecvMPI_WaitMPI_Test

#include <mpi.h>
#include <stdio.h>

int main(int argc, char *argv[]) {
MPI_Init(&argc, &argv);

int world_rank;
MPI_Comm_rank(MPI_COMM_WORLD, &world_rank);
int world_size;
MPI_Comm_size(MPI_COMM_WORLD, &world_size);

const int TAG = 0;
MPI_Request request;
MPI_Status status;

if (world_size < 2) {
fprintf(stderr, "World size must be greater than 1 for %s\n", argv[0]);
MPI_Abort(MPI_COMM_WORLD, 1);
}

int number;
if (world_rank == 0) {
number = -1;
MPI_Isend(&number, 1, MPI_INT, 1, TAG, MPI_COMM_WORLD, &request);
MPI_Wait(&request, &status); // Wait for the send to complete
printf("Process 0 sent number %d to process 1\n", number);
} else if (world_rank == 1) {
MPI_Irecv(&number, 1, MPI_INT, 0, TAG, MPI_COMM_WORLD, &request);
int flag = 0;
while (!flag) {
MPI_Test(&request, &flag, &status); // Test for the receive
// Perform other work here...
}
printf("Process 1 received number %d from process 0\n", number);
}

MPI_Finalize();
return 0;
}

在这个例子中,进程 0 发送一个数字给进程 1。进程 0 使用 MPI_IsendMPI_Wait 来发送数字,并等待发送完成。进程 1 使用 MPI_IrecvMPI_Test 来接收数字,并且在等待过程中可以执行其他工作。注意,实际的应用中,MPI_Test 循环中应包含一些有用的计算或处理,以避免忙等待。

示例

下面的示例代码展示了如何使用 MPI_IsendMPI_Irecv

#include <stdio.h>
#include <mpi.h>

int main(int argc, char* argv[]) {
MPI_Init(&argc, &argv);

int world_rank;
MPI_Comm_rank(MPI_COMM_WORLD, &world_rank);
int world_size;
MPI_Comm_size(MPI_COMM_WORLD, &world_size);

const int TAG = 0;
MPI_Request request;
MPI_Status status;

if (world_size < 2) {
fprintf(stderr, "World size must be greater than 1 for %s\n", argv[0]);
MPI_Abort(MPI_COMM_WORLD, 1);
}

int number;
if (world_rank == 0) {
number = -1;
MPI_Isend(&number, 1, MPI_INT, 1, TAG, MPI_COMM_WORLD, &request);
// 在这里可以执行其他任务
MPI_Wait(&request, &status); // 等待发送完成
} else if (world_rank == 1) {
MPI_Irecv(&number, 1, MPI_INT, 0, TAG, MPI_COMM_WORLD, &request);
// 在这里可以执行其他任务
MPI_Wait(&request, &status); // 等待接收完成
printf("Process 1 received number %d from process 0\n", number);
}

MPI_Finalize();
return 0;
}

在这个示例中,进程 0 使用 MPI_Isend 发送一个整数给进程 1,而进程 1 使用 MPI_Irecv 来接收这个整数。发送和接收操作都是非阻塞的,但我们在这里使用 MPI_Wait 来确保在程序结束前通信操作已经完成。在实际应用中,你可能会在调用 MPI_Wait 之前执行一些与通信无关的计算,以此来隐藏通信延迟。

有关非阻塞通信地一些函数

image-20240206003237926

非阻塞通信示例

当然可以。下面是一个使用MPI(消息传递接口)的非阻塞通信的简单示例。在这个示例中,我们将有两个进程:一个发送数据,另一个接收数据。发送进程(rank 0)将使用非阻塞发送(MPI_Isend),而接收进程(rank 1)将使用非阻塞接收(MPI_Irecv)。

首先,确保你有MPI环境安装好了。在许多系统上,你可以使用mpicc来编译MPI程序,并使用mpirunmpiexec来运行它们。

下面是一个简单的C语言程序,演示了非阻塞通信:

#include <stdio.h>
#include <mpi.h>

int main(int argc, char *argv[]) {
MPI_Init(&argc, &argv);

int rank;
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Request request;
MPI_Status status;

const int TAG = 0;
if (rank == 0) {
// 发送进程
int data_to_send = 123; // 这是我们要发送的数据
MPI_Isend(&data_to_send, 1, MPI_INT, 1, TAG, MPI_COMM_WORLD, &request);
// 这里可以执行其他操作...
MPI_Wait(&request, &status); // 等待非阻塞发送完成
printf("Process %d sent data %d\n", rank, data_to_send);
} else if (rank == 1) {
// 接收进程
int received_data;
MPI_Irecv(&received_data, 1, MPI_INT, 0, TAG, MPI_COMM_WORLD, &request);
// 这里可以执行其他操作...
MPI_Wait(&request, &status); // 等待非阻塞接收完成
printf("Process %d received data %d\n", rank, received_data);
}

MPI_Finalize();
return 0;
}

要编译和运行这个程序,你可以使用以下命令(假设你的文件名为non_blocking_mpi.c):

mpicc -o non_blocking_mpi non_blocking_mpi.c
mpirun -np 2 ./non_blocking_mpi

这里,-np 2告诉MPI运行两个进程。当你运行这个程序时,你应该看到进程0发送数据,进程1接收数据,并且每个进程都在其非阻塞操作完成后打印一条消息。注意,在实际的程序中,你可能想要在MPI_Wait之前执行一些有用的工作,以便更好地利用非阻塞通信的优势。

三种通信方式的选用

选择使用阻塞、缓冲或非阻塞通信的最佳时机通常取决于应用程序的特定需求、通信模式和性能目标。下面是一些指导原则帮助你决定:

阻塞通信 (MPI_Send, MPI_Recv)

  • 简单性:如果你的程序逻辑简单,不需要同时进行计算和通信,标准的阻塞通信可能是最简单的选择。
  • 确定性:当你需要确保在执行后续代码之前消息已经被发送或接收时,阻塞通信提供了这种确定性。
  • 小消息:对于小消息,阻塞通信的开销可能可以忽略不计,因为小消息通常很快就能被发送出去或者接收。

缓冲发送 (MPI_Bsend)

  • 可用缓冲区:如果你的系统有足够的缓冲区资源,并且你希望避免发送操作可能的阻塞,缓冲发送可以是一个好的选择。
  • 中等大小的消息:对于中等大小的消息,使用缓冲发送可以减少发送操作的阻塞时间,因为数据会被复制到缓冲区中。
  • 计算与通信重叠:如果你希望在消息发送的同时执行一些计算,缓冲发送可以提供这种重叠的可能性,尽管它不如非阻塞通信灵活。

非阻塞通信 (MPI_Isend, MPI_Irecv)

  • 性能:当你需要最大限度地提高程序性能,特别是在需要计算和通信重叠的情况下,非阻塞通信通常是首选。
  • 大消息:对于大消息,非阻塞通信允许发送操作在数据传输的同时进行其他计算,从而提高资源利用率。
  • 复杂的通信模式:在具有复杂通信模式的程序中,非阻塞通信可以提供更好的控制,因为它允许同时启动多个通信操作,并在它们完成时进行处理。
  • 流水线操作:如果你的应用程序可以分为多个可以并行处理的阶段,非阻塞通信可以帮助你设置流水线,其中计算和通信可以在不同阶段并行执行。

总结

  • 如果你的应用程序通信模式简单,或者你刚开始使用MPI,那么从标准的阻塞通信开始是合理的。
  • 如果你的应用程序需要在通信时做一些计算,而且你不想处理非阻塞通信的复杂性,那么缓冲发送可能是一个好的中间选择。
  • 如果你需要最大化性能,尤其是在有大量并发通信和计算的情况下,那么非阻塞通信是最好的选择。

在任何情况下,最好的方法是通过实验和性能分析来确定哪种通信方式最适合你的应用程序。不同的硬件和网络架构也可能影响最佳选择。

集合通信

概念

集合通信的概念,一对多

每个进程都会调用集体通信函数,但其行为会因自身的rank和设置的参数不同而各异。 集体通信的范围取决于通信器(communicator)。

image-20240206112830684

归约、MPI_Op类型

在MPI中,MPI_Op是一个枚举类型,用于指定在某些集合通信操作中使用的归约操作(reduce operations)。归约操作是指将所有进程中的元素按照某种数学运算合并成一个单一的结果。这些操作通常用在MPI_ReduceMPI_AllreduceMPI_ScanMPI_Exscan等函数中。

下面是一些预定义的MPI_Op操作:

  1. MPI_MAX: 返回所有元素中的最大值。
  2. MPI_MIN: 返回所有元素中的最小值。
  3. MPI_SUM: 计算所有元素的和。
  4. MPI_PROD: 计算所有元素的乘积。
  5. MPI_LAND: 对所有元素执行逻辑与操作。
  6. MPI_BAND: 对所有元素执行按位与操作。
  7. MPI_LOR: 对所有元素执行逻辑或操作。
  8. MPI_BOR: 对所有元素执行按位或操作。
  9. MPI_LXOR: 对所有元素执行逻辑异或操作。
  10. MPI_BXOR: 对所有元素执行按位异或操作。
  11. MPI_MAXLOC: 返回所有元素中的最大值和该值的位置。
  12. MPI_MINLOC: 返回所有元素中的最小值和该值的位置。

这些操作都是关联的(associative)和交换的(commutative),这意味着操作的顺序不会影响最终结果,这是并行计算中非常重要的属性。

使用这些操作时,你需要确保操作和数据类型是兼容的。例如,逻辑操作(MPI_LAND, MPI_LOR, MPI_LXOR)通常用于布尔类型的数据,而按位操作(MPI_BAND, MPI_BOR, MPI_BXOR)用于整型数据。

例如,如果你想要在所有进程中计算数据的总和,你可以使用MPI_SUM

#include <mpi.h>

int main(int argc, char** argv) {
MPI_Init(&argc, &argv);

int rank;
MPI_Comm_rank(MPI_COMM_WORLD, &rank);

int data = rank + 1; // 假设每个进程有一个不同的数据值
int result;

// 将所有进程的data值相加,结果存储在rank为0的进程的result变量中
MPI_Reduce(&data, &result, 1, MPI_INT, MPI_SUM, 0, MPI_COMM_WORLD);

if (rank == 0) {
// 此时,result将是所有进程data值的总和
printf("The sum of all ranks is: %d\n", result);
}

MPI_Finalize();
return 0;
}

在上面的例子中,每个进程都有一个data变量,其值是该进程的rank加1。MPI_Reduce函数将所有data值相加,并将最终的总和存储在根进程(rank 0)的result变量中。

总的来说,MPI_Op提供了一系列预定义的操作,用于在集合通信函数中执行归约操作,这些操作是并行计算中常见的数学和逻辑运算。

MPI中的集合通信函数

image-20240206113540407

MPI_Bcast

MPI_Bcast函数用于将一个进程的数据广播到通信器中的所有进程。函数原型如下:

int MPI_Bcast(void *buffer, int count, MPI_Datatype datatype, int root, MPI_Comm comm)

MPI库在内部已经处理了数据从根进程到所有参与进程的单播或组播传输过程,对于非根进程而言,它们实际上是隐式地执行了接收操作。在使用MPI编程时,只需调用MPI_Bcast函数就可以完成数据的广播,无需再显式地为每个进程调用recv函数来接收数据。

参数说明:

  • buffer:指向要广播的数据的指针。
  • count:要广播的数据的数量。
  • datatype:要广播的数据的类型。
  • root:广播的根进程的rank,即从哪个进程开始广播。
  • comm:通信器。

示例:

#include <mpi.h>
#include <stdio.h>

int main(int argc, char *argv[]) {
int rank, size, data = 0;

MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);

if (rank == 0) {
data = 123;
}

MPI_Bcast(&data, 1, MPI_INT, 0, MPI_COMM_WORLD);

printf("Process %d received data: %d\n", rank, data);

MPI_Finalize();
return 0;
}

MPI_Scatter

MPI_Scatter函数用于将一个进程的数据散布到通信器中的所有进程。函数原型如下:

此函数按照进程排名由小到大从向量中发送安排序发送数据,即0进程收到向量中第一(or 0)个sendcount个对象,1进程收到第二(or 1)个sendcount个

这是一个块分发函数

sendcount表示的是要发送给每个进程的数据的数量,此函数会将向量的sendcount个对象所组成的第一个块发送给第一个进程,将下一个sendcount个对象所组成的第一个块发送给第二个进程

int MPI_Scatter(void *sendbuf, int sendcount, MPI_Datatype sendtype, void *recvbuf, int recvcount, MPI_Datatype recvtype, int root, MPI_Comm comm)

参数说明:

  • sendbuf:指向要发送的数据的指针。
  • sendcount:要发送给每个进程的数据的数量**(注意这里是发送到每个进程的数据量)**。
  • sendtype:要发送的数据的类型。
  • recvbuf:指向要接收的数据的指针。
  • recvcount:每个进程要接收的数据的数量,一个进程要接收的数据量。
  • recvtype:要接收的数据的类型。
  • root:散布的根进程的rank,即从哪个进程开始散布。
  • comm:通信器。

示例:

#include <mpi.h>
#include <stdio.h>

int main(int argc, char *argv[]) {
int rank, size;
int sendbuf[4] = {0, 1, 2, 3};
int recvbuf;

MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);

MPI_Scatter(sendbuf, 1, MPI_INT, &recvbuf, 1, MPI_INT, 0, MPI_COMM_WORLD);

printf("Process %d received data: %d\n", rank, recvbuf);

MPI_Finalize();
return 0;
}

MPI_Gather

此函数按照进程排名由小到大从向量中发送安排序接收数据,即0进程发送到向量第一(or 0)个recvcount个对象的位置,1进程发送到第二(or 1)个sendcount个对象的位置

MPI_Gather函数用于将通信器中的所有进程的数据收集到一个进程中。函数原型如下:

int MPI_Gather(void *sendbuf, int sendcount, MPI_Datatype sendtype, void *recvbuf, int recvcount, MPI_Datatype recvtype, int root, MPI_Comm comm)

参数说明:

  • sendbuf:指向要发送的数据的指针。
  • sendcount:要发送的数据的数量,一个进程要发送的数据量。
  • sendtype:要发送的数据的类型。
  • recvbuf:指向要接收的数据的指针。
  • recvcount:要从每个进程那里接收的数据的数量**(注意这里是从每个进程那里接收的数据量)**。
  • recvtype:要接收的数据的类型。
  • root:收集的根进程的rank,即收集到哪个进程。
  • comm:通信器。

示例:

#include <mpi.h>
#include <stdio.h>

int main(int argc, char *argv[]) {
int rank, size;
int sendbuf = 123;
int recvbuf[4];

MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);

MPI_Gather(&sendbuf, 1, MPI_INT, recvbuf, 1, MPI_INT, 0, MPI_COMM_WORLD);

if (rank == 0) {
printf("Root process received data:");
for (int i = 0; i < size; i++) {
printf(" %d", recvbuf[i]);
}
printf("\n");
}

MPI_Finalize();
return 0;
}

MPI_Allgather

MPI_Allgather函数用于将通信器中的所有进程的数据收集到所有进程中。函数原型如下:

int MPI_Allgather(void *sendbuf, int sendcount, MPI_Datatype sendtype, void *recvbuf, int recvcount, MPI_Datatype recvtype, MPI_Comm comm)

参数说明:

  • sendbuf:指向要发送的数据的指针。
  • sendcount:要发送的数据的数量。
  • sendtype:要发送的数据的类型。
  • recvbuf:指向要接收的数据的指针。
  • recvcount:要接收的数据的数量。
  • recvtype:要接收的数据的类型。
  • comm:通信器。

示例:

#include <mpi.h>
#include <stdio.h>

int main(int argc, char *argv[]) {
int rank, size;
int sendbuf = 123;
int recvbuf[4];

MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);

MPI_Allgather(&sendbuf, 1, MPI_INT, recvbuf, 1, MPI_INT, MPI_COMM_WORLD);

printf("Process %d received data:", rank);
for (int i = 0; i < size; i++) {
printf(" %d", recvbuf[i]);
}
printf("\n");

MPI_Finalize();
return 0;
}

MPI_Alltoall

MPI_Alltoall函数用于将通信器中的所有进程的数据发送给所有进程。函数原型如下:

int MPI_Alltoall(void *sendbuf, int sendcount, MPI_Datatype sendtype, void *recvppbuf, int recvcount, MPI_Datatype recvtype, MPI_Comm comm)

参数说明:

  • sendbuf:指向要发送的数据的指针。
  • sendcount:要发送的数据的数量。
  • sendtype:要发送的数据的类型。c
  • recvbuf:指向要接收的数据的指针。
  • recvcount:要接收的数据的数量。
  • recvtype:要接收的数据的类型。
  • comm:通信器。

示例:

#include <mpi.h>
#include <stdio.h>

int main(int argc, char *argv[]) {
int rank, size;
int sendbuf[4] = {0, 1, 2, 3};
int recvbuf[4];

MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);

MPI_Alltoall(sendbuf, 1, MPI_INT, recvbuf, 1, MPI_INT, MPI_COMM_WORLD);

printf("Process %d received data:", rank);
for (int i = 0; i < size; i++) {
printf(" %d", recvbuf[i]);
}
printf("\n");

MPI_Finalize();
return 0;
}

MPI_Reduce

MPI_Reduce函数用于将通信器中的所有进程的数据归约到一个进程中。函数原型如下:

int MPI_Reduce(void *sendbuf, void *recvbuf, int count, MPI_Datatype datatype, MPI_Op op, int root, MPI_Comm comm)

参数说明:

  • sendbuf:指向要发送的数据的指针。
  • recvbuf:指向要接收的数据的指针。
  • count:要归约的数据的数量。
  • datatype:要归约的数据的类型。
  • op:归约操作,比如MPI_SUM、MPI_MAX等。
  • root:归约的根进程的rank,即归约到哪个进程。
  • comm:通信器。
  • image-20240217012628619

示例:

#include <mpi.h>
#include <stdio.h>

int main(int argc, char *argv[]) {
int rank, size;
int sendbuf = 123;
int recvbuf;

MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);

MPI_Reduce(&sendbuf, &recvbuf, 1, MPI_INT, MPI_SUM, 0, MPI_COMM_WORLD);

if (rank == 0) {
printf("Root process received data: %d\n", recvbuf);
}

MPI_Finalize();
return 0;
}

MPI_Allreduce

MPI_Allreduce函数用于将通信器中的所有进程的数据归约到所有进程中。函数原型如下:

int MPI_Allreduce(void *sendbuf, void *recvbuf, int count, MPI_Datatype datatype, MPI_Op op, MPI_Comm comm)

所有进程都会有一份归约后的数据

参数说明:

  • sendbuf:指向要发送的数据的指针。
  • recvbuf:指向要接收的数据的指针。
  • count:要归约的数据的数量。
  • datatype:要归约的数据的类型。
  • op:归约操作,比如MPI_SUM、MPI_MAX等。
  • comm:通信器。image-20240217012628619

示例:

#include <mpi.h>
#include <stdio.h>

int main(int argc, char *argv[]) {
int rank, size;
int sendbuf = 123;
int recvbuf;

MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);

MPI_Allreduce(&sendbuf, &recvbuf, 1, MPI_INT, MPI_SUM, MPI_COMM_WORLD);

printf("Process %d received data: %d\n", rank, recvbuf);

MPI_Finalize();
return 0;
}

MPI_Scan

MPI_Scan函数用于将通信器中的所有进程的数据进行部分归约,并将中间结果在进程组内分发。函数原型如下:

int MPI_Scan(void *sendbuf, void *recvbuf, int count, MPI_Datatype datatype, MPI_Op op, MPI_Comm comm)

参数说明:

  • sendbuf:指向要发送的数据的指针。
  • recvbuf:指向要接收的数据的指针。
  • count:要归约的数据的数量。
  • datatype:要归约的数据的类型。
  • op:归约操作,比如MPI_SUM、MPI_MAX等。
  • comm:通信器。

函数功能详解

当然,让我们用一个非常形象的例子来解释MPI_Scan的功能。

假设你和你的朋友们(总共4个人)站成一排,每个人手里都有一些糖果。你们想要知道,从队伍的开始到每个人为止,大家一共有多少糖果。每个人只知道自己有多少糖果,而且只能和站在他前面的人交流。

这是你们的初始状态,假设糖果的数量分别是:

  • 你(0号):5颗糖果
  • 1号朋友:3颗糖果
  • 2号朋友:6颗糖果
  • 3号朋友:2颗糖果

现在你们开始"扫描"(MPI_Scan):

  1. 你(0号)是队伍的第一个人,所以你没有前面的人可以交流,你只能说出你自己有多少糖果:5颗。
  2. 1号朋友听到你有5颗糖果,他自己有3颗,所以他可以说出从队伍开始到他为止,总共有8颗糖果。
  3. 2号朋友听到1号朋友有8颗糖果,他自己有6颗,所以他可以说出从队伍开始到他为止,总共有14颗糖果。
  4. 3号朋友听到2号朋友有14颗糖果,他自己有2颗,所以他可以说出从队伍开始到他为止,总共有16颗糖果。

在这个过程中,每个人都只与前一个人交流了一次,并计算出了从队伍开始到自己为止的糖果总数。这就是MPI_Scan的功能:它帮助每个进程累积前面所有进程的数据,并得到一个局部的累计结果。对于0号进程(你),结果就是你自己的数据;对于其他进程,结果是它们自己的数据加上前面所有进程的数据之和。

在实际的MPI程序中,这个操作是并行执行的,每个进程都会得到一个局部的累计结果,而不需要像我们这样一个接一个地计算。这就是MPI_Scan能够高效协同工作的原因。

示例:

#include <mpi.h>
#include <stdio.h>

int main(int argc, char *argv[]) {
int rank, size;
int sendbuf = 123;
int recvbuf;

MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);

MPI_Scan(&sendbuf, &recvbuf, 1, MPI_INT, MPI_SUM, MPI_COMM_WORLD);

printf("Process %d received data: %d\n", rank, recvbuf);

MPI_Finalize();
return 0;
}

MPI_Barrier

MPI_Barrier函数用于同步通信器中的所有进程,直到所有进程都到达这个点后才能继续执行。函数原型如下:

int MPI_Barrier(MPI_Comm comm)

参数说明:

  • comm:通信器。

示例:

#include <mpi.h>
#include <stdio.h>

int main(int argc, char *argv[]) {
int rank, size;

MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);

printf("Process %d before barrier\n", rank);
MPI_Barrier(MPI_COMM_WORLD);
printf("Process %d after barrier\n", rank);

MPI_Finalize();
return 0;
}

以上是MPI中集合通信函数的详细介绍和简短示例。每个函数都有不同的参数和使用方法,但它们都是用于在通信器中的所有进程之间进行集合通信的。

Allgather和Alltoall

当然可以。让我们通过一个简单的例子来说明MPI_AlltoallMPI_Allgather的区别。

假设我们有4个进程,编号为0, 1, 2, 3,并且每个进程开始时都有一个包含4个元素的数组,这些元素用进程的编号和元素的索引来标识。例如,进程0有一个数组[00, 01, 02, 03],其中00表示进程0的第一个元素,01表示进程0的第二个元素,依此类推。

MPI_Alltoall 示例:

初始数据状态:

  • 进程0: [00, 01, 02, 03]
  • 进程1: [10, 11, 12, 13]
  • 进程2: [20, 21, 22, 23]
  • 进程3: [30, 31, 32, 33]

在执行MPI_Alltoall之后,每个进程将从每个其他进程接收一个元素,并将其放在相应的位置。结果如下:

  • 进程0: [00, 10, 20, 30]
  • 进程1: [01, 11, 21, 31]
  • 进程2: [02, 12, 22, 32]
  • 进程3: [03, 13, 23, 33]

可以看到在MPI_Alltoall操作之后,进程0收集了所有进程的第一个元素,进程1收集了所有进程的第二个元素,以此类推。每个进程都发送了不同的数据给其他进程,并从其他进程接收了不同的数据。

MPI_Allgather 示例:

让我们使用相同的初始数据状态:

  • 进程0: [00, 01, 02, 03]
  • 进程1: [10, 11, 12, 13]
  • 进程2: [20, 21, 22, 23]
  • 进程3: [30, 31, 32, 33]

在执行MPI_Allgather之后,每个进程将自己的完整数组发送给所有其他进程。结果如下:

  • 进程0: [00, 01, 02, 03, 10, 11, 12, 13, 20, 21, 22, 23, 30, 31, 32, 33]
  • 进程1: [00, 01, 02, 03, 10, 11, 12, 13, 20, 21, 22, 23, 30, 31, 32, 33]
  • 进程2: [00, 01, 02, 03, 10, 11, 12, 13, 20, 21, 22, 23, 30, 31, 32, 33]
  • 进程3: [00, 01, 02, 03, 10, 11, 12, 13, 20, 21, 22, 23, 30, 31, 32, 33]

MPI_Allgather操作之后,每个进程都有了一个包含所有进程所有数据的数组。

从这个例子可以清楚地看出,MPI_Alltoall是用于进程间个性化的数据交换,而MPI_Allgather是用于每个进程收集所有其他进程相同数据的操作。

MPI_Scatterv

MPI_Scatterv 是一个用于在并行计算中分发数据的 MPI (Message Passing Interface) 函数。与 MPI_Scatter 类似,它将一个数组中的数据分发到一组进程中,但与 MPI_Scatter 不同的是,它允许发送不同数量的数据到不同的进程。

MPI_Scatterv 的函数原型如下:

int MPI_Scatterv(
const void *sendbuf, // 根进程中待发送数据的起始地址
const int sendcounts[], // 数组,包含发送到每个进程的数据数量
const int displs[], // 数组,包含每个进程接收的数据在sendbuf中的偏移量
MPI_Datatype sendtype, // 发送数据的类型
void *recvbuf, // 接收数据的起始地址(对于接收进程)
int recvcount, // 接收数据的数量(对于接收进程)
MPI_Datatype recvtype, // 接收数据的类型
int root, // 发送数据的根进程的排名
MPI_Comm comm // 通信器
);

这里是一个使用 MPI_Scatterv 的例子,假设我们有一个根进程,它有一个待发送的整数数组,希望将这个数组的不同部分发送到不同的进程中。每个进程接收的元素数量是不同的。

#include <mpi.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char** argv) {
MPI_Init(&argc, &argv);

int rank, size;
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);

// 根进程的数据
int *sendbuf = NULL;
int sendcounts[size];
int displs[size];

// 每个进程接收的数据缓冲区
int recvbuf[10]; // 假设最大接收数量为10

if (rank == 0) {
// 根进程初始化发送缓冲区
int sendbuf_size = 0;
for (int i = 0; i < size; ++i) {
sendcounts[i] = i + 1; // 第i个进程将接收i+1个元素
sendbuf_size += sendcounts[i];
}

sendbuf = (int*)malloc(sendbuf_size * sizeof(int));

// 填充发送缓冲区
for (int i = 0; i < sendbuf_size; ++i) {
sendbuf[i] = i;
}

// 初始化偏移量数组
displs[0] = 0;
for (int i = 1; i < size; ++i) {
displs[i] = displs[i - 1] + sendcounts[i - 1];
}
}

// 分发数据
MPI_Scatterv(sendbuf, sendcounts, displs, MPI_INT, recvbuf, 10, MPI_INT, 0, MPI_COMM_WORLD);

// 打印接收到的数据
printf("Process %d received:", rank);
for (int i = 0; i < sendcounts[rank]; ++i) {
printf(" %d", recvbuf[i]);
}
printf("\n");

// 根进程需要释放发送缓冲区
if (rank == 0) {
free(sendbuf);
}

MPI_Finalize();
return 0;
}

在这个例子中,根进程(rank 0)有一个整数数组 sendbuf,它想要将这个数组分散给所有进程。每个进程将接收的元素数量由 sendcounts 数组指定,并且 displs 数组指定了每个进程接收的元素在 sendbuf 中的起始位置。每个进程都有一个接收缓冲区 recvbuf,在这个例子中,我们假设每个进程最多接收10个元素,这是一个简化的假设,实际上你会根据实际情况来分配接收缓冲区的大小。

MPI_Gatherv

MPI_Gatherv 是 MPI (Message Passing Interface) 中的一个函数,它用于从一组进程中收集不同数量的数据,并将这些数据聚集到根进程的接收缓冲区中。与 MPI_Gather 相比,MPI_Gatherv 允许每个进程发送不同数量的数据到根进程。

MPI_Gatherv 的函数原型如下:

int MPI_Gatherv(
const void *sendbuf, // 发送数据的起始地址(对于发送进程)
int sendcount, // 发送数据的数量(对于发送进程)
MPI_Datatype sendtype, // 发送数据的类型
void *recvbuf, // 接收数据的起始地址(仅对根进程有效)
const int recvcounts[], // 数组,包含每个进程将发送的数据数量
const int displs[], // 数组,包含每个进程的数据在recvbuf中的偏移量
MPI_Datatype recvtype, // 接收数据的类型(仅对根进程有效)
int root, // 接收数据的根进程的排名
MPI_Comm comm // 通信器
);

下面是一个使用 MPI_Gatherv 的例子,假设我们有一组进程,每个进程都有一个整数数组,它们希望将这个数组中的一部分数据发送到根进程中。

#include <mpi.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char** argv) {
MPI_Init(&argc, &argv);

int rank, size;
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);

// 每个进程的发送缓冲区
int sendbuf[10]; // 假设每个进程发送10个整数
for (int i = 0; i < 10; ++i) {
sendbuf[i] = rank * 10 + i;
}

// 根进程的接收缓冲区和相关数组
int *recvbuf = NULL;
int recvcounts[size];
int displs[size];

if (rank == 0) {
// 根进程计算总的接收数量和每个进程的偏移量
int total_count = 0;
for (int i = 0; i < size; ++i) {
recvcounts[i] = i + 10; // 假设第i个进程发送i+10个整数
displs[i] = total_count;
total_count += recvcounts[i];
}
recvbuf = (int*)malloc(total_count * sizeof(int));
}

// 收集数据
MPI_Gatherv(sendbuf, 10, MPI_INT, recvbuf, recvcounts, displs, MPI_INT, 0, MPI_COMM_WORLD);

// 根进程打印接收到的数据
if (rank == 0) {
printf("Root process has gathered the following data:\n");
for (int i = 0; i < displs[size - 1] + recvcounts[size - 1]; ++i) {
printf("%d ", recvbuf[i]);
}
printf("\n");
free(recvbuf);
}

MPI_Finalize();
return 0;
}

在这个例子中,我们假设每个进程有一个包含10个整数的发送缓冲区 sendbuf,每个整数初始化为该进程的排名乘以10加上索引值。根进程(排名为0的进程)需要准备一个足够大的接收缓冲区 recvbuf 来接收所有其他进程发送的数据。

每个进程调用 MPI_Gatherv,发送它的 sendbuf 中的数据。根进程使用 recvcounts 数组来指定它期望从每个进程接收的数据数量,displs 数组来指定每个进程的数据在接收缓冲区中的偏移量。

更多高级功能

image-20240207020822559

定义矩阵类型

image-20240207020915342

切分通信器

image-20240207020958789

划分进程组

image-20240207021057819

详细信息见参考资料

image-20240207020643329

在Slurm上运行MPI程序

流程

**Q:**我想在我的集群的两个计算节点上开启两个进程运行这个pingpong程序,每个节点有56个核心,我应该怎么编译、编写slurm脚本

**A:**要在两个计算节点上使用MPI运行ping-pong程序,您需要编译程序并创建一个Slurm作业脚本来提交作业。以下是步骤:

编译MPI程序

首先,确保您的集群上安装了MPI库,比如Open MPI或者MPICH。然后使用相应的编译器(比如mpicc)来编译您的程序。如果您的程序保存在pingpong.c文件中,您可以使用以下命令编译它:

mpicc -o pingpong pingpong.c

这将产生一个可执行文件pingpong

编写Slurm脚本

Slurm作业脚本是一个包含了Slurm指令和要运行的命令的文本文件。创建一个名为pingpong.slurm的文件,并写入以下内容:

#!/bin/bash
#SBATCH --job-name=pingpong # 作业名
#SBATCH --output=pingpong.out # 标准输出和错误输出文件
#SBATCH --time=00:10:00 # 预计运行时间 (D-HH:MM:SS)
#SBATCH --partition=your_partition # 集群的分区名,根据您的集群情况填写
#SBATCH --nodes=2 # 需要使用的节点数
#SBATCH --ntasks=2 # 启动的进程总数
#SBATCH --ntasks-per-node=1 # 每个节点上的进程数
#SBATCH --cpus-per-task=56 # 每个进程的CPU核心数



# 加载MPI模块,如果需要的话
# module load mpi

# 使用mpirun运行程序
mpi ./pingpong

请注意,您需要根据您的集群环境来调整#SBATCH指令中的参数。特别是--partition选项,您需要将其设置为您集群中的合适分区名。

提交作业

使用sbatch命令提交Slurm作业脚本:

sbatch pingpong.sh

提交后,Slurm会将您的作业排队,并在资源可用时运行它。

注意事项

  • 如果您的集群需要加载特定的模块来使用MPI(例如,使用module load命令),请确保在Slurm脚本中包含这些命令。

  • **每个节点上只需要运行一个进程,因为Slurm只能将一个节点的所有核心分配到一个进程中,所以即使申请一个节点上运行多个进程,实际的每个节点使用的核心数也只是申请的一个进程的核心数,即:实际使用核心数=申请节点数(nodes)*每个进程使用核心数(cpus-per-task)**但是,如果您确实想在每个核心上都运行一个进程,请相应地调整--ntasks-per-node--cpus-per-task选项。

  • *实际使用核心数=申请节点数(nodes)每个进程使用核心数(cpus-per-task

  • 启动进程数与你申请的总进程数(ntasks)相同

    image-20240207231005731

    image-20240207235035509

  • 如果您选择使用mpirun或者mpiexec,在某些情况下它们可能不会自动从Slurm获取进程数,所以您可能需要手动指定-np参数。**但是,许多现代的MPI实现能够与Slurm集成,能够自动识别Slurm分配的资源,因此即使使用mpirunmpiexec,在很多情况下也不需要手动指定-np参数。**这取决于您的具体MPI实现和集群配置。

image-20240207234931472

犯的错误与问题

关于reduce中的变量的定义问题

原代码

if (rank == 0)
{
double max_elapsed_time;
}
MPI_Reduce(&elapsed_time, &max_elapsed_time, 1, MPI_DOUBLE, MPI_MAX, 0, MPI_COMM_WORLD);

==未定义标识符max_elapsed_time==

问题

MPI_Reduce调用的max_elapsed_time只在rank == 0的代码块中声明,这将导致编译错误,因为在其他进程中它是未声明的

疑问

MPI_Reduce调用的max_elapsed_time只在rank == 0的代码块中声明为什么会报错,所有进程的数据都归约到0号进程的max_elapsed_time,只在0号进程定义max_elapsed_time不行吗

解答

MPI_Reduce函数中,所有参与的进程都需要提供一个输入值(在这个情况下是elapsed_time),并且所有进程都需要有一个接收结果的变量(max_elapsed_time。这是因为MPI_Reduce是一个集体操作,涉及所有在communicator中的进程(在这个例子中是MPI_COMM_WORLD)。即使结果只在根进程(rank 0)中被收集,所有进程都需要参与这个操作。

**在调用MPI_Reduce时,每个进程都需要知道max_elapsed_time变量的地址,因为它们不知道自己是否是根进程。**MPI 运行时需要在所有进程中都能访问这个地址,以便能够正确地将数据传递给根进程。

如果只在根进程中声明max_elapsed_time,那么其他进程在执行MPI_Reduce时将无法访问未声明的变量,这将导致编译错误或运行时错误。

为了修复这个问题,您需要在所有进程中声明max_elapsed_time变量,而不仅仅是在根进程中。这样,每个进程都有一个有效的地址可以在MPI_Reduce调用中使用,而结果将只在根进程的max_elapsed_time变量中更新。

这是修改后的代码片段,其中max_elapsed_time在所有进程中声明:

// ...

double elapsed_time = end_time - start_time;
double max_elapsed_time; // 声明在所有进程中

MPI_Reduce(&elapsed_time, &max_elapsed_time, 1, MPI_DOUBLE, MPI_MAX, 0, MPI_COMM_WORLD);

if (rank == 0) {
printf("Max elapsed time: %f seconds\n", max_elapsed_time);
}

// ...

在上面的代码中,max_elapsed_time被所有进程声明,但只有在根进程中它才会被MPI_Reduce操作更新为所有进程中的最大elapsed_time值。其他进程的max_elapsed_time变量不会被更新,因为它们不是归约操作的接收者。