如何在 MPI 中将矩阵从一个过程传输到另一个过程?

How to transfer a matrix from one process to another in MPI?

我需要传递数据类型vector<vector<int>>,但这不在 MPI 数据类型中。如何创建它?在这种情况下如何使用MPI_Recv和MPI_Send?
这是我的代码算法(我安装了 8 个进程):

vector<vector<int>> p1, p2, p3, p4, p5, p6, p7; // our matrices

switch(WORLD_RANK) {
    case 1: {
        p1 = multiStrassen(summation(a11, a22), summation(b11, b22), n);
        // send matrix p1
    }
    case 2: {
        p2 = multiStrassen(summation(a21, a22), b11, n);
        // send matrix p2
    }
    case 3: {
        p3 = multiStrassen(a11, subtraction(b12, b22), n);
        // send matrix p3
    }
    case 4: {
        p4 = multiStrassen(a22, subtraction(b21, b11), n);
        // send matrix p4
    }
    case 5: {
        p5 = multiStrassen(summation(a11, a12), b22, n);
        // send matrix p5
    }
    case 6: {
        p6 = multiStrassen(subtraction(a21, a11), summation(b11, b12), n);
        // send matrix p6
    }
    case 7: {
        p7 = multiStrassen(subtraction(a12, a22), summation(b21, b22), n);
        // send matrix p7
    }
    case 0: {
        // wait for the completion of processes 1-7
        // get matrices p1-p7 and use them
        vector<vector<int>> c11 = summation(summation(p1, p4), subtraction(p7, p5));
        vector<vector<int>> c12 = summation(p3, p5);
        vector<vector<int>> c21 = summation(p2, p4);
        vector<vector<int>> c22 = summation(subtraction(p1, p2), summation(p3, p6));
    }
}

这个话题每周至少出现一次。我通常会将您的问题作为重复问题关闭,但由于您使用的是 std::vector 而不是原始指针,我想您应该得到一个更详尽的答案,该答案涉及 MPI 鲜为人知但非常强大的功能,即它的数据类型系统.

首先,std::vector<std::vector<T>>不是连续类型。想想它在内存中是如何布局的。 std::vector<T> 本身通常被实现为一个结构,该结构包含指向分配在堆上的数组的指针和一堆簿记信息,例如数组容量及其当前大小。一个向量的向量是一个结构,它包含一个指向堆结构数组的指针,每个结构包含一个指向另一个堆数组的指针:

p1 [ data ] ---> p1[0] [ data ] ---> [ p1[0][0] | p1[0][1] | ... ]
   [ size ]            [ size ]
   [ cap. ]            [ cap. ]
                 p1[1] [ data ] ---> [ p1[1][0] | p1[1][1] | ... ]
                       [ size ]
                       [ cap. ]
                 ...

这是访问给定行的数据所需的两级指针间接寻址。当您编写 p1[i][j] 时,编译器会读取两次 std::vector<T>::operator[]() 的代码,最后生成指针算术和解引用表达式,给出该特定矩阵元素的地址。

MPI 不是编译器扩展。它也不是某种深奥的模板库。它对 C++ 容器对象的内部结构一无所知。它只是一个通信库,仅提供一个可在 C 和 Fortran 中工作的薄层抽象。在构思 MPI 的时候,Fortran 甚至没有对用户定义的聚合类型(C/C++ 中的结构)的主流支持,因此 MPI API 非常以数组为中心。也就是说,这并不意味着 MPI 只能发送数组。相反,它有一个非常复杂的类型系统,允许您发送任意形状的对象,如果您愿意为此投入额外的时间和代码。让我们研究一下可能的不同方法。

MPI 很乐意在连续的内存区域中发送或接收数据。将非连续内存布局转换为连续内存布局并不难。您创建一个大小为 N2 的平面 std::vector<T>,而不是 NxN 形 std::vector<std::vector<T>>,然后在平面中构建一个辅助指针数组:

vector<int> mat_data();
vector<int *> mat;

mat_data.resize(N*N);
for (int i = 0; i < N; i++)
  mat.push_back(&mat_data[0] + i*N);

您可能希望将其封装在一个新的 Matrix2D class 中。通过这种安排,您仍然可以使用 mat[i][j] 来引用矩阵元素,但现在所有行都在内存中一个接一个地整齐排列。如果你想发送这样的对象,你只需调用:

