第9章 多GPU编程

在一个计算节点内或者跨多个GPU加速节点实现跨GPU扩展应用。

CUDA提供了大量实现多GPU编程的功能,包括:在一个或多个进程中管理多设备,使用统一的虚拟寻址(Unifined Virtual Addressing)直接访问其他设备内存,GPUDirect,以及使用流和异步函数实现的多设备计算通信重叠。在本章需要掌握的内容有以下几个方面:

在多GPU上管理和执行内核;

在多GPU上重叠计算和通信;

使用流和事件实现多GPU同步执行;

在GPU加速集群上扩展CUDA-aware- MPI应用程序;

9.1 从一个GPU到多GPU

添加多GPU的支持,其最常见的原因是以下几个方面:

        问题域的大小:现有的数据集太大,单GPU内存大小与其不相符合;

吞吐量和效率:如果单GPU适合处理任务,那么可以通过使用多GPU并发地处理多任务来增加应用程序的吞吐量。

当使用多GPU运行应用程序时,需要整正确设计GPU间的通信。GPU间数据传输的效率取决于GPU是如何连接在一个节点上并跨集群的。在多GPU系统里有两种链接方式:

多GPU通过单个节点连接到PCIe总线上;

多GPU连接到集群中的网络交换机上;

因为PCIe链路是双工的,所以可以使用CUDA API在PCIe链路上映射一条路劲,以避免总线竞争,同时也可以在GPU间共享数据。

为了设计一个利用多GPU的程序,需要跨设备分配工作负载。根据应用程序,这种分配会导致两种常见的GPU间通信模式:

        问题分区之间没有必要进行数据交换,因此在各GPU间没有数据共享;

        问题分区之间有部分数据交换,在各GPU间需要冗余数据存储。

第一种模式是最基本情况,每个问题分区可以在不同的GPU上独立运行。要处理这些问题,只需了解在多个设备中传输数据及调用内核。

在第二模式下,GPU之间的数据交换是必需的,必须考虑数据如何在设备之间实现最优移动。总之,要避免通过主机内存中转数据(即数据复制到主机,只能将它复制到另一个GPU上)。

9.1.1 在多GPU上执行

cudaGetDeviceCount()函数确定系统内可用的使CUDA设备的数量。

在利用CUDA与多GPU一起工作的CUDA应用程序时,必须显式地指定那个GPU是当前所有CUDA运算的目标。使用cudaSetDevice(int id)函数设置当前设备。该函数将具有标识符id的设备设置为当前设备。该函数不会与其他设备同步,因此是一个抵销的调用。

如果在首个CUDA API调用之前,没有显示地调用cudaSetDevice函数,那么当前设备会被自动设置设备0.

一旦选定了当前设备,所有的CUDA运算将被应用到那个设备上:

        任何从主线程中分配来的设备内存将完全常驻于该设备个;

        任何由CUDA运行时函数分配的主机内存都会有与设备相关的生存时间;

        任何由主机线程创建的流或事件都会与该设备相关;

        任何由主机线程启动的内核都会在该设备上执行;

可以在以下情形下使用多GPU:

        在一个节点的单CPU线程上;

        在一个节点的多CPU线程上

        在一个节点的多CPU进程上;

        在多个节点的多CPU进程上;

下面代码准确展示了如何执行内核和单一的主机线程中进行内存拷贝:

for (int i = 0; i < ngpus; i++)
{
    cudaSetDevice(i);

    kernel<<>>(...);

    cudaMemcpyAsync();

}

因为循环中内核启动和数据传输是异步的,因此在每次调用操作后控制将很快返回到主机线程。

9.1.2 点对点通信

在计算能力2.0或以上的设备中,在64位应用程序上执行的内核,可以直接访问任何GPU的全局内存,这些GPU连接到同一个PCIe根节点上。如果想这样操作,必须使用CUDA点对点(P2P)API来实现设备间的直接通信。点对点通信需要CUDA4.0或更高版本。

有两个由CUDA P2P API支持的模式,它们允许GPU之间直接通信:

        点对点访问:在CUDA内核和GPU间直接加载和存储地址;

        点对点传输:在GPU间直接复制数据;

在一个系统内,如果两个GPU连接到不同的PCIe根节点上,那么不允许直接进行点对点访问,并且CUDA P2P API将会通知你。仍然可以使用CUDA P2P API在这些设备之间进行点对点传输,但是驱动器将会通过主机内存透明地传输数据,而不是通过PCIe总线直接传输数据。

9.1.2.1 启用点对点访问

