3031 words
15 minutes
Writing Your First Kernel: Step-by-Step Introduction to CUDA Programming

Writing Your First Kernel: Step-by-Step Introduction to CUDA Programming#

In recent years, Graphics Processing Units (GPUs) have evolved from being dedicated, specialized hardware for graphics rendering to becoming highly parallel and programmable engines for accelerating a wide range of computational tasks. CUDA (Compute Unified Device Architecture), developed by NVIDIA, enables developers to harness the power of GPUs by writing kernels and parallelizing code that can significantly speed up math-heavy or data-intensive processes.

In this comprehensive guide, we will walk through the essentials of CUDA programming, starting from basic principles and gradually moving to more advanced concepts. By the end of this blog post, you will have a clear understanding of how to write your first CUDA kernels, manage GPU memory, optimize performance, and extend your skills to tackle large-scale problems in a professional setting.


Table of Contents#

  1. Introduction to GPU Programming
  2. Setting Up Your CUDA Environment
  3. Understanding the CUDA Programming Model
  4. Writing Your First CUDA Kernel: A “Hello, World!” Example
  5. Threads, Blocks, and Grids: The Key Building Blocks
  6. Memory Management and Data Transfer
  7. Shared Memory, Registers, and the CUDA Memory Hierarchy
  8. Handling Concurrency and Streams
  9. Synchronization and Atomic Operations
  10. Advanced Topics: Unified Memory, Constant Memory, and Texture Memory
  11. Performance Optimization Techniques
  12. Debugging and Profiling Your CUDA Code
  13. Real-World Example: Vector Addition and Matrix Multiplication
  14. Best Practices and Professional-Level Expansions
  15. Conclusion

1. Introduction to GPU Programming#

A. Why GPUs for General-Purpose Computing?#

GPUs were originally crafted to manage massive amounts of graphical computations in parallel. Unlike Central Processing Units (CPUs), which handle general-purpose serial tasks, GPUs are designed with a large number of smaller, more specialized cores that excel at doing multiple operations in parallel. This architecture proves beneficial for workloads involving:

  • Matrix operations (e.g., linear algebra, deep learning)
  • Image processing or computer vision
  • Scientific computations (e.g., molecular dynamics, fluid simulations)
  • Data analytics and machine learning

B. The Emergence of CUDA#

NVIDIA noticed that scientific and engineering communities, as well as data scientists, were repurposing GPUs for more general calculations. To simplify development, they introduced CUDA—an extension to the C/C++ language (with bindings to other languages too) that provides specific syntax and APIs to program GPUs. With CUDA, you can define special functions (kernels) that run in parallel on thousands of GPU threads to speed up your workloads.

C. High-Level Brainstorm: CPU vs. GPU#

Before diving into syntactical details, it is useful to conceptualize the distinction between CPU and GPU execution flows:

  • CPU:

    • Few cores.
    • Optimized for serial tasks.
    • Large cache memory.
    • High clock speed.
  • GPU:

    • Thousands of cores in parallel.
    • Optimized for throughput.
    • Smaller cache per core, but huge aggregate memory bandwidth.
    • Ideal for data-parallel tasks.

2. Setting Up Your CUDA Environment#

Before writing your first kernel, you must ensure your development environment is correctly configured. Below is a quick overview of the main steps:

  1. Hardware Requirements

    • A CUDA-capable NVIDIA GPU (compute capability usually >= 3.0 recommended for modern development).
  2. Software Requirements

    • NVIDIA Driver
    • CUDA Toolkit, which includes the compiler (nvcc), libraries, and documentation.
    • A C/C++ compiler toolchain (e.g., gcc on Linux, Visual Studio on Windows).
  3. Verification

    • After installation, verify by running nvcc --version in a terminal or command prompt.
    • Alternatively, compile a sample CUDA program from the installed toolkit samples (like deviceQuery).

For reference, here is a simplified table showing the key components you’ll need:

