Foundations of GPU Computing: Closing Lecture

Lawrence Murray

Outline

  1. Tie up some loose threads from the practicals.
  2. A taste of more advanced kernel programming.

This lecture is part of the course Foundations of GPU Computing.


Error checking

In the practicals, weโ€™ve been very lazy about error checking.

Instead, the usual idiom is:

cudaError_t err = cudaMalloc(...);  // or kernel call, kernel<<<...>>>()
if (err != cudaSuccess) {
  printf("error: %s\n", cudaGetErrorString(err));
}

See Error Handling in the CUDA Runtime API documentation.


Streaming memory allocation

In the practicals, we allocated all memory upfront. What about situations where we do not know memory requirements upfront?

Some alternatives:


Multiple streams

In the practicals, we used one stream to enqueue kernels for the GPU. We can in fact use many.


Events

In the practicals, we used cudaDeviceSynchronize() to wait for the GPU to complete work enqueued in a stream. Events facilitate more fine-grained synchronization.


Advanced kernel programming

In the practicals, we only looked at kernels that perform simple transformations, where an operation is applied element-wise to a vector or matrix, and threads do not need to interact.

When might we need to write our own custom kernel?


Example: sum of two discrete random variables

This is needed for automatic marginalization in Birch.

The problem: 1. given a random variable xx on the integers {0,โ€ฆ,mโˆ’1}\{0,\ldots,m-1\} with a vector of probabilities ๐’‘\mathbf{p} with pi=P(x=i)p_i = P(x = i), and 2. another random variable yy on the integers {0,โ€ฆ,nโˆ’1}\{0,\ldots,n-1\} with a vector of probabilities ๐’’\mathbf{q} with qi=P(y=i)q_i = P(y = i), 3. consider the sum x+yx + y on the integers {0,m+nโˆ’2}\{0, m + n - 2\} and compute its vector of probabilities ๐’“\mathbf{r} with ri=P(x+y=i)r_i = P(x + y = i).

The basic computation:

ri=โˆ‘j=max⁡(0,iโˆ’n+1)min⁡(mโˆ’1,i)pjqiโˆ’j.r_i = \sum_{j = \max(0,i-n+1)}^{\min (m-1,i)} p_j q_{i-j}.


Example: sum of two discrete random variables

We can write this in matrix form as:

๐’“=(p00โ€ฆโ€ฆโ€ฆโ‹ฎp00โ€ฆโ€ฆpmโˆ’1โ‹ฎp00โ€ฆ0pmโˆ’1โ‹ฎโ‹ฑ0โ‹ฎ0pmโˆ’1โ‹ฎp0โ‹ฎโ‹ฎ0โ‹ฑโ‹ฎโ‹ฎโ‹ฎโ‹ฎ0pmโˆ’1)๐’’,\mathbf{r} = \left(\begin{array}{cccccc} p_0 & 0 & \ldots & \ldots & \ldots \\ \vdots & p_0 & 0 & \ldots & \ldots \\ p_{m-1} & \vdots & p_0 & 0 & \ldots \\ 0 & p_{m-1} & \vdots & \ddots & 0 \\ \vdots & 0 & p_{m-1} & \vdots & p_0 \\ \vdots & \vdots & 0 & \ddots & \vdots \\ \vdots & \vdots & \vdots & 0 & p_{m-1} \\ \end{array}\right) \mathbf{q},

where there are nn number of columns.


Example: sum of two discrete random variables

Or illustrate it for m=n=4m = n = 4:

The numbers are not representing the values of the elements here, but rather the associated xx and yy. We can see that the matrix-vector product accumulates the probabilities of pairs of xx and yy that sum to the same outcome.

This is the product of a banded matrix and a vector.


Example: sum of two discrete random variables

Unfortunately, as with most (all?) BLAS implementations, the lead must be positive and at least as large as the number of rows in the matrix or an error is given. So weโ€™ll need a custom kernel!


Version 0

The length of ๐’“\mathbf{r} is m+nโˆ’1m + n - 1. We can assign one thread to compute each element of ๐’“\mathbf{r}.

__global__ void kernel_enumerate(const int m, const int n, const float* p,
    const int incp, const float* q, const int incq, float* r,
    const int incr) {
  /* element of r for which thread is responsible */
  int i = threadIdx.y + blockIdx.y*blockDim.y;
  
  /* sum across elements in each thread */
  float result = 0.0f;
  for (j = 0; j < n; ++j) {
    if (0 <= i - j && i - j < m) {
      result += p[(i - j)*incp]*q[j*incq];
    }
  }
  
  /* write element */
  r[i] = result;
}

