Prefer to read without ads? Become a member — from $10/month — and support the work. Already a member? Log in to read ad-free on this device.

9.5 Single-Threaded Multi-GPU

When using the CUDA runtime, a single-threaded application can drive multiple GPUs by calling cudaSetDevice() to specify which GPU will be operated by the calling CPU thread. This idiom is used in Listing 9-1 to switch between the source and destination GPUs during the peer-to-peer memcpy, as well as the single-threaded, multi-GPU implementation of N-body described in Section 9.5.2.

In the driver API, CUDA maintains a stack of current contexts, so that subroutines can easily change and restore the caller’s current context.

9.5.1 Current Context Stack

Driver API applications can manage the current context with the current-context stack: cuCtxPushCurrent() makes a new context current, pushing it onto the top of the stack; cuCtxPopCurrent() pops the current context and restores the previous current context. Listing 9-2 gives a driver API version of chMemcpyPeerToPeer(), which uses cuCtxPopCurrent() and cuCtxPushCurrent() to perform a peer-to-peer memcpy between two contexts.

The current context stack was introduced to CUDA in v2.2, and at the time, the CUDA runtime and driver API could not be used in the same application. That restriction has been relaxed in subsequent versions.

CUresultchMemcpyPeerToPeer(     void *_dst, CUcontext dstContext, int dstDevice,    const void *_src, CUcontext srcContext, int srcDevice,    size_t N ) {    CUresult status_cuda;    CUdeviceptr dst = (CUdeviceptr) (intptr_t) _dst;    CUdeviceptr src = (CUdeviceptr) (intptr_t) _src;    int stagingIndex = 0;     while ( N ) {        size_t thisCopySize = min( N, STAGING_BUFFER_SIZE );         cu(CtxPushCurrent( srcContext ) );        cu(StreamWaitEvent(             NULL, g_events[dstDevice][stagingIndex], 0 ) );        cu(MemcpyDtoHAsync(             g_hostBuffers[stagingIndex],             src,             thisCopySize,             NULL ) );        cu(EventRecord(             g_events[srcDevice][stagingIndex],             0 ) );         cu(CtxPopCurrent( &srcContext ) );        cu(CtxPushCurrent( dstContext ) );        cu(StreamWaitEvent(             NULL,             g_events[srcDevice][stagingIndex],             0 ) );        cu(MemcpyHtoDAsync(             dst,             g_hostBuffers[stagingIndex],             thisCopySize,             NULL ) );        cu(EventRecord(             g_events[dstDevice][stagingIndex],             0 ) );         cu(CtxPopCurrent( &dstContext ) );         dst += thisCopySize;        src += thisCopySize;        N -= thisCopySize;        stagingIndex = 1 - stagingIndex;    }     // Wait until both devices are done    cu(CtxPushCurrent( srcContext ) );    cu(CtxSynchronize() );    cu(CtxPopCurrent( &srcContext ) );     cu(CtxPushCurrent( dstContext ) );    cu(CtxSynchronize() );    cu(CtxPopCurrent( &dstContext ) );    Error_cuda:    return status_cuda;}
Listing 9-2. chMemcpyPeerToPeer() (driver API version) (source on GitHub)

9.5.2 N-Body

The N-body computation (described in detail in Chapter 14) computes N forces in O(N2) time, and the outputs may be computed independently. On a system with k GPUs, our multi-GPU implementation splits the computation into k parts that each compute \(\frac{N}{k}\) outputs.

Our implementation makes the common assumption that the GPUs are identical, so it divides the computation evenly; applications targeting GPUs of unequal performance or whose workloads have less-predictable runtimes can divide the computation more finely and have the host code submit work items to the GPUs from a queue.

Listing 9-3 gives a modified version of Listing 14-3 that takes two additional parameters (a base index base and size n of the subarray of forces), to compute a subset of the output array for an N-body computation. This __device__ function is invoked by wrapper kernels that are declared as __global__; it is structured this way to reuse the code without incurring link errors. When this book was first written, declaring the function __global__ would have generated a linker error about duplicate symbols2.

inline __device__ voidComputeNBodyGravitation_Shared_multiGPU(     float *force,     float *posMass,     float softeningSquared,     size_t base,    size_t n,    size_t N ){    extern __shared__ float4 shPosMass[];    for ( int m = blockIdx.x*blockDim.x + threadIdx.x;              m < n;              m += blockDim.x*gridDim.x )    {        size_t i = base+m;        float acc[3] = {0};        float4 myPosMass = ((float4 *) posMass)[i];#pragma unroll 4        for ( int j = 0; j < N; j += blockDim.x ) {            shPosMass[threadIdx.x] = ((float4 *) posMass)[j+threadIdx.x];            __syncthreads();            for ( size_t k = 0; k < blockDim.x; k++ ) {                float fx, fy, fz;                float4 bodyPosMass = shPosMass[k];                 bodyBodyInteraction(                     &fx, &fy, &fz,                     myPosMass.x, myPosMass.y, myPosMass.z,                     bodyPosMass.x,                     bodyPosMass.y,                     bodyPosMass.z,                     bodyPosMass.w,                     softeningSquared );                acc[0] += fx;                acc[1] += fy;                acc[2] += fz;            }            __syncthreads();        }        force[3*m+0] = acc[0];        force[3*m+1] = acc[1];        force[3*m+2] = acc[2];    }}
Listing 9-3. N-body kernel (multi-GPU) (source on GitHub)

The host code for a single-threaded, multi-GPU version of N-body is shown in Listing 9-43. The arrays dptrPosMass and dptrForce track the device pointers for the input and output arrays for each GPU (the maximum number of GPUs is declared as a constant in nbody.h - default is 32). Similar to dispatching work into CUDA streams, the function uses separate loops for different stages of the computation: the first loop allocates and populates the input array for each GPU; the second loop launches the kernel and an asynchronous copy of the output data; and the third loop calls cudaDeviceSynchronize() on each GPU in turn. Structuring the function this way maximizes CPU/GPU overlap: during the first loop, asynchronous host→device memcpy’s to GPUs 0..i-1 can proceed while the CPU is busy allocating memory for GPU i. If the kernel launch and asynchronous device→host memcpy were in the first loop, the synchronous cudaMalloc() calls would decrease performance because they are synchronous with respect to the current GPU.

floatComputeGravitation_multiGPU_singlethread(     float *force,     float *posMass,    float softeningSquared,    size_t N){    cudaError_t status_cudart;     float ret = 0.0f;     float *dptrPosMass[g_maxGPUs];    float *dptrForce[g_maxGPUs];    int oldDevice;     std::chrono::steady_clock::time_point start, end;    start = std::chrono::steady_clock::now();     memset( dptrPosMass, 0, sizeof(dptrPosMass) );    memset( dptrForce, 0, sizeof(dptrForce) );    size_t bodiesPerGPU = N / g_numGPUs;    if ( (0 != N % g_numGPUs) || (g_numGPUs > g_maxGPUs) ) {        return 0.0f;    }    cuda(GetDevice( &oldDevice ) );     // kick off the asynchronous memcpy's - overlap GPUs pulling    // host memory with the CPU time needed to do the memory     // allocations.    for ( int i = 0; i < g_numGPUs; i++ ) {        cuda(SetDevice( i ) );        cuda(Malloc( &dptrPosMass[i], 4*N*sizeof(float) ) );        // we only need 3*N floatsw for the cross-check. otherwise we         // would need 3*bodiesPerGPU        cuda(Malloc( &dptrForce[i], 3*N*sizeof(float) ) );        cuda(MemcpyAsync(             dptrPosMass[i],             g_hostAOS_PosMass,             4*N*sizeof(float),             cudaMemcpyHostToDevice ) );    }    for ( int i = 0; i < g_numGPUs; i++ ) {        cuda(SetDevice( i ) );        if ( g_bGPUCrossCheck ) {            ComputeNBodyGravitation_multiGPU_onethread<<<300,256,256*sizeof(float4)>>>(                 dptrForce[i],                dptrPosMass[i],                softeningSquared,                0,                N,                N );            cuda(MemcpyAsync(                 g_hostAOS_gpuCrossCheckForce[i],                 dptrForce[i],                 3*N*sizeof(float),                 cudaMemcpyDeviceToHost ) );            cuda(MemcpyAsync(                 g_hostAOS_Force+3*bodiesPerGPU*i,                 dptrForce[i]+3*bodiesPerGPU*i,                 3*bodiesPerGPU*sizeof(float),                 cudaMemcpyDeviceToHost ) );        }        else {            ComputeNBodyGravitation_multiGPU_onethread<<<300,256,256*sizeof(float4)>>>(                 dptrForce[i],                dptrPosMass[i],                softeningSquared,                i*bodiesPerGPU,                bodiesPerGPU,                N );            cuda(MemcpyAsync(                 g_hostAOS_Force+3*bodiesPerGPU*i,                 dptrForce[i],                 3*bodiesPerGPU*sizeof(float),                 cudaMemcpyDeviceToHost ) );        }    }    // Synchronize with each GPU in turn.    for ( int i = 0; i < g_numGPUs; i++ ) {        cuda(SetDevice( i ) );        cuda(DeviceSynchronize() );    }    end = std::chrono::steady_clock::now();    ret = std::chrono::duration<double>(end - start).count() * 1000.0f;     if ( g_fGPUCrosscheckOutput ) {        if ( 1 != fwrite( g_hostAOS_Force, 3*N*sizeof(float), 1, g_fGPUCrosscheckOutput ) )            goto Error_cudart;    }    if ( g_fGPUCrosscheckInput ) {        if ( 1 != fread( g_hostAOS_Force_Golden, 3*N*sizeof(float), 1, g_fGPUCrosscheckInput ) )            goto Error_cudart;    }  Error_cudart:    for ( int i = 0; i < g_numGPUs; i++ ) {        cudaFree( dptrPosMass[i] );        cudaFree( dptrForce[i] );    }    cudaSetDevice( oldDevice );    return ret;}
Listing 9-4. N-body host code (single-threaded multi-GPU) (source on GitHub)

  1. This is a bit of an old-school workaround; CUDA’s linker enables the __global__ function to be compiled into a static library and linked into the application.↩︎

  2. To avoid awkward formatting, error checking has been removed.↩︎