MPI_Send(mat[0], N*N, MPI_INT, ...);

如果您已经在接收方分配了一个 NxN 矩阵,只需执行以下操作:

MPI_Recv(mat[0], N*N, MPI_INT, ...);

如果您还没有分配矩阵,并且希望能够接收任意大小的 square 矩阵,请执行:

MPI_Status status;
// Probe for a message
MPI_Probe(..., &status);
// Get message size in number of integers
int nelems;
MPI_Get_count(&status, MPI_INT, &nelems);
N = sqrt(nelems);

// Allocate an NxN matrix mat as show above

// Receive the message
MPI_Recv(mat[0], N*N, MPI_INT, status.MPI_SOURCE, status.MPI_TAG, ...);

不幸的是,并不总是可以简单地将 vector<vector<T>> 换成平面数组类型,尤其是当您调用您无法控制的外部库时。在这种情况下,您还有两个选择。

当矩阵很小时,手动打包和解包它们的数据以进行通信并非不可行:

std::vector<int> p1_flat;
p1_flat.reserve(p1.size() * p1.size());
for (auto const &row : p1)
   std::copy(row.begin(), row.end(), std::back_inserter(p1_flat));

MPI_Send(&p1_flat[0], ...);

在接收端,你做相反的事情。

当矩阵很大时,打包和解包变得非常耗时和内存activity。幸运的是,MPI 提供了允许您跳过该部分并让它为您打包的规定。因为,如前所述,MPI 只是一个简单的通信库,它不会自动理解语言类型,它使用 MPI 数据类型形式的提示来正确处理底层语言类型。 MPI 数据类型类似于菜谱,它告诉 MPI 在何处以及如何访问内存中的数据。它是 (offset, primitive type):

形式的元组集合
  • offset 告诉 MPI 相应的数据块相对于给函数的地址的位置,例如 MPI_Send()
  • 原始类型 告诉 MPI 在该特定偏移处的原始语言数据类型是什么

最简单的 MPI 数据类型是与语言标量类型相对应的预定义数据类型。例如,MPI_INT 是元组 (0, int) 的底层,它告诉 MPI 将直接位于所提供地址的内存视为 int 的实例。当你告诉 MPI 你实际上发送的是整个 MPI_INT 数组时,它知道它需要从缓冲区位置取出一个元素,然后在内存中向前移动 int 的大小,再取一个, 等等。 C++ 数据序列化库的工作方式不太可能。就像您可以在 C++ 中从更简单的数据类型构建聚合类型一样,MPI 允许您从更简单的数据类型构建复杂的数据类型。例如,[(0, int), (16, float)] 数据类型告诉 MPI 从缓冲区地址中获取 int 并从缓冲区地址后的 16 个字节中获取 float

有两种构造数据类型的方法。您可以创建一个更简单类型的数组,重复特定的访问模式(这也允许您在该模式中指定统一的间隙),或者您可以创建一个任意更简单数据类型的结构。你需要的是后者。您需要能够告诉 MPI 以下内容: "Listen. I have those N arrays I want to send/receive, but they are scattered unpredictably all over the heap. Here are their addresses. Do something to concatenate them and send/receive them as a single message." 您可以通过使用 MPI_Type_create_struct.

构造结构数据类型来告诉它

struct 数据类型构造函数有四个输入参数:

  • int count - 新数据类型中的块(组件)数量,在您的情况下为 p.size()p 是其中之一 vector<vector<int>>s) ;
  • int array_of_blocklengths[] - 同样,由于 MPI 的数组性质,结构化数据类型实际上是更简单数据类型的数组(块)的结构;在这里你必须指定一个数组,其中的元素设置为相应行的大小;
  • MPI_Aint array_of_displacements[] - 对于每个块,MPI 需要知道它相对于数据缓冲区地址的位置;这可以是正数也可以是负数,最简单的方法是在这里简单地传递所有数组的地址;
  • MPI_Datatype array_of_types[] - 结构的每个块中的数据类型;您需要传递一个元素设置为 MPI_INT.
  • 的数组

在代码中:

// Block lengths
vector<int> block_lengths;
// Block displacements
vector<MPI_Aint> block_displacements;
// Block datatypes
vector<MPI_Datatype> block_dtypes(p.size(), MPI_INT);

for (auto const &row : p) {
  block_lengths.push_back(row.size());
  block_displacements.push_back(static_cast<MPI_Aint>(&row[0]));
}

