CUDA Programming Series — Tutorial 02

Your First CUDA Kernel

From nvcc setup to vector addition — host/device workflow, memory allocation, kernel launch syntax, error handling.

CUDA nvcc Kernels Host/Device Memory Management Error Handling
Setup → Host vs Device → Workflow → Vector Add → Launch Syntax → Error Handling → Compiling → Exercises
00

Topics We'll Cover

This tutorial takes you from installing the CUDA toolkit to writing, compiling, and running your first GPU kernel. By the end you will have a complete, working vector addition program.

Prerequisites

Tutorial 01 — GPU Architecture & the CUDA Execution Model. You should understand SMs, warps, and the thread hierarchy before writing your first kernel.

01

Setting Up the CUDA Toolkit

Before writing any GPU code you need the CUDA Toolkit, which includes the nvcc compiler, runtime libraries, and developer tools.

Installation by Platform

Platform Method Notes
Linux (Ubuntu/Fedora) NVIDIA .run installer or package manager Preferred: apt install nvidia-cuda-toolkit or download from developer.nvidia.com
Windows CUDA Toolkit installer (.exe) Requires Visual Studio with C++ workload installed first
macOS Not supported since CUDA 10.2 Use Google Colab or a remote Linux machine instead
Google Colab Pre-installed — zero setup Free tier gives T4 GPU; great for learning

Verify Your Installation

terminal
# Check the CUDA compiler version
$ nvcc --version
nvcc: NVIDIA (R) Cuda compiler driver
Cuda compilation tools, release 12.6, V12.6.77

# Check GPU driver & device info
$ nvidia-smi
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 560.35    Driver Version: 560.35    CUDA Version: 12.6     |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|===============================+======================+======================|
|   0  NVIDIA GeForce ...  Off  | 00000000:01:00.0  On |                  N/A |
+-------------------------------+----------------------+----------------------+
DGX Spark

If you have access to an NVIDIA DGX Spark, the CUDA toolkit is pre-installed. Just ssh in and start coding — no setup required.

02

Host vs Device

In CUDA programming, your code runs on two processors. The host (CPU) orchestrates everything: allocating memory, launching kernels, and copying data. The device (GPU) executes the parallel work.

Host (CPU)

  • Runs main() and sequential code
  • Allocates host & device memory
  • Copies data between host & device
  • Launches kernels on the GPU
  • Compiled by your system C++ compiler

Device (GPU)

  • Runs kernel functions in parallel
  • Has its own memory (VRAM)
  • Thousands of threads execute simultaneously
  • Cannot directly access host memory
  • Compiled by nvcc to PTX/SASS

Function Qualifiers

CUDA extends C++ with three function qualifiers that tell the compiler where a function runs and who can call it.

Qualifier Executes On Callable From Purpose
__global__ Device (GPU) Host (CPU) Kernel entry point — this is what you launch with <<<...>>>
__device__ Device (GPU) Device (GPU) Helper function called from within a kernel
__host__ Host (CPU) Host (CPU) Normal CPU function (default if no qualifier)
function_qualifiers.cu
// Runs on GPU, called from CPU
__global__ void myKernel(float* data, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) data[idx] *= 2.0f;
}

// Runs on GPU, called from GPU only
__device__ float square(float x) {
    return x * x;
}

// Runs on CPU (default — __host__ is optional)
__host__ void printResult(float* data, int n) {
    for (int i = 0; i < n; i++) printf("%f\n", data[i]);
}
Tip

You can combine __host__ and __device__ on the same function to compile it for both CPU and GPU. Useful for utility functions like max() or clamp().

03

The CUDA Workflow

Every CUDA program follows the same five-step pattern. The host (CPU) is responsible for orchestrating data movement and kernel execution.

1. Allocate device memory
↓
2. Copy data Host → Device
↓
3. Launch kernel on GPU
↓
4. Copy results Device → Host
↓
5. Free device memory

The API Calls

Step CUDA Function Description
Allocate cudaMalloc(&d_ptr, size) Allocate size bytes on GPU global memory
Copy H→D cudaMemcpy(d_ptr, h_ptr, size, cudaMemcpyHostToDevice) Copy data from host to device
Launch kernel<<<grid, block>>>(args...) Execute kernel with specified grid/block dimensions
Copy D→H cudaMemcpy(h_ptr, d_ptr, size, cudaMemcpyDeviceToHost) Copy results from device to host
Free cudaFree(d_ptr) Free device memory allocation
Key Insight

Host and device have separate address spaces. You cannot dereference a device pointer on the host or vice versa. Data must be explicitly copied across the PCIe bus (or NVLink). This transfer overhead is why you want to maximise computation per byte transferred.

04

Hello World — Vector Addition