ComponentPurposeExample Version(s)
NVIDIA GPUHardware resource for CUDAGTX 1080, RTX 3080
NVIDIA DriverCommunication between OS and GPU510.xx or later
CUDA ToolkitContains nvcc compiler, libraries, and tools11.x, 12.x
C/C++ CompilerCompiles host codeGCC, Clang, MSVC
IDE/Text EditorOptional for code editing and project managementVS Code, Visual Studio

3. Understanding the CUDA Programming Model#

A. Kernels and Parallel Execution#

The programming model for CUDA is straightforward in concept:

  1. You write functions called kernels that describe operations to be done on the GPU.
  2. You launch these kernels from your CPU (host) code.
  3. The GPU (device) runs these kernels in parallel across many threads.

A kernel is marked in C/C++ code with the __global__ keyword. For example:

__global__ void myKernel() {
// Code executed on the GPU
}

When you call myKernel<<<blocks, threads>>>() from your host code, you specify how many blocks and threads per block you want. The GPU then launches that many instances of myKernel().

B. Threads, Blocks, and Grids#

CUDA organizes parallel execution hierarchically:

  1. A grid consists of one or more blocks.
  2. Each block consists of one or more threads.

This structure provides flexibility, allowing your program to scale from smaller GPUs to very large ones without necessarily being hardcoded for a specific number of cores.

C. Memory Model#

The CUDA memory model can be visualized as follows:

  • Global memory: Large memory visible to all threads, but slower to access.
  • Shared memory: A smaller, high-speed memory shared among threads in the same block.
  • Registers: Fastest memory, private to each thread.
  • Constant and texture memory: Special memories for read-only or specialized access patterns.

Understanding when and how to use different memory spaces is essential for achieving high performance. We’ll explore each in detail later.


4. Writing Your First CUDA Kernel: A “Hello, World!” Example#

Let’s start with a bare-minimum program that demonstrates how to write a simple kernel and call it from the host. We’ll create two files for organization: hello.cu (CUDA source code) and optionally a simple compile command.

A. Kernel Definition#

hello.cu
#include <iostream>
// Define a simple kernel
__global__ void helloKernel() {
// Each thread prints a message.
// This can lead to interleaved prints if using multiple threads.
printf("Hello from thread %d\n", threadIdx.x);
}
int main() {
// Launch the kernel with 1 block of 8 threads
helloKernel<<<1, 8>>>();
// Wait for GPU to finish before accessing results (on desktop this is usually automatic,
// but better practice is to use cudaDeviceSynchronize)
cudaDeviceSynchronize();
return 0;
}
  1. We include <iostream> (for C++ output streams) and rely on <cstdio> for printf.
  2. The kernel __global__ void helloKernel() will run on the GPU, but it’s launched from the host.
  3. helloKernel<<<1, 8>>>(); indicates that we want 8 threads in a single block.
  4. threadIdx.x is a built-in CUDA variable indicating the current thread’s index within the block.

B. Compilation and Execution#

  • Compile: nvcc hello.cu -o hello
  • Run: ./hello

You should see:

Hello from thread 0
Hello from thread 1
...
Hello from thread 7

Although this example does not process any data, you’ve successfully compiled and executed your first CUDA kernel!


5. Threads, Blocks, and Grids: The Key Building Blocks#

A. Thread Indexing#

Within a kernel, you can access built-in variables to determine your thread’s position:

  • threadIdx.x, blockIdx.x – The index of the current thread or block in the x dimension.
  • blockDim.x – The number of threads in each block.
  • gridDim.x – The number of blocks in the grid.

If you want a unique global thread ID, you can do:

int globalThreadId = blockIdx.x * blockDim.x + threadIdx.x;

B. Multi-Dimensional Indexing#

You can also specify kernels along multiple dimensions (up to 3D for blocks and 3D for grids). For example:

dim3 threadsPerBlock(16, 16);
dim3 numBlocks(32, 32);
myKernel<<<numBlocks, threadsPerBlock>>>();