// Create the datatype
MPI_Datatype my_matrix_type;
MPI_Type_create_struct(p.size(), block_lengths, block_displacements, block_dtypes, &my_matrix_type);
// Commit the datatatype to make it usable for communication
MPI_Type_commit(&my_matrix_type);

最后一步告诉 MPI 新创建的数据类型将用于通信。如果它只是构建更复杂数据类型的中间步骤,则可以省略提交步骤。

我们现在可以使用my_matrix_type发送p中的数据:

MPI_Send(MPI_BOTTOM, 1, my_matrix_type, ...);

MPI_BOTTOM 到底是什么?也就是地址的底部space,在很多平台上基本都是0。在大多数系统上,它与 NULLnullptr 相同,但没有指向任何地方的指针的语义。我们这里使用MPI_BOTTOM是因为在上一步中我们使用了每个数组的地址作为对应块的偏移量。我们可以减去第一行的地址:

for (auto const &row : p) {
  block_lengths.push_back(row.size());
  block_displacements.push_back(static_cast<MPI_Aint>(&row[0] - &p[0][0]));
}

然后我们使用以下方式发送 p

MPI_Send(&p[0][0], 1, my_matrix_type, ...);

请注意,您只能使用此数据类型发送 p 的内容,而不能发送其他 vector<vector<int>> 实例的内容,因为偏移量会有所不同。使用绝对地址或第一行地址的偏移量创建 my_matrix_type 并不重要。因此,该 MPI 数据类型的生命周期应与 p 本身的生命周期相同。

当不再需要时,my_matrix_type 应该被释放:

MPI_Type_free(&my_matrix_type);

vector<vector<T>>中接收数据完全相同。您首先需要调整外部向量的大小,然后调整内部向量的大小以准备内存。然后构建 MPI 数据类型并使用它来接收数据。如果您不再重复使用同一缓冲区,则释放 MPI 数据类型。

您可以巧妙地将上述所有步骤打包到 MPI 感知二维矩阵 class 中,从而在 class 析构函数中释放 MPI 数据类型。它还将确保您将为每个矩阵构建一个单独的 MPI 数据类型。

与第一种方法相比,这有多快?它比简单地使用平面数组慢一点,并且可能比打包和解包慢或快。这绝对比将每一行作为单独的消息发送更快。此外,一些网络适配器支持聚集读取和分散写入,这意味着 MPI 库只需要将 MPI 数据类型中的偏移量直接传递给硬件,而后者将完成将分散数组组装成单个消息的繁重工作。这可以在沟通渠道的两侧非常有效地完成。

请注意,您不必在发送方和接收方都执行相同的操作。在发送方使用用户定义的 MPI 数据类型并在接收方使用简单的平面数组是非常好的。或相反亦然。 MPI 不关心,只要发送方总共发送 N2MPI_INTs 并且接收方期望 N2[=142= 的整数倍] MPI_INTs,即发送和接收类型是否一致。

提醒一句: MPI 数据类型具有相当的可移植性,可以在许多平台上工作,甚至允许在异构环境中进行通信。但它们的构造可能比看起来更棘手。例如,块偏移量是 MPI_Aint 类型,它是一个指针大小的有符号整数,这意味着它可以用于可靠地寻址整个内存,给定一个以地址中间为中心的基数 space.但它不能表示相距内存大小一半以上的地址之间的差异。在大多数将虚拟地址 space 拆分为用户和内核部分的操作系统上,这不是问题,其中包括 x86 上的 32 位 Linux、32 位 [= x86 上的 164=] 没有 4 GB 调整,64 位版本的 Linux,Windows,x86 和 ARM 上的 macOS,以及大多数其他 32 位和 64 位架构。但是有些系统要么完全分离用户地址和内核地址 spaces,一个值得注意的例子是 32 位 macOS,要么可以进行除 1:1 以外的拆分,一个例子是 32-位 Windows 与 4 GB 调整 3:1 拆分。在这样的系统上,不应将 MPI_BOTTOM 与块偏移的绝对地址一起使用,也不应使用第一行的相对偏移。相反,应该派生一个指向地址中间的指针 space 并从中计算偏移量,然后将该指针用作 MPI 通信原语中的缓冲区地址。

免责声明:这是一篇很长的文章 post,可能存在我未注意到的错误。期待编辑。另外,我声称编写地道的 C++ 的能力为零。