【问题标题】:How to transfer a matrix from one process to another in MPI?如何在 MPI 中将矩阵从一个进程转移到另一个进程?
【发布时间】:2020-05-12 19:07:54
【问题描述】:

我需要传递数据类型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));
    }
}

【问题讨论】:

  • 将所有内容转换成一个巨大的整数块,然后通过 MPI 发送。
  • 基本上你有两个选择:1)单独发送所有矩阵行,或2)将矩阵存储在内存中的一维数组中并立即发送。后一种方法对于矩阵计算也更有效,因为元素是连续存储的(对缓存/预取更友好,堆开销更少)。 Vector-of-vectors 是最简单的编码,但如果他们关心性能,没有人会在实践中使用它。
  • @stackoverblast,怎么做?是否可以示例代码?我使用最大为 3000x3000 的方阵
  • @DanielLangr,同样的问题。如何做到这一点,示例代码?我不太明白它会是什么样子
  • @Samuel 这是 MPI 标签下关于 SO 的一个非常常见的问题。快速搜索将指向一些代码和/或有关如何处理它的 C++ 风格的建议。

标签: c++ parameter-passing mpi


【解决方案1】:

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

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

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&lt;T&gt;::operator[]() 的代码,最后生成指针算术和解引用表达式,该表达式给出该特定矩阵元素的地址。

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

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

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&lt;vector&lt;T&gt;&gt; 替换为平面数组类型,尤其是当您调用无法控制的外部库时。在这种情况下,您还有两个选择。

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

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&lt;vector&lt;int&gt;&gt;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。在大多数系统上,它与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&lt;vector&lt;int&gt;&gt; 实例的内容,因为偏移量会有所不同。使用绝对地址或从第一行地址的偏移量创建my_matrix_type 都没有关系。因此,该 MPI 数据类型的生命周期应与 p 本身的生命周期相同。

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

MPI_Type_free(&my_matrix_type);

同样适用于接收vector&lt;vector&lt;T&gt;&gt; 中的数据。您首先需要调整外部向量的大小,然后调整内部向量的大小以准备内存。然后构建 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++ 的能力为零。

【讨论】:

  • @Hristolliev,谢谢,但我需要时间来理解你的回答
猜你喜欢
  • 2012-05-23
  • 2021-03-21
  • 1970-01-01
  • 2021-12-19
  • 2011-05-14
  • 2020-09-27
  • 2013-04-13
  • 2011-05-02
  • 2023-01-26
相关资源
最近更新 更多