【MPI学习5】MPI并行程序设计模式:组通信MPI程序设计


相关章节:第13章组通信MPI程序设计。

 

MPI组通信与点到点通信的一个重要区别就是:组通信需要特定组内所有成员参与,而点对点通信只涉及到发送方和接收方。

由于需要组内所有成员参与,因此也是一种比较复杂的通信方式。程序员在设计组通信语句的时候,需要同时考虑两点:

a. 程序运行起来之后,当前正在运行的进程的行为方式

b. 将组通信作为一个整体,考虑所有进程的行为方式

 

(1)概述

组通信从功能上实现了三个方面:

a. 通信:完成组内数据传输(广播、收集、散发、组收集、全互换各种数据交换传输方式

b. 同步:能够让组内所有进程在特定的位置上执行进度上取得一致,组通信虽然可以有同步功能,但是并不意味这同步一定发生(MPI_Barrier同步函数

c. 计算:通过给定数据的OP归约操作完成(分为MPI预定义OP归约操作、用户自定义OP归约操作

下面分别阐述上述三个方面的相关代码示例,采用的方法是把书上的代码段扩充成完整的可执行的程序来体会。

 

(2)各部分代码

下面阐述几个代码示例,把通信、同步、计算的例子都融入到具体的代码中。

 

示例1(Bcast广播通信)

从root进程读入一个数,并Bcast广播给组内所有进程并打印到终端上,代码如下:

#include "mpi.h" #include <stdio.h> #include <stdlib.h>

int main(int argc, char *argv[]) { int rank, value=0; MPI_Init(&argc, &argv); MPI_Comm_rank(MPI_COMM_WORLD, &rank); while(value>=0) { if (0==rank) { scanf("%d", &value); } MPI_Bcast(&value, 1, MPI_INT, 0, MPI_COMM_WORLD); printf("Process %d got %d\n", rank, value); } MPI_Finalize(); return 0; }

代码执行结果如下:

 

上述代码中涉及到了Bcast函数的基本用法,在每个进程中都要出现,然后通过函数参数中的root进程号,进而区分哪个进程是发送方。

 

示例2(Alltoall全互换通信)

全互换。全互换是形式上最复杂的一种组通信方式,内容上是各种通信形式的全集。

全互换通信模式中,在组内所有通信涉及到的进程中:

a. 每个进程发送给其他进程的数据是不同的

b. 每个进程从其他进程获得数据也是不同的

这样描述还是太抽象,通过具体的图来解释一下:

 

上图描述了全互换发生前和发生后,数据变化的情况:

纵轴代表不同进程编号;左图的横轴代表发送缓冲区的段位大小,右图的横轴代表接受缓冲区的大小。

a. 第i个进程的发送缓冲区中的第j段数据(即左图Aij)代表的意义是:第i个进程发送给第j个进程的数据

b. 第j个进程的接受缓冲区中的第i段数据(即右图的Aji)代表的意义是:第j个进程从第i个进程中接收到的数据

结合a、b两点,我们可以知道全互换这种方式类似:将“进程-发送缓冲区”矩阵,转置成“进程-接受缓冲区”矩阵

对于同一对(i,j),左图的Aij的大小要求一定与右图的Aji的大小一样。

比如第0个进程发送给第2个进程的数据(即A02),必须与第2个进程给第0个进程留出来的接受缓冲区大小一致(即A20),满足这样的条件,就实现了全互换。

每个进程在全通信中既向所有进程发送数据,又同时从所有进程接受数据,但是需要注意上述的发送、接受缓冲区大小匹配。

看如下的代码:

 1 #include "mpi.h"
 2 #include <stdlib.h>
 3 #include <stdio.h>
 4 #include <string.h>
 5 #include <errno.h>
 6 
 7 int main(int argc, char *argv[])  8 {  9     int rank, size; 10     int chunk = 2; // 发送到一个进程的数据块大小
11     int i,j; 12     int *sbuf, *rbuf; 13 
14     MPI_Init(&argc, &argv); 15     MPI_Comm_rank(MPI_COMM_WORLD, &rank); 16     MPI_Comm_size(MPI_COMM_WORLD, &size); 17     // 申请发送缓冲区 和 接收缓冲区
18     sbuf = (int*)malloc(size*chunk*sizeof(int)); // 申请发送缓冲区
19     if (!sbuf) { 20         perror("can't allocate send buffer"); 21  MPI_Abort(MPI_COMM_WORLD, EXIT_FAILURE); 22  } 23     rbuf = (int*)malloc(size*chunk*sizeof(int)); // 申请接收缓冲区
24     if (!rbuf) { 25         perror("can't allocate recv buffer"); 26         free(sbuf); 27  MPI_Abort(MPI_COMM_WORLD, EXIT_FAILURE); 28  } 29     // 设置每个进程的发送缓冲区 和 接收缓冲区的数据
30     for(i=0; i<size; i++) // i代表进程编号
31  { 32         for(j=0; j<chunk; j++) // j代表myid发送给进程i的第j个数据
33  { 34             sbuf[i*chunk+j] = rank+i*chunk+j; // 设置发送缓冲区的数据
35             printf("myid = %d, send to id = %d, data[%d] = %d\n",rank ,i ,j, sbuf[i*chunk+j]); 36             rbuf[i*chunk+j] = 0; // 接收缓冲区清零
37  } 38  } 39     // 执行all to all调用
40  MPI_Alltoall(sbuf, chunk, MPI_INT, rbuf, chunk, MPI_INT, MPI_COMM_WORLD); 41     // 打印接收缓冲区从其他进程接收的数据
42     for(i=0; i<size; i++) 43  { 44         for(j=0; j<chunk; j++) 45  { 46             printf("myid = %d, recv from id = %d, data[%d] = %d\n",rank ,i ,j, rbuf[i*chunk+j]); 47  } 48  } 49     free(rbuf); 50     free(sbuf); 51  MPI_Finalize(); 52     return 0; 53 }

程序执行结果如下:

 

一共有2个进程,每个进程共有4个数据;可以看到执行全互换后的效果。

 

示例3

利用近似积分公式求圆周率π的大小。(Bcast广播、Barrier同步、REDUCE归约)

圆周率的大小可以用一个积分表达式来求解,求这个积分的过程可以用MPI组通信技术实现

代码如下:

 1 #include "mpi.h"
 2 #include <stdio.h>
 3 #include <stdlib.h>
 4 
 5 double f(double);  6 
 7 int main(int argc, char *argv[])  8 {  9     int rank, size; 10     int n; // 细分出来的小矩阵个数
11     double pi; // 存放pi的计算值
12     
13     MPI_Init(&argc, &argv); 14     MPI_Comm_size(MPI_COMM_WORLD, &size); 15     MPI_Comm_rank(MPI_COMM_WORLD, &rank); 16     if (0==rank) { 17         printf("number of small rectangle:\n"); 18         scanf("%d",&n); 19  } 20     MPI_Bcast(&n, 1, MPI_INT, 0, MPI_COMM_WORLD); // 将n值广播到各个进程中
21     int i; 22     double x, h, sum=0.0; 23     for (i=rank+1; i<=n; i+=size) 24  { 25         x = (i-0.5)/(double)n; 26         sum += f(x); 27  } 28     sum = sum/n; 29     MPI_Reduce(&sum, &pi, 1, MPI_DOUBLE, MPI_SUM, 0, MPI_COMM_WORLD); 30     if (0==rank) { 31         printf("Approximately of pi is : %.16f\n",pi); 32  fflush(stdout); 33  } 34  MPI_Finalize(); 35 } 36 
37 double f(double x) { return 4.0/(1.0+x*x); }

上述代码是按照如下思路实现的:

比如总共要把积分区间按X轴划分成1000份,即计算任务是求1000个小矩形的面积,

而MPI计算进程共有5个;则为了负载均衡,考虑让每个计算节点平均搞定200个小矩形的面积。

代码技巧:

这里有个需要处理的地方是:如何告诉每个计算节点(进程),需要计算哪些小矩形的面积呢?

一种直观的思路是,让第0个进程处理0~199小矩形,让第1个进程处理200~399小矩形...,以此类推。

上述的思路没有错误,但是对边界条件的处理可能麻烦一些:因为用这种连续区间的任务分配方式,就需要知道提前知道头和尾各在哪里,需要额外处理。

书上给的代码技巧是通过间隔划分的方式,比如有5个计算进程,第0个进程计算0,5,10,...这些矩形面积,第1个进程计算1,6,11,...这些矩形面积。

这样一来,任务分配的代码就简单了,一个循环直接搞定,而且不同进程不用区别对待。

上述代码执行结果如下:

 

小矩形个数越多,计算的值约逼近真实值。

 

 

示例4

有一个大数据集,每个计算进程中各有数据集中的一部分,现在想让每个进程都有全部的数据集(Bcast广播、Barrier同步)

代码如下:

 1 #include "mpi.h"
 2 #include <stdlib.h>
 3 #include <stdio.h>
 4 
 5 int main(int argc, char *argv[])  6 {  7     int rank,size,i;  8     int *table;  9     int errors = 0; 10  MPI_Aint address; 11  MPI_Datatype type, newtype; 12     int lens; 13 
14     MPI_Init(&argc, &argv); 15     MPI_Comm_rank(MPI_COMM_WORLD, &rank); 16     MPI_Comm_size(MPI_COMM_WORLD, &size); 17     table = (int*)calloc(size, sizeof(int)); 18     table[rank] = rank + 1; // 准备要广播的数据
19  MPI_Barrier(MPI_COMM_WORLD); 20     for(i=0; i<size; i++) MPI_Bcast(&table[i], 1, MPI_INT, i, MPI_COMM_WORLD); // 每个进程只当一次root
21     for(i=0; i<size; i++) 22  { 23         if (table[i]!=i+1) { 24             errors++; 25  } 26  } 27  MPI_Barrier(MPI_COMM_WORLD); 28     if (0==rank) { 29         for(i=0;i<size;i++) printf("table[%d]:%d\n",i,table[i]); 30  } 31  MPI_Finalize(); 32 }

代码执行结果如下:

代码分析:

a. line19执行了第一次Barrier同步,目的是让各个进程都准备好各自拥有数据集的一部分(这里同步的目的是,让各个进程一方面table分配好空间,并且准备好数据)

b. line27执行了第二次Barrier同步,目的是让Bcast都完成,然后在root进程中查看table数据是否都发送过去了

其中line20的代码技巧也值得学习,每个进程只当一次root,而其余的时候全都是当配角,通信中的接收一方

 

示例5

组通信中的死锁。组通信的相同函数有可能是同步的,也有可能是异步的,这个受软硬件平台影响;为了构建可移植性好的代码,在设计组通信程序的时候,无论是异步还是同步的,都应该保证程序不出现死锁。下面用几个例子解释死锁出现的情况。

代码1. 同一个通信域中的死锁示例

 1 #include "mpi.h"
 2 #include <stdio.h>
 3 #include <stdlib.h>
 4 #include <unistd.h> 
 5 
 6 #define LEN 10
 7 
 8 int main(int argc, char *argv[])  9 { 10     int rank,size; 11 
12     MPI_Init(&argc, &argv); 13     MPI_Comm_rank(MPI_COMM_WORLD, &rank); 14     MPI_Comm_size(MPI_COMM_WORLD, &size); 15 
16     if(2!=size) MPI_Abort(MPI_COMM_WORLD, EXIT_FAILURE); 17     int* buf1 = (int*)malloc(sizeof(int)*LEN); 18     int* buf2 = (int*)malloc(sizeof(int)*LEN); 19     buf1[0] = 1; 20     buf2[0] = 1; 21     if (0==rank) { 22         MPI_Bcast(buf1,LEN,MPI_INT,0,MPI_COMM_WORLD); 23         MPI_Bcast(buf2,LEN,MPI_INT,1,MPI_COMM_WORLD); 24         printf("0 done\n"); 25  } 26     if (1==rank) { 27         MPI_Bcast(buf2,LEN,MPI_INT,1,MPI_COMM_WORLD); 28         MPI_Bcast(buf1,LEN,MPI_INT,0,MPI_COMM_WORLD); 29         printf("1 done\n"); 30  } 31     free(buf1); 32     free(buf2); 33  MPI_Finalize(); 34 }

代码执行结果如下:

 

可以看到并没有出现deadlock。原因是由于LEN定义的发送和接收数据长度太小,MPI给Bcast执行方式选择的是缓存非同步方式,即Bcast不必等到所有需要参加组通信的进程都完成了再返回。

如果我们把LEN定义的长度改为100000,则执行结果如下:

 

执行结果表明deadlock出现了。原因是由于LEN定义的发送和接受的数据长度较大,MPI给Bcast选择的执行方式是同步方式,即Bcast必须等到所有需要参加广播通信的进程都完成了,才能够正确返回。

书上并没有提Bcast是异步还是同步的事情,我在stackoverflow上提问才知道了上面的道理(http://stackoverflow.com/questions/35628524/why-this-mpi-bcast-related-code-not-deadlock)。感谢stackoverflow上的认真回答。

 

代码2. 不同通信域中的死锁示例

这部分内容与后面第15章的进程组和通信域知识相关,需要调换顺序提前补充。

总共有3个进程,三个进程在MPI_COMM_WORLD中的rank分别是{0,1,2}

现在构造三个新的通信域comm0 comm1 comm2

comm0中包含MPI_COMM_WORLD中rank为0,1的进程

comm1中包含MPI_COMM_WORLD中rank为1,2的进程

comm2中包含MPI_COMM_WORLD中rank为2,0的进程

需要注意是,虽然进程客观上只有3个,但是相同的进程在不同的通信域中的rank号是不同的。

下面的代码构造了一个循环依赖的deadlock

 1 #include "mpi.h"
 2 #include <stdio.h>
 3 #include <stdlib.h>
 4 
 5 #define LEN 100000
 6 
 7 int main(int argc, char *argv[])  8 {  9     MPI_Init(&argc, &argv); 10     int rank, size; 11     MPI_Comm_size(MPI_COMM_WORLD, &size); 12     if (3!=size) { 13         printf("wrong proc num\n"); 14  MPI_Abort(MPI_COMM_WORLD, EXIT_FAILURE); 15  } 16     const int ranks[3] = {0,1,2}; 17  MPI_Group world_group; 18     MPI_Comm_group(MPI_COMM_WORLD, &world_group); 19     // 构造三个新的进程组对象
20  MPI_Group group0, group1, group2; 21     const int ranks0[2] = {0,1}; 22     const int ranks1[2] = {1,2}; 23     const int ranks2[2] = {2,0}; 24     MPI_Group_incl(world_group, 2, ranks0, &group0); 25     MPI_Group_incl(world_group, 2, ranks1, &group1); 26     MPI_Group_incl(world_group, 2, ranks2, &group2); 27 
28     // 利用三个进程对象构造通信域
29  MPI_Comm comm0, comm1, comm2; 30     MPI_Comm_create(MPI_COMM_WORLD, group0, &comm0); 31     MPI_Comm_create(MPI_COMM_WORLD, group1, &comm1); 32     MPI_Comm_create(MPI_COMM_WORLD, group2, &comm2); 33     MPI_Comm_rank(MPI_COMM_WORLD, &rank); 34     int *buf1 = (int*)malloc(sizeof(int)*LEN); 35     int *buf2 = (int*)malloc(sizeof(int)*LEN); 36     buf1[0] = 1; 37     buf2[0] = 1; 38     int sub_rank; 39     if (0==rank) { 40         MPI_Group_translate_ranks(world_group,1,&ranks[0],group0,&sub_rank); 41  MPI_Bcast(buf1, LEN, MPI_INT, sub_rank, comm0); 42         MPI_Group_translate_ranks(world_group,1,&ranks[2],group2,&sub_rank); 43  MPI_Bcast(buf2, LEN, MPI_INT, sub_rank, comm2); 44  } 45     if (1==rank) { 46         MPI_Group_translate_ranks(world_group,1,&ranks[1],group1,&sub_rank); 47  MPI_Bcast(buf1, LEN, MPI_INT, sub_rank, comm1); 48         MPI_Group_translate_ranks(world_group,1,&ranks[0],group0,&sub_rank); 49  MPI_Bcast(buf2, LEN, MPI_INT, sub_rank, comm0); 50  } 51     if (2==rank) { 52         MPI_Group_translate_ranks(world_group,1,&ranks[2],group2,&sub_rank); 53  MPI_Bcast(buf1, LEN, MPI_INT, sub_rank, comm2); 54         MPI_Group_translate_ranks(world_group,1,&ranks[1],group1,&sub_rank); 55  MPI_Bcast(buf2, LEN, MPI_INT, sub_rank, comm1); 56  } 57  MPI_Finalize(); 58 }

代码执行结果是deadlock,这个deadlock本身不难理解,就是comm0依赖comm1,comm1依赖comm2,comm2依赖comm0,循环依赖造成了死锁,互相都等着。

书上只给出来了关键代码的原型,并没有给出来通信域是怎么构造的,Bcast中的sub_rank是怎么得来的,需要补充一些进程组和通信域的知识。

进程组和通信域使得MPI程序与传统的串行程序的设计思路上有很大不同,初学甚至有些别扭,我也没有全部领会。

额外参考了https://www.rc.usf.edu/tutorials/classes/tutorial/mpi/chapter9.html

只能先把与这个例子相关的知识记录下来:

a. 通信域(Communicator)是大的概念,进程组是通信域的一个属性

b. 一个通信域对应一个进程组

c. 一个进程可以在多个进程组中,因此也可以在多个通信域中

d. 通过进程组来构造通信域是通信域的一种生成方式

e. 相同的进程在不同的通信域中的rank是不同的,可以通过函数来查阅这种对应关系

f. 比如,调用MPI_Comm_create函数,利用进程组{0,1}构造一个通信域comm0;那么进程2也会执行这条语句,则进程2中对应的comm0就是MPI_COMM_NULL

关于上面提到的f,我用http://mpitutorial.com/tutorials/introduction-to-groups-and-communicators/里面的一个代码示例进行了验证,加深理解:

 

 1 #include "mpi.h"
 2 #include <stdio.h>
 3 #include <stdlib.h>
 4 
 5 int main(int argc, char *argv[])  6 {  7     MPI_Init(&argc, &argv);  8     int world_rank, world_size;  9     MPI_Comm_rank(MPI_COMM_WORLD, &world_rank); 10     MPI_Comm_rank(MPI_COMM_WORLD, &world_size); 11 
12  MPI_Group world_group; 13     MPI_Comm_group(MPI_COMM_WORLD, &world_group); 14 
15     int n = 7; 16     const int ranks[7] = {1,2,3,5,7,11,13}; 17 
18  MPI_Group prime_group; 19     // 构造新的进程组 这个进程组是客观的 不管当前进程是哪个
20     MPI_Group_incl(world_group, 7, ranks, &prime_group); 21 
22  MPI_Comm prime_comm; 23     // 如果当前进程不在prime_group中 则prime_com就是MPI_COMM_NULL
24     MPI_Comm_create_group(MPI_COMM_WORLD, prime_group, 0, &prime_comm); 25 
26     int prime_rank = -1, prime_size = -1; 27     if (MPI_COMM_NULL!=prime_comm) { 28         MPI_Comm_rank(prime_comm, &prime_rank); 29         MPI_Comm_size(prime_comm, &prime_size); 30  } 31 
32     printf("WORLD RANK/SIZE: %d/%d \t PRIME RANK/SIZE: %d/%d\n",world_rank, world_size, prime_rank, prime_size); 33     if(MPI_COMM_NULL!=prime_comm) MPI_Barrier(prime_comm); 34  MPI_Barrier(MPI_COMM_WORLD); 35     if(MPI_GROUP_NULL!=world_group) MPI_Group_free(&world_group); 36     if(MPI_GROUP_NULL!=prime_group) MPI_Group_free(&prime_group); 37     if(MPI_COMM_NULL!=prime_comm) MPI_Comm_free(&prime_comm); 38  MPI_Finalize(); 39 }

 

代码执行结果如下:

 

从原来进程中扣出来几个进程构造新进程;对于没被扣出来的那些进程,执行到MPI_Comm_create这里的时候,prime_comm就是MPI_COMM_NULL。

 

 

 

示例6

归约操作。这部分代码都是书上的内容,认真看书即可。

代码1 MINLOC和MAXLOC

有时候不仅需要知道一堆数据中的最小(最大)值,而且需要知道最小最大值对应的编号(或进程号)是多少。

通过MPI提供的MPI_REDUCE归约函数,以及预定义的归约操作MPI_MINLOC活MPI_MAXLOC即可方便实现。

 1 #include "mpi.h"
 2 #include <stdio.h>
 3 #include <stdlib.h>
 4 #include <time.h> 
 5 
 6 int main(int argc, char *argv[])  7 {  8     MPI_Init(&argc, &argv);  9     int i, myrank, root; 10     root = 0; 11     double ain[3]; // 各个进程的输入数据
12     double aout[3]; // 只有root进程有用 用于存放规约后的数字的具体值
13     int ind[3]; // 只有root进程有用 用于存放规约后的数字的编号
14     struct{ 15         double val; 16         int rank; 17     } in[3], out[3]; // 规约操作的发送缓冲区和接收缓冲区
18     MPI_Comm_rank(MPI_COMM_WORLD, &myrank); 19     srand((unsigned int)(myrank+time(NULL))); 20     for(i=0;i<3;i++) ain[i] = (1.0*rand())/RAND_MAX; 21     for(i=0; i<3; i++){ 22         in[i].val = ain[i]; 23         in[i].rank = myrank; 24  } 25     printf("myrank:%d %.3f,%.3f,%.3f\n",myrank, ain[0],ain[1],ain[2]); 26     MPI_Reduce(in, out, 3, MPI_DOUBLE_INT, MPI_MAXLOC, root, MPI_COMM_WORLD); 27     if (myrank==root) { 28         printf("reduce result:\n"); 29         for(i=0; i<3; i++){ 30             aout[i] = out[i].val; 31             ind[i] = out[i].rank; 32             printf("%.3f of rank %d\n", aout[i], ind[i]); 33  } 34  } 35  MPI_Finalize(); 36 }

代码执行结果如下:

 

可以看到这种归约操作:

a. 要求每个进程中的输入数据的大小是一样的(在这个例子中都是长度为3的double数组),

b. 归约的对象是不同进程的数组中相同位置的数据

 

另外,也允许用户自定义归约操作,如下面代码定义了复数相乘的归约操作(书上只给了代码框架,具体细节中的问题参考http://stackoverflow.com/questions/9285442/mpi-get-processor-with-minimum-value)。

 1 #include "mpi.h"
 2 #include <stdio.h>
 3 #include <stdlib.h>
 4 #include <time.h> 
 5 
 6 typedef struct{  7     double real;  8     double imag;  9 }Complex; 10 
11 void multiplyOP(Complex *, Complex *, int *, MPI_Datatype *); 12 
13 int main(int argc, char *argv[]) 14 { 15     MPI_Init(&argc, &argv); 16     int root = 0; 17     int rank; 18     MPI_Comm_rank(MPI_COMM_WORLD, &rank); 19     Complex input[2], output[2]; 20     if (0==rank) { 21         input[0].real = 1; input[0].imag = 1; 22         input[1].real = 1; input[1].imag = 2; 23  } 24     if (1==rank) { 25         input[0].real = 1; input[0].imag = -1; 26         input[1].real = 1; input[1].imag = 2; 27  } 28     // 在MPI中注册和构造复数类型
29  MPI_Datatype ctype; 30     MPI_Type_contiguous(2, MPI_DOUBLE, &ctype); // 在MPI中构造复数结构体 连续的两个DOUBLE类型
31     MPI_Type_commit(&ctype); // 在MPI中注册刚构造的复数结构体 32     // 在MPI中构造自定义归约操作
33  MPI_Op myOp; 34     MPI_Op_create((MPI_User_function*)multiplyOP, 1, &myOp); // 生成用户定义的乘积操作
35     MPI_Reduce(input, output, 2, ctype, myOp, root, MPI_COMM_WORLD); // 执行复数乘积操作 36     // 在root中打印结果
37     if (root==rank) { 38         printf("reduce result of 0 is : %1.0f+(%1.0f)i\n",output[0].real, output[0].imag); 39         printf("reduce result of 1 is : %1.0f+(%1.0f)i\n",output[1].real, output[1].imag); 40  } 41  MPI_Finalize(); 42 } 43 
44 // 计算复数的乘积
45 void multiplyOP(Complex *in, Complex *inout, int *len, MPI_Datatype *datatype) 46 { 47     int i; 48  Complex c; 49     for(i=0; i<*len; i++){ 50         c.real = inout->real*in->real - inout->imag*in->imag; 51         c.imag = inout->real*in->imag + inout->imag*in->real; 52         *inout = c; // 把计算结果存回到inout的位置
53         in++; 54         inout++; 55  } 56 }

代码的执行结果如下:

 


免责声明!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系本站邮箱yoyou2525@163.com删除。



 
粤ICP备18138465号  © 2018-2025 CODEPRJ.COM