Inside myKernel, you can reference threadIdx.y, blockIdx.y, and so on. This flexibility is particularly useful for image processing (2D) or volumetric data (3D).

C. Determining Grid and Block Sizes#

Choosing the right threads-per-block (also known as block size) and the number of total threads can significantly impact performance. Typical guidance includes:

  • Use a multiple of the warp size (32 threads) whenever possible.
  • Consider the hardware constraints: For instance, some GPUs support up to 1024 threads per block.
  • Generally, each block runs on one Streaming Multiprocessor (SM), so you often want enough blocks to keep all SMs busy.

6. Memory Management and Data Transfer#

To perform meaningful computations, you must typically copy data from the host to the GPU, operate on it, and then copy the results back to the host. Common CUDA API functions for memory management include:

  • cudaMalloc(&devicePtr, size)
  • cudaFree(devicePtr)
  • cudaMemcpy(dest, src, size, cudaMemcpyHostToDevice)
  • cudaMemcpy(dest, src, size, cudaMemcpyDeviceToHost)

A. Example: Allocating and Copying Data#

#include <iostream>
__global__ void incrementKernel(int* d_data) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
d_data[idx] += 1;
}
int main() {
const int arraySize = 256;
const int bytes = arraySize * sizeof(int);
// Allocate host memory
int* h_data = (int*)malloc(bytes);
// Initialize
for(int i = 0; i < arraySize; i++) {
h_data[i] = i;
}
// Allocate device memory
int* d_data;
cudaMalloc(&d_data, bytes);
// Copy from host to device
cudaMemcpy(d_data, h_data, bytes, cudaMemcpyHostToDevice);
// Kernel launch
int threads = 128;
int blocks = arraySize / threads;
incrementKernel<<<blocks, threads>>>(d_data);
// Copy results back to host
cudaMemcpy(h_data, d_data, bytes, cudaMemcpyDeviceToHost);
// Check results
for(int i = 0; i < 5; i++) {
std::cout << "h_data[" << i << "] = " << h_data[i] << std::endl;
}
// Cleanup
cudaFree(d_data);
free(h_data);
return 0;
}

In this code, each element gets incremented by 1. After copying the data back to the host, you can verify the correctness of the GPU operations.

B. Data Transfer Overheads#

Keep in mind that transferring data across the PCIe bus between the host and device can be a bottleneck. Strategies to minimize or overlap data transfers are essential in high-performance applications.


7. Shared Memory, Registers, and the CUDA Memory Hierarchy#

A. Global Memory#

  • Large capacity.
  • Slowest to access (compared to shared or registers).
  • Persistent across kernel launches if allocated outside the kernel.

B. Shared Memory#

  • On-chip, significantly faster than global memory.
  • Only accessible by threads within the same block.
  • Size is limited (e.g., 48 KB to 100 KB or more per SM, depending on GPU architecture).

Example usage of shared memory:

__global__ void sharedMemoryExample(float* d_input, float* d_output) {
extern __shared__ float s_data[];
int idx = threadIdx.x + blockDim.x * blockIdx.x;
// Load data from global memory to shared memory
s_data[threadIdx.x] = d_input[idx];
__syncthreads();
// Perform some computation
s_data[threadIdx.x] *= 2.0f;
__syncthreads();
// Write back to global memory
d_output[idx] = s_data[threadIdx.x];
}

extern __shared__ allows you to dynamically specify the size of shared memory at kernel launch:

int sharedMemSize = blockSize * sizeof(float);
sharedMemoryExample<<<gridSize, blockSize, sharedMemSize>>>(d_input, d_output);

C. Registers#

Registers are the fastest form of memory on the GPU, private to each thread. They store immediate values and variables needed for calculations. While incredibly fast, their limited size means the compiler will spill values to local memory if you use too many.

D. Constant and Texture Memory#

