Parallel Computing and GPU Programming
Parallel Computing and GPU Programming
Late 1990s - Early 2000s: A surge in demand for highly realistic computer video games and
animations drove the development of specialized processors.
Graphics Processing Units (GPUs): These were the result. Their initial purpose was precisely as
their name suggests: to accelerate the rendering of detailed images and video. They excel at highly
parallel computations involving vast amounts of pixel data, vertex transformations, and texture
mapping.
Evolution to GPGPU
Temptation of Power: Programmers not involved in graphics soon realized the immense
computational power inherent in GPUs. This power, designed for visual tasks, held potential for
much broader applications.
Early 2000s: GPGPU Emerges: This led to the movement of General Purpose computing on GPUs
(GPGPU). The idea was to harness GPU power for non-graphics problems like searching, sorting,
scientific simulations, and more.
The biggest hurdle for early GPGPU developers was that GPUs could only be programmed using existing
computer graphics APIs. This meant using libraries like Direct3D (Microsoft's API for Windows) and
OpenGL (a cross-platform industry standard).
Programmers had to contort their general computational algorithms into graphics concepts. For example,
treating data as textures, computations as shader operations on pixels, and results as rendered images. This
"hack" added significant complexity and made GPGPU development very difficult and non-intuitive.
The complexity quickly drove efforts to create new languages, compilers, and APIs that would allow
programmers to write general-purpose code for GPUs using more conventional, high-level programming
paradigms, closer to what they used for CPUs.
The mention of "Execution of branch on a SIMD system" in Table 6.1 (though the table content isn't provided)
implicitly refers to a key architectural principle of GPUs: Single Instruction, Multiple Data (SIMD).
SIMD: GPUs are massive SIMD machines. They have hundreds or thousands of processing cores, but these
GPU architectures
CPUs vs. GPUs in Flynn's Taxonomy
CPUs (Conventional Processors): Often characterized as SISD (Single Instruction, Single Data). This
means a single control unit fetches one instruction at a time and executes it on a small number of data items.
This is the traditional sequential processing model.
GPUs: Are fundamentally SIMD (Single Instruction, Multiple Data) processors. This means a single control
unit broadcasts the same instruction to multiple processing units (datapaths), and each datapath executes that
instruction on its own distinct piece of data.
Recall from Section 2.3 (presumably from the book) that a SIMD processor comprises:
if (x[i] >= 0)
x[i] += 1;
else
x[i] -= 2;
If this code runs on a SIMD system with n datapaths, where datapath i works on x[i]:
A typical GPU can be thought of as containing one or more SIMD processors. Nvidia GPUs are structured around
Streaming Multiprocessors (SMs).
SMs as SIMD Processors: Each SM itself can be seen as comprising one or more SIMD processors. An SM has:
o Several control units: This allows for more flexibility than a single, monolithic SIMD control unit.
o Many more datapaths (cores/SPs): These are the actual execution units.
Asynchronous SM Operation:
A crucial aspect is that SMs operate asynchronously. This means there's no performance penalty if different
branches of an if-else statement execute on different SMs.
For example, if all x[i] >= 0 computations happen on SM1 and all x[i] < 0 computations happen on SM2, then both
branches can execute concurrently across the two SMs, potentially requiring only two stages (one for each
branch) for the entire GPU to complete the operation.
Nvidia Terminology:
o Datapaths: Nvidia calls these cores, Streaming Processors (SPs). The text states it will use "SP" to
avoid confusion with "CPU cores" in MIMD architectures.
o SIMT (Single Instruction, Multiple Thread): Nvidia's term for its SIMD architecture. This
emphasizes that while threads on an SM execute the same instruction, their execution isn't necessarily
perfectly simultaneous. Threads might block to hide memory access latency, allowing other threads
that have their data ready to proceed. This is a form of fine-grained multithreading within the SM.
o Shared Memory per SM: Each SM has a relatively small block of very fast memory that is
shared among its SPs. This is often called shared memory in CUDA, and it's programmer-
managed scratchpad memory.
Physical Separation: Historically, the GPU (the device) and its memory were physically separate from the CPU
(the host) and its main memory.
Explicit Data Transfer: In older systems, data had to be explicitly transferred between host memory and device
memory using specific function calls (e.g., cudaMemcpy). This was a significant programming overhead and
bottleneck.
More recent Nvidia systems support "Unified Memory" (or similar concepts). This allows the programmer to
conceptually access GPU memory directly from the CPU (and vice versa) without explicit transfer calls for
correctness. The system handles data migration automatically.
However, the text notes that explicit transfers may still improve overall performance because the automatic
migration might not always be optimal.
Heterogeneous computing
What is Heterogeneous Computing? Definition:
Heterogeneous computing refers to systems where different types of processors work together to execute a program.
Up until this point, the discussion largely implied homogeneous systems where all processors (e.g., CPU cores
in a multi-core CPU) shared the same architecture.
The most common example of heterogeneous computing today is a system combining a conventional CPU (the
host processor) with a GPU (the device processor).
Architectural Differences:
As previously discussed, CPUs and GPUs have fundamentally different architectures (SISD/MIMD for CPUs
vs. SIMD/SIMT for GPUs). This architectural difference is what makes the system "heterogeneous."
Single Program, Multiple Processors: Even though you're writing for different processor types, you still
typically write a single overall program.
o Host-side code: Functions written for and executed by the CPU. This typically handles
overall program control, I/O, and sequential parts of the algorithm.
o Device-side code (Kernels): Functions (often called "kernels" in GPGPU contexts) written
specifically for and executed by the GPU. These are the highly parallel portions of the algorithm.
o Data transfer code (explicit or implicit) between host and device memory.
o From roughly 1986 to 2003, single-thread CPU performance increased dramatically (over 50%
per year). This meant applications got faster simply by waiting for the next generation of CPUs.
o Post-2003: This trend significantly slowed down. Since 2003, single-thread performance
improvements have fallen drastically (less than 4% per year from 2015-2017). This is often
attributed to hitting physical limits (e.g., power consumption, heat dissipation) for increasing
clock speeds and instruction-level parallelism.
The Search for Performance: With the "free lunch" of increasing single-core speed largely over,
programmers and hardware designers are aggressively seeking alternative ways to boost performance.
o GPUs: Excel at highly parallel, data-intensive computations, especially those that can be
expressed as SIMD operations.
o FPGAs (Field-Programmable Gate Arrays): Contain configurable logic blocks that can be
customized prior to program execution to implement highly optimized, application-specific
hardware circuits. They offer extreme parallelism and low latency for specific algorithms but are
harder to program.
Thread Hierarchy
CUDA organizes threads in a hierarchical manner to map computations efficiently onto the GPU's hardware
architecture (SMs and SPs).
Threads:
o An individual execution unit that runs the kernel code (the global function).
o Each thread executes on a Streaming Processor (SP).
o A thread's unique identifier within its block is given by threadIdx.x (or y, z for multi-dimensional
blocks).
Grids:
o A grid is the entire collection of all thread blocks launched by a single kernel call.
o It represents the total work to be done by the kernel.
o A block's unique identifier within its grid is given by blockIdx.x (or y, z for multi-dimensional grids).
The triple angle brackets <<<...>>> are used in CUDA to launch a kernel onto the GPU. KernelName<<<gridDim,
blockDim>>>(arguments);
gridDim (First argument): Specifies the number of thread blocks in the grid.
blockDim (Second argument): Specifies the number of threads within each block.
Within the global kernel function, threads can access several predefined variables to determine
their unique position and the dimensions of their execution environment.
These are all dim3 structs with x, y, and z fields (though often only x is used for 1D problems). threadIdx:
o Type: dim3
o Purpose: The unique index of the current thread within its thread block.
o Example: threadIdx.x gives the thread's rank in its block along the x-dimension.
blockIdx:
o Type: dim3
blockDim:
o Type: dim3
o Purpose: The dimensions (size) of the thread blocks. This value is constant for all threads within the
same kernel launch.
o Example: blockDim.x will be equal to th_per_blk from the kernel launch.
gridDim:
o Type: dim3
o Purpose: The dimensions (size) of the grid (i.e., the total number of blocks). This value is constant
for all threads within the same kernel launch.
o Example: gridDim.x will be equal to blk_ct from the kernel launch.
Default Initialization: When you use simple integer values in the kernel launch <<<blk_ct, th_per_blk>>>,
gridDim.x is set to blk_ct, and blockDim.x is set to th_per_blk. The y and z fields of these dim3 variables
default to 1, effectively creating 1D blocks and grids.
Multi-dimensional Launch: You can explicitly define dim3 variables for grid_dims and block_dims to create 2D
or 3D thread blocks and grids, which is useful for processing multi-dimensional data (e.g., matrices, images)
This launch would create 2times3times1=6 blocks, each containing 4times4times4=64 threads.
Definition: Compute Capability (CC) is a version number (e.g., a.b) that describes the hardware features and
capabilities of a specific Nvidia GPU. It determines:
Version Numbering:
o Major Revision Number (a): Indicates the core architecture generation (e.g., 1, 2, 3, 5, 6, 7, 8).
The text notes the absence of a major revision number 4.
o Minor Revision Number (b): Indicates incremental improvements or variations within a major
architecture (range 0-7).
CUDA Support:
o CUDA continually evolves, and older compute capabilities may be deprecated. The text states that
"CUDA no longer supports devices with compute capability < 3." This means if you have a very old
Nvidia GPU with CC 1.x or 2.x, you might not be able to run recent CUDA applications or compile
code for them with modern CUDA toolkits.
o Maximum Threads per Block: For devices with CC > 1, the limit is 1024 threads per block.
o Maximum Threads per SM:
CC 2.b: 1536 threads per SM.
CC > 2: Currently 2048 threads per SM.
o Block/Grid Dimension Limits:
CC > 1: Maximum x- or y-dimension for blocks and grids is 1024.
CC > 1: Maximum z-dimension for blocks and grids is 64.
o These limits are important for programmers to consider when defining their kernel launch
configurations (<<<gridDim, blockDim>>>). Exceeding these limits will result in an error. The
exact limits for newer architectures can be found in the CUDA C++ Programming Guide.
Nvidia also assigns codenames to its GPU microarchitectures, which correspond to different compute capabilities.
o Early Architectures (not explicitly listed in text but implied by CC 1,2,3): Tesla, Fermi, Kepler
o Kepler: CC 3.x
o Maxwell: CC 5.x
o Pascal: CC 6.x
o Volta: CC 7.0
o Turing: CC 7.5
o Ampere: CC 8.x
o Hopper: CC 9.0 (very recent as of current knowledge, not in the provided text's dated list
but relevant in 2025)
o Ada Lovelace: CC 8.9 (also very recent)
o Blackwell: CC 10.x, 12.x (even more recent)
Confusing Naming: The text highlights a point of confusion: Nvidia uses "Tesla" as both an early architecture
name and a product family name for GPGPU-focused products (e.g., Tesla V100, Tesla P100, etc.).
Product Families: Nvidia's product families can range from discrete graphics cards (e.g., GeForce) to "system on
a chip" (SoC) solutions that integrate many hardware components, like those found in mobile phones or
embedded systems.
It's important to note that the version of the CUDA API (e.g., CUDA 11.0, CUDA 12.0) does not
directly correspond to the Compute Capability of a GPU.
A specific CUDA Toolkit version will support a range of compute capabilities. For example, CUDA
12.x might support GPUs from CC 5.x up to CC 9.0 (Hopper) and newer.
Newer CUDA Toolkits generally add support for newer compute capabilities, but they also often
drop support for very old ones. This is why it's crucial to check the compatibility matrix (e.g., on
Nvidia's developer website) when choosing a CUDA Toolkit version for your specific GPU.
This is the CUDA program for Vector Addition (Program 6.3), which is a fundamental example of data- parallel
programming on GPUs.
Purpose: This function runs on the GPU (device) and performs the actual element-wise addition.
global : A CUDA keyword indicating that this function is a kernel, callable from the CPU (host)
and executed by many threads on the GPU.
Thread Indexing (my_elt):
o int my_elt = blockDim.x * blockIdx.x + threadIdx.x;
o This formula calculates a unique, global 0-indexed ID for each thread across the entire grid
of threads launched.
o blockDim.x: Number of threads per block.
o blockIdx.x: Index of the current block.
o threadIdx.x: Index of the current thread within its block.
o This global ID (my_elt) directly corresponds to the array index i for which the current thread
is responsible.
Boundary Check:
o if (my_elt < n): Essential because the total number of threads launched might be greater than
the vector size n. This ensures that only valid threads perform computations, preventing out-of-
bounds memory access.
Computation: z[my_elt] = x[my_elt] + y[my_elt]; Each thread computes one element of the result.
Runs on CPU: Manages the overall program flow, including data setup, kernel launch, and result verification.
Argument Parsing & Setup: Calls Get_args to read n (vector size), blk_ct (number of blocks), and
th_per_blk (threads per block) from command-line arguments.
o This is where the GPU computation begins. blk_ct blocks, each with th_per_blk threads, are
launched.
Synchronization (cudaDeviceSynchronize()):
o Since kernel launches are asynchronous (CPU can proceed while GPU works), this call
blocks the CPU until the Vec_add kernel has finished executing on the GPU. This ensures z
contains the final results before the CPU attempts to read it.
Correctness Check:
o Serial_vec_add(x, y, cz, n): Computes the vector sum on the CPU for comparison.
o Two_norm_diff(z, cz, n): Calculates the difference between the GPU's result (z) and the CPU's
reference (cz) to verify accuracy.
This program serves as a foundational example for understanding how to leverage the parallel processing power
of GPUs for simple, data-intensive tasks using CUDA's thread hierarchy and unified memory model.
Problem: In standard C, you can pass a pointer to a variable (&sum) to a function, and the function can
modify the variable through that pointer. This doesn't work directly with CUDA kernels.
Reason: "Addresses on the host are, in most systems, invalid on the device, and vice versa." The CPU's memory
space and the GPU's memory space are typically distinct. If you pass a CPU memory address (&sum) to a
kernel, the GPU will interpret that address within its own memory space, likely leading to:
Example Provided: The first Add kernel and main function demonstrate this failure. The &sum address
passed from the host will be meaningless or incorrect on the device.
CUDA kernels cannot return values like regular C functions (they have void return type). Instead, results are
returned by writing them to memory locations that the host can eventually access.
Memory Allocation: Instead of declaring sum as a local int on the host, declare an int* sum_p; and
allocate memory for a single integer using cudaMallocManaged(&sum_p, sizeof(int));.
Host/Device Access: cudaMallocManaged allocates memory that is accessible by both the host and
the device. CUDA's Unified Memory system automatically handles migrating the data between
physical host and device memories as needed.
Kernel Operation: The kernel (Add <<<1, 1>>>(2, 3, sum_p);) writes the result to *sum_p.
Synchronization: cudaDeviceSynchronize(); is essential after the kernel launch. This ensures that the
GPU has completed writing the result to *sum_p and that the data has been migrated back to host
memory (if necessary) before the printf statement on the host tries to read it.
Cleanup: cudaFree(sum_p); is used to deallocate the managed memory.
Benefit: Simplifies code by abstracting explicit data transfers.
This approach is necessary for older GPUs (Compute Capability < 3.0) or when a programmer wants explicit
control over data movement for performance:
Kernel Operation: The kernel (Add <<<1, 1>>>(2, 3, dsum_p);) writes the result to *dsum_p (the device memory
location).
Implicit Synchronization: cudaMemcpy is a synchronous operation. It blocks the host thread until the data
transfer (and implicitly, any preceding kernel that generated the data) is complete. Therefore,
cudaDeviceSynchronize() is not strictly necessary before this specific cudaMemcpy call, as the copy
operation itself ensures kernel completion.
Benefit: Gives the programmer fine-grained control over data movement, which can be critical for
performance optimization.
This is another option available with Unified Memory, but it uses a global variable:
o The managed qualifier declares sum as a variable that resides in Unified Memory, accessible
from both host and device. It implies global scope.
Kernel Operation: The kernel ( global void Add(int x, int y) { sum = x + y; }) directly writes to the
global sum variable.
Synchronization: cudaDeviceSynchronize(); is required before the host attempts to read sum. Similar to
cudaMallocManaged, the system manages data migration, and the host needs to wait for the GPU to
finish writing and for the data to be coherent in host memory.
Restrictions:
Disadvantage: Using global variables can reduce modularity and make code harder to maintain and debug
compared to passing pointers as arguments.
Purpose: To estimate the definite integral of a function f(x) over an interval [a,b].
Method: Divides the interval [a,b] into n equal subintervals, each of width h=(b−a)/n. For each subinterval
[x_i,x_i+1], it approximates the area under the curve as the area of a trapezoid with height h and parallel sides
f(x_i) and f(x_i+1).
Goal: Parallelize the for loop, where the majority of the work lies. Each thread could calculate one f(x_i) and add
it to a running sum.
1. Initialization of h and trap: These are kernel arguments. If they were local variables, each thread
would have its own, preventing shared modification/initialization.
2. my_i range: The serial loop covers i=1,ldots,n−1. my_i ranges from 0 to total_threads - 1, so a
boundary check is needed. Threads with my_i=0 or my_i \ge n should not contribute to the sum of
f(x_i) terms.
3. Race Condition on trap: If multiple threads simultaneously try to update trap (a shared variable),
their updates can overwrite each other, leading to an incorrect final result. This is a classic race
condition.
4. Kernel Return Type: Kernels must have void return types. trap needs to be returned to the host.
5. Final Update: The final multiplication by h needs to happen after all threads have finished
their additions.
Formal Arguments are Private: CUDA threads, like Pthreads/OpenMP threads, have their own stacks.
Kernel arguments are allocated on the thread's stack, meaning each thread gets its own private copy. Changes
made by one thread to its h or trap argument are not visible to other threads.
Solution for h: h can be calculated on the host once and passed as an argument to the kernel. Each thread
receives a copy of this constant value.
o Allocate trap in global memory on the device (or using Unified Memory) before the kernel launch.
This memory location will be shared among all threads.
o Pass a pointer to this shared memory location (float* trap_p) to the kernel. Each thread will get
a copy of trap_p, but all copies will point to the same shared memory.
o The initial term (0.5 * (f(a) + f(b))) is computed on the host and stored in *trap_p before the kernel
launch. This addresses initialization.
o The final multiplication by h is also done on the host after the kernel completes execution (and
results are synchronized). This addresses the final update.
o A common practice is to encapsulate the host-side setup, kernel launch, and post-kernel
cleanup/finalization into a "wrapper" function.
o Trap_wrapper handles:
Initial calculation of 0.5 * (f(a) + f(b)) on *trap_p.
Calculation of h.
Launching the Dev_trap kernel.
cudaDeviceSynchronize(): Essential to wait for kernel completion.
Final multiplication *trap_p = h * (*trap_p).
o This also implicitly solves the problem of "returning a value" from the kernel, as trap_p points to
memory accessible by the host after synchronization.
Solution: The kernel (Dev_trap) includes the condition if (0 < my_i && my_i < n) (Line 11).
o This ensures that only threads responsible for indices 1 through n-1 (which correspond to
the intermediate f(x_i) terms) perform the main computation and contribute to the sum.
o Threads with my_i = 0 (for f(x_0)=f(a)) or my_i \ge n (for f(x_n)=f(b) or excess threads) skip
the addition part. The terms f(a) and f(b) are handled by the host-side initialization.
Race Condition: The core problem is that multiple threads concurrently try to add their my_trap value to the
shared *trap_p. A simple *trap_p += my_trap; is not safe because addition is not an atomic (indivisible)
operation at the hardware level. Threads could read the old value, then one thread's write could be overwritten
by another.
Solution: Atomic Operations: CUDA provides atomic functions to guarantee that an operation on a shared
memory location appears as a single, indivisible instruction to all threads.
Performance Analysis
Timing: The run-time of the CUDA trapezoidal rule is measured by timing the Trap_wrapper function on the host,
as it encompasses all relevant computations (host setup, kernel execution, host finalization).
While the Titan X is the fastest, the text notes that with 3072 SPs, one might expect even greater performance
compared to a single Core i7 core.
This suggests there might be bottlenecks or inefficiencies in this particular CUDA implementation that prevent it
from fully utilizing the GPU's potential, despite using atomics for correctness.
This often points to issues like global memory access patterns or the cost of atomic operations themselves.
Problem with atomicAdd: While atomicAdd ensures correctness by preventing race conditions, it inherently
serializes the additions to the shared *trap_p variable. Only one thread can update it at a time. For N threads,
this can lead to N sequential additions, significantly limiting parallelism, especially for large N.
Imagine a series of boxes, each representing a thread (t0, t1, ..., t7 for 8 threads).
All these threads have a value (my_trap) they want to add to a single global sum location (*trap_p).
Figure 6.3 would show arrows from each thread pointing to a single central box representing
*trap_p. The arrows would likely be stacked sequentially or indicate a funnel, emphasizing that even
though many threads want to update, they must do so one at a time.
Table 6.6 (provided in the text) shows a hypothetical sequence: thread 5 updates, then thread 2,
then thread 3, etc. This sequential nature is the bottleneck.
Solution: Tree-Structured Sum (Reduction Tree): Instead of all threads adding directly to a single global sum,
threads are paired up in stages. In each stage, half of the "active" threads add their partial sum to their partner's
partial sum. This process continues until only one thread holds the final sum.
Imagine the threads arranged in a line at the bottom (t0, t1, ..., t7). Each thread has its my_trap
value.
CUDA Implementations:
This section provides a crucial digression into the CUDA memory hierarchy, essential for understanding
performance optimization.
Memory Hierarchy (Fastest to Slowest, Smallest to Largest): This hierarchy (registers > shared memory >
global memory) is depicted in Table 6.7 in the text, showing typical sizes.
1. Registers:
Fastest: ~1 cycle access.
Smallest: Very limited per thread/SM.
Usage: Used for local variables if sufficient space is available.
2. Shared Memory:
Local Variables:
Warp: A fundamental scheduling unit on an Nvidia GPU. It's a group of 32 threads with consecutive ranks within a
thread block.
Warp Shuffle Functions: Introduced in CUDA Compute Capability ≥3.0. These functions allow threads within a
warp to directly read values from the registers of other threads in the same warp. This is extremely fast
because it bypasses shared or global memory.
o device float shfl_down_sync(unsigned mask, float var, unsigned diff, int width)
o mask: A bitmask indicating participating threads (usually 0xffffffff for all threads in the warp).
Ensures synchronization (_sync).
o var: The variable in the calling thread's register whose partner's value will be read.
o diff: Specifies which thread's value to read. If called by thread l, it reads from thread l + diff.
o "Shuffle Down": Reads from a higher-ranked thread.
o Behavior when l + diff >= warpSize: The call returns the caller's own var value. This is
crucial for handling partial warps or threads at the end of a warp.
o Returns: The value of var from the source thread (l + diff).
This function demonstrates a tree-structured reduction within a single warp using shfl_down_sync.
o It iterates with diff starting from warpSize/2 and halving in each step (diff = diff/2).
o In each iteration, var += shfl_down_sync_sync(mask, var, diff); adds the value from a partner
thread (l + diff) to the current thread's var.
o Result: After the loop, only threadIdx.x == 0 (lane 0) holds the correct sum of all values in the
warp. Other threads will hold partial sums or incorrect values.
Visualizing Warp_sum (Referring to Figure 6.5: Tree-structured sum using warp shuffle):
This diagram specifically illustrates the shfl_down_sync process for a warpSize of 8 (though warpSize is
typically 32, this smaller example is used for clarity).
Input Layer: Shows 8 ovals, labeled with thread lane IDs (0 to 7) and their initial var values (e.g., var_0,
var_1, ..., var_7).
For thread l=0, it attempts to read from l+diff = 4. So var_0 receives var_4.
For l=1, it receives var_5.
For l=2, it receives var_6.
For l=3, it receives var_7.
Crucially, for l=4, 5, 6, 7, since l+diff >= warpSize (i.e., 4+4 >= 8, 5+4 >= 8, etc.),
shfl_down_sync returns their own current var value. The diagram would show this by an
arrow looping back onto the same oval, causing var to effectively double.
Subsequent Iterations (diff = 2, diff = 1): The process repeats, with fewer threads actively receiving values
from others as diff decreases. Threads for whom l+diff is out of bounds (within the current width or warpSize)
continue to "add to themselves."
Final Result: The diagram clearly shows that only the oval for lane 0 contains the sum of all initial values.
For CC < 3.0: Warp shuffles are not available. Shared memory is the next best option for fast intra-block
communication.
o shared Memory: For optimal performance, the shared_vals array should be declared as
shared in the kernel (e.g., shared float shared_vals[32];). This places it in the fast shared
memory of the SM.
o Dissemination Sum/Reduction: This implementation is a variant of a tree reduction where all threads
read from a partner and update their own sum.
o Synchronization-free within Warp: Since threads in a warp operate in lockstep (SIMD), no explicit
syncthreads() is needed within this function if it's strictly operating on a single warp. All threads read
their shared_vals[source] before any thread writes to its shared_vals[my_lane].
o Result: Unlike Warp_sum, every thread in the warp will hold the correct total sum after this function
completes. This is useful for applications where all threads need the final result.
Visualizing Shared_mem_sum (Referring to Figure 6.6: Dissemination sum using shared memory):
o extern shared float shared_vals[];: If the shared memory size isn't known at compile time.
o Kernel launch syntax: Dev_trap <<>>(..., th_per_blk * sizeof(float)) (third argument in triple angle
brackets specifies dynamic shared memory size).
CUDA trapezoidal rule III: blocks with more than one warp
Problem with Single-Warp Blocks: Limiting thread blocks to only 32 threads (one warp) underutilizes modern
GPUs, which can support blocks with up to 1024 threads (32 warps). Larger blocks allow for more threads to
cooperate and synchronize efficiently.
The Race Condition: When Warp 0 tries to sum the warp results, it needs to ensure all other warps have finished
their individual warp sums. Since warps operate independently, Warp 0 might try to read a warp sum before
the contributing warp has finished writing it. This is a race condition.
Solution: syncthreads():
1. All Threads Must Call It: If even one thread in a block skips the syncthreads() call (e.g., due to
an if condition), the threads that do call it will wait forever, leading to a deadlock.
2. Block-Local Only: syncthreads() only synchronizes threads within the same thread block. Threads
in different blocks operate independently and cannot be synchronized using syncthreads(). For
synchronization across blocks, cudaDeviceSynchronize() on the host is typically used, or more
advanced techniques like cooperative groups (CUDA 9+) for grid-wide synchronization.
Problem: After syncthreads(), how does Warp 0 access the sums from other warps? Warp shuffles only work
within a warp. Direct register access across warps is not possible.
Solution: Shared Memory. Shared memory is accessible by all threads within the same thread block.
Implementation Strategy:
1. Declare a shared array: shared float warp_sum_arr[WARPSZ]; (WARPSZ being 32, the number
of warps a block can have if it's 1024 threads).
2. After each warp computes its sum (my_result = Warp_sum(my_trap)), the thread with lane == 0
within that warp writes its my_result to a specific index in warp_sum_arr corresponding to its warp
ID (my_warp).
This section discusses the finer points of managing shared memory for reductions in larger blocks, especially
when all threads' initial contributions are also stored in shared memory.
o Problem: If other warps try to store their my_result directly back into thread_calcs[my_warp] before
Warp 0 has finished using the original thread_calcs data to calculate its own sum.
o Solution (with two syncthreads() calls):
o Cost of Multiple syncthreads(): Each syncthreads() call incurs overhead as it forces all threads to
wait. This can be costly, especially if the number of threads in the block is very large and not all
threads can execute concurrently (some will be delayed reaching the barrier).
o Instead of overwriting thread_calcs, use a separate shared array specifically for warp sums, as discussed
in 6.13.2: shared float warp_sum_arr[WARPSZ];. This avoids the need for a second
syncthreads() for the data transfer, only needing one before Warp 0 starts its summation.
o if (my_lane == 0) warp_sum_arr[my_warp] = my_result; (Safe update without race, as each my_warp
has a unique index).
o Why is if (my_lane == 0) shared_vals[0] = my_result; (mentioned in the text) safe? Because each warp
is operating on its own shared_vals subarray. So, shared_vals[0] for my_warp=0 is thread_calcs[0],
shared_vals[0] for my_warp=1 is thread_calcs[32], etc.
o These are distinct memory locations, so there's no race condition here. However, the warp_sum_arr
approach is cleaner for the final sum.
Shared Memory Banking: Nvidia GPUs divide shared memory into 32 (or 16 for older cards) independent
"banks."
Concurrent Access:
o If 32 threads in a warp simultaneously access 32 different banks, the access is fully parallel (no
serialization).
o Table 6.9 (Shared memory banks): This table illustrates how thread_calcs elements map to banks.
Columns are banks (0-31), rows are consecutive elements. E.g., thread_calcs[0], thread_calcs[32],
thread_calcs[64] all fall into Bank 0.
o If multiple threads (e.g., from the same warp) access different memory locations that reside in the
same bank, these accesses must be serialized. This causes a bank conflict, significantly slowing down
the operation.
o Problem Example: If we stored warp sums in warp_sum_arr[0], warp_sum_arr[32],
warp_sum_arr[64], etc. (using indices that are multiples of warpSize), all these would map to Bank 0,
causing serialization. This would make the final warp 0 summation very slow.
Solution: Store the warp sums in a contiguous subarray of shared memory, like warp_sum_arr[0],
warp_sum_arr[1], ..., warp_sum_arr[31]. This ensures that each warp_sum_arr[my_warp] element maps to a
different bank when accessed by different threads of Warp 0, enabling parallel access.
o After the syncthreads() call, Warp 0 (specifically if (my_warp == 0)) takes over.
o It handles potential uninitialized elements in warp_sum_arr if blockDim.x is not a multiple of warpSize.
Threads in Warp 0 for indices higher than the actual number of active warps set their warp_sum_arr
element to 0.0.
o Then, Warp 0 performs a second Shared_mem_sum (or Warp_sum if using shuffles) on warp_sum_arr
to compute the blk_result (the total sum for the entire block).
o Finally, only thread 0 of the entire block (if (threadIdx.x == 0)) uses atomicAdd(trap_p, blk_result); to
add the block's total sum to the global trap_p variable. This reduces the number of atomicAdd calls from
N (total threads) to num_blocks (number of blocks), which is a huge optimization.
Program 6.15: Provides the full kernel code for the shared memory version, incorporating all these strategies.
The warp shuffle version would be similar, just calling Warp_sum instead of Shared_mem_sum and
potentially not needing thread_calcs if input values are handled differently.
Significant Improvement: Table 6.10 shows the mean run-times for the optimized CUDA trapezoidal rule
using large thread blocks (1024 threads/block).
o Nvidia GK20A:
Original (basic atomicAdd, 32 ths/blk): 20.7 ms