Next issue: Each thread has a different amount of work to do, leading to warp divergence.


Warp divergence

The fact that warps can diverge makes programming kernels much easier, but reducing warp divergence will improve performance.


Version 1: Reducing warp divergence

Consider the following transformation of the problem:


Version 1: Reducing warp divergence

__global__ void kernel_enumerate(const int m, const int n, const float* p,
    const int incp, const float* q, const int incq, float* r,
    const int incr) {
  /* first element of r for which thread is responsible */
  int i = threadIdx.y + blockIdx.y*blockDim.y;
  
  /* sum across elements in each thread */
  float result1 = 0.0f, result2 = 0.0f;
  for (j = 0; j < n; ++j) {
    if (0 <= i - j) {
      result1 += p[(i - j)*incp]*q[j*incq];
    } else {
      result2 += p[(m + i - j)*incp]*q[j*incq];
    }
  }
  
  /* write elements */
  r[i] = result1;
  if (i < m - 1) {
    r[i + m] = result2;
  }
}

Next issue: Each thread reads the whole vector ๐’’\mathbf{q} from device memory.


Shared memory

We can use shared memory to read ๐’“\mathbf{r} from device memory only once per thread block, i.e.ย threads in the same block can share a single read.


Version 2: Using shared memory

__global__ void kernel_enumerate(const int m, const int n, const float* p,
    const int incp, const float* q, const int incq, float* r,
    const int incr) {
  __shared__ float* q_shared;
  int i = threadIdx.y + blockIdx.y*blockDim.y;
  float result1 = 0.0f, result2 = 0.0f;
  
  for (int base = 0; base < n; base += blockDim.y) {
    /* read the next batch into shared memory */
    int j = threadIdx.y;
    q_shared[j] = base + j < n ? q[(base + j)*incq] : 0.0f;
    __syncthreads();

    /* sum across elements in each thread  */
    for (j = 0; j < blockDim.y; ++j) {
      if (0 <= i - j) {
        result1 += p[(base + i - j)*incp]*q_shared[j];
      } else {
        result2 += p[(base + m + i - j)*incp]*q_shared[j];
      }
    }
  }

  /* set the final result */
  r[i] = result1;
  if (i < m - 1) {
    r[i + m] = result2;
  }
}

When calling the kernel, a third argument is given to specify the amount of shared memory required: kernel_enumerate<<<grid,block,shared>>>(...).

Next issue: The kernel offers mm-way parallelism, which may not be enough to occupy the GPU.


Increasing parallelism

The current kernel uses a thread per two elements of the output, giving mm-way parallelism. If we use a warp per two elements of the output, we can increase this to 32m32m-way parallelism.

Each thread of the warp computes a partial sum, then they interact to compute the total sum. We have a few options for the second step:


Version 3: Increasing parallelism

__global__ void kernel_enumerate(const int m, const int n,
    const float* p, const int incp, const float* q, const int incq, float* r,
    const int incr) {
  assert(blockDim.x == warpSize && gridDim.x == 1);

  __shared__ float* q_shared;
  int i = blockIdx.x*blockDim.x;
  float result1 = 0.0f, result2 = 0.0f;
  
  for (int base = 0; base < n; base += warpSize*blockDim.y) {
    /* read the next batch into shared memory */
    int j = threadIdx.y*warpSize + threadIdx.x;
    q_shared[j] = base + j < n ? q[(base + j)*incq] : 0.0f;
    __syncthreads();

    /* sum across elements in each thread  */
    for (j = threadIdx.x; j < warpSize*blockDim.y; j += warpSize) {
      /* read the next batch into shared memory */
      if (0 <= i - j) {
        result1 += p[(base + i - j)*incp]*q_shared[j];
      } else {
        result2 += p[(base + m + i - j)*incp]*q_shared[j];
      }
    }
  }

  /* sum across threads of warp, using butterfly sum */
  for (int k = 16; k >= 1; k /= 2) {
    result1 += __shfl_xor_sync(0xffffffff, result1, k, warpSize);
    result2 += __shfl_xor_sync(0xffffffff, result2, k, warpSize);
  }

  /* set the final result, only first thread in each warp */
  if (threadIdx.x == 0) {
    r[i] = result1;
    if (i < m - 1) {
      r[i + m] = result2;
    }
  }
}

Summary

Version 0
We started with a basic implementation of a kernel to enumerate a sum of two discrete random variables.
Version 1
We improved performance by reducing warp divergence.
Version 2
We improved performance by using shared memory to avoid repeated reads from device memory.
Version 3
We increased parallelism by having whole warps, rather than single threads, share the computation of each element of the output.

General tips


Further reading