MPI教程4

§自定义数据类型

§内置类型

MPI内置类型及其对应的MPI_Datatype如下表所示:

MPI_Datatype 对应c语言类型
MPI_CHAR char
MPI_SHORT short
MPI_INT int
MPI_LONG long
MPI_FLOAT float
MPI_DOUBLE double
MPI_UNSIGNED_CHAR unsigned char
MPI_UNSIGNED_SHORT unsigned short
MPI_UNSIGNED unsigned int
MPI_UNSIGNED_LONG unsigned long
MPI_LONG_LONG_INT long long
MPI_LONG_DOUBLE long double

MPI系统的内置数据类型只适合于收发一组在内存中连续存放的数据,如果要发送和接收的数据在内存中不连续或者由不同类型的数据构成时,需要使用自定义的数据类型。自定义数据类型用于描述要发送或接收的数据在内存中的确切分布。使用适当的自定义类型可以有效地减少消息传递的次数,增大通信粒度,提升程序性能。

§类型提交与释放

所有新创建的数据类型在首次用于消息传递前必须进行提交。新数据类型提交后就可以和 MPI 原始数据类型完全一样地在消息传递中使用。如果一个数据类型仅被用于创建其他数据类型的中间步骤 而并不直接在消息传递中使用,则不必将它提交,一旦基于它的其他数据类型创建完毕即可立即将其释放。

当一个数据类型不再需要时应该将它释放以便释放其所占用的内存。释放一个数据类型不会影响正在进行的使用该数据类型的通信,也不会影响基于它创建的其他数据类型。

类型提交与释放函数分别为MPI_Type_commit和MPI_Type_free,它们的声明为:

1
2
int MPI_Type_commit(MPI_Datatype *datatype);
int MPI_Type_free(MPI_Datatype *datatype);

MPI_Type_free函数返回时将datatype设置为MPI_DATATYPE_NULL,即空数据类型。

§连续复制类型

通过MPI_Type_contiguous函数可以将多个连续的相同的数据类型合成一个数据类型,其声明为:

1
2
3
4
5
int MPI_Type_contiguous(
    int count;              // 旧类型的个数
    MPI_Datatype oldtype,   // 旧数据类型
    MPI_Datatype *newtype   // 新数据类型
);

MPI_Type_contiguous的用处在于,为一个固定长度的数组或者只含同种类型成员的结构体创建一个新的类型。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <mpi.h>

struct Complex {
    double real;
    double image;
};

int main(int argc, char *argv[]) {
    MPI_Init(&argc, &argv);

    int id = 0;
    MPI_Comm_rank(MPI_COMM_WORLD, &id);

    Complex c;
    if (0 == id) {
        std::cin >> c.real >> c.image;
    }

    // 为Complex创建MPI数据类型
    MPI_Datatype ComplexType;
    MPI_Type_contiguous(2, MPI_DOUBLE, &ComplexType);
    MPI_Type_commit(&ComplexType);

    MPI_Bcast(&c, 1, ComplexType, 0, MPI_COMM_WORLD);

    MPI_Type_free(&ComplexType);

    if (1 == id)
        std::cout << c.real << " " << c.image << std::endl;

    MPI_Finalize();

    return 0;
}

§向量数据类型

连续复制类型不允许类型之间有间隔,向量数据类型支持从不连续存放的相同类型创建新的类型。向量数据类型创建函数有两个:MPI_Type_vector和MPI_Type_hvector。它们的声明为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
int MPI_Type_vector(
    int count,              // 块的数量
    int blocklength,        // 每个块中所含元素的个数
    int stride,             // 各块第一个元素之间相隔的元素数
    MPI_Datatype oldtype,   // 旧数据类型
    MPI_Datatype *newtype   // 新数据类型
);

int MPI_Type_hvector(
    int count,              // 块的数量
    int blocklength,        // 每个块中所含元素的个数
    MPIAint stride,         // 各块第一个元素之间相隔的字节数
    MPI_Datatype oldtype,   // 旧数据类型
    MPI_Datatype *newtype   // 新数据类型
);