点对点访问允许各GPU连接到同一个PCIe根节点上,使其直接引用存储在其他GPU设备内存上的数据。

使用cudaDeviceCanAccessPeer()检查设备是否支持P2P。如果设备能直接访问对等设备peerDevice的全局内存,那么函数变量返回值为整型1,否则为0;

在两个设备间,必须使用以下的cudaDeviceEnablePeerAccess()显式启用点对点内存访问。该函数允许从当前设备到peerDevice进行点对点访问。该函数授权的访问是单向的。

点对点访问保持启用状态,直到它被cudaDeviceDisablePeerAccess()显式禁用。

32位应用程序不支持点对点访问。

9.1.2.2 点对点内存复制

两个设备之间启用对等访问之后,使用cudaMemcpyPeerAsync()可以异步地复制设备上的数据。该函数将数据从设备的srcDev设备传输到设备dstDev的设备中。如果srcDev和dstDev共享相同的PCIe根节点,那么数据传输是沿着PCIe最短路径执行的,不需要通过主机内存中转。

9.1.3 多GPU间同步

每一个流和事件与单一设备相关联。多GPU应用程序中使用流和事件的典型工作流程如下所示:

        1. 选择这个应用程序将使用的GPU集;

        2. 为每个设备创建流和事件;

        3. 为每个设备分配设备资源;(如设备内存)

        4. 通过流在每个GPU上启动任务;(数据传输或内核执行)

        5. 使用流和事件来查询和等待任务完成;

        6. 清空所有设备的资源;

只有与该流相关联的设备是当前设备时,在流中才能启动内核。只有与该流相关联的设备是当前设备时,才可以在流中记录事件。

任何时间都可以在任何流中进行内存拷贝,无论该流与什么设备相关或当前设备是什么。即使流或事件与当前设备不相关,也可以查询或同步它们。

9.2 多GPU间细分计算

9.2.1 在多设备上分配内存

// 在从主机向多个设备分配任务之前,首先需要确定在当前系统中有多少可用的GPU:
int ngpus;
cudaGetDeviceCount(&npus);
printf("CUDA-capable devices: %i\n", ngpus);

// 声明多个设备需要的主机内存、设备内存、流和事件
float* d_A[ngpus], * d_B[ngpus], * d_C[ngpus];
float* h_A[ngpus], * h_B[ngpus], * h_C[ngpus];
cudaStream_t streams[ngpus];

// 每个设备分配的数据大小
int size = 1 << 24;
int iSize = size / ngpus;

size_t iBytes = iSize * sizeof(float);

// 分配主机和设备内存,以及创建流
for (int i = 0; i < ngpus; i++)
{
    cudaSetDevice(i);

    cudaMalloc((void**)&d_A[i], iBytes);
    cudaMalloc((void**)&d_B[i], iBytes);
    cudaMalloc((void**)&d_C[i], iBytes);

    // 分配锁页内存是为了在设备和主机之间进行异步数据传输
    cudaMallocHost((void**)&h_A[i], iBytes);
    cudaMallocHost((void**)&h_B[i], iBytes);
    cudaMallocHost((void**)&hostRef[i], iBytes);
    cudaMallocHost((void**)&gpuRef[i], iBytes);

    cudaStreamCreate(&streams[i]);
}

  9.2.2 单主机线程分配工作

// 在设备间分配操作之前,为每个设备初始化主机数组的状态
for (int i = 0; i < ngpus; i++)
{
    cudaSetDevice(i);
    initial(h_A[i], iSize);
    initial(h_B[i], iSize);
}

// 在多个设备间分配数据和计算
for (int i = 0; i < ngpus; i++)
{
    cudaSetDevice(i);

    cudaMemcpyAsync(d_A[i], h_A[i], iBytes, cudaMemcpyHostToDevice, streams[i]);
    cudaMemcpyAsync(d_B[i], h_B[i], iBytes, cudaMemcpyHostToDevice, streams[i]);

    iKernel<<>>(d_A[i], d_B[i], d_C[i], iSize);

    cudaMemcpyAsync(gpuRef[i], d_C[i], iBytes, cudaMemcpyDeviceToHost, stream[i]);
}

cudaDeviceSynchronize();

 这个循环遍历多个GPU,为设备异步地复制输入数组。然后在想要的流中操作iSize个数据元素以便启动内核。最后,设备发出异步拷贝命令,把结果从内核返回到主机。因为所有的元素都是异步的,所以控制会立即返回到主机线程。

9.3 多个GPU上的点对点通信

