这里主要介绍MPI框架,以及怎样将其与CUDA结合起来运用。
1. MPI
MPI可以视为大一号的CUDA。一个MPI框架由分布式计算节点组成,每一个节点可以视为是一个“Thread”,但这里的不同之处在于这些节点没有所谓的共享内存,或者说Global Memory。所以,在后面也会看到,一般会有一个节点专门处理数据传输和分配的问题。MPI和CUDA的另一个不同之处在于MPI只有一级结构,即所有的节点都在一个全局命名空间下,不像CUDA那样有Grid/Block/Thread三级层次。MPI同样也是基于SPMD模型,所有的节点执行相同的指令,而每个节点根据自己的ID来确定指令处理的数据,产生相应的输出。
2. MPI API
仍然以向量相加为例:
int main(int argc, char *argv[]) {
int size = 1024;
int pid = -1;
int np = -1;
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &pid);
MPI_Comm_size(MPI_COMM_WORLD, &np);
if (np < 3) {
if (pid == 0) printf("Need 3 or more processes.\n");
MPI_Abort(MPI_COMM_WORLD, 1);
return 1;
}
if (pid < np - 1)
compute_node(size / (np - 1));
else
data_server(szie);
MPI_Finalize();
return 0;
}
上述代码展示了一个简单的向量相加的MPI实现。MPI_Init()和MPI_Finalize()用于初始化和结束MPI框架;MPI_COMM_WORLD代表了所有分配到的节点的集群;MPI_Comm_rank()用于获取节点在集群中的标号,相当与CUDA中的threadIdx.x;MPI_Comm_size()用于获取集群节点的数量,相当于blockDim.x;MPI_Abort()用于中止执行。
如前所述,有一个节点,也就是np-1节点,来负责数据的传输和分配,而其他的节点则负责计算。
3. 节点间通信
并行计算节点间的通信是必不可少的部分,MPI中可以用MPI_Send()和MPI_Recv()来实现这个功能。
所以,上述的np-1节点可以如下实现:
void data_server(unsigned int size) {
int np;
int first = 0;
unsigned int num_bytes = size * sizeof(float);
float *a = 0; float *b = 0; float *c = 0;
MPI_Comm_size(MPI_COMM_WORLD, &np);
a = (float *) malloc(num_bytes);
b = (float *) malloc(num_bytes);
c = (float *) malloc(num_bytes);
random_data(a, size);
random_data(b, size);
float *ptr_a = a;
float *ptr_b = b;
// send data
for (int i = 0; i < np - 1; i++) {
MPI_Send(ptr_a, size / (np - 1), MPI_FLOAT, i, DATA_DISTRIBUTE, MPI_COMM_WORLD);
ptr_a += size / (np - 1);
MPI_Send(ptr_b, size / (np - 1), MPI_FLOAT, i, DATA_DISTRIBUTE, MPI_COMM_WORLD);
ptr_b += size / (np - 1);
}
// wait for nodes to compute
MPI_Barrier(MPI_COMM_WORLD);
// collect output data
MPI_Status status;
for (int i = 0; i < np - 1; i++) {
MPI_Recv(c + i * size / (np - 1), size / (np - 1), MPI_REAL, i, DATA_COLLECT, MPI_COMM_WORLD, &status);
}
store_output(c);
free(a); free(b); free(c);
}
上述代码将向量a和b分为了np - 1段,然后为每一个节点分配一段。随后的MPI_Barrier()保证了该节点会等到其他节点计算完毕后再接收数据。MPI_Barrier()的工作机制是首先阻塞该节点,直到该节点所处的集群中所有的其他节点都进入到调用该节点这一步。这也意味着,其他节点一定有与该节点通信的步骤,比如MPI_Send()之类的。
计算节点的代码与上述的相差无几:首先是接收数据,然后计算,最后发送数据。这里就不再赘述了。
4. 与CUDA结合
若节点支持CUDA,则还可以与CUDA结合起来进一步提高运算速度。以上面的计算节点为例:
void compute_node(unsigned int vector_size ) {
int np;
unsigned int num_bytes = vector_size*sizeof(float);
float *h_a, *h_b, *h_output;
float* d_A, d_B, d_output;
MPI_Status status;
MPI_Comm_size(MPI_COMM_WORLD, &np);
int server_process = np - 1;
/* Allocate memory */
cudaHostAlloc((void **)&h_a, num_bytes, cudaHostAllocDefault);
cudaHostAlloc((void **)&h_b, num_bytes, cudaHostAllocDefault);
cudaHostAlloc((void **)&h_output, num_bytes, cudaHostAllocDefault);
/* Get the input data from server process */
MPI_Recv(h_a, vector_size, MPI_FLOAT, server_process, DATA_DISTRIBUTE, MPI_COMM_WORLD, &status);
MPI_Recv(h_b, vector_size, MPI_FLOAT, server_process, DATA_DISTRIBUTE, MPI_COMM_WORLD, &status);
/* Transfer data to CUDA device */
cudaMalloc((void **) &d_A, size);
cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice);
cudaMalloc((void **) &d_B, size);
cudaMemcpy(d_B, h_B, size, cudaMemcpyHostToDevice);
cudaMalloc((void **) &d_output, size);
/* Compute the partial vector addition */
dim3 Db(BLOCK_SIZE);
dim3 Dg((vector_size + BLOCK_SIZE – 1)/BLOCK_SIZE);
vector_add_kernel<<
MPI_Barrier(d_output);
/* Send the output */
MPI_Send(output, vector_size, MPI_FLOAT, server_process, DATA_COLLECT, MPI_COMM_WORLD);
/* Release device memory */
cudaFree(d_a);
cudaFree(d_b);
cudaFree(d_output);
}
上面使用了Pinned Memory,可以提高数据传输的效率。这里所做的工作,就是将原来串行的向量相加用CUDA的方式来实现。还有一点小差别在与,这里不需要使用cudaThreadSynchronize(),而此时是MPI_Barrier()完成了同步的功能。