两个函数功能相同,区别仅在于:stride在MPI_Type_vector中以oldtype为单位,而在MPI_Type_hvector中以字节为单位。其中MPI_Aint为存放地址或位移的变量,在不同的mpi实现中不同,如msmpi定义为long long,而在mpich中定义为long。

MPI_Type_vector的功能是创建一个新数据类型newtype,它由count个数据块构成,每个数据块由blocklength个连续存放的类型为oldtype的数据构成,相邻两个数据块的首地址相差stride个oldtype类型的数据。以下为count=2, blocklength=2, stride=3的示意图,它表示新数据类型有两个不连续的块,块的首地址相差3个oldtype,每块有2个连续的oldtype。

type_vector

MPI_Type_vector用于将数组内不连续存放的数据发送给另一个进程,例如,对于数组A,希望将下标为偶数的数据发送给进程1,下标为奇数的数据发送给进程2,代码描述如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
#include <vector>
#include <algorithm>
#include <mpi.h>

int main(int argc, char *argv[]) {
    MPI_Init(&argc, &argv);

    int id = 0;
    MPI_Comm_rank(MPI_COMM_WORLD, &id);

    const int N = 15;
    std::vector<double> A(N, 0);

    if (0 == id)
        std::generate(A.begin(), A.end(), []() { static int n = 1; return n++;});

    MPI_Datatype EvenType;
    MPI_Type_vector(
        (N + 1) / 2,    // 块的数量
        1,              // 块内元素个数
        2,              // 相邻块首地址间隔
        MPI_DOUBLE,     // 旧数据类型
        &EvenType       // 偶数下标类型
    );
    MPI_Type_commit(&EvenType);

    if (0 == id)
        MPI_Send(A.data(), 1, EvenType, 1, 0, MPI_COMM_WORLD);

    if (1 == id)
        MPI_Recv(A.data(), 1, EvenType, 0, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE);

    // 奇数下标类似

    MPI_Type_free(&EvenType);

    if (1 == id) {
        for (int i = 0; i < N; ++i)
            std::cout << A[i] << " ";
        std::cout << std::endl;
    }

    MPI_Finalize();

    return 0;
}

2个进程执行结果:

1 0 3 0 5 0 7 0 9 0 11 0 13 0 15

§索引数据类型

向量数据类型的块间间隔必须相同,而索引数据类型则不必。与MPI_Type_vector和MPI_Type_hvector类似,索引数据类型创建函数也有两个,分别为MPI_Type_indexed和MPI_Type_hindexed,它们的声明为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
int MPI_Type_indexed(
    int count,              // 块的数量
    const int blocklens[],  // 每个块中所含元素的个数
    const int indices[],    // 各块首地址偏移量
    MPI_Datatype oldtype,   // 旧数据类型
    MPI_Datatype *newtype   // 新数据类型
);

int MPI_Type_hindexed(
    int count,              // 块的数量
    int blocklens[],        // 每个块中所含元素的个数
    MPI_Aint indices[],     // 各块偏移字节
    MPI_Datatype oldtype,   // 旧数据类型
    MPI_Datatype *newtype   // 新数据类型
);

两者的区别仅在于参数indices在MPI_Type_indexed以旧数据类型为单位,而在MPI_Type_hindexed中以字节为单位。

MPI_Type_indexed创建的新类型newtype由count个数据块构成,数据块i由blocklens[i]个连续存放、类型为oldtype的数据构成,首地址为indices[i]个oldtype的偏移量。以下为count=2, blocklens={1, 2},indices={0, 3}的示意图。它表示新数据类型有2个块,第一块有1个数据,起始地址偏移为0,第二块有2个数据,起始地址偏移3个oldtype。

type_indexed

MPI_Type_indexed用于将数组中不连续存放且间隔不相同的数据一次性发送到另一个进程。例如使用MPI_Type_indexed将数组中下标为质数的数发送到另一个进程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#include <iostream>
#include <vector>
#include <algorithm>
#include <mpi.h>