在本节中,将测试以下3种情况;

        两个GPU之间的单向内存复制;

        两个GPU之间的双向内存复制;        

        内核中对等设备内存的访问;

9.3.1 实现点对点访问

首先,必须对所有设备启用双向点对点访问,代码如下;

// 启动双向点对点访问权限
inline void enableP2P(int ngpus)
{
    for (int i = 0; i < ngpus; i++)
    {
        cudaSetDevice(i)
        for (int j = 0; j < ngpus; j++)
        {
            if (i == j)
                continue;
            
            int peer_access_available = 0;

            cudaDeviceCanAccessPeer(&peer_access_available, i, j);

            if (peer_access_avilable)
            {
                cudaDeviceEnablePeerAccess(j, i);
                printf(" > GP%d enbled direct access to GPU%d\n", i, j);
            }
            else
                printf("(%d, %d)\n", i, j);
        }
    }
}

函数enbleP2P遍历所有设备对(i,j),如果支持点对点访问,则使用cudaDeviceEnablePeerAccess函数启用双向点对点访问。

9.3.2 点对点内存复制

不能启用点对点访问的最有可能的原因是它们没有连接到同一个PCIe根节点上。如果两个GPU之间不支持点对点访问,那么这两个设备之间的点对点内存复制将通过主机内存中转,从而降低其性能。

启用点对点访问后,下面的代码在两个设备间执行ping-pong同步内存复制,次数为100次。

// ping-pong undirectional gmem copy
cudaEventRecord(start, 0);
for (int i = 0; u < 100; i++)
{
    if (i % 2 == 0)
        cudaMemcpy(d_src[1], drc[0], iBytes, cudaMemcpyDeviceToHost);
    else
        cudaMemcpy(d_src[0], drc[1], iBytes, cudaMemcpyDeviceToHost);
}

请注意,在内存复制之前没有指定设备,因为跨设备的内存复制不需要显式地设定当前设备。如果在内存复制前指定了设备,也不会影响它的行为。

如需衡量设备之间数据传输的性能,需要把启动和停止事件记录在同一设备上,并将ping-pong内存复制包含在内。然后,用cudaEventElapsedTime计算两个事件之间消耗的时间。

// ping-pong undirectional gmem copy
cudaEventRecord(start, 0);
for (int i = 0; u < 100; i++)
{
    if (i % 2 == 0)
        cudaMemcpy(d_src[1], drc[0], iBytes, cudaMemcpyDeviceToHost);
    else
        cudaMemcpy(d_src[0], drc[1], iBytes, cudaMemcpyDeviceToHost);
}

cudaEventRecord(start, 0);
for (int i = 0; u < 100; i++)
{
...
}
cudaSetDevice(0);
cudaEventRecord(stop, 0);
cudaEventSynchronize(stop);

float elapsed_time_ms;
cudaEventElapsedTime(&elapsed_time_ms, start, stop);

elapsed_time_ms /= 100;
printf("Ping-pong unidirectional cudaMemcpy: \t\t %8.2f ms", elapsed_time_ms);
printf("performance: %8.2f GB/s\n", (float)iBytes / (elapsed_time_ms * 1e6f));

因为PCIe总线支持任何两个端点之间的全双工通道,所以也可以使用异步复制函数来进行双向的且点对点的内存复制。

// bidirectional asynchronous gmem copy
for (int i = 0; u < 100; i++)
{
    if (i % 2 == 0)
        cudaMemcpyAsync(d_src[1], drc[0], iBytes, cudaMemcpyDeviceToHost);
    else
        cudaMemcpyAsync(d_rcv[0], drcv[1], iBytes, cudaMemcpyDeviceToHost);
}

注意,由于PCIe总线是一次两个方向上使用的,所以获得的带宽增加了一倍。

9.3.3 统一虚拟寻址的点对点内存访问

第4章介绍的统一虚拟寻址,是将CPU系统内存和设备的全局内存映射到一个单一的虚拟地址空间中。

第9章 多GPU编程_第1张图片

将点对点CUDA  API与UVA相结合,可以实现对任何设备内存的透明访问。不必手动管理单独的内存缓存区,也不必从主机内存中进行显式的复制。底层系统能使我们避免显式地执行这些操作,从而简化了代码。请注意,过于依赖UVA进行对等访问对性能将产生负面的影响。

下面代码演示了如何检查设备是否支持统一寻址:

int deviceId = 0;
cudaDeviceProp prop;
cudaGetDeviceProperties(&prop, deviceId);
printf("GPU%d: %s unified addressing\n", deviceId, prop.unifiedAddressing ? "supprots" : "dose not support");

