AnyLearn
All lessons
Programmingintermediate

Your First CUDA Kernel: Vector Addition End-to-End

The hello-world of CUDA, done properly. Allocate device memory, copy inputs, launch a kernel, copy results back, free, and check every return code. The full driver + kernel in one runnable file.

Updated · AI-authored, review-gated · how lessons are made

Not signed in: your progress and quiz score won't be saved.
Progress1 / 9

What we're building

Vector addition: ci=ai+bic_i = a_i + b_i for i[0,N)i \in [0, N). On a CPU it's a one-line loop; on a GPU it's the canonical first kernel because every step is independent — perfect for one-thread-per-element parallelism.

The full program does six things, and that pattern repeats in every CUDA app:

  1. Allocate input/output buffers on the host (malloc).
  2. Allocate matching buffers on the device (cudaMalloc).
  3. Copy inputs from host to device (cudaMemcpy).
  4. Launch the kernel.
  5. Copy the result back to the host.
  6. Free both sides.

Master this six-step ritual and you've got the skeleton for everything else.

Full lesson text

All 9 steps on one page, for reading, reference, and search.

Show

1. What we're building

Vector addition: ci=ai+bic_i = a_i + b_i for i[0,N)i \in [0, N). On a CPU it's a one-line loop; on a GPU it's the canonical first kernel because every step is independent — perfect for one-thread-per-element parallelism.

The full program does six things, and that pattern repeats in every CUDA app:

  1. Allocate input/output buffers on the host (malloc).
  2. Allocate matching buffers on the device (cudaMalloc).
  3. Copy inputs from host to device (cudaMemcpy).
  4. Launch the kernel.
  5. Copy the result back to the host.
  6. Free both sides.

Master this six-step ritual and you've got the skeleton for everything else.

2. The kernel itself

A single thread does one addition. The grid is sized so one thread exists per element.

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

Three things to notice:

  • __global__ — kernel, called from host, runs on device.
  • The 1D index formula is the same in every CUDA program.
  • The if (i < n) guard matters: we'll launch slightly more threads than n so the count divides into whole blocks. Without the guard, the extra threads would write past the end of c.

3. Allocating on host and device

The host and device have separate address spaces. A host pointer is not dereferenceable on the device, and vice versa. You need two allocations per buffer:

const int N = 1 << 20;        // ~1 million elements
size_t bytes = N * sizeof(float);

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

float *d_a, *d_b, *d_c;
cudaMalloc(&d_a, bytes);
cudaMalloc(&d_b, bytes);
cudaMalloc(&d_c, bytes);

Convention: prefix host pointers with h_ and device pointers with d_. It's a tiny habit that saves you from cudaMemcpy(h_a, d_a, ...) mix-ups, which the compiler is happy to let through and the runtime is happy to segfault on.

4. Copying inputs to the device

cudaMemcpy is the bridge. Its fourth argument is the direction of the copy.

for (int i = 0; i < N; ++i) {
  h_a[i] = (float)i;
  h_b[i] = 2.0f * (float)i;
}

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

The four directions are HostToDevice, DeviceToHost, DeviceToDevice, and HostToHost. cudaMemcpy is synchronous with respect to the host — it blocks until the copy finishes. For overlapping compute and transfers you'd use cudaMemcpyAsync with streams, but for the basics this is fine.

5. Sizing the grid: the ceil-div pattern

Pick a block size (256 is a safe default — multiple of 32, balances occupancy on most GPUs). Then compute the number of blocks with ceiling division so you launch at least N threads:

const int threads = 256;
const int blocks  = (N + threads - 1) / threads;
vecAdd<<<blocks, threads>>>(d_a, d_b, d_c, N);

For N = 1,048,576 and threads = 256 you get 4096 blocks × 256 = 1,048,576 threads — exact. For N = 1,000,000 you'd get 3907 blocks × 256 = 1,000,192 threads and the last 192 threads would fall into the if (i < n) guard. The launch is asynchronous — control returns to the host immediately. You sync when you need the result.

6. Synchronising and error checking

Kernel launches don't return errors directly — they can't, they're async. You ask for them with two API calls:

cudaError_t launchErr = cudaGetLastError();   // catches launch-time errors
if (launchErr != cudaSuccess) {
  fprintf(stderr, "launch: %s\n", cudaGetErrorString(launchErr));
  exit(1);
}

cudaError_t runErr = cudaDeviceSynchronize();  // catches runtime errors
if (runErr != cudaSuccess) {
  fprintf(stderr, "runtime: %s\n", cudaGetErrorString(runErr));
  exit(1);
}

cudaGetLastError catches problems at launch (bad config, missing symbol). cudaDeviceSynchronize waits for the kernel to finish and surfaces problems that happened during execution (out-of-bounds, illegal address). In production code, wrap both in a CUDA_CHECK macro and use it everywhere.

7. Host-to-device-to-host flow

The six-step ritual.