These are specialized forms of memory:

  • Constant Memory is read-only from kernels but can be written from the host. Cached for broadcast usage.
  • Texture Memory is specialized for 2D spatial locality. May be beneficial for certain image processing tasks.

8. Handling Concurrency and Streams#

CUDA allows asynchronous execution, letting you overlap GPU computation with host operations or other GPU kernels. This is particularly helpful for hiding memory transfer latencies.

A. Streams#

A stream in CUDA is essentially a queue of operations that execute in order on the GPU. By using multiple streams, you can overlap:

  1. Kernel execution on one stream.
  2. Data transfer on another stream.

Example:

cudaStream_t stream1, stream2;
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);
// Launch kernel in stream1
myKernel<<<grid, block, 0, stream1>>>(d_dataA);
// Copy data in stream2
cudaMemcpyAsync(d_dataB, h_dataB, size, cudaMemcpyHostToDevice, stream2);
// Wait for streams to finish
cudaStreamSynchronize(stream1);
cudaStreamSynchronize(stream2);
cudaStreamDestroy(stream1);
cudaStreamDestroy(stream2);

B. Overlapping Data Transfers and Computation#

By default, CUDA operations on the default stream (NULL stream) are serialized. Leveraging multiple streams can yield substantial performance improvements when properly orchestrated.


9. Synchronization and Atomic Operations#

A. Thread Synchronization#

Because GPU threads run in parallel, certain situations require synchronization:

  1. Block-level synchronization: Using __syncthreads(), all threads in a block must reach the synchronization point before continuing.
  2. Grid-level synchronization: Typically, you must use separate kernel launches or sophisticated techniques.

B. Atomic Operations#

When multiple threads access and modify shared data concurrently, you need atomic operations to avoid race conditions. CUDA provides atomic functions (e.g., atomicAdd, atomicMin, atomicMax) in global and shared memory.

__global__ void atomicExample(int* data) {
int idx = threadIdx.x + blockIdx.x * blockDim.x;
atomicAdd(&data[0], idx);
}

Use atomic operations judiciously: they can serialize parts of your code and reduce performance if overused.


10. Advanced Topics: Unified Memory, Constant Memory, and Texture Memory#

A. Unified Memory#

Unified Memory simplifies memory management by automatically migrating data between CPU and GPU. You allocate memory with cudaMallocManaged():

int* data;
cudaMallocManaged(&data, n * sizeof(int));
// Use data on CPU or GPU without explicit cudaMemcpy

However, behind the scenes, data movements still occur, so performance may vary. Unified Memory is invaluable for rapid development and simpler code but may not achieve the same performance as carefully optimized explicit transfers.

B. Constant Memory#

Constant memory is beneficial for read-only data that is often accessed by many threads. You declare it with the __constant__ keyword:

__constant__ float constData[256];

Then on the host:

float hostData[256] = { /* ... initialize ... */ };
cudaMemcpyToSymbol(constData, hostData, sizeof(float)*256);

Access in kernel:

__global__ void useConstantMemory() {
float value = constData[threadIdx.x];
// ...
}

C. Texture Memory#

Texture memory is designed for spatial locality and can provide some caching benefits. It’s often used in graphics or image processing tasks, where threads in close vicinity read from nearby memory addresses.


11. Performance Optimization Techniques#

After mastering the basics, the next step is optimizing for speed and resource utilization. Some common strategies include:

  1. Optimize Memory Access Patterns
    • Use coalesced memory access by grouping threads reading consecutive memory addresses.
  2. Use Shared Memory
    • Reduces global memory bandwidth usage.
  3. Limit Divergence
    • Avoid branching statements (e.g., if/else) that cause threads in a warp to take different execution paths.
  4. Optimize Block and Grid Sizes
    • Ensure enough blocks to keep all SMs occupied, but not so large that memory usage is inefficient.
  5. Leverage Profilers
    • NVIDIA Nsight, CUDA Profiler, or Visual Profiler can help identify bottlenecks.

12. Debugging and Profiling Your CUDA Code#