为了使用UVA,应用程序必须在设备的计算能力为2.0以及以上的64位架构上进行编译,并且CUDA版本为4.0或以上。如果同时启用点对点访问和UVA,那么一个设备上执行的核函数,可以解除另一个设备上存储的指针。

以下代码将设备0置为当前设备,并有一个核函数使用指针d_src[1]从设备1中读取全局内存,同时通过全局内存你的指针d_rcv[0]将结果写入当前设备中。

cudaSetDevice(0);
iKernel<<>>(d_rcv[0], d_src[1]);
cudaSetDevice(1);
iKernel<<>>(d_rcv[1], d_src[0]);

如果GPU没有连接到相同的PCIe根节点上,或点对点访问被禁止,那么将出现以下错误信息:

        GPU0 disable direct access to GPU0;

9.4 多GPU上的有限差分

9.4.1 二维波动方程的模板计算

第9章 多GPU编程_第2张图片

 9.4.2 多GPU程序的典型模式

为了准确地模拟通过不同介质的波传播,需要大量的数据。但单个GPU的全局内存没有足够的空间存储模拟过程的状态。这就需要跨多个GPU的数据域分解。

第9章 多GPU编程_第3张图片

        1. 在一个流中使用相邻的GPU计算halo区域和交换halo数据;

        2. 在不同流中计算内部区域;

        3. 在进行下一个循环之前,在所有设备上进行同步计算;

如果使用两个不同的流,一个用于halo计算和通信,另一个用于内部区域的计算,步骤1可以与步骤2重叠。如果内部计算所需的计算时间比halo操作所需的时间长,可以通过使用多个GPU隐藏halo通信的性能影响来实现线性加速。

在两个GPU上进行模板计算的伪代码如下:

for (int istep = 0; istep < nsteps; istep++)
{
    for (int i = 0; i < 2; i++)
    {
        cudaSetDevice(i);
        2dfd_kernel<<>>(...);
    }

    cudaMemcpyAsync(..., cudaMemcpyDeviceToDevice, stream_halo[0]);
    cudaMemcpyAsync(..., cudaMemcpyDeviceToDevice, stream_halo[1]);   

    for (int i = 0; i < 2; i++)
    {
        cudaSetDevice(i);
        2dfd_kernel<<>>(...);
    }

    for (int i = 0; i < 2; i++)
    {
        cudaSetDevice(i);
        cudaDeviceSynchronize();
    }
}

9.4.3 多GPU上的二维模板计算

第9章 多GPU编程_第4张图片

因为跟新一个点需要访问最近的9个点,所以很多点都将共享输入数据。因此,使用共享内存可以减少全局内存的访问。共享内存的使用量等同于保护相邻的线程块的大小,该线程块被8个点填充。(左右各4个点)

用来存储y轴模板值的9个浮点值被声明为一个核函数的本地数组,并因此存储在寄存器中。当沿y轴在当前元素的前后加载元素时,用到的寄存器很像用来减少冗余访问的共享内存。 

第9章 多GPU编程_第5张图片

// 2D模板计算的完整内核代码如下所示:
__global__ void kernel_2dfd(float* g_ul, float* g_u2, const int nx, const int iStart, const int iEnd)
{
    // global thread index to row index
    unsigned int ix = blockIdx.x * blockDim.x + threadIdx.x;

    // smem idx for current point
    unsigned int stx = threadIdx.x + NPAD;

    // global index with offset to start line
    unsigned int idx = ix + iStart * nx;

    // declare the shared memory for x dimension
    __shared__ float line[BDIMX + NPAD * 2];

    // a coefficient related to physical properties
    const int alpha = 0.12f;

    // declare nine registers for y value;
    float yval[NPAD * 2 + 1];
    for (int i = 0; i < NPAD * 2; i++)
        yval[i] = g_u2(idx + (i - NPAD) * nx);
    
    // offset from current point to yval[8]
    int iSkip = NPAD * nx;

    #pragma unroll 9
    for (int iy = 0; iy < iEnd; iy++)
    {
        // set yval[8] here
        yval[8] = g_u2(idx + iSkip);

        // read halo part in x dimension: both left and right
        if (threadIdx.x < NPAD)
        {
            line[threadIdx.x] = g_u2[idx - NPAD];
            line[stx + BDIMX] = g_u2[idx + BDIMX];
        }

        // current point
        line[stx] = yval[4];

        __syncthreads();

        // fd operator: 8th order in space and 2nd order in time
        if ((ix > NPAD) && (ix < nx-NPAD))
        {
            // update center point
            float temp = coef[0] * stx * 2.0f;

            // 8th order in x dimension
            #pragma unroll
            for (int d = 1; d <= 4; d++)
                temp += coef[d] * (line[stx + d] + line[stx - d]);

            // 8th order in y dimension
            #pragma unroll
            for (int d = 1; d <= 4; d++)
                temp += coef[d] * (yval[stx + d] + yval[stx - d]);

            // 2th order in time dimension
            g_u1[idx] = yval[4] + yval[4] - g_u1[idx] + alpha * temp;
        }

        // advance on yval[]
        #pragma unroll 8
        for (int i = 0; i < 8; i++) yval[i] = yval[i + 1];

        // update global idx
        idx += nx;

        // synchronize for next step
        __syncthreads();

    }

}

 9.4.4 重叠计算和通信

