GPU Parallel Programming using CUDA (Part 1)

Welcome to this short “GPU Parallel Programming using CUDA (Part 1)

WHY SHORT ?
Because keeping sections short allows us to discuss everything in smaller, focused parts. This helps the reader understand and absorb each concept more effectively, in simple language, without feeling overwhelmed. Let’s take it step by step and understand everything steadily.

Nowadays, processing datasets and overall programme has become more relevant as technology continues to advance and NVIDIA GPUs are being used everywhere. Day by day, devices are getting smarter by using basic and advanced algorithms or different types of AI. As we move forward on this path, processing information is becoming a crucial part of it.

Moving forward in our daily work, I am not sure how many of us actually utilize the GPU, but it is good to know how to use it or even how it works.

High-level Comparison -> CPU vs GPU

CPU

  • Powerful ALU(s)
    Each CPU core has complex arithmetic/logic units able to handle wide instruction sets and complicated control flow (branching, out-of-order execution).
  • Large cache per core
    Multi-level caches (L1, L2, often L3 shared) are large and optimized for low-latency access for a few threads.
    Another words
NOTE

What is a cache?

A cache is a small, very fast type of memory that sits close to the CPU core.
Its main purpose is to store copies of frequently used data so the CPU doesn’t have to fetch it from the much slower main memory (RAM) every time. Think of it like…

A small notepad on your desk (cache) for quick notes,
instead of opening a big file cabinet (RAM) every time you need something.

“Large cache per core”  what does it mean?

Each CPU core has its own private cache  usually called L1 and L2 cache.
These caches are quite large (in comparison to GPU caches) and are built for speed and low latency meaning they can respond in just a few CPU cycles. Because CPUs usually run a few threads (not thousands like GPUs), they dedicate more cache per core to make sure each thread gets data fast.

Good for sequential and latency-sensitive tasks. CPUs are optimized for single-thread performance and tasks with lots of branching and unpredictable memory access patterns.

GPU

1. Thousands of small ALUs (cores).

A GPU doesn’t rely on a few big, complex cores like a CPU does.
Instead, it contains thousands of small, efficient Arithmetic Logic Units (ALUs) also called CUDA cores (in NVIDIA GPUs) that can all work simultaneously.

Each ALU performs simple math operations (like add, multiply, compare) very quickly. But the point is they all run the same instruction on different pieces of data this is called SIMD (Single Instruction, Multiple Data) OR in CUDA, SIMT (Single Instruction, Multiple Threads).

Example: Suppose you want to add two arrays A and B of one million elements 

// CPU version (simplified)
for (int i = 0; i < 1000000; i++) 
{    
    C[i] = A[i] + B[i];
}


A CPU might do this with 4 to 16 threads at most (depending on cores).
A GPU, however, can launch tens of thousands of threads at once each thread adding one or more elements in parallel.

// GPU version
__global__ void addArrays(float *A, float *B, float *C) 
{
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    C[idx] = A[idx] + B[idx];
}

When you launch this kernel with, say, 1000 blocks × 1024 threads = 1,024,000 threads,
each CUDA core performs one addition all simultaneously.

Result -> The entire million-element addition finishes in milliseconds.

Point to remember -> GPUs trade complex single-thread power for massive parallel throughput.

2. Small caches per core, larger shared caches across groups.

Each GPU core (ALU) is lightweight and doesn’t have large private caches like a CPU core.
Instead, memory is organized in a hierarchy that balances size and speed

This table is from lower to higher end

LevelScopeTypical SizeAccess SpeedNotes
RegistersPer threadFew KB1 cycleFastest storage for thread variables
Shared memory / L1 cachePer SM (shared by many threads)64–128 KB~20 cyclesUser-managed cache for cooperation within a block
L2 cacheShared by all SMsSeveral MB~200 cyclesUsed for global memory traffic
Global (Device) memoryEntire GPUGBs400–800 cyclesVery high latency, but very high bandwidth

So, while each core has very limited private memory, the SM (Streaming Multiprocessor) which groups many cores provides shared fast memory that all threads in that block can use to exchange data efficiently.

Imagine each thread reads a small piece of a matrix for multiplication.
Instead of every thread re-reading from slow global memory, all threads in a block can:

1. Load a tile of the matrix into shared memory use this (__shared__),
2. Reuse it many times while computing partial results,
3. Then store the final result.

__shared__ float tileA[32][32];
__shared__ float tileB[32][32];

This shared memory acts like a manually controlled cache and drastically reduces global memory traffic.
Result -> Shared memory = faster cooperation = higher performance.

3. What is a Warp?