Debugging and profiling are essential to ensure correctness and performance.

A. Tools for Debugging#

  • cuda-gdb: A GPU-capable version of the GDB debugger.
  • NVIDIA Nsight Eclipse Edition: Offers a visual environment for debugging and profiling on Linux and other platforms.
  • NVIDIA Nsight Visual Studio Edition: Provides an integrated solution for Windows developers.

B. Profiling with Nsight Systems and Nsight Compute#

  • Nsight Systems: Visualize CPU/GPU timelines, identify execution bottlenecks, and see how kernels overlap with memory transfers.
  • Nsight Compute: In-depth performance analysis of individual kernels (memory bandwidth, occupancy, etc.).

13. Real-World Example: Vector Addition and Matrix Multiplication#

A. Vector Addition Example#

__global__ void vectorAdd(float* A, float* B, float* C, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) {
C[i] = A[i] + B[i];
}
}
int main() {
int n = 1 << 20; // 1 million elements
size_t bytes = n * sizeof(float);
// Allocate host memory
float *h_A = (float*)malloc(bytes);
float *h_B = (float*)malloc(bytes);
float *h_C = (float*)malloc(bytes);
// Initialize data
for (int i = 0; i < n; i++) {
h_A[i] = 1.0f;
h_B[i] = 2.0f;
}
// Allocate device memory
float *d_A, *d_B, *d_C;
cudaMalloc(&d_A, bytes);
cudaMalloc(&d_B, bytes);
cudaMalloc(&d_C, bytes);
// Copy data to device
cudaMemcpy(d_A, h_A, bytes, cudaMemcpyHostToDevice);
cudaMemcpy(d_B, h_B, bytes, cudaMemcpyHostToDevice);
// Launch kernel
int blockSize = 256;
int gridSize = (n + blockSize - 1) / blockSize;
vectorAdd<<<gridSize, blockSize>>>(d_A, d_B, d_C, n);
// Copy results back
cudaMemcpy(h_C, d_C, bytes, cudaMemcpyDeviceToHost);
// Verify
for (int i = 0; i < 5; i++) {
std::cout << "C[" << i << "] = " << h_C[i] << std::endl;
}
// Cleanup
cudaFree(d_A); cudaFree(d_B); cudaFree(d_C);
free(h_A); free(h_B); free(h_C);
return 0;
}

B. Matrix Multiplication Example#

Matrix multiplication is a classic example that benefits from GPU parallelism. For instance, we can multiply two square matrices A and B to produce C:

__global__ void matMulKernel(float* A, float* B, float* C, int n) {
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
if(row < n && col < n) {
float sum = 0.0f;
for(int k = 0; k < n; k++) {
sum += A[row * n + k] * B[k * n + col];
}
C[row * n + col] = sum;
}
}
int main() {
int n = 512;
size_t bytes = n * n * sizeof(float);
// Alloc and init host data
float* h_A = (float*)malloc(bytes);
float* h_B = (float*)malloc(bytes);
float* h_C = (float*)malloc(bytes);
for(int i = 0; i < n*n; i++) {
h_A[i] = 1.0f;
h_B[i] = 2.0f;
}
// Device
float *d_A, *d_B, *d_C;
cudaMalloc(&d_A, bytes);
cudaMalloc(&d_B, bytes);
cudaMalloc(&d_C, bytes);
cudaMemcpy(d_A, h_A, bytes, cudaMemcpyHostToDevice);
cudaMemcpy(d_B, h_B, bytes, cudaMemcpyHostToDevice);
// Launch kernel
dim3 threads(16, 16);
dim3 blocks((n + threads.x - 1) / threads.x, (n + threads.y - 1) / threads.y);
matMulKernel<<<blocks, threads>>>(d_A, d_B, d_C, n);
cudaMemcpy(h_C, d_C, bytes, cudaMemcpyDeviceToHost);
// Check a few values
for(int i = 0; i < 5; i++) {
std::cout << "C[" << i << "] = " << h_C[i] << std::endl;
}
// Free
cudaFree(d_A); cudaFree(d_B); cudaFree(d_C);
free(h_A); free(h_B); free(h_C);
return 0;
}