因为halo区域计算和数据交换被安排在每个设备stream_halo流中,内部区域的计算被安排在每个设备的stream_internal流中,所以在此二维模板上计算和通信是可以重叠的。

// add a disturbance onto gpu0 on the first time step
cudaSetDevice(0);
kernel_add_wavelet<<>>(d_u2[0], 20.0, nx, iny, ngpus);

// for each time step
for (int iStep = 0; iStep < nSteps; iStep++)
{
    if (istep == 0)
    {
        cudaSetDevice(gpuid[0]);
        kernel_add_wavelet<<>>(d_u2[0], 20.0, nx, iny, ngpus);
    }

    // update halo and internal asynchronously
    for (int i = 0; i < ngpus; i++)
    {
        cudaSetDevice(i);

        // compute the halo region values in the halo stream
        kernel_2dfd<<>>(d_u1[i], d_u2[i], nx, haloStart[i], haloEnd[i]);

        // compute the internal region values in the internal stream
        kernel_2dfd<<>>(d_u1[i], d_u2[i], nx, bodyStart[i], bodyEnd[i]);
    }

    // exchange halos in the halo stream
    if (ngpus > 1)
    {
        cudaMemcpyAsync(d_u1[1] + dst_skip[0], d_u1[0] + src_skip[0], iexchange, cudaMemcpyDeviceToDevice, stream_halo[0]);
        cudaMemcpyAsync(d_u1[0] + dst_skip[1], d_u1[1] + src_skip[1], iexchange, cudaMemcpyDeviceToDevice, stream_halo[0]);
    }

    // synchronize for the next step
    for (int i = 0; i < ngpus; i++)
    {
        cudaSetDevice(i);
        cudaDeviceSynchronize();

        // swap global memory pointers
        float* tempu0 = d_u1[i];
        d_u1[i] = d_u2[i];
        d_u2[i] = tempu0;
    }
}

9.5 跨GPU集群扩展应用程序

与同构系统相比,GPU加速集群被公认为极大地提升了性能效果和节省了计算密集型应用程序的功耗。

MPI和CUDA是完全兼容的。支持GPU之间不同节点上移动数据的MPI有两种实现方式:传统的MPI和CUDA-aware MPI。

在传统的MPI中,只有主机内存的内容可以直接通过MPI函数来传输。在MPI把数据传递给另一个节点之前,GPU内存中的内容必须首先使用CUDA API复制回主机内存。

在CUDA-aware MPI中,可以把GPU内存中的内容直接传递给MPI函数上,而不需要主机内存中传输数据。

9.5.1 CPU到CPU的数据传输

一般来说,MPI程序包括四个步骤:

        1. 初始化MPI环境;

        2. 使用阻塞或非阻塞MPI函数在不同节点间的进程传递消息;

        3. 跨节点同步;

        4. 清理MPI环境

9.5.1.1 实现节点间的MPI通信

下面代码展示了一个简单的MPI程序框架:

int main(int argc, char* argv[])
{
    // initialize the MPI enviroment
    int rank, nprocs;
    MPI_Init(&argc, &argv);
    MPI_Comm_size(MPI_COMM_WORLD, &nproc);
    MPI_Comm_rank(MPI_COMM_WORLD, &rank);

    // transmit message with MPI calls
    MPI_Send(sbuf, size, MPI_CHAR, 1, 100, MPI_COMM_WORLD);
    MPI_Recv(rbuf, size, MPI_CHAR, 0, 100, MPI_COMM_WORLD, &reqstat);

    // synchronize
    MPI_Barrier(MPI_COMM_WORLD);

    // clean up the MPI enviroment
    MPI_Finalize();

    return EXIT_SUCCESS;
}
char* s_buf = (char* )malloc(MYBUFSIZE);
char* r_buf = (char* )malloc(MYBUFSIZE);

