GPU Programming with CUDA Overview
GPU Programming with CUDA Overview
Notes On
Course Name: Parallel Computing
Course Code: B C S 7 0 2
Module – 5
By
1
Dept. of ISE
Parallel Computing-BCS702 Module 5
In the late 1990s, GPUs became powerful for rendering graphics in games and
animations. Developers began using GPUs for non-graphics applications, leading to
GPGPU (General Purpose computing on GPUs). GPUs were originally programmed
using APIs like Direct3D and OpenGL. These APIs were limited to graphics;
developers had to reformulate problems using graphics constructs (e.g., vertices,
textures).
Emergence of CUDA and OpenCL
CUDA (developed by NVIDIA) allows direct programming of GPUs using a C/C++-
like language. OpenCL is a cross-platform API supporting multiple devices including
GPUs and CPUs. CUDA is widely adopted due to its simplicity but only supports
NVIDIA GPUs. CPUs follow SISD (Single Instruction, Single Data), executing one
instruction on one data at a time. GPUs use SIMD (Single Instruction, Multiple Data),
applying the same instruction across many data elements. GPU hardware is
optimized for parallel data processing with many lightweight cores.
Example: Branching in SIMD
if (a[i] > 0)
x[i] = 1;
else
x[i] = 0;
If a[i] > 0 varies across data, different execution paths must be taken.
SIMD struggles with such divergence, causing inefficiencies.
Shows how execution splits when different data paths require different instructions.
Paths with a[i] > 0 are executed separately from those with a[i] <= 0.
SIMT – Single Instruction, Multiple Threads
Used in CUDA architecture.
Threads in a group (warp) execute the same instruction concurrently unless control
divergence occurs.
Threads within a warp follow different execution paths when conditions vary.
Reduces efficiency as some threads must wait for others to complete.
2
Dept. of ISE
Parallel Computing-BCS702 Module 5
3
Dept. of ISE
Parallel Computing-BCS702 Module 5
4
Dept. of ISE
Parallel Computing-BCS702 Module 5
CUDA enables this model, allowing the GPU to handle parts of the program using its
many cores. GPUs use SIMT (Single Instruction, Multiple Threads), allowing
thousands of threads to execute the same instructions with separate data. This is
ideal for large data-parallel problems.
CUDA allows users to write GPU-accelerated code in a C/C++-like syntax.
Developers must manage device memory, define kernel functions (code that runs on
the GPU), and launch them from the CPU (host).
Introduction to CUDA API
CUDA provides several libraries and APIs to facilitate GPU programming.
Common CUDA components include:
o nvcc: NVIDIA’s CUDA compiler.
o CUDA runtime API: Provides functions for memory management and kernel
launch.
o CUDA driver API: Lower-level interface compared to the runtime API.
Example of CUDA Memory Allocation
c
CopyEdit
float *a = (float *)malloc(sizeof(float));
This C-style memory allocation works on the host (CPU).
On the device (GPU), you must use CUDA-specific memory allocation functions like
cudaMalloc().
Kernel Function
c
CopyEdit
__global__ void hello()
5
Dept. of ISE
Parallel Computing-BCS702 Module 5
{
printf("Hello from GPU thread!\n");
}
__global__ indicates the function will execute on the GPU and can be called from the
host.
printf() is supported within device code starting with CUDA 5.0+.
Launching a Kernel
c
CopyEdit
hello<<<1, 5>>>();
This launches 1 block with 5 threads.
The syntax <<<blocks, threads>>> controls the GPU execution configuration.
Compiling a CUDA Program
CUDA Compiler
nvcc is the compiler used to compile CUDA programs.
The command:
bash
CopyEdit
nvcc -o hello [Link]
This compiles the CUDA source file [Link] and outputs an executable hello.
Running the Program
Once compiled, run the binary like a standard program:
bash
CopyEdit
./hello
Output should show 5 print statements, one from each thread.
CUDA simplifies GPU programming by using familiar C/C++ syntax.
Key components: kernel definition, memory management, and kernel launch
configuration.
Proper compilation and execution setup is required to run CUDA code effectively.
6
Dept. of ISE
Parallel Computing-BCS702 Module 5
7
Dept. of ISE
Parallel Computing-BCS702 Module 5
8
Dept. of ISE
Parallel Computing-BCS702 Module 5
Level Description
Thread Cooperation
Threads within a block can:
o Share shared memory
o Use __syncthreads() to synchronize
Threads from different blocks cannot directly communicate.
9
Dept. of ISE
Parallel Computing-BCS702 Module 5
Note that all the blocks must have the same dimensions. More importantly, CUDA requires
that thread blocks be independent. So one thread block must be able to complete its
execution, regardless of the states of the other thread blocks: the thread blocks can be
executed sequentially in any order.
10
Dept. of ISE
Parallel Computing-BCS702 Module 5
Ampere 8.0
Turing 7.5
Volta 7.0
Pascal 6.b
Maxwell 5.b
Kepler 3.b
Fermi 2.b
Tesla 1.b
5. Key Notes
Tesla is both a product name and an architecture family, which can be confusing.
Some GPUs are part of NVIDIA SoCs (e.g., Jetson Nano), combining GPU + CPU on
a single chip.
Compute capability affects kernel execution limits and memory usage.
Knowing your GPU’s compute capability is essential for writing compatible and
efficient CUDA code.
CUDA C++ programming guide provides detailed compute capability limits and
specs.
11
Dept. of ISE
Parallel Computing-BCS702 Module 5
12
Dept. of ISE
Parallel Computing-BCS702 Module 5
13
Dept. of ISE
Parallel Computing-BCS702 Module 5
o
4. Handling Uneven Vector Sizes
Always check bounds to avoid accessing beyond array size:
if (rank < n)
z[rank] = x[rank] + y[rank];
5. Serial vs. Parallel
If CUDA is unavailable, a serial version loops through each index:
for (int i = 0; i < n; i++)
z[i] = x[i] + y[i];
The CUDA version replaces this loop by assigning each iteration to a separate
thread.
6. Design Consideration
This design simplifies the mapping of computations.
Makes it easier to convert existing serial loops into GPU-parallel kernels.
7. Program 6.4 Summary
Serial_vec_add() performs vector addition on the CPU.
Highlights contrast between CPU loops and GPU thread-based execution.
14
Dept. of ISE
Parallel Computing-BCS702 Module 5
15
Dept. of ISE
Parallel Computing-BCS702 Module 5
4. Performance Tip
For better performance, developers may still prefer explicit memory transfers (e.g.,
using cudaMemcpy) to control timing and minimize overhead.
16
Dept. of ISE
Parallel Computing-BCS702 Module 5
17
Dept. of ISE
Parallel Computing-BCS702 Module 5
18
Dept. of ISE
Parallel Computing-BCS702 Module 5
2. Kernel
Unchanged from the unified memory version.
Receives x, y, z, and n as arguments.
Each thread computes:
c
CopyEdit
if (my_elt < n)
z[my_elt] = x[my_elt] + y[my_elt];
3. Main Function Changes
Structure is mostly the same, but memory handling differs:
o Pointers on the host are not valid on the device, and vice versa.
o Using a host pointer on the GPU can lead to errors or unintended behavior.
4. Separate Memory for Host and Device
Host arrays: hx, hy, hz → Allocated with malloc()
Device arrays: dx, dy, dz → Allocated with cudaMalloc()
Memory is separated and must be manually transferred between host and device.
5. Allocation
Memory allocation is handled in Allocate_vectors() (Program 6.10):
o Host arrays: malloc()
o Device arrays: cudaMalloc()
Without unified memory, developers must explicitly manage data transfers and allocations
using malloc and cudaMalloc. This provides more control but requires additional care to
ensure correctness and avoid memory access errors across host and device.
19
Dept. of ISE
Parallel Computing-BCS702 Module 5
20
Dept. of ISE
Parallel Computing-BCS702 Module 5
3. Correct Approaches
A. With Unified Memory
Allocate result memory using cudaMallocManaged():
21
Dept. of ISE
Parallel Computing-BCS702 Module 5
int *sum_p;
cudaMallocManaged(&sum_p, sizeof(int));
Kernel writes directly to sum_p, and the value becomes accessible from the host
after kernel execution.
B. Without Unified Memory
Allocate:
o Device memory for result using cudaMalloc()
o Host memory for result using malloc()
Copy result back using cudaMemcpy():
cudaMemcpy(hsum_p, dsum_p, sizeof(int), cudaMemcpyDeviceToHost);
4. Alternate Option
Instead of using a pointer, return the result in a global memory variable declared
with __device__.
CUDA kernels don’t return values directly.
Use shared or copied memory to communicate results from the device to the host.
Unified memory simplifies the process, but manual copying is required without it.
1. Purpose
Implement a CUDA version of the trapezoidal rule to approximate the area under a
curve between two points a and b.
22
Dept. of ISE
Parallel Computing-BCS702 Module 5
23
Dept. of ISE
Parallel Computing-BCS702 Module 5
Approach
Each thread computes one iteration of the serial loop:
c
CopyEdit
int my_i = blockDim.x * blockIdx.x + threadIdx.x;
float my_x = a + my_i * h;
float my_trap = f(my_x);
trap += my_trap;
Threads independently compute f(x_i) and contribute to the shared variable trap.
Problems Identified
1. Uninitialized Variables: h and trap are not initialized in the kernel.
2. Incorrect Index Range: my_i may go out of bounds; the loop should range from 1 to
n−1n-1n−1, but CUDA indices can start from 0.
3. Race Condition: trap is shared across threads. Multiple threads writing to it
simultaneously causes data races, leading to incorrect results.
4. Return Type Mismatch: CUDA kernels return void, so trap can’t be returned directly
like in the serial function.
5. Final Summation Needed: The values computed by threads must be summed
correctly on the host to get the total result.
Key Takeaway
While it's possible to parallelize the trapezoidal rule using CUDA, care must be taken to
handle indexing, memory access, and result aggregation to avoid errors and ensure
correctness.
24
Dept. of ISE
Parallel Computing-BCS702 Module 5
Proposed Approach:
Use thread 0 (in block 0) to:
1. Initialize h and trap:
2. if (my_i == 0) {
3. h = (b - a)/n;
4. trap = 0.5 * (f(a) + f(b));
5. }
6. Perform final multiplication:
7. if (my_i == 0)
8. trap = trap * h;
Solution Directions:
For h:
o Compute on the host before kernel launch and pass as a kernel argument.
For trap:
o Use shared memory or dynamically allocate device memory.
o Each thread writes its result to an element of a global array (e.g., trap[]),
which can be reduced on the host after kernel execution.
Key Takeaway:
To correctly parallelize the trapezoidal rule in CUDA:
Avoid thread-local variables for shared computation.
Use global memory for shared data like trap.
Ensure synchronization or properly designed memory access to avoid race
conditions.
25
Dept. of ISE
Parallel Computing-BCS702 Module 5
A wrapper function is a function whose main purpose is to call another function. It can
perform any preparation needed for the call. It can also perform any additional work needed
after the call.
26
Dept. of ISE
Parallel Computing-BCS702 Module 5
Key Takeaway:
Use conditional execution to avoid invalid threads.
Use atomicAdd() for safe parallel accumulation into shared variables like trap_p.
27
Dept. of ISE
Parallel Computing-BCS702 Module 5
Timing is done using macros like GET_TIME to record start and finish times, and
then computing elapsed time.
The same approach can be used to time the serial trapezoidal rule for comparison.
Timings can vary due to system load, so it’s best to run the program multiple times
and report the mean or median.
Run-time
33.6 20.7 4.48 3.08
(serial)
The GPU runs are much faster than serial CPU runs.
CUDA on the Nvidia GK20A GPU and GTX Titan X offers significant speedups over
ARM and Intel CPUs.
Performance gains come from the massive parallelism on GPUs (thousands of
threads).
Key points:
The CUDA trapezoidal rule can drastically reduce runtime compared to serial code.
Multiple runs help average out variability in timing measurements.
Hardware and GPU architecture greatly affect performance.
28
Dept. of ISE
Parallel Computing-BCS702 Module 5
This method reduces serialization caused by multiple threads updating the same
variable trap_p with atomicAdd.
A tree-structured sum aggregates partial sums in a hierarchical way, improving
performance by decreasing the number of sequential steps.
Tree-structured communication
The global sum is done in multiple stages, where pairs of threads add their values,
then pairs of pairs, and so on, halving the number of active threads each step.
Table 6.6 shows an example with 8 threads adding values in a tree-like manner,
gradually combining partial sums until one final result is produced.
This approach reduces the time complexity from linear (n additions) to logarithmic
(log₂ n additions).
The tree-structured approach reduces thread serialization and improves efficiency,
especially with many threads.
Key points:
Tree-structured summation is more efficient for parallel reduction.
It avoids bottlenecks caused by many threads updating the same variable.
This method is common in parallel programming for summing or reducing values.
29
Dept. of ISE
Parallel Computing-BCS702 Module 5
30
Dept. of ISE
Parallel Computing-BCS702 Module 5
Performance Tip:
Use registers as much as possible to improve kernel performance.
Minimize usage of shared and especially global memory unless necessary.
Be aware of the limited register size, which can cause spilling if overused.
31
Dept. of ISE
Parallel Computing-BCS702 Module 5
Key Concepts:
Warp: A group of 32 threads (usually) in CUDA that execute together in SIMD
(Single Instruction, Multiple Data) fashion.
_shfl_down_sync: A warp shuffle function that allows a thread to access the value
of a variable held by another thread in the same warp at a lower lane index.
Common Usage:
Used to implement tree-structured reductions (e.g., summing values across threads).
cpp
CopyEdit
val = __shfl_down_sync(mask, val, diff);
mask: Bitmask indicating participating threads (usually set to 0xFFFFFFFF).
val: Value to shuffle.
diff: The offset by which to shuffle (e.g., 1, 2, 4...).
If a thread's lane + diff exceeds warpSize, the result is undefined.
Use register-level communication via warp shuffle when reducing or aggregating data
within a warp — it’s faster and avoids race conditions common in shared/global memory.
32
Dept. of ISE
Parallel Computing-BCS702 Module 5
To implement an efficient tree-structured global sum within a warp using the fast warp
shuffle instruction.
🔁 Code Overview:
🧮 How It Works:
At each step, threads with lower lane IDs add values from higher lanes offset by
diff.
diff starts at warpSize / 2 and halves every loop.
Thread 0 ends up with the full sum of values from all threads in the warp.
Shows how values propagate and are summed across lanes using a tree structure.
E.g., in the first iteration (diff = 4), thread 0 adds value from thread 4, thread 1 from
thread 5, etc.
The result is progressively accumulated in thread 0.
⚠️ Important Notes:
This function only returns the full sum on lane 0 (i.e., thread with lane ID 0).
33
Dept. of ISE
Parallel Computing-BCS702 Module 5
If all threads need the result, use __shfl_xor_sync instead (discussed in Exercise
6.6).
✅ Summary:
To implement a warp-level sum without using warp shuffle functions, which aren’t
available on GPUs with compute capability < 3.0.
💡 Key Idea:
Use shared memory instead of warp shuffle for inter-thread communication within a warp.
Threads in the same warp execute in lockstep (SIMD fashion), so updates to shared
memory can be synchronized implicitly.
📜 Function: Shared_mem_sum
34
Dept. of ISE
Parallel Computing-BCS702 Module 5
return shared_vals[my_lane];
}
Works like a tree-based reduction, but uses shared memory.
Each thread reads from shared_vals and updates its own lane’s value.
No race condition: all threads are executing in parallel and synchronously.
⚠️ Notes:
✅ Summary:
35
Dept. of ISE
Parallel Computing-BCS702 Module 5
36
Dept. of ISE
Parallel Computing-BCS702 Module 5
This optimized version uses warp-level parallelism and minimizes global memory updates.
It’s both faster and more scalable than the earlier naive implementations.
37
Dept. of ISE
Parallel Computing-BCS702 Module 5
5.12.4 Performance
Key Takeaways:
38
Dept. of ISE
Parallel Computing-BCS702 Module 5
5.13 CUDA trapezoidal rule III: blocks with more than one
warp
This section explores extending CUDA implementations of the trapezoidal rule by using
larger thread blocks—beyond a single warp (i.e., more than 32 threads, up to 1024). This
approach boosts flexibility and performance by better utilizing GPU resources.
39
Dept. of ISE
Parallel Computing-BCS702 Module 5
Ensures all warp-level sums are computed before a designated warp (e.g., warp 0)
begins the final summation.
Key Takeaways:
Larger blocks (e.g., 1024 threads = 32 warps) allow parallel summation across
warps.
__syncthreads() is critical to coordinate these warps and prevent race conditions.
Synchronization ensures correctness when aggregating results across multiple warps
in a block.
Developers must be careful using __syncthreads() with conditional execution, as
divergent threads may never reach the sync point, causing deadlock.
This approach enables more scalable, performant implementations by combining intra-warp
optimizations (like warp shuffle) with inter-warp coordination using __syncthreads().
The second important caveat about __syncthreads() is that it only synchronizes threads
within the same block, not across different blocks. Even if every thread in the entire grid
calls __syncthreads(), threads in different blocks will still operate independently.
Therefore, __syncthreads() cannot be used to synchronize threads across a whole
grid.
40
Dept. of ISE
Parallel Computing-BCS702 Module 5
If multiple threads access the same location in a bank, the value is broadcast
simultaneously, which is efficient.
Proper memory layout is crucial to avoid serialization and achieve maximum speed.
The CUDA programming guide provides full details on bank conflicts and
optimization.
In short, understanding and optimizing shared memory bank usage is key for high-
performance CUDA programs.
41
Dept. of ISE
Parallel Computing-BCS702 Module 5
42
Dept. of ISE