This naive matrix multiplication can be optimized using shared memory to reuse sub-blocks of the matrices, drastically reducing memory fetches from global memory.


14. Best Practices and Professional-Level Expansions#

A. Code Structuring#

  1. Separate Device and Host Code

    • Keep kernel definitions in .cu files.
    • Create separate headers for function prototypes to facilitate large-scale projects.
  2. Error Checking

    • Always check the return status of CUDA calls.
    • Use macros to simplify error handling. For example:
      #define CUDA_CHECK(ans) { gpuAssert((ans), __FILE__, __LINE__); }
      inline void gpuAssert(cudaError_t code, const char *file, int line) {
      if (code != cudaSuccess) {
      fprintf(stderr,"GPUassert: %s %s %d\n", cudaGetErrorString(code), file, line);
      exit(code);
      }
      }

B. Using C++ Features#

While CUDA can be combined with modern C++ features (C++11 or later), there are specific limitations regarding templates, inheritance, and certain class functionalities on the device side. Carefully check NVCC documentation for compatibility.

C. Profiling and Micro-Optimizations#

  1. Occupancy

    • The ratio of active warps to the maximum number of warps supported on an SM.
    • Use occupancy calculators (Nsight) to tune block sizes.
  2. Loop Unrolling

    • The compiler can unroll small loops to reduce loop overhead.
    • #pragma unroll can be used to guide unrolling.
  3. Bank Conflicts in Shared Memory

    • Shared memory is divided into banks. Accessing the same bank by multiple threads concurrently can cause serialization. Reorganize data to avoid conflicts.

D. Multi-GPU and MPI#

For very large-scale problems, you might employ multiple GPUs:

  • Use CUDA-aware MPI or peer-to-peer memory access (P2P) on multi-GPU systems.
  • Distribute data across nodes in a cluster environment.

E. Libraries and Frameworks#

There is a rich ecosystem of GPU libraries:

  • cuBLAS for linear algebra.
  • cuFFT for Fast Fourier Transforms.
  • Thrust (C++ STL-like library) for vectors, sorting, and more.
  • cuDNN for deep learning primitives.

Leveraging these libraries often means you can sidestep writing and optimizing your own GPU kernels for standard operations.


15. Conclusion#

CUDA programming opens a gateway to unleashing massive parallelism on NVIDIA GPUs. By understanding threads, blocks, the memory model, and kernel launches, you gain the ability to accelerate computationally heavy applications. From the simplest “Hello, World!” kernel to advanced techniques involving shared memory and concurrency, each step offers new performance improvements and insights.

Key takeaways include:

  1. Properly managing data transfers is critical to optimizing performance.
  2. Shared memory and register usage can significantly speed up frequent data access.
  3. Streams and concurrent kernel executions help in overlapping data transfers and computations.
  4. Profiling tools are essential for diagnosing performance bottlenecks.
  5. Advanced techniques—like using Unified Memory or specialized memory spaces—can reduce development complexity or further boost efficiency.

With the basics now well in hand, you can keep refining and scaling your GPU solutions. As you gain experience, experiment with multi-dimensional kernels, advanced memory optimizations, and more sophisticated concurrency patterns. Explore the extensive NVIDIA libraries to expedite development and unlock the true potential of GPU computing. By doing so, you will be well-equipped to tackle challenging computational problems in areas as diverse as scientific simulation, deep learning, and real-time analytics, bringing professional-grade performance to your applications.

Writing Your First Kernel: Step-by-Step Introduction to CUDA Programming
https://science-ai-hub.vercel.app/posts/c753b030-35cb-4edb-aa81-4faca75a45f2/3/
Author
AICore
Published at
2025-06-25
License
CC BY-NC-SA 4.0