// if this is the first MPI process
if (rank == 0)
{
    for (int i = 0; i < nRepeat; i++)
    {
        // Asynchronously receive size bytes from other_proc into rbuf
        MPI_Irecv(rbuf, size, MPI_CHAR, other_proc, 10, MPI_COMM_WORLD, &recv_request);

        // Asynchronously send size bytes to other_proc from itself
        MPI_Isend(sbuf, size, MPI_CHAR, other_proc. 100, MPI_COMM_WORLD, &send_request);

        // wait for the send to complete
        NPI_Waitall(1, &send_request, &reqstat);

        // wait for the receive to complete
        NPI_Waitall(1, &recv_request, &reqstat);
    }
}
else if(rank == 1)
{
    for (int i = 0; i < nRepeat; i++)
    {
        // Asynchronously receive size bytes from other_proc into rbuf
        MPI_Irecv(rbuf, size, MPI_CHAR, other_proc, 100, MPI_COMM_WORLD, &recv_request);

        // Asynchronously send size bytes to other_proc from itself
        MPI_Isend(sbuf, size, MPI_CHAR, other_proc. 10, MPI_COMM_WORLD, &send_request);

        // wait for the send to complete
        NPI_Waitall(1, &send_request, &reqstat);

        // wait for the receive to complete
        NPI_Waitall(1, &recv_request, &reqstat);
    }
}

9.5.1.2 CPU亲和性

在操作系统的控制下,一个进程或线程将暂停或移动到一个新核心内。这种行为将造成较差的数据局部性,从而对性能有负面影响。因此,将一个进程或线程绑定到一个单CPU核(或一组相邻的CPU核)上可以帮助提高主机性能。

限制进程或线程在特定的CPU核上执行,被称为CPU亲和性。

MVAPICH2提供了一种办法,使其在运行时使用MV2_ENABLE_AFFINITY环境变量来设置CPU亲和性。

对于单线程或单进程的应用程序而言,启用CPU亲和性可以避免操作系统在处理器之间移动进程或线程,从而提同同等或更好的性能。另一方面,当禁用CPU亲和性时,多线程和多进程应用程序的性能可能会得到提升。

9.5.2 使用传统MPI在GPU和GPU间传输数据

为了简化GPU间数据交换和提高性能,应该在每个节点的每个GPU上绑定MPI进程。

9.5.2.1 MPI-CUDA程序内的亲和性

在特定的GPU中绑定MPI进程被称为GPU亲和性,通常是使用MPI_Init函数初始化MPI环境之前进行的。

必须首先使用由MPI库提供的环境变量。MV2_COMM_WORLD_LOCAL_RANK。这个本地ID,也可称为本地秩,可以将一个MPI进程绑定到一个CUDA设备上。

int n_device;
int local_rank = atoi(getenv("MV2_COMM_WORLD_LOCAL_RANK"));
cudaGetDeviceCount(&n_device);
int device = local_rank % n_device;
cudaSetDevice(device);
...
MPI_Init(argc, argv);

然而,如果首次使用环境变量MV2_ENABLE_AFFINITY设置MPI进程的CPU亲和性,然后使用MV2_COMM_WORLD_LOCAL_RANK设置GPU亲和性,那么无法保证正在运行MPI进程的CPU和分配的GPU是最佳组合。如果它们不是最佳组合,那么主机应用程序和设备内存之间的延迟和带宽可能会变得不理想。因此,可以使用便携式的Hardware Locality包(hwloc)来分析节点的硬件拓扑结构,并且让MPI进程所在的CPU核与分配给该MPI进程的GPU是最佳组合。

以下代码使用了进程MPI局部秩来选择一个GPU。然后,对于选定的GPU,用hwloc确定最佳CPU核心来绑定这个进程。

rank = atoi(getenv("MV2_COMM_WORLD_RANK"));
local_rank = atoi(getenv("MV2_COMM_WORLD_LOCAL_RANK"));

// load a full hardware topology of all PCI devices in this node
hwloc_topology_init(&topology);
hwloc_topology_set_flags(topology, HWLOC_TOPOLOGY_FLAG_WHOLE_IO);
hwloc_topology_load(topology);

// choose a GPU based on MPI local rank
cudaSetDevice(local_rank);
cudaGetDevice(&device);