Vector addition is the "Hello World" of CUDA: simple enough to understand immediately, yet it demonstrates every step of the host/device workflow. This is a complete, compilable program.

vector_add.cu — complete program
#include <stdio.h>
#include <stdlib.h>
#include <cuda_runtime.h>

// ── The kernel: each thread computes one element ──
__global__ void vectorAdd(const float* a, const float* b, float* c, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        c[idx] = a[idx] + b[idx];
    }
}

int main(void) {
    int n = 1000000;                     // 1 million elements
    size_t size = n * sizeof(float);

    // ── Step 0: Allocate and initialise host memory ──
    float* h_a = (float*)malloc(size);
    float* h_b = (float*)malloc(size);
    float* h_c = (float*)malloc(size);

    for (int i = 0; i < n; i++) {
        h_a[i] = 1.0f;
        h_b[i] = 2.0f;
    }

    // ── Step 1: Allocate device memory ──
    float* d_a;
    float* d_b;
    float* d_c;
    cudaMalloc((void**)&d_a, size);
    cudaMalloc((void**)&d_b, size);
    cudaMalloc((void**)&d_c, size);

    // ── Step 2: Copy input data Host → Device ──
    cudaMemcpy(d_a, h_a, size, cudaMemcpyHostToDevice);
    cudaMemcpy(d_b, h_b, size, cudaMemcpyHostToDevice);

    // ── Step 3: Launch the kernel ──
    int blockSize = 256;                  // threads per block
    int gridSize = (n + blockSize - 1) / blockSize;  // enough blocks to cover n
    vectorAdd<<<gridSize, blockSize>>>(d_a, d_b, d_c, n);

    // ── Step 4: Copy result Device → Host ──
    cudaMemcpy(h_c, d_c, size, cudaMemcpyDeviceToHost);

    // ── Verify the result ──
    int errors = 0;
    for (int i = 0; i < n; i++) {
        if (fabs(h_c[i] - 3.0f) > 1e-5) {
            errors++;
        }
    }
    printf("Vector addition: %s (%d errors)\n",
           errors == 0 ? "PASSED" : "FAILED", errors);

    // ── Step 5: Free device and host memory ──
    cudaFree(d_a);
    cudaFree(d_b);
    cudaFree(d_c);
    free(h_a);
    free(h_b);
    free(h_c);

    return 0;
}

Line-by-Line Breakdown

Lines What It Does
6-10 The __global__ kernel. Each thread calculates its unique index idx and adds one element. The if (idx < n) guard prevents out-of-bounds access when n is not a multiple of the block size.
13-15 Define problem size (1M floats) and byte count. All CUDA memory functions work in bytes.
18-24 Allocate and fill host arrays with test data: 1.0 + 2.0 = 3.0 for easy verification.
27-32 cudaMalloc allocates GPU memory. Note the (void**) cast — it takes a pointer-to-pointer so it can write the allocated address back to your variable.
35-36 cudaMemcpy transfers data across the PCIe bus. The fourth argument specifies the direction.
39-41 Launch with 256 threads per block and enough blocks to cover all elements. The <<<gridSize, blockSize>>> syntax is CUDA-specific.
44 Copy results back to host memory for verification.
47-52 Verify every element equals 3.0. In production you would use proper error checking (see Slide 06).
56-61 Free GPU memory with cudaFree, then host memory with free. Always clean up both.
Naming Convention

By convention, host pointers use the h_ prefix and device pointers use d_. This makes it obvious which address space a pointer belongs to and helps prevent accidentally passing a host pointer to a device function.

05

Kernel Launch Syntax Deep Dive

The triple-chevron <<<...>>> syntax is how the host tells the GPU how many threads to launch and how to organise them.

kernel
<<<
gridDim
,
blockDim
,
sharedMem
,
stream
>>>
(args...)
Parameter Type Required Description
gridDim dim3 or int Yes Number of blocks in the grid (x, y, z)
blockDim dim3 or int Yes Number of threads per block (x, y, z)
sharedMem size_t No Bytes of dynamically-allocated shared memory (default: 0)
stream cudaStream_t No CUDA stream for async execution (default: stream 0)

Choosing Block Size

Block size should always be a multiple of 32 (the warp size). Common choices are 128, 256, or 512. Smaller blocks increase scheduling flexibility; larger blocks allow more shared memory reuse.

choosing_grid_size.cu
// The standard formula: ceiling division
int blockSize = 256;
int gridSize  = (n + blockSize - 1) / blockSize;

// Example: n = 1000
//   gridSize = (1000 + 255) / 256 = 1255 / 256 = 4
//   Total threads = 4 × 256 = 1024 (covers all 1000 elements)
//   Threads 1000–1023 will hit the `if (idx < n)` guard and exit