A warp is a group of 32 threads that execute the same instruction at the same time on a GPU.

It is the basic unit of execution in NVIDIA GPUs.
Instead of scheduling each thread individually, the GPU scheduler handles threads in groups of 32 these are warps.

Simple analogy

Imagine a warp as a team of 32 soldiers marching in perfect sync.

They all receive the same command (instruction).
Each soldier works on a different piece of data (thread index).
They all step forward together same pace, same action.

If one soldier has to stop and take a different path (a branch), the rest of the team must wait until that soldier finishes this is called warp divergence.

So, warps work best when all 32 threads do the same kind of work. (note this point)

Example

Let’s take a simple CUDA kernel

__global__ void addArrays(float *A, float *B, float *C, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < N)
        C[idx] = A[idx] + B[idx];
}

If you launch this with

dim3 block(128); // 128 threads per block
dim3 grid(N / 128);

Each block has 128 threads. Those 128 threads are divided into 4 warps.

Warp 0 → threads 0–31
Warp 1 → threads 32–63
Warp 2 → threads 64–95
Warp 3 → threads 96–127

All threads in a warp execute the same instruction at a time.
for example, all perform C[idx] = A[idx] + B[idx] simultaneously on different indices.

What happens if there is a branch? wait I didn’t mentioned anything about branch

A branch is when your program has a decision point.

if (condition) 
{ ... } 
else 
{ ... }

or

for, while, switch, break, continue, return

Basically, a branch changes the normal “straight-line” flow of instructions
some threads may go one way, others another.Normal Flow vs. Branch)

No Branch

C[idx] = A[idx] + B[idx];

Every thread executes the same instruction add two numbers.
Perfectly parallel, all threads in the warp move together.

With Branch

if (A[idx] > 0)
    C[idx] = A[idx] * 2;
else
    C[idx] = A[idx] / 2;

Now we have a branch there are two possible paths.

Some threads may take the “if” path (A[idx] > 0),
others may take the “else” path (A[idx] <= 0).

How It Affects a Warp

A warp = 32 threads executing the same instruction at the same time. So if a branch occurs inside a warp, and 20 threads need to run the “if” block and 12 threads need to run the “else” block, the warp can’t do both simultaneously.

Instead, the GPU does this, Execute the if path for the 20 threads while the other 12 are paused. Then execute the else path for the 12 threads while the first 20 are paused. So effectively, both paths are run sequentially, not in parallel anymore.

That is why it is called warp divergence.

All 32 threads eventually finish their work, but half of them sit idle during each path which means lower efficiency.

    Table Time

    TermMeaningExampleImpact
    BranchA decision point in code (if/else, for, while)if (A[idx] > 0)May cause divergence
    Warp DivergenceWhen threads in the same warp take different branchesSome do “if”, others do “else”GPU runs both paths serially
    Best CaseAll threads take the same pathAll A[idx] > 0 or all <= 0No divergence, max efficiency

    Again some more to take in

    TermMeaning
    WarpGroup of 32 threads that execute together
    SIMT (Single Instruction, Multiple Threads)Execution model one instruction, 32 threads in parallel
    Warp DivergenceHappens when threads in a warp take different branches
    Best practiceKeep threads in a warp following the same execution path

    Ideal Workloads for GPUs

    GPUs shine when…

    The same operation runs across many data elements.
    Operations are independent or easily separable.

    Examples:

    • Graphics: Shading millions of pixels.
    • Machine Learning: Large matrix operations.
    • Scientific Computing: Simulating thousands of particles.
    • Image Processing: Applying filters per pixel.

    Example -> Grayscale Conversion

    we will look into this later discussing this in detail while learning about programming with CUDA later you can refer this example if needed for now just keep this in mind

    __global__ void rgbToGray(unsigned char *rgb, unsigned char *gray, int width, int height) {
        int idx = blockIdx.x * blockDim.x + threadIdx.x;
        if (idx < width * height) {
            unsigned char r = rgb[3*idx];
            unsigned char g = rgb[3*idx + 1];
            unsigned char b = rgb[3*idx + 2];
            gray[idx] = (unsigned char)(0.3f*r + 0.59f*g + 0.11f*b);
        }
    }

    Each thread handles one pixel -> millions processed in parallel.

    One more table to read [CPU and GPU]

    FeatureCPUGPU
    Core TypeFew powerful coresThousands of simple cores
    CacheLarge per-core cacheSmall private, large shared
    Best ForSequential, control-heavy tasksData-parallel, throughput tasks

    THANK YOU

    Comments

    No comments yet. Why don’t you start the discussion?

    Leave a Reply

    Your email address will not be published. Required fields are marked *