// Iterate through all CPU cores that are physically close to the selected GPU
// this code evenly distributes processes across cores using local_rank
cpuset = hwloc_bitmap_alloc();
hwloc_cudart_get_device_cpuset(topology, device, cpuset);
match = 0;
hwloc_bitmap_foreach_begin(i, cpuset);
if (match == local_rank)
{
    cpu = i;
    break;
}
++match;
hwloc_bitmap_foreach_end();

// this process to selected GPU
onecpu = hwloc_bitmap_alloc();
hwloc_bitmap_set(onecpu, cpu);
hwloc_set_cpubind(topology, onecpu, 0);

// clean up
hwloc_bitmap_free(onecpu);
hwloc_bitmap_free(cpuset);
hwloc_topology_destory(topology);

gethostname(hostname, sizeof(hostname));
cpu = sched_getcpu();
printf("MPI rank %d using GPU %d and CPU %d on host %s\n", rank, device, cpu, hostname);
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WWORLD, &rank);
if (MPI_SUCCESS != MPI_Get_processor_name(procname, &length))
    strcpy(procname, "unnake");

9.5.2.2 使用MPI执行GPU间的通信

一旦MPI进程通过cudaSetDevice函数被调度到一个GPU中,那么设备内存和主机固定内存可以被分配给当前设备:

char* h_src, *h_rcv;
cudaMallocHost((void**)&h_src, MYBUFSIZE);
cudaMallocHost((void**)&h_rcv, MYBUFSIZE);

char* d_src, d_rcv;
cudaMalloc((void**)&h_src, MYBUFSIZE);
cudaMalloc((void**)&h_rcv, MYBUFSIZE);

使用传统的MPI的两个GPU间的双向数据传输可以分两步执行:

        首先,将数据从设备内存复制到主机内存;

        其次,使用MPI通信库在MPI进程之间交换主机内存里的数据;

if (rank == 0)
{
    for (int i = 0; i < loop; i++)
    {
        cudaMemcpy(h_src, d_src, size, cudaMemcpyDeviceToHost);

        // bi-direction bandwidth
        MPI_Irecv(h_rcv, size, MPI_CHAR, other_proc, 10, MPI_COMM_WORLD, &recv_request);
        MPI_Isend(h_src, size, MPI_CHAR, other_proc, 100, MPI_COMM_WORLD, &send_request);
    
        MPI_Waitall(1, &recv_request, &reqstat);
        MPI_Waitall(1, &send_request, &reqstat);

        cudaMemcpy(d_rcv, h_rcv, size, cudaMemcpyHostToDevice);
    }
}
else
{
    for (int i = 0; i < loop; i++)
    {
        cudaMemcpy(h_src, d_src, size, cudaMemcpyDeviceToHost);

        // bi-direction bandwidth
        MPI_Irecv(h_rcv, size, MPI_CHAR, other_proc, 100, MPI_COMM_WORLD, &recv_request);
        MPI_Isend(h_src, size, MPI_CHAR, other_proc, 10, MPI_COMM_WORLD, &send_request);

        MPI_Waitall(1, &recv_request, &reqstat);
        MPI_Waitall(1, &send_request, &reqstat);

        cudaMemcpy(d_rcv, h_rcv, size, cudaMemcpyHostToDevice);
    }

}

使用mpicc -std=c99 -03 simpleP2P.c -o simpleP2P编译;

用mpirun_sh -np 2 node01 node 02 ./simpleP2P启动MPI程序;

9.5.3 使用CUDA-aware MPI进行GPU到GPU的数据传输

MVAPICH2也是一个CUDA-aware MPI实现,它通过标准MPI API支持GPU到GPU的通信,可以直接将设备内存的指针传给MPI函数(并且避免传统MPI所需的额外的cudaMemcpy调用)。

if (rank == 0)
{
    for (int i = 0; i < loop; i++)
    {
        // bi-direction bandwidth
        MPI_Irecv(d_rcv, size, MPI_CHAR, other_proc, 10, MPI_COMM_WORLD, &recv_request);
        MPI_Isend(d_src, size, MPI_CHAR, other_proc, 100, MPI_COMM_WORLD, &send_request);
    
        MPI_Waitall(1, &recv_request, &reqstat);
        MPI_Waitall(1, &send_request, &reqstat);
    }
}
else
{
    for (int i = 0; i < loop; i++)
    {
        // bi-direction bandwidth
        MPI_Irecv(d_rcv, size, MPI_CHAR, other_proc, 100, MPI_COMM_WORLD, &recv_request);
        MPI_Isend(d_src, size, MPI_CHAR, other_proc, 10, MPI_COMM_WORLD, &send_request);

        MPI_Waitall(1, &recv_request, &reqstat);
        MPI_Waitall(1, &send_request, &reqstat);
    }
}

