From nvcc setup to vector addition — host/device workflow, memory allocation, kernel launch syntax, error handling.
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.
Tutorial 01 — GPU Architecture & the CUDA Execution Model. You should understand SMs, warps, and the thread hierarchy before writing your first kernel.
Before writing any GPU code you need the CUDA Toolkit, which includes the nvcc compiler, runtime libraries, and developer tools.
| 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 |
# 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 |
+-------------------------------+----------------------+----------------------+
If you have access to an NVIDIA DGX Spark, the CUDA toolkit is pre-installed. Just ssh in and start coding — no setup required.
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.
main() and sequential codenvcc to PTX/SASSCUDA 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) |
// 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]);
}
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().
Every CUDA program follows the same five-step pattern. The host (CPU) is responsible for orchestrating data movement and kernel execution.
| 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 |
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.
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.
#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;
}
| 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. |
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.
The triple-chevron <<<...>>> syntax is how the host tells the GPU how many threads to launch and how to organise them.
| 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) |
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.
// 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);
256 / 32 = 8 full warps. No wasted threads. Maximum hardware efficiency.
100 / 32 = 3 full warps + 4 leftover threads. The last warp runs with 28 idle lanes — 12.5% waste.
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.
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.
| 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) |
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.
#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
#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;
}
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.
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).
# Compile and run the vector addition program
$ nvcc -o vector_add vector_add.cu
$ ./vector_add
Vector addition: PASSED (0 errors)
| 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 |
For larger projects, you can compile device code and host code separately, then link them:
# 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
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).
Hands-on practice is essential. Modify the vector addition code from Slide 04 and experiment with the ideas below.
Modify the vectorAdd kernel to perform element-wise multiplication instead of addition. Change the verification check to expect 1.0 * 2.0 = 2.0.
#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;
}
Run the vector addition program with different block sizes and observe the effect on timing. Use CUDA events for accurate measurement:
#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;
}
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.
nvcc --version and nvidia-smi__global__, __device__, __host__ qualifiers<<<gridDim, blockDim, sharedMem, stream>>>cudaGetLastError(), cudaDeviceSynchronize(), and the CUDA_CHECK macronvcc flags, separate compilation, architecture targetsHost and device memory are separate. Every byte crosses the PCIe bus. Minimise transfers.
Block size should be a multiple of 32. Use the ceiling-division formula for grid size.
Wrap every CUDA call in CUDA_CHECK. Silent failures are the enemy.
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.