GPU n-Body Solvers and Shared-Memory Tiling
On massively parallel GPUs, computing performance is driven by throughput rather than single-thread latency. Modern GPUs feature thousands of arithmetic cores organized across dozens of Streaming Multiprocessors (SMs). To fully saturate the hardware and hide memory access latencies, applications must launch tens or hundreds of thousands of concurrent threads.
In this lesson, we explore how to map the -body problem to NVIDIA GPUs using CUDA, examine why the reduced algorithm’s memory footprint breaks down on GPUs, and introduce Shared-Memory Tiling—a powerful architectural optimization that slashes global DRAM traffic by up to .
7.11 Mapping the Basic Solver to CUDA
Section titled “7.11 Mapping the Basic Solver to CUDA”In MIMD programming (OpenMP and MPI), we assigned large blocks of particles to each core because systems have relatively few processing units ( to ). On GPUs, however, hardware schedulers switch between ready warps in single clock cycles to hide latency. Consequently, the optimal mapping strategy assigns one thread per particle:
Each thread computes:
int my_particle = blockIdx.x * blockDim.x + threadIdx.x;for (int step = 1; step <= n_steps; step++) { Compute_force(my_particle, forces, curr, n); Update_pos_vel(my_particle, forces, curr, n, delta_t);}Critical Race Conditions & Grid-Wide Synchronization
Section titled “Critical Race Conditions & Grid-Wide Synchronization”The naive single-kernel implementation contains two severe race conditions:
- Intra-Timestep Race: If Thread A finishes its force calculation and updates while Thread B is still evaluating forces at time , Thread B will read the future position , corrupting its force calculation.
- Inter-Timestep Race: If Thread A rushes ahead into timestep while Thread B is still computing timestep , Thread A reads out-of-date position data for particle B.
Because __syncthreads() only synchronizes threads within the same block (and cannot synchronize across an entire multi-block grid), placing __syncthreads() inside this loop fails.
The Host-Coordinated Grid Barrier Pattern
Section titled “The Host-Coordinated Grid Barrier Pattern”To enforce a global grid-wide barrier without specialized hardware primitives, we extract the timestep loop into a host function that re-launches distinct kernels:
sequenceDiagram
autonumber
participant Host as CPU Host
participant K1 as Kernel: Compute_force
participant K2 as Kernel: Update_pos_vel
loop For each Timestep
Host->>K1: Launch Compute_force<<<blk_ct, th_per_blk>>>
Note over K1: All threads compute net forces F[q]
K1-->>Host: Implicit Grid Barrier (Kernel Completes)
Host->>K2: Launch Update_pos_vel<<<blk_ct, th_per_blk>>>
Note over K2: All threads update pos[q] and vel[q]
K2-->>Host: Implicit Grid Barrier (Kernel Completes)
end
Host->>Host: cudaDeviceSynchronize()/* Host-Coordinated Timestep Driver */__host__ void Nbody_sim(vect_t forces[], struct particle_s curr[], double delta_t, int n, int n_steps, int blk_ct, int th_per_blk) { for (int step = 1; step <= n_steps; step++) { /* Kernel 1: Evaluates forces for all particles */ Compute_force<<<blk_ct, th_per_blk>>>(forces, curr, n);
/* Implicit barrier: Update_pos_vel waits for Compute_force to finish */ Update_pos_vel<<<blk_ct, th_per_blk>>>(forces, curr, n, delta_t); /* Implicit barrier: Next Compute_force waits for Update_pos_vel */ } cudaDeviceSynchronize();}7.12 Empirical Performance of the Basic CUDA Solver
Section titled “7.12 Empirical Performance of the Basic CUDA Solver”The basic CUDA solver was tested on an NVIDIA Pascal GPU (Compute Capability 6.1, 20 SMs, 2560 CUDA cores @ 1.73 GHz) using float precision and threads per block:
Table 7.7: Run-times of Basic CUDA vs. Serial CPU n-Body Solver
Section titled “Table 7.7: Run-times of Basic CUDA vs. Serial CPU n-Body Solver”Blocks (blk_ct) | Particles () | Serial CPU Run-time | CUDA GPU Run-time | Speedup vs. CPU |
|---|---|---|---|---|
| 1 | ||||
| 2 | ||||
| 32 | ||||
| 64 | ||||
| 256 | () | |||
| 1024 | () |
While the serial CPU implementation takes over 14 hours to simulate 1 million particles, CUDA completes the identical workload in just 48 seconds—an astounding speedup!
7.13 Why the Reduced Algorithm Fails on GPUs
Section titled “7.13 Why the Reduced Algorithm Fails on GPUs”On CPU architectures, the reduced algorithm halved execution time by avoiding redundant calculations. Can we port this strategy to CUDA?
Recall that in the reduced algorithm, thread writes partial force contributions to particle . To avoid race conditions in parallel, each thread requires a private buffer:
On a GPU where every particle has its own thread ():
For a simulation with particles:
Because high-end GPUs typically provide 16 GB to 80 GB of global memory, the reduced algorithm’s memory footprint is physically impossible to deploy on GPU hardware!
7.14 The Solution: Shared-Memory Tiling
Section titled “7.14 The Solution: Shared-Memory Tiling”To accelerate the basic algorithm without exceeding GPU memory constraints, we optimize memory hierarchy utilization.
In the naive kernel, every thread independently fetches the positions and masses of all particles from off-chip Global Memory (DRAM). Accessing global DRAM requires 400–800 clock cycles of latency. For threads, this produces massive memory traffic:
Instead of relying on hardware L1/L2 caches, we implement Shared-Memory Tiling:
- Partition the global arrays into logical “tiles” matching the thread block size .
- In each tile step, the threads of a block collaboratively load particle each into on-chip
__shared__memory. - Synchronize with
__syncthreads(). - All threads evaluate forces against the cached tile data directly from ultra-fast shared memory (1–2 cycles latency).
- Synchronize with
__syncthreads()before loading the next tile.
flowchart LR
subgraph Tiling["Figure 7.9: Shared-Memory Tile Execution Workflow"]
direction LR
GM["Global Memory (DRAM)<br/>Massive Array of n Particles"] -->|"Coalesced Load: 3 words per thread"| SM["Fast On-Chip Shared Memory<br/>Tile t (1024 Particles = 24 KB)"]
SM -->|"syncthreads Barrier"| COMP["Compute Forces<br/>Inner Loop: 1024 Steps in Fast SRAM"]
COMP -->|"syncthreads Barrier"| NEXT["Load Next Tile t + 1"]
endTiled CUDA Kernel Implementation
Section titled “Tiled CUDA Kernel Implementation”#define TH_PER_BLK 1024
__global__ void Compute_force_tiled( vect_t forces[], const struct particle_s curr[], const int n) { __shared__ float s_pos_x[TH_PER_BLK]; __shared__ float s_pos_y[TH_PER_BLK]; __shared__ float s_mass[TH_PER_BLK];
int my_q = blockDim.x * blockIdx.x + threadIdx.x; int my_lane = threadIdx.x;
float my_x = curr[my_q].pos[X]; float my_y = curr[my_q].pos[Y]; float my_m = curr[my_q].mass; float f_x = 0.0f, f_y = 0.0f;
int num_tiles = (n + blockDim.x - 1) / blockDim.x;
for (int t = 0; t < num_tiles; t++) { int tile_idx = t * blockDim.x + my_lane;
/* Collaborative load into on-chip shared memory */ if (tile_idx < n) { s_pos_x[my_lane] = curr[tile_idx].pos[X]; s_pos_y[my_lane] = curr[tile_idx].pos[Y]; s_mass[my_lane] = curr[tile_idx].mass; } __syncthreads(); /* Wait for tile data to be ready */
/* Compute interactions against all particles in the tile */ for (int k = 0; k < blockDim.x; k++) { if (t * blockDim.x + k < n && (t * blockDim.x + k) != my_q) { float dx = my_x - s_pos_x[k]; float dy = my_y - s_pos_y[k]; float dist = sqrtf(dx * dx + dy * dy); float dist3 = dist * dist * dist; float force = G * my_m * s_mass[k] / dist3; f_x -= force * dx; f_y -= force * dy; } } __syncthreads(); /* Prevent overwriting tile before all threads finish */ }
forces[my_q][X] = f_x; forces[my_q][Y] = f_y;}Memory Bandwidth Reduction Factor
Section titled “Memory Bandwidth Reduction Factor”Let’s calculate the reduction in global memory reads:
Basic CUDA Solver: Every thread independently loads particles from global memory:
Tiled CUDA Solver: Each block loads a tile of particles once. Across tiles, all threads collaboratively load:
The bandwidth improvement ratio between the two approaches is:
With , the tiled kernel loads data from global DRAM fewer times, eliminating memory bus saturation!
Empirical Benchmarks: Basic vs. Shared Memory
Section titled “Empirical Benchmarks: Basic vs. Shared Memory”Both kernels were executed on the same NVIDIA Pascal system with threads per block:
Table 7.8: Run-times of Basic vs. Shared-Memory CUDA Solvers
Section titled “Table 7.8: Run-times of Basic vs. Shared-Memory CUDA Solvers”Blocks (blk_ct) | Particles () | Basic Solver Run-time | Shared-Memory Solver | Speedup Factor |
|---|---|---|---|---|
| 1 | ||||
| 2 | ||||
| 32 | ||||
| 64 | ||||
| 256 | ||||
| 1024 |
At 1 million particles, the shared-memory tiling optimization reduces execution time from down to , achieving an additional speedup purely by transforming global memory accesses into programmer-managed on-chip cache reuse.