int main(int argc, char *argv[]) {
    MPI_Init(&argc, &argv);

    int id = 0;
    MPI_Comm_rank(MPI_COMM_WORLD, &id);

    const int N = 15;
    std::vector<double> A(N, 0);

    if (0 == id)
        std::generate(A.begin(), A.end(), []() { static int n = 0; return n++;});

    // 筛掉所有合数
    std::vector<bool> is_prime(N, true);
    is_prime[0] = is_prime[1] = false;
    for (int i = 2; i < N; ++i) {
        if (!is_prime[i])
            continue;
        for (int j = 2 * i; j < N; j += i)
            is_prime[j] = false;
    }

    // 计算质数个数
    int num_primes = 0;
    for (int i = 2; i < N; ++i) {
        if (is_prime[i])
            ++num_primes;
    }

    // 计算每块有多少个数
    std::vector<int> blocklens(num_primes);
    blocklens[0] = 2;
    for (int i = 1; i < num_primes; ++i)
        blocklens[i] = 1;

    // 计算每块的偏移量
    int n = 0;
    std::vector<int> indices(num_primes);
    for (int i = 0; i < N; ++i) {
        if (is_prime[i])
            indices[n++] = i;
    }

    // 提交新类型
    MPI_Datatype PrimeType;
    MPI_Type_indexed(
        num_primes,        // 块的数量
        blocklens.data(),  // 块内元素个数
        indices.data(),    // 各块首地址偏移量
        MPI_DOUBLE,        // 旧数据类型
        &PrimeType         // 质数下标类型
    );
    MPI_Type_commit(&PrimeType);

    if (0 == id)
        MPI_Send(A.data(), 1, PrimeType, 1, 0, MPI_COMM_WORLD);

    if (1 == id)
        MPI_Recv(A.data(), 1, PrimeType, 0, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE);

    MPI_Type_free(&PrimeType);

    if (1 == id) {
        for (int i = 0; i < N; ++i)
            std::cout << A[i] << " ";
        std::cout << std::endl;
    }

    MPI_Finalize();

    return 0;
}

2个进程执行的结果为:

0 0 2 3 0 5 0 7 0 0 0 11 0 13 0

§结构体类型

前面三种方法是为数组为同种类型的多个数据创建新类型,而对于许多自定义类型,其成员变量的类型并不相同,此时需要使用MPI_Type_struct创建结构体类型。

MPI_Type_struct的声明为:

1
2
3
4
5
6
7
int MPI_Type_struct(
    int count,                // 块的数量
    int blocklens[],          // 每个块中所含元素的个数
    MPI_Aint indices[],       // 各块偏移字节数
    MPI_Datatype oldtypes[],  // 旧数据类型
    MPI_Datatype *newtype     // 新数据类型
);

新数据类型newtype由count个数据块构成,数据块i由blocklens[i]个连续存放、类型为oldtypes[i]的数据构成,数据块i的首地址为indices[i],此处的偏移为字节数,而不是旧数据类型个数。

MPI_Type_struct是MPI中最一般的类型构造函数,它和MPI_Type_hindexed相比,区别在于各个数据块可以由不同的数据类型构成。这就引入了一个非常重要的问题:对齐要求。

考虑一个由内置类型构成的自定义类型,其各个成员必须满足的对齐要求和大小相同,各种内置类型的大小见下表:

type_size

仅考虑64位系统,visual studio和gcc/clang分别采用LLP64和LP64模型,区别仅在于long的尺寸不一样,因此应当尽量避免使用long类型。因此char需要对齐到1字节,short需要对齐到2字节,int需要对齐到4字节,long long需要对齐到8字节。浮点类型的宽度由IEEE 754标准给出,编译器间无区别,float为4字节,需要对齐到4字节,double为8字节,需要对齐到8字节。

这样的对齐要求可以保证一个内置类型不会跨越字长边界。如64位系统字长为8字节,int类型对齐到4字节则必然存储于一个字的低地址或者高地址的半字。

对于自定义类型,其对齐要求为成员变量中最严格的一个。如

1
2
3
4
5
struct T1 {
    char c;
    int n;
    double d;
};

T1需要对齐到8字节,这样才能保证其成员d不会跨越字长边界。对于成员中有数组类型的,其对齐要求按元素类型处理,如

