这是一个每周至少弹出一次的主题。我通常会将您的问题作为重复的问题结束,但由于您使用的是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 类中。有了这种安排,您仍然可以使用mat[i][j] 来引用矩阵元素,但现在所有行都整齐地排列在内存中。如果你想发送这样一个对象,你只需调用:
MPI_Send(mat[0], N*N, MPI_INT, ...);
如果您已经在接收方分配了一个 NxN 矩阵,只需执行以下操作:
MPI_Recv(mat[0], N*N, MPI_INT, ...);
如果您尚未分配矩阵并且希望能够接收任意大小的 正方形 矩阵,请执行以下操作:
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], ...);
在接收方,你做相反的事情。
当矩阵很大时,打包和解包成为耗时和消耗内存的活动。幸运的是,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 以下内容:“听着。我有那些要发送/接收的 N 数组,但它们不可预测地分散在整个堆中。这是它们的地址。做点什么来连接它们并将它们作为一条消息发送/接收。”然后你可以通过使用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 到底是什么?那是地址空间的底部,在许多平台上基本上是0。在大多数系统上,它与NULL 或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]));
}
然后我们使用以下方式发送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 感知 2D 矩阵类中,该类在类析构函数中释放 MPI 数据类型。它还将确保您为每个矩阵构建单独的 MPI 数据类型。
与第一种方法相比,这有多快?它比简单地使用平面数组要慢一些,并且可能比打包和拆包更慢或更快。这绝对比将每一行作为单独的消息发送要快。此外,一些网络适配器支持聚集读取和分散写入,这意味着 MPI 库只需将 MPI 数据类型中的偏移量直接传递给硬件,后者将完成将分散数组组装成单个消息的繁重工作。这可以在沟通渠道的两端都非常有效地完成。
请注意,您不必在发送方和接收方都这样做。在发送方使用用户定义的 MPI 数据类型并在接收方使用简单的平面数组是非常好的。或相反亦然。 MPI 不在乎,只要发送方总共发送 N2MPI_INTs 并且接收方期望 N2MPI_INTs 的整数倍,即如果发送和接收类型一致。
请注意: MPI 数据类型相当可移植,可在许多平台上工作,甚至允许在异构环境中进行通信。但它们的构造可能比看起来更棘手。例如,块偏移量的类型为MPI_Aint,它是指针大小的有符号整数,这意味着它可以用于可靠地寻址整个内存,给定一个位于地址空间中间的基址。但它不能表示相隔超过一半内存大小的地址之间的差异。在大多数将虚拟地址空间 1:1 拆分为用户和内核部分的操作系统上,这不是问题,其中包括 x86 上的 32 位 Linux,x86 上的 32 位 Windows 没有 4 GB 调整,x86 和 ARM 以及大多数其他 32 位和 64 位架构上的 64 位版本的 Linux、Windows 和 macOS。但是有些系统要么完全分离用户和内核地址空间,其中一个显着的例子是 32 位 macOS,要么可以进行除 1:1 以外的拆分,其中一个例子是 32 位 Windows 与 4 GB进行 3:1 分割的调音。在这样的系统上,不应将MPI_BOTTOM 与块偏移的绝对地址一起使用,也不应使用与第一行的相对偏移。相反,应该派生一个指向地址空间中间的指针并从中计算偏移量,然后将该指针用作 MPI 通信原语中的缓冲区地址。
免责声明:这是一篇很长的帖子,可能有一些错误被我忽略了。期待编辑。另外,我声称编写惯用 C++ 的能力为零。