Skip to content

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 nn-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 1024×1024\times.


In MIMD programming (OpenMP and MPI), we assigned large blocks of particles to each core because systems have relatively few processing units (44 to 6464). 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:

Total Threads=n,Block Count=blk_ct=nth_per_blk\text{Total Threads} = n, \quad \text{Block Count} = \text{blk\_ct} = \dfrac{n}{\text{th\_per\_blk}}

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:

  1. Intra-Timestep Race: If Thread A finishes its force calculation and updates sA(t+Δt)\mathbf{s}_A(t + \Delta t) while Thread B is still evaluating forces at time tt, Thread B will read the future position sA(t+Δt)\mathbf{s}_A(t + \Delta t), corrupting its force calculation.
  2. Inter-Timestep Race: If Thread A rushes ahead into timestep t+1t + 1 while Thread B is still computing timestep tt, 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.

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 10241024 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 (nn)Serial CPU Run-timeCUDA GPU Run-timeSpeedup vs. CPU
11,0241,0240.0701 s0.0701\,\text{s}0.00193 s0.00193\,\text{s}36.3×36.3\times
22,0482,0480.282 s0.282\,\text{s}0.00289 s0.00289\,\text{s}97.6×97.6\times
3232,76832,76850.7 s50.7\,\text{s}0.0734 s0.0734\,\text{s}690.7×690.7\times
6465,53665,536202 s202\,\text{s}0.294 s0.294\,\text{s}687.1×687.1\times
256262,144262,1443,230 s3,230\,\text{s} (53.8 min53.8\,\text{min})2.24 s2.24\,\text{s}1,442.0×1,442.0\times
10241,048,5761,048,57652,000 s52,000\,\text{s} (14.4 hours14.4\,\text{hours})48.1 s48.1\,\text{s}1,081.1×1,081.1\times

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 1000×1000\times 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 qq writes partial force contributions to particle kk. To avoid race conditions in parallel, each thread requires a private buffer:

Memory Required=2×thread_count×n floats\text{Memory Required} = 2 \times \text{thread\_count} \times n \text{ floats}

On a GPU where every particle has its own thread (thread_count=n\text{thread\_count} = n):

Total Memory=2n2 floating-point words\text{Total Memory} = 2n^2 \text{ floating-point words}

For a simulation with n=1,000,000n = 1,000,000 particles:

Memory=2×(106)2=2×1012 floats×4 bytes=8 Terabytes of VRAM!\text{Memory} = 2 \times (10^6)^2 = 2 \times 10^{12} \text{ floats} \times 4\,\text{bytes} = \mathbf{8 \text{ Terabytes of VRAM}!}

Because high-end GPUs typically provide 16 GB to 80 GB of global memory, the reduced algorithm’s O(n2)O(n^2) memory footprint is physically impossible to deploy on GPU hardware!


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 nn particles from off-chip Global Memory (DRAM). Accessing global DRAM requires 400–800 clock cycles of latency. For nn threads, this produces massive memory traffic:

Global Loads=3n(n−1)≈3n2 words\text{Global Loads} = 3n(n - 1) \approx 3n^2 \text{ words}

Instead of relying on hardware L1/L2 caches, we implement Shared-Memory Tiling:

  1. Partition the global arrays into logical “tiles” matching the thread block size tile_sz=th_per_blk≤1024\text{tile\_sz} = \text{th\_per\_blk} \le 1024.
  2. In each tile step, the threads of a block collaboratively load 11 particle each into on-chip __shared__ memory.
  3. Synchronize with __syncthreads().
  4. All threads evaluate forces against the cached tile data directly from ultra-fast shared memory (1–2 cycles latency).
  5. 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"]
  end

#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;
}

Let’s calculate the reduction in global memory reads:

Basic CUDA Solver: Every thread independently loads n−1n - 1 particles from global memory:

Global Words LoadedBasic=3n(n−1)\text{Global Words Loaded}_{\text{Basic}} = 3n(n - 1)

Tiled CUDA Solver: Each block loads a tile of th_per_blk\text{th\_per\_blk} particles once. Across blk_ct\text{blk\_ct} tiles, all threads collaboratively load:

Global Words LoadedTiled=3n×blk_ct=3n(nth_per_blk)=3n2th_per_blk\text{Global Words Loaded}_{\text{Tiled}} = 3n \times \text{blk\_ct} = 3n \left( \dfrac{n}{\text{th\_per\_blk}} \right) = \dfrac{3n^2}{\text{th\_per\_blk}}

The bandwidth improvement ratio between the two approaches is:

Bandwidth Improvement Ratio=3n(n−1)3n2th_per_blk≈th_per_blk\text{Bandwidth Improvement Ratio} = \dfrac{3n(n - 1)}{\dfrac{3n^2}{\text{th\_per\_blk}}} \approx \text{th\_per\_blk}

With th_per_blk=1024\text{th\_per\_blk} = 1024, the tiled kernel loads data from global DRAM 1024×1024\times 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 10241024 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 (nn)Basic Solver Run-timeShared-Memory SolverSpeedup Factor
11,0241,0241.93×10−3 s1.93\times 10^{-3}\,\text{s}1.59×10−3 s1.59\times 10^{-3}\,\text{s}1.21×1.21\times
22,0482,0482.89×10−3 s2.89\times 10^{-3}\,\text{s}2.17×10−3 s2.17\times 10^{-3}\,\text{s}1.33×1.33\times
3232,76832,7687.34×10−2 s7.34\times 10^{-2}\,\text{s}4.17×10−2 s4.17\times 10^{-2}\,\text{s}1.76×1.76\times
6465,53665,5362.94×10−1 s2.94\times 10^{-1}\,\text{s}1.98×10−1 s1.98\times 10^{-1}\,\text{s}1.48×1.48\times
256262,144262,1442.24 s2.24\,\text{s}1.96 s1.96\,\text{s}1.14×1.14\times
10241,048,5761,048,57648.1 s48.1\,\text{s}29.1 s29.1\,\text{s}1.65×1.65\times

At 1 million particles, the shared-memory tiling optimization reduces execution time from 48.1 s48.1\,\text{s} down to 29.1 s29.1\,\text{s}, achieving an additional 1.65×1.65\times speedup purely by transforming global memory accesses into programmer-managed on-chip cache reuse.