1
2
3
4
struct T2 {
    char s[7];
    int n;
};

T2的对齐要求是4而不是7或者8,因为按4字节对齐可以保证T2的成员变量都不跨越字长边界存储。

为满足对齐要求,自定义类型的成员变量之间可能有间隙,间隙不存储任何有效数据,但会影响类型的大小,比如T1的c后面有3个字节的间隙,T2的s后面有1个字节的间隙,而下面的类型T3

1
2
3
4
5
struct T3 {
    int n;
    double d;
    int m;
};

在n和m的后面都有4个字节的间隙,所以T1、T2、T3的大小分别为16、12、24字节。

在创建结构体类型时需要正确计算间隙的的大小,这样才能正确地计算出各个块的偏移量。如T1、T2和T3类型的MPI_Type_struct的参数分别为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// T1
int count = 3;
int blocklens[] = {1, 1, 1};
MPI_Aint indices[] = {0, 4, 8};

// T2
int count = 2;
int blocklens[] = {7, 1};
MPI_Aint indices[] = {0, 8};

// T3
int count = 3;
int blocklens[] = {1, 1, 1};
MPI_Aint indices[] = {0, 8, 16};

自定义类型在提交后,传输方式和普通类型一般无二,例如:

1
2
std::vector<T3> A(N);
MPI_send(A.data(), N, T3Type, dest, tag, MPI_COMM_WORLD);

T3尾部m后面有4个字节的空隙,这个空隙没有在创建类型的时候指定,但通过sizeof就可以简单地计算出T3的大小,即下一个T3对象的地址,这个过程不需要用户操心。

§自定义函数

对于自定义的类型,如果要使用带计算的聚合通信函数,则需要向MPI提交自定义二元函数。在MPI中,二元函数应当具有如下的形式:

1
2
3
4
5
6
void user_function_name(
    void *invec,            // 操作数1所在缓冲区的首地址
    void *inoutvec,         // 操作数2所在缓冲区的首地址,返回结果要保存到这个数组里
    int *len,               // 数组长度
    MPI_Datatype *datatype  // 数据类型
);

这个函数应当完成的操作类似于:

1
2
for (i = 0; i < *len; i++)
    inoutvec[i] = invec[i] op inoutvec[i];

自定义函数通过MPI_Op_create提交、MPI_Op_free释放:

1
2
3
4
5
6
7
8
9
int MPI_Op_create(
    MPI_User_function *function,    // 用户自定义函数
    int commute,                    // 是否可交换,非0表示可交换,0表示不可交换
    MPI_Op *op                      // 函数句柄
);

int MPI_Op_free(
    MPI_Op *op                      // 函数句柄
);

例如,对复数类型累积。各个进程已经求出本进程的累积结果,使用全局归约函数求出所有复数累积的结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <mpi.h>

struct Complex {
    double real;
    double imag;
};

void ComplexProd(void *inP, void *inoutP, int *len, MPI_Datatype *dptr) {
    Complex c;
    Complex *in = (Complex *)inP;
    Complex *inout = (Complex *)inoutP;
    for (int i = 0; i < *len; ++i) {
        c.real = inout->real * in->real - inout->imag * in->imag;
        c.imag = inout->real * in->imag + inout->imag * in->real;
        *inout = c;
        in++; inout++;
    }
}

int main(int argc, char *argv[]) {
    // ...
    
    Complex a[100], answer[100];

    MPI_Datatype ComplexType;

    MPI_Type_contiguous(2, MPI_DOUBLE, &ComplexType);
    MPI_Type_commit(&ComplexType);

    MPI_Op ComplexProdOp;
    MPI_Op_create(ComplexProd, true, &ComplexProdOp);
    MPI_Reduce(a, answer, 100, ComplexType, ComplexProdOp, 0, MPI_COMM_WORLD);

    MPI_Op_free(&ComplexProdOp);
    
    // ...
}

§文档

[1] https://www.open-mpi.org/doc/current/.

[2] https://www.mpich.org/static/docs/latest/.

[3] https://www.mpi-forum.org/docs/mpi-3.1/mpi31-report.pdf.

加载评论