编译之后,启动MPI程序之前,需要通过设置下列环境变量来确保在MVAPICH2启用CUDA支持export MV2_USE_CUDA=1;

也可以在MPI程序调用时设置该环境变量mpirun_sh -np 2 node01 node 02 MV2_USE_CUDA-1 ./simpleP2P;

9.5.4 使用CUDA-aware MPI进行节点内GPU到GPU的数据传输

在同一个节点的两个GPU内,也可以使用CUDA-aware MPI库执行数据传输。如果两个GPU连接到同一个PCIe总线上,会自动使用点对点传输。

9.5.5 调整消息块大小

通过重叠主机与设备通信和节点间的通信来最小化通信开销,MVAPICH2将来自GPU内存的大量信息自动划分成块。块的大小可以用MV2_CUDA_BLOCK_SIZE环境变量调整。默认的块大小是256KB。它可以被设置为512KB,命令如下;

mpirun_rsh -np2 node01 node02 MV2_USE_CUDA=1 MV2_CUDA_BLOCK_SIZE=524288 ./simpleP2P

最优的块大小取决于多个因素,包括互联带宽/延迟,GPU适配器的特点,平台特点,以及MPI函数允许使用的内存大小。

9.5.6 使用GPUDirect RADM技术进行GPU到GPU的数据传输

NVIDIA的GPUDirect实现了在PCIe总线上的GPU和其他设备之间的低延迟通信。使用GPUDirect,第三方网络适配器和其他设备可以直接通过基于主机的固定内存区域交换数据,从而消除了不必要的主机内存复制,使得运行在多个设备上的应用程序的数据传输性得到了显著提升。

第9章 多GPU编程_第6张图片

GPUDirect的第一个版本,与CUDA3.1一同发布,允许InfiniBand设备和GPU设备共享CPU内存中相同的锁页缓冲区。数据从一个节点中的GPU发送到另一个节点的GPU中,该数据是从源GPU复制到系统内存中固定的、共享的数据缓冲区,然后通过InfiniBand互连直接从共享缓冲区复制到其他GPU可以访问的与目的节点相匹配的缓冲区。

GPUDirect的第二个版本,与CUDA4.0一起发布,加入了点对点API和统一虚拟寻址支持。这些改进提高了单节点多GPU的性能,并通过消除不同地址空间中管理多个指针的需要提供了程序员的效率。

GPUDirect的第三个版本,与CUDA5.0一起发布,添加了远程直接内存访问(RDMA)支持。RDMA允许通过InfiniBand使用直接通信路径,它在不同集群节点的GPU间使用标准的PCIe适配器。使用GPUDirect RAMD,在两个节点的GPU间通信可以在没有主机处理器的参与下执行。这减少了处理器的消耗和通信延迟。

第9章 多GPU编程_第7张图片

 当GPUDirect RDMA被添加到CUDA-aware MPI时,性能得到了显著的提升.

请注意,因为使用CUDA加速了应用程序的计算分配,所有应用程序中的IO将迅速成为整体性能的障碍。GPUDirect通过减少GPU之间的延迟,提供了一个直截了当的解决方案。

9.6 总结

多GPU系统非常适合处理现实中那些单GPU无法处理的超大数据集问题,或者是那些吞吐量和效率可以通过使用多GPU系统得以解决的问题。通常,执行多GPU应用程序有两种配置:

        单节点多设备;

        多节点GPU加速群上的多设备;

MVAPICH2是一个CUDA-aware MPI通用的实现形式,它使用InfiniBand,10GidEiWARP和RoCE网络技术,实现了高端计算机所需的低延迟、高带宽、可扩张性和容错性。通过MPI函数直接传递设备内存,大大简化了MPI-CUDA程序的开发,提高了GPU集群的性能。

GPUDirect促进了点对点设备内存的访问。使用GPUDirect,可以直接在多个设备上交换数据,这些设备属于一个集群的相同节点或不同节点。GPUDirect的RDMA功能可以使第三方设备直接访问GPU全局内存,如固态硬盘、网络接口卡和Infiniband适配器,这使得这些设备和GPU间的延迟显著减少了。

CUDA提供了许多在多设备上管理和执行核函数的办法。在一个节点内跨多设备可以扩展应用程序,或跨GPU加速集群节点来扩展应用程序。使用计算来隐藏通信延迟的负载平衡,可以实现近似线性的性能增益。

你可能感兴趣的:(CUDA,C编程,权威指南,Grossman,c++,cuda,并行计算,性能优化)