// 2D grid example for matrix operations:
dim3 block(16, 16);       // 16×16 = 256 threads per block
dim3 grid(
    (width  + block.x - 1) / block.x,
    (height + block.y - 1) / block.y
);
matrixKernel<<<grid, block>>>(d_matrix, width, height);

Why Multiples of 32?

Block Size = 256

256 / 32 = 8 full warps. No wasted threads. Maximum hardware efficiency.

Block Size = 100

100 / 32 = 3 full warps + 4 leftover threads. The last warp runs with 28 idle lanes — 12.5% waste.

Rule of Thumb

Start with 256 threads per block. It is a safe default that works well on all NVIDIA architectures from Kepler to Blackwell. Optimise later with the CUDA Occupancy Calculator.

06

Error Handling

CUDA functions return a cudaError_t status code. Kernel launches do not return an error directly — you must call cudaGetLastError() after the launch. Always check every CUDA call in development; silent failures are the #1 source of debugging pain.

The Two Essential Checks

Function When to Use What It Catches
cudaGetLastError() After every kernel launch Invalid launch configuration, missing kernel, invalid arguments
cudaDeviceSynchronize() After launch + getLastError Runtime errors inside the kernel (illegal memory access, assertions)

The Macro Wrapper Pattern

This macro wraps every CUDA API call and immediately checks for errors. It prints the file, line, and error message, then exits. Use it religiously during development.

cuda_check.cuh — error checking macro
#ifndef CUDA_CHECK_CUH
#define CUDA_CHECK_CUH

#include <stdio.h>
#include <stdlib.h>
#include <cuda_runtime.h>

#define CUDA_CHECK(call) \
    do { \
        cudaError_t err = (call); \
        if (err != cudaSuccess) { \
            fprintf(stderr, "CUDA error at %s:%d — %s\n", \
                    __FILE__, __LINE__, \
                    cudaGetErrorString(err)); \
            exit(EXIT_FAILURE); \
        } \
    } while (0)

#endif

Using the Macro

usage_example.cu
#include "cuda_check.cuh"

int main(void) {
    float* d_data;
    CUDA_CHECK( cudaMalloc((void**)&d_data, 1024 * sizeof(float)) );

    // Kernel launches don't return cudaError_t, so check after:
    myKernel<<<4, 256>>>(d_data, 1024);
    CUDA_CHECK( cudaGetLastError() );
    CUDA_CHECK( cudaDeviceSynchronize() );

    CUDA_CHECK( cudaMemcpy(h_data, d_data, 1024 * sizeof(float),
                           cudaMemcpyDeviceToHost) );
    CUDA_CHECK( cudaFree(d_data) );

    return 0;
}
Warning

Without error checking, a failed cudaMalloc gives you a null device pointer. The subsequent cudaMemcpy silently fails. The kernel silently crashes. You get garbage results with zero indication of what went wrong. Always use CUDA_CHECK.

07

Compiling & Running

CUDA source files use the .cu extension and are compiled by nvcc, NVIDIA's CUDA compiler driver. Under the hood, nvcc separates host code (sent to your system C++ compiler) from device code (compiled to PTX and then to GPU machine code).

Basic Compilation

terminal
# Compile and run the vector addition program
$ nvcc -o vector_add vector_add.cu
$ ./vector_add
Vector addition: PASSED (0 errors)

Useful nvcc Flags

Flag Purpose Example
-arch=sm_XX Target a specific GPU architecture (compute capability) -arch=sm_89 for Ada Lovelace (RTX 4090)
-O2 / -O3 Optimisation level (applied to both host and device) nvcc -O2 -o prog prog.cu
-lineinfo Embed source line info for profiling tools (Nsight, compute-sanitizer) nvcc -lineinfo -o prog prog.cu
-G Enable device-code debugging (disables optimisations) nvcc -G -g -o prog_debug prog.cu
-Xcompiler Pass flags to the host compiler (gcc/cl.exe) -Xcompiler -Wall,-Wextra
--ptxas-options=-v Show register and memory usage per kernel Useful for occupancy tuning

Separate Compilation

For larger projects, you can compile device code and host code separately, then link them:

terminal — separate compilation
# Compile device code to relocatable device code
$ nvcc -dc -arch=sm_89 -o kernels.o kernels.cu

# Compile host code
$ nvcc -dc -arch=sm_89 -o main.o main.cu

# Link everything together
$ nvcc -arch=sm_89 -o program kernels.o main.o
Tip

Not sure which -arch to use? Run nvidia-smi to find your GPU, then look up its compute capability on the NVIDIA CUDA GPUs page. Common values: sm_75 (Turing), sm_86 (Ampere), sm_89 (Ada Lovelace), sm_90 (Hopper), sm_100 (Blackwell).

08

Exercises

Hands-on practice is essential. Modify the vector addition code from Slide 04 and experiment with the ideas below.