flowchart LR
  H1["Host: malloc + fill inputs"] --> M1["cudaMalloc on device"]
  M1 --> C1["cudaMemcpy HostToDevice"]
  C1 --> K["Launch vecAdd kernel"]
  K --> S["cudaDeviceSynchronize"]
  S --> C2["cudaMemcpy DeviceToHost"]
  C2 --> F["cudaFree + free"]

8. The full program

Putting it all together in one runnable file. Compile with nvcc vec_add.cu -o vec_add.

#include <cuda_runtime.h>
#include <cstdio>
#include <cstdlib>

__global__ void vecAdd(const float* a, const 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() {
  const int N = 1 << 20;
  size_t bytes = N * sizeof(float);

  float *h_a = (float*)malloc(bytes);
  float *h_b = (float*)malloc(bytes);
  float *h_c = (float*)malloc(bytes);
  for (int i = 0; i < N; ++i) { h_a[i] = i; h_b[i] = 2.0f * i; }

  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);

  int threads = 256;
  int blocks  = (N + threads - 1) / threads;
  vecAdd<<<blocks, threads>>>(d_a, d_b, d_c, N);

  if (cudaGetLastError() != cudaSuccess) return 1;
  if (cudaDeviceSynchronize() != cudaSuccess) return 2;

  cudaMemcpy(h_c, d_c, bytes, cudaMemcpyDeviceToHost);
  printf("c[42] = %f\n", h_c[42]);  // expect 126.0

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

9. Why this exact program is slow

Vector add does one add per two loads + one store — three memory ops per FLOP. It's the textbook memory-bound kernel: even with perfect coalescing the GPU spends most of its time waiting on VRAM. Don't expect 80 TFLOPS here; expect a number close to your card's memory bandwidth divided by 12 bytes.

Also, cudaMemcpy is dwarfing the kernel. For N=1M floats:

  • Kernel: ~50 µs
  • 8 MB host→device: ~250 µs
  • 4 MB device→host: ~125 µs

This is why nobody runs a single GPU kernel in isolation. Real CUDA code uploads once and runs hundreds of kernels back-to-back. Vector add is the right thing to write to learn the ritual, and the wrong thing to write if you want to demonstrate GPU speed.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. What does `cudaMalloc(&d_a, bytes)` actually do?
    • Allocates host memory pinned for fast transfers
    • Allocates `bytes` of memory in device VRAM and writes the device pointer into `d_a`
    • Allocates unified memory accessible from both sides
    • Copies an existing host buffer to the device
  2. You launch `vecAdd<<<3907, 256>>>(...)` for N = 1,000,000. Why is the `if (i < n)` guard required?
    • Threads might fail to launch otherwise
    • 3907 * 256 = 1,000,192 threads, and the last 192 would write past the end of the array without the guard
    • CUDA requires every kernel to have a guard
    • The guard improves coalescing
  3. What's the right way to detect that a kernel had a runtime error like an illegal memory access?
    • Check the kernel's return value
    • Inspect `cudaGetLastError()` immediately after the `<<<...>>>` line
    • Call `cudaDeviceSynchronize()` and check its return code
    • It's not detectable until the next `cudaMemcpy`
  4. Why is vector addition typically memory-bound on a modern GPU?
    • Floating-point adds are slow
    • Each thread does one add but three memory operations, so VRAM bandwidth is the bottleneck
    • Warp divergence on the boundary
    • Kernel launch overhead
  5. Which direction argument copies from device VRAM back to host RAM?
    • `cudaMemcpyHostToDevice`
    • `cudaMemcpyDeviceToHost`
    • `cudaMemcpyDeviceToDevice`
    • `cudaMemcpyDefault`

Related lessons

AI
advanced

Writing Fused Kernels Without Writing CUDA

The reason most teams never fuse anything is that CUDA asks you to manage threads, shared memory and synchronisation by hand. Triton moves the unit of programming from a thread to a block and hands the rest to a compiler. This lesson covers what that buys, what it still asks of you, how to decide a kernel is worth writing, and how to be sure it is correct.

10 steps·~15 min
AI
advanced

Online Softmax: Tiling Across a Reduction

Softmax normalises over a whole row, which appears to require the whole row before anything can be produced, which appears to require the score matrix to exist. This lesson works through the rescaling identity that removes that obstacle, the running max and sum that make it numerically safe, and the backward pass trick of recomputing the matrix from two saved statistics.

10 steps·~15 min
AI
advanced

Attention Is Memory-Bound, and Nobody Noticed for Five Years

For years attention was optimised by reducing FLOPs, and approximate methods that cut FLOPs kept failing to run faster. The reason is that attention was never compute-bound: it spends its time moving a matrix between GPU memory tiers. This lesson establishes that hierarchy, counts the traffic a standard implementation generates, and shows why an exact algorithm beat every approximation.

10 steps·~15 min
AI
advanced

Finding the Next One: Fusion Beyond Attention

The pattern that made attention slow recurs across the stack, and once you know what to look for it is easy to find. This lesson applies the diagnosis to normalisation layers, optimizer steps, loss functions and inference decoding, covers why fused attention silently stops applying when a model deviates slightly from standard, and gives the profiling routine that decides where to look first.

10 steps·~15 min