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

Sam*_*uel 1 c++ mpi parameter-passing

我需要传递数据类型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));
    }
}
Run Code Online (Sandbox Code Playgroud)

Hri*_*iev 6

这是一个每周至少出现一次的话题。我通常会将您的问题视为重复,但由于您使用的是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. ]
                 ...
Run Code Online (Sandbox Code Playgroud)

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

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

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

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);
Run Code Online (Sandbox Code Playgroud)

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

MPI_Send(mat[0], N*N, MPI_INT, ...);
Run Code Online (Sandbox Code Playgroud)

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

MPI_Recv(mat[0], N*N, MPI_INT, ...);
Run Code Online (Sandbox Code Playgroud)

如果您尚未分配矩阵并且希望能够接收任意大小的方阵,请执行以下操作:

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, ...);
Run Code Online (Sandbox Code Playgroud)

不幸的是,并不总是可以简单地交换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], ...);
Run Code Online (Sandbox Code Playgroud)

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

当矩阵很大时,打包和解包就成为消耗时间和内存的活动。幸运的是,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 允许您从更简单的数据类型构建复杂的数据类型。例如,数据类型告诉 MPI从缓冲区地址中[(0, int), (16, float)]获取一个,并从缓冲区地址之后的 16 个字节中获取一个。intfloat

有两种构造数据类型的方法。您可以创建一个更简单类型的数组,重复某种访问模式(这还允许您在该模式中指定统一的间隙),也可以创建任意更简单数据类型的结构。你需要的是后者。您需要能够告诉 MPI 以下内容:“听着。我有N想要发送/接收的数组,但它们不可预测地分散在堆上。这是它们的地址。执行某些操作来连接它们并发送/接收它们作为一条消息。” 你可以通过使用 构造一个结构体数据类型来告诉它这一点MPI_Type_create_struct

结构数据类型构造函数采用四个输入参数:

  • int count- 新数据类型中的块(组件)数量,在您的情况下是p.size()p是其中之一vector<vector<int>>);
  • 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);
Run Code Online (Sandbox Code Playgroud)

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

我们现在可以使用以下方式my_matrix_type发送数据p

MPI_Send(MPI_BOTTOM, 1, my_matrix_type, ...);
Run Code Online (Sandbox Code Playgroud)

到底是什么MPI_BOTTOM?这是地址空间的底部,基本上0在许多平台上都是如此。NULL在大多数系统上,它与or相同nullptr,但没有指向任何地方的指针的语义。我们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]));
}
Run Code Online (Sandbox Code Playgroud)

p然后我们使用以下命令发送:

MPI_Send(&p[0][0], 1, my_matrix_type, ...);
Run Code Online (Sandbox Code Playgroud)

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

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

MPI_Type_free(&my_matrix_type);
Run Code Online (Sandbox Code Playgroud)

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

您可以将上述所有步骤整齐地打包在 MPI 感知的 2D 矩阵类中,该类在类析构函数中释放 MPI 数据类型。它还将确保您为每个矩阵构建单独的 MPI 数据类型。

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

请注意,您不必在发送方和接收方都执行相同的操作。在发送方使用用户定义的 MPI 数据类型并在接收方使用简单的平面数组是完全可以的。或相反亦然。只要发送方总共发送 N 2且接收方期望 N 2的整数倍(即发送和接收类型是否一致),MPI 并不关心。MPI_INT MPI_INT

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

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