Exercise 1 — Element-wise Multiply

Modify the vectorAdd kernel to perform element-wise multiplication instead of addition. Change the verification check to expect 1.0 * 2.0 = 2.0.

vector_mul.cu — complete program
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <cuda_runtime.h>

__global__ void vectorMul(const float* a, const float* b, float* c, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        c[idx] = a[idx] * b[idx];
    }
}

int main(void) {
    int n = 1000000;
    size_t size = n * sizeof(float);

    float* h_a = (float*)malloc(size);
    float* h_b = (float*)malloc(size);
    float* h_c = (float*)malloc(size);

    for (int i = 0; i < n; i++) {
        h_a[i] = 1.0f;
        h_b[i] = 2.0f;
    }

    float* d_a; float* d_b; float* d_c;
    cudaMalloc((void**)&d_a, size);
    cudaMalloc((void**)&d_b, size);
    cudaMalloc((void**)&d_c, size);

    cudaMemcpy(d_a, h_a, size, cudaMemcpyHostToDevice);
    cudaMemcpy(d_b, h_b, size, cudaMemcpyHostToDevice);

    int blockSize = 256;
    int gridSize = (n + blockSize - 1) / blockSize;
    vectorMul<<<gridSize, blockSize>>>(d_a, d_b, d_c, n);

    cudaMemcpy(h_c, d_c, size, cudaMemcpyDeviceToHost);

    int errors = 0;
    for (int i = 0; i < n; i++) {
        if (fabs(h_c[i] - 2.0f) > 1e-5) errors++;
    }
    printf("Vector multiply: %s (%d errors)\n",
           errors == 0 ? "PASSED" : "FAILED", errors);

    cudaFree(d_a); cudaFree(d_b); cudaFree(d_c);
    free(h_a); free(h_b); free(h_c);

    return 0;
}

Exercise 2 — Block Size Experiment

Run the vector addition program with different block sizes and observe the effect on timing. Use CUDA events for accurate measurement:

timing_experiment.cu — complete program
#include <stdio.h>
#include <stdlib.h>
#include <cuda_runtime.h>

__global__ void vectorAdd(const float* a, const float* b, float* c, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        c[idx] = a[idx] + b[idx];
    }
}

int main(void) {
    int n = 10000000;  // 10 million elements
    size_t size = n * sizeof(float);

    float* h_a = (float*)malloc(size);
    float* h_b = (float*)malloc(size);
    float* h_c = (float*)malloc(size);

    for (int i = 0; i < n; i++) {
        h_a[i] = 1.0f;
        h_b[i] = 2.0f;
    }

    float* d_a; float* d_b; float* d_c;
    cudaMalloc((void**)&d_a, size);
    cudaMalloc((void**)&d_b, size);
    cudaMalloc((void**)&d_c, size);

    cudaMemcpy(d_a, h_a, size, cudaMemcpyHostToDevice);
    cudaMemcpy(d_b, h_b, size, cudaMemcpyHostToDevice);

    int blockSizes[] = {32, 64, 128, 256, 512, 1024};
    int numTests = 6;

    for (int t = 0; t < numTests; t++) {
        int bs = blockSizes[t];
        int gs = (n + bs - 1) / bs;

        cudaEvent_t start, stop;
        cudaEventCreate(&start);
        cudaEventCreate(&stop);

        cudaEventRecord(start);
        vectorAdd<<<gs, bs>>>(d_a, d_b, d_c, n);
        cudaEventRecord(stop);
        cudaEventSynchronize(stop);

        float ms = 0.0f;
        cudaEventElapsedTime(&ms, start, stop);
        printf("Block size %4d → %8d blocks → %.4f ms\n", bs, gs, ms);

        cudaEventDestroy(start);
        cudaEventDestroy(stop);
    }

    cudaFree(d_a); cudaFree(d_b); cudaFree(d_c);
    free(h_a); free(h_b); free(h_c);

    return 0;
}
Expected Observation

For this memory-bandwidth-bound kernel, you should see similar timings across block sizes 128-512. The kernel is so simple that occupancy differences are masked by memory throughput. In compute-bound kernels, block size matters more.

09

Summary & Next Steps

What We Covered

Key Takeaways

Memory Matters

Host and device memory are separate. Every byte crosses the PCIe bus. Minimise transfers.

Think in Blocks

Block size should be a multiple of 32. Use the ceiling-division formula for grid size.

Check Everything

Wrap every CUDA call in CUDA_CHECK. Silent failures are the enemy.

Next Tutorial

Up Next

Tutorial 03 — Thread Hierarchy — deep dive into grids, blocks, and threads. We will explore threadIdx, blockIdx, blockDim, and gridDim in 1D, 2D, and 3D configurations, and learn how to map thread indices to data for matrix operations.