4. 初探MPI——集体通信

系列文章目录

  1. 初探MPI——MPI简介
  2. 初探MPI——(阻塞)点对点通信
  3. 初探MPI——(非阻塞)点对点通信
  4. 初探MPI——集体通信


前言

点对点通信的方式只会涉及两个不同进程之间的通信。而集体通信指的是涉及 communicator 里面所有进程的一个方法。

接下来的内容将要讲述:

  • Broadcast : One process sends a message to every other process
  • Reduction : One process gets data from all the other processes and applies an operation on it (sum, minimum, maximum, etc.
  • Scatter : A single process partitions the data to send pieces to every other process 单个进程将数据分区然后将数据块发送到其他进程
  • Gather : A single process assembles the data from different process in a buffer 单个进程将来自不同进程的数据组装在缓冲区中

一、集体通信以及同步点

同步点:这意味着所有的进程在执行代码的时候必须首先都到达一个同步点才能继续执行后面的代码。

MPI 有一个特殊的函数来做同步进程的这个操作。

MPI_Barrier(MPI_Comm communicator)

在这里插入图片描述

注意:始终记得每一个你调用的集体通信方法都是同步的。也就是说,如果没法让所有进程都完成 MPI_Barrier,那么你也没法完成任何集体调用。如果你在没有确保所有进程都调用 MPI_Barrier的情况下调用了它,那么程序会空闲下来。

二、MPI_Bcast 广播

广播 (broadcast) 是标准的集体通信技术之一。一个广播发生的时候,一个进程会把同样一份数据传递给一个 communicator 里的所有其他进程。广播的主要用途之一是把用户输入传递给一个分布式程序,或者把一些配置参数传递给所有的进程。

在这里插入图片描述
广播可以使用 MPI_Bcast 来做到,函数声明是:

MPI_Bcast(
    void* data,
    int count,
    MPI_Datatype datatype,
    int root,
    MPI_Comm communicator)

尽管根节点和接收节点做不同的事情,它们都是调用同样的这个 MPI_Bcast 函数来实现广播。

  • 当根节点(在我们的例子是节点0)调用 MPI_Bcast 函数时, data 变量里的值会被发送到其他的节点上。
  • 当其他的节点调用 MPI_Bcast 时,data 变量会被赋值成从根节点接受到的数据。

2.1 使用MPI_SendMPI_Recv 来做广播

粗略看的话,似乎 MPI_Bcast 仅仅是在 MPI_SendMPI_Recv 基础上进行了一层包装。事实上,我们就可以自己来做这层封装。我们的函数叫做 my_bcast。它跟 MPI_Bcast 接受一样的参数,看起来像这样:

void my_bcast(void* data, int count, MPI_Datatype datatype, int root, 
			  MPI_Comm communicator){
	int rank;
	MPI_Comm_rank(communicator, &rank);
	int size;
	MPI_Comm_size(communicator, &size);
	if (rank == root){
		for (int i = 0; i < size; i++){
		if (i != rank){
			MPI_Send(data, count, datatype, i, 0, communicator);
			}
		}
	} else {
		MPI_Recv(data, count, datatype, root, 0, communicator, MPI_STATUS_IGNORE);
	}
}

这个函数的时间复杂度应该是O(n)的
在这里插入图片描述采用树算法的时间复杂度是O(logn)。

2.2 MPI_BcastMPI_Send 以及 MPI_Recv 的比较

在这里插入图片描述

2.3 Blocking or non-blocking ?

这里仅给出阻塞版本。but you just need to add the I to switch to non-blocking mode (eg MPI_Bcast will become MPI_Ibcast). non-blocking globals require the use of MPI_Wait and MPI_Test to be completed correctly.

三、MPI Scatter, Gather, and Allgather

两个额外的机制来补充集体通信的知识 - MPI_Scatter 以及 MPI_Gather。还会讲一个 MPI_Gather 的变体:MPI_Allgather

3.1 MPI_Scatter介绍

MPI_BcastMPI_Scatter 的主要区别很小但是很重要。

  • MPI_Bcast 给每个进程发送的是同样的数据,
  • MPI_Scatter 给每个进程发送的是一个数组的一部分数据。

在这里插入图片描述
尽管根进程(进程0)拥有整个数组的所有元素,MPI_Scatter 还是会把正确的属于进程0的元素放到这个进程的接收缓存中。

MPI_Scatter 函数的原型:

MPI_Scatter(
    void* send_data,
    int send_count,
    MPI_Datatype send_datatype,
    void* recv_data,
    int recv_count,
    MPI_Datatype recv_datatype,
    int root,
    MPI_Comm communicator)
  1. send_data是在根进程上的一个数据数组
  2. send_countsend_datatype分别描述了发送给每个进程的数据数量和数据类型:
    如果send_count 是1,send_datatypeMPI_INT的话,进程0会得到数据里的第一个整数,以此类推。如果send_count是2的话,进程0会得到前两个整数,进程1会得到第三个和第四个整数,以此类推。在实践中,一般来说send_count会等于数组的长度除以进程的数量。除不尽怎么办?会在后面讲这个问题 。
  3. 函数定义里面接收数据的参数跟发送的参数几乎相同。recv_data 参数是一个缓存,它里面存了recv_countrecv_datatype数据类型的元素。
  4. rootcommunicator 分别指定开始分发数组的根进程以及对应的communicator。

3.2 MPI_Gather 的介绍

MPI_GatherMPI_Scatter 是相反的。MPI_Gather 从好多进程里面收集数据到一个进程上面而不是从一个进程分发数据到多个进程。这个机制对很多平行算法很有用,比如并行的排序和搜索。
在这里插入图片描述MPI_Scatter类似,MPI_Gather从其他进程收集元素到根进程上面。元素是根据接收到的进程的秩排序的。MPI_Gather的函数原型跟MPI_Scatter长的一样。

MPI_Gather(
    void* send_data,
    int send_count,
    MPI_Datatype send_datatype,
    void* recv_data,
    int recv_count,
    MPI_Datatype recv_datatype,
    int root,
    MPI_Comm communicator)

MPI_Gather中,只有根进程需要一个有效的接收缓存。所有其他的调用进程可以传递NULLrecv_data

注意:别忘记recv_count参数是从每个进程接收到的数据数量,而不是所有进程的数据总量之和。这一点对MPI初学者来说经常容易搞错。

3.3 使用 MPI_ScatterMPI_Gather 来计算平均数

这段代码展示了如何使用MPI来把工作拆分到不同的进程上,每个进程对一部分数据进行计算,然后再把每个部分计算出来的结果汇集成最终的答案。程序步骤是:

  1. 在根进程(进程0)上生成一个充满随机数字的数组。
  2. 把所有数字用MPI_Scatter分发给每个进程,每个进程得到的同样多的数字。
  3. 每个进程计算它们各自得到的数字的平均数。
  4. 根进程收集所有的平均数,然后计算这个平均数的平均数,得出最后结果。
// Author: Wes Kendall
// Copyright 2012 www.mpitutorial.com
// This code is provided freely with the tutorials on mpitutorial.com. Feel
// free to modify it for your own use. Any distribution of the code must
// either provide a link to www.mpitutorial.com or keep this header intact.
//
// Program that computes the average of an array of elements in parallel using
// MPI_Scatter and MPI_Gather
//
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <mpi.h>
#include <assert.h>

// Creates an array of random numbers. Each number has a value from 0 - 1
float *create_rand_nums(int num_elements) {
  float *rand_nums = (float *)malloc(sizeof(float) * num_elements);
  assert(rand_nums != NULL);
  int i;
  for (i = 0; i < num_elements; i++) {
    rand_nums[i] = (rand() / (float)RAND_MAX);
  }
  return rand_nums;
}

// Computes the average of an array of numbers
float compute_avg(float *array, int num_elements) {
  float sum = 0.f;
  int i;
  for (i = 0; i < num_elements; i++) {
    sum += array[i];
  }
  return sum / num_elements;
}

int main(int argc, char** argv) {
  if (argc != 2) {
    fprintf(stderr, "Usage: avg num_elements_per_proc\n");
    exit(1);
  }

  int num_elements_per_proc = atoi(argv[1]);
  // Seed the random number generator to get different results each time
  srand(time(NULL));

  MPI_Init(NULL, NULL);

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

  // Create a random array of elements on the root process. Its total
  // size will be the number of elements per process times the number
  // of processes
  float *rand_nums = NULL;
  if (world_rank == 0) {
    rand_nums = create_rand_nums(num_elements_per_proc * world_size);
  }

  // For each process, create a buffer that will hold a subset of the entire
  // array
  float *sub_rand_nums = (float *)malloc(sizeof(float) * num_elements_per_proc);
  assert(sub_rand_nums != NULL);

  // Scatter the random numbers from the root process to all processes in
  // the MPI world
  MPI_Scatter(rand_nums, num_elements_per_proc, MPI_FLOAT, sub_rand_nums,
              num_elements_per_proc, MPI_FLOAT, 0, MPI_COMM_WORLD);

  // Compute the average of your subset
  float sub_avg = compute_avg(sub_rand_nums, num_elements_per_proc);

  // Gather all partial averages down to the root process
  float *sub_avgs = NULL;
  if (world_rank == 0) {
    sub_avgs = (float *)malloc(sizeof(float) * world_size);
    assert(sub_avgs != NULL);
  }
  MPI_Gather(&sub_avg, 1, MPI_FLOAT, sub_avgs, 1, MPI_FLOAT, 0, MPI_COMM_WORLD);

  // Now that we have all of the partial averages on the root, compute the
  // total average of all numbers. Since we are assuming each process computed
  // an average across an equal amount of elements, this computation will
  // produce the correct answer.
  if (world_rank == 0) {
    float avg = compute_avg(sub_avgs, world_size);
    printf("Avg of all elements is %f\n", avg);
    // Compute the average across the original data for comparison
    float original_data_avg =
      compute_avg(rand_nums, num_elements_per_proc * world_size);
    printf("Avg computed across original data is %f\n", original_data_avg);
  }

  // Clean up
  if (world_rank == 0) {
    free(rand_nums);
    free(sub_avgs);
  }
  free(sub_rand_nums);

  MPI_Barrier(MPI_COMM_WORLD);
  MPI_Finalize();
}

上面这段代码有点犯懒,我本人没有写,直接copy Wes Kendall的代码,我简单来讲讲这个代码的思路:

// 在根进程中,随机产生elements_per_proc * world_size个随机数字的数组
if (world_rank == 0) {
  rand_nums = create_rand_nums(elements_per_proc * world_size);
}

// 创建一个根进程传给其他进程的子数组作为缓存区,子数组的长度为elements_per_proc
float *sub_rand_nums = malloc(sizeof(float) * elements_per_proc);

// 创建好了子数组作为缓存区之后,根进程就开始分发数据了
MPI_Scatter(rand_nums, elements_per_proc, MPI_FLOAT, sub_rand_nums,
            elements_per_proc, MPI_FLOAT, 0, MPI_COMM_WORLD);

// 将每个进程得到的子数组的元素去平均值
float sub_avg = compute_avg(sub_rand_nums, elements_per_proc);

// 再创建一个缓存区用来存储其他进程上传它们计算得到的平均值
float *sub_avgs = NULL;
if (world_rank == 0) {
  sub_avgs = malloc(sizeof(float) * world_size);
}
// 根进程gather到了其他进程传过来的数据
MPI_Gather(&sub_avg, 1, MPI_FLOAT, sub_avgs, 1, MPI_FLOAT, 0,
           MPI_COMM_WORLD);
           
// 再去计算这些平均数的平均数们就能得到总的平均数
if (world_rank == 0) {
  float avg = compute_avg(sub_avgs, world_size);
}

3.4 MPI_Allgather

上面两个用来操作多对一或者一对多通信模式,也就是说多个进程要么向一个进程发送数据,要么从一个进程接收数据。

MPI_Allgather却是多个元素到多个进程(也就是多对多通信模式)。

在这里插入图片描述函数原型:

MPI_Allgather(
    void* send_data,
    int send_count,
    MPI_Datatype send_datatype,
    void* recv_data,
    int recv_count,
    MPI_Datatype recv_datatype,
    MPI_Comm communicator)

MPI_Allgather的方法定义跟MPI_Gather几乎一样,只不过MPI_Allgather不需要root这个参数来指定根节点。

四、并行排名

4.1 问题概述

在这里插入图片描述

4.2 并行排名API定义

在深入研究并行排名问题之前,首先确定函数的行为方式。

  1. 函数需要在每个进程上取一个数字,并返回其相对于所有其他进程中的数字的排名
  2. 需要正在使用的communicator
  3. 被排名的数字的数据类型

函数原型:

TMPI_Rank(
	void* send_data, //作为缓冲区
	void* recv_data, // send_datade 排名
	MPI_Datatype datatype,
	MPI_Comm comm)

4.3 解决并行排名问题

4.3.1 对所有进程中的数字进行排序

最简单的方法是将所有数字收集到一个进程中并对数字进行排序。gather_numbers_to_root 函数负责将所有数字收集到根进程(root process)。

// 为进程0的TMPI_Rank收集数字。为MPI的数据类型分配空间
// 对进程0返回 void * 指向的缓冲区
// 对所有其他进程返回NULL
void *gather_numbers_to_root(void *number, MPI_Datatype datatype,
                             MPI_Comm comm) {
  int comm_rank, comm_size;
  MPI_Comm_rank(comm, &comm_rank);
  MPI_Comm_size(comm, &comm_size);

  // 在根进程上分配一个数组
  // 数组大小取决于所用的MPI数据类型
  int datatype_size;
  MPI_Type_size(datatype, &datatype_size);
  void *gathered_numbers;
  if (comm_rank == 0) {
    gathered_numbers = malloc(datatype_size * comm_size);
  }

  // 在根进程上收集所有数字
  MPI_Gather(number, 1, datatype, gathered_numbers, 1,
             datatype, 0, comm);

  return gathered_numbers;
}
  • gather_numbers_to_root 函数获取要收集的数字(即 send_data 变量)、数字的数据类型 datatypecomm 通讯器。
  • 根进程必须在此函数中收集 comm_size 个数字,因此它会分配 datatype_size * comm_size 长度的数组.
  • 这里通过使用新的MPI函数- MPI_Type_size 来收集datatype_size变量。

4.3.2 排序数字并维护所属

  • 在我们的排名函数中,排序数字不一定是难题。CPP中提供了许多排序算法(我们也可以自己写)
  • 排序的困难在于,我们必须维护各个进程将数字发送到根进程的次序。 如果我们要对收集到根进程的数组进行排序而不给数字附加信息,则根进程将不知道如何将数字的排名发送回原来请求的进程!
  • 为了便于将所属进程附到对应数字上,我们在代码中创建了一个结构体(struct)来保存此信息。
    该结构体定义如下:
// 保存进程在通讯器中的次序(rank)和对应数字
// 该结构体用于数组排序,
// 并同时完整保留所属进程信息

typedef struct {
  int comm_rank;
  union {
    float f;
    int i;
  } number;
} CommRankNumber;

CommRankNumber 结构体保存了我们要排序的数字(记住它可以是浮点数或整数,因此我们使用联合体union),并且它拥有该数字所属进程在通讯器中的次序(rank)

get_ranks 函数,负责创建这些结构体并对它们进行排序。

// 这个函数在根进程上对收集到的数字排序
// 返回一个数组,数组按进程在通讯器中的次序排序
// 注意 - 该函数只在根进程上运行

int *get_ranks(void *gathered_numbers, int gathered_number_count,
               MPI_Datatype datatype) {
  int datatype_size;
  MPI_Type_size(datatype, &datatype_size);

  // 将收集到的数字数组转换为CommRankNumbers数组
  // 这允许我们在排序的同时,完整保留数字所属进程的信息
  
  CommRankNumber *comm_rank_numbers = malloc(
    gathered_number_count * sizeof(CommRankNumber));
  int i;
  for (i = 0; i < gathered_number_count; i++) {
    comm_rank_numbers[i].comm_rank = i;
    memcpy(&(comm_rank_numbers[i].number),
           gathered_numbers + (i * datatype_size),
           datatype_size);
  }

  // 根据数据类型对comm_rank_numbers排序
  if (datatype == MPI_FLOAT) {
    qsort(comm_rank_numbers, gathered_number_count,
          sizeof(CommRankNumber), &compare_float_comm_rank_number);
  } else {
    qsort(comm_rank_numbers, gathered_number_count,
          sizeof(CommRankNumber), &compare_int_comm_rank_number);
  }

  // 现在comm_rank_numbers是排好序的,下面生成一个数组,
  // 包含每个进程的排名,数组第i个元素是进程i的数字的排名
  
  int *ranks = (int *)malloc(sizeof(int) * gathered_number_count);
  for (i = 0; i < gathered_number_count; i++) {
    ranks[comm_rank_numbers[i].comm_rank] = i;
  }

  // 清理并返回排名数组
  free(comm_rank_numbers);
  return ranks;
}
  • get_ranks 函数首先创建一个CommRankNumber结构体数组,并附上该数字所属进程在通讯器中的次序。 如果数据类型为 MPI_FLOAT ,则对我们的结构体数组调用 qsort 时,会使用特殊的排序函数。类似的,如果数据类型为 MPI_INT ,我们将使用不同的排序函数。

  • 在对数字进行排序之后,我们必须以适当的顺序创建一个排名数组(array of ranks),以便将它们分散(scatter)回到请求的进程中。这是通过创建 ranks 数组并为每个已排序的 CommRankNumber 结构体填充适当的排名来实现的。

4.3.3 整合

现在有了两个主要函数,可以将它们全部整合到 TMPI_Rank 函数中。此函数将数字收集到根进程,并对数字进行排序以确定其排名,然后将排名分散回请求的进程。 代码如下所示:

// 获取send_data的排名, 类型为datatype
// 排名用recv_data返回,类型为datatype
int TMPI_Rank(void *send_data, void *recv_data, MPI_Datatype datatype,
             MPI_Comm comm) {
  // 首先检查基本情况 - 此函数只支持MPI_INT和MPI_FLOAT

  if (datatype != MPI_INT && datatype != MPI_FLOAT) {
    return MPI_ERR_TYPE;
  }

  int comm_size, comm_rank;
  MPI_Comm_size(comm, &comm_size);
  MPI_Comm_rank(comm, &comm_rank);

  // 为了计算排名,必须将数字收集到一个进程中
  // 对数字排序, 然后将排名结果分散传回
  // 首先在进程0上收集数字
  void *gathered_numbers = gather_numbers_to_root(send_data, datatype,
                                                  comm);

  // 获取每个进程的次序(rank)
  int *ranks = NULL;
  if (comm_rank == 0) {
    ranks = get_ranks(gathered_numbers, comm_size, datatype);
  }

  // 分散发回排名结果
  MPI_Scatter(ranks, 1, MPI_INT, recv_data, 1, MPI_INT, 0, comm);

  // 清理
  if (comm_rank == 0) {
    free(gathered_numbers);
    free(ranks);
  }
}

TMPI_Rank 函数使用我们刚刚创建的两个函数 gather_numbers_to_rootget_ranks 来获取数字的排名。然后,函数执行最后的 MPI_Scatter,以将所得的排名分散传回进程。

4.4 最终结果

最终代码参看tutorials/performing-parallel-rank-with-mpi/code/tmpi_rank.c

以下是整个数据流说明:
在这里插入图片描述

五、MPI Reduce and Allreduce

5.1 归约/归化(reduce)简介

这个概念在OpenMPI中介绍过,详情参见C/C++实现高性能并行计算——2.使用OpenMP进行共享内存编程

5.2 MPI_Reduce

函数原型:

MPI_Reduce(
    void* send_data,
    void* recv_data,
    int count,
    MPI_Datatype datatype,
    MPI_Op op,
    int root,
    MPI_Comm communicator)
  1. send_data 参数是每个进程都希望归约的 datatype 类型元素的数组。
  2. recv_data 仅与具有 root 秩的进程相关。 recv_data 数组包含归约的结果,大小为sizeof(datatype)* count
  3. op 参数是希望应用于数据的操作。 MPI 包含一组可以使用的常见归约运算。 尽管可以定义自定义归约操作,但这里不作介绍。

MPI定义的归约操作包括:
在这里插入图片描述
在这里插入图片描述

5.3 使用MPI_Reduce计算均值

在 第三节 中,展示了使用 MPI_ScatterMPI_Gather 计算平均值。 使用 MPI_Reduce 可以简化上一节的代码。

float *rand_nums = NULL;
rand_nums = create_rand_nums(num_elements_per_proc);

// Sum the numbers locally
float local_sum = 0;
int i;
for (i = 0; i < num_elements_per_proc; i++) {
  local_sum += rand_nums[i];
}

// Print the random numbers on each process
printf("Local sum for process %d - %f, avg = %f\n",
       world_rank, local_sum, local_sum / num_elements_per_proc);

// Reduce all of the local sums into the global sum
float global_sum;
MPI_Reduce(&local_sum, &global_sum, 1, MPI_FLOAT, MPI_SUM, 0,
           MPI_COMM_WORLD);

// Print the result
if (world_rank == 0) {
  printf("Total sum = %f, avg = %f\n", global_sum,
         global_sum / (world_size * num_elements_per_proc));
}
  1. 每个进程都会创建随机数并计算和保存在 local_sum
  2. 然后使用 MPI_SUMlocal_sum 归约至根进程。
  3. 全局平均值为 global_sum / (world_size * num_elements_per_proc)

5.4 MPI_Allreduce

如果所有进程而不是仅仅在根进程中访问归约的结果。 MPI_Allreduce 将归约值并将结果分配给所有进程。 函数原型如下:

MPI_Allreduce(
    void* send_data,
    void* recv_data,
    int count,
    MPI_Datatype datatype,
    MPI_Op op,
    MPI_Comm communicator)

在这里插入图片描述

5.4.1 使用 MPI_Allreduce 计算标准差

详情代码见tutorials/mpi-reduce-and-allreduce/code/reduce_stddev.c


总结

其实这篇文章到了后期我写的不是很满意,因为代码都是直接复制粘贴,不像之前的都是自己写再去对照别人的代码。可能是状态不好,也有可能是太急于求成了,罪过,罪过。太想进步啦!!!后期我可能会重新编辑这篇文章,代码我也要重新写一遍。

  1. 集体通信和同步点
  2. 广播MPI_Bcast
  3. 发散MPI_Scatter和收集MPI_Gather(所有进程都要一份)MPI_Allgather
  4. 归约/归化 MPI_Reduce,所有进程都要一份归化MPI_Allreduce

参考

  1. MPI Tutorials
  • 26
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值