The n-Body Problem and Shared-Memory Solvers
In the preceding chapters, we explored fundamental parallel programming APIs in isolation: MPI for distributed-memory systems, OpenMP and Pthreads for shared-memory architectures, and CUDA for massively parallel GPUs. In this final chapter, we transition from individual APIs to end-to-end parallel program development.
We will examine two realistic computational problems:
- The -body problem: Simulating the gravitational interactions of celestial bodies or molecular particles over time.
- Sample sort: A scalable parallel sorting algorithm designed for unknown data distributions.
By designing solutions across all three architectural paradigms, we will uncover striking similarities in work partitioning, identify patterns where parallel algorithms diverge sharply from their serial counterparts, and learn how to select the right parallel API for a given problem.
7.1 The -Body Problem
Section titled “7.1 The nnn-Body Problem”In an -body problem, we seek the positions and velocities of a collection of interacting particles over a specified duration of time. Applications span astrophysics (simulating stars, galaxies, and planetary systems) and molecular dynamics (modeling protein folding and atomic interactions).
The input to the simulation comprises:
- The number of particles .
- The mass , initial position , and initial velocity for each particle .
- The timestep length and the total simulation duration .
The output consists of the positions and velocities of all particles at discrete timesteps , or simply at the final time . To maintain computational clarity while preserving the mathematical essence, we assume motion occurs in a two-dimensional plane ().
7.1.1 Physical Laws and Mathematical Formulation
Section titled “7.1.1 Physical Laws and Mathematical Formulation”The physical model is governed by Newton’s second law of motion () and his law of universal gravitation.
The gravitational force exerted on particle by particle at time is:
Where:
- is the gravitational constant ().
- are the masses of particles and .
- are 2D position vectors.
- is the Euclidean distance between particle and particle :
The net gravitational force acting on particle is the vector sum of forces from all other particles ( with ):
Since acceleration is the second time derivative of position () and , the mass cancels:
This represents a coupled system of second-order nonlinear differential equations. Because closed-form analytical solutions exist only for (the Kepler two-body problem), simulations with require numerical integration.
7.1.2 Numerical Integration: Euler’s Method
Section titled “7.1.2 Numerical Integration: Euler’s Method”To advance particle positions and velocities through time, we employ Euler’s method, which approximates a function along its tangent line:
flowchart LR
subgraph Euler["Figure 7.1 and 7.2: Numerical Integration via Euler's Method"]
direction LR
T0["Time t = t0<br/>Position s(t0)<br/>Velocity v(t0)"] -->|"Calculate Force F(t0)"| ACC["Acceleration:<br/>a(t0) = F(t0) / m"]
ACC -->|"Euler Tangent Step"| T1["Time t = t0 + dt<br/>v(t0 + dt) = v(t0) + dt x a(t0)<br/>s(t0 + dt) = s(t0) + dt x v(t0)"]
endApplying this linear approximation to velocity and position yields:
At each timestep, the simulation computes all pairwise forces, evaluates accelerations, and updates velocities and positions.
7.2 Two Serial Algorithms: Basic vs. Reduced
Section titled “7.2 Two Serial Algorithms: Basic vs. Reduced”1. The Basic Algorithm ( Force Evaluations)
Section titled “1. The Basic Algorithm (O(n2)O(n^2)O(n2) Force Evaluations)”In the basic approach, each particle independently iterates through all other particles to compute its total force:
for each timestep { /* 1. Compute forces */ for each particle q { forces[q][X] = forces[q][Y] = 0.0; for each particle k != q { x_diff = pos[q][X] - pos[k][X]; y_diff = pos[q][Y] - pos[k][Y]; dist = sqrt(x_diff * x_diff + y_diff * y_diff); dist_cubed = dist * dist * dist; forces[q][X] -= G * masses[q] * masses[k] / dist_cubed * x_diff; forces[q][Y] -= G * masses[q] * masses[k] / dist_cubed * y_diff; } } /* 2. Update positions and velocities */ for each particle q { pos[q][X] += delta_t * vel[q][X]; pos[q][Y] += delta_t * vel[q][Y]; vel[q][X] += (delta_t / masses[q]) * forces[q][X]; vel[q][Y] += (delta_t / masses[q]) * forces[q][Y]; }}The force calculation requires pairwise force evaluations per timestep.
2. The Reduced Algorithm (Halving Arithmetic via Newton’s 3rd Law)
Section titled “2. The Reduced Algorithm (Halving Arithmetic via Newton’s 3rd Law)”Newton’s third law states that every action generates an equal and opposite reaction:
This means the matrix of interparticle forces is skew-symmetric with zeros along the main diagonal:
Rather than evaluating all entries, we need only evaluate the entries strictly above the diagonal (), computing exactly:
Whenever interaction is evaluated, it is simultaneously added to and subtracted from :
/* Program 7.1: Reduced Algorithm for Computing n-Body Forces */for each particle q forces[q] = 0;
for each particle q { for each particle k > q { x_diff = pos[q][X] - pos[k][X]; y_diff = pos[q][Y] - pos[k][Y]; dist = sqrt(x_diff * x_diff + y_diff * y_diff); dist_cubed = dist * dist * dist; force_qk[X] = G * masses[q] * masses[k] / dist_cubed * x_diff; force_qk[Y] = G * masses[q] * masses[k] / dist_cubed * y_diff;
forces[q][X] += force_qk[X]; forces[q][Y] += force_qk[Y]; forces[k][X] -= force_qk[X]; /* Newton's third law */ forces[k][Y] -= force_qk[Y]; }}7.3 Applying Foster’s Methodology
Section titled “7.3 Applying Foster’s Methodology”To parallelize the -body problem, we apply Foster’s four-stage design methodology: Partitioning, Communication, Agglomeration, and Mapping.
flowchart TD
subgraph Fosters["Foster's Methodology Applied to n-Body Simulation"]
direction TB
P["1. Partitioning<br/>Tasks: calculate s(t), v(t), and F(t) for every particle at each timestep"]
C["2. Communication<br/>Massive data exchange between position updates and force calculations"]
A["3. Agglomeration<br/>Group all computations for particle q across time into a composite task"]
M["4. Mapping<br/>Assign particles to cores (Block vs. Cyclic distribution)"]
P --> C --> A --> M
end1. Partitioning and Communication
Section titled “1. Partitioning and Communication”Initially, primitive tasks compute individual position updates, velocity updates, and force contributions. However, analyzing communication dependencies reveals that calculating requires and , and requires and .
2. Agglomeration
Section titled “2. Agglomeration”Because the tightest communication occurs among the variables of a single particle, we agglomerate all calculations associated with particle () into a single composite task.
- In the basic algorithm, particle receives position from all other particles .
- In the reduced algorithm, particle receives position for , and sends the computed interaction back to particle .
3. Mapping: Block vs. Cyclic Work Partitioning
Section titled “3. Mapping: Block vs. Cyclic Work Partitioning”Since simulation time is sequential, we map spatial particles to cores:
- Basic Algorithm: Every particle performs exactly pairwise checks. A contiguous block partition ( particles per core) yields perfect load balance and optimal cache spatial locality.
- Reduced Algorithm: Particle performs inner loop iterations, while particle performs iterations! A static block partition causes severe load imbalance (early threads do nearly all the work). A cyclic partition distributes iterations evenly across threads, though at the expense of strided memory accesses.
7.4 Parallelizing the Basic Solver with OpenMP
Section titled “7.4 Parallelizing the Basic Solver with OpenMP”In the basic algorithm, parallelizing the particle loops is straightforward:
#pragma omp parallel num_threads(thread_count)for (int step = 1; step <= n_steps; step++) { #pragma omp single if (output_freq) Print_particles(curr, n);
#pragma omp for schedule(static, n/thread_count) for (int q = 0; q < n; q++) { Compute_total_force(q, forces, curr, n); }
#pragma omp for schedule(static, n/thread_count) for (int q = 0; q < n; q++) { Update_particle(q, forces, curr, n, delta_t); }}Critical Synchronization & Correctness Details:
Section titled “Critical Synchronization & Correctness Details:”- Thread Team Reuse: Placing
#pragma omp paralleloutside the timestep loop forks the thread team once, avoiding thousands of expensive fork-join cycles. - I/O Serialization: The
#pragma omp singledirective guarantees that exactly one thread executes console output. - Loop Barriers: The implicit barrier at the end of
#pragma omp forensures all forces are computed before any thread updates positions, and all positions are updated before the next timestep starts. - Data Race Freedom: Thread only writes to
forces[q],pos[q], andvel[q]. Position reads from other particles are read-only during the force calculation phase.
7.5 Parallelizing the Reduced Solver with OpenMP
Section titled “7.5 Parallelizing the Reduced Solver with OpenMP”When we attempt to parallelize the reduced algorithm, a major challenge emerges:
If Thread 0 computes and Thread 1 computes , both threads attempt to update forces[3] concurrently. This creates a critical data race condition!
Flawed Approaches:
Section titled “Flawed Approaches:”- Single
#pragma omp critical: Surrounding force updates with a critical section serializes all force computations across all cores, causing massive contention and running far slower than serial code. - Array of Particle Locks: Allocating an array of
omp_lock_t(one per particle) reduces contention, but lock acquisition/release overhead still degrades performance significantly.
The Two-Phase Private Accumulation Strategy
Section titled “The Two-Phase Private Accumulation Strategy”The optimal solution divides force evaluation into two distinct phases:
- Phase 1 (Local Accumulation): Each thread writes its force contributions into its own private subarray
loc_forces[my_rank][n][DIM]. Because thread only writes to row , no synchronization or locking is needed! - Phase 2 (Global Reduction): An implicit barrier finishes Phase 1. Then, each thread aggregates all partial contributions for its assigned particles.
The implementation in OpenMP is structured as follows:
/* Phase 1: Private accumulation */#pragma omp for schedule(runtime)for (int q = 0; q < n; q++) { for (int k = q + 1; k < n; k++) { Compute_pair_force(q, k, force_qk); loc_forces[my_rank][q][X] += force_qk[X]; loc_forces[my_rank][q][Y] += force_qk[Y]; loc_forces[my_rank][k][X] -= force_qk[X]; loc_forces[my_rank][k][Y] -= force_qk[Y]; }}
/* Phase 2: Owner aggregation */#pragma omp for schedule(static, n/thread_count)for (int q = 0; q < n; q++) { forces[q][X] = forces[q][Y] = 0.0; for (int t = 0; t < thread_count; t++) { forces[q][X] += loc_forces[t][q][X]; forces[q][Y] += loc_forces[t][q][Y]; }}Table 7.1: Phase 1 Computation Grid with Block Partition (3 Threads, 6 Particles)
Section titled “Table 7.1: Phase 1 Computation Grid with Block Partition (3 Threads, 6 Particles)”| Thread | Particle | Thread 0 Contributions | Thread 1 Contributions | Thread 2 Contributions |
|---|---|---|---|---|
| 0 | 0 | |||
| 0 | 1 | |||
| 1 | 2 | |||
| 1 | 3 | |||
| 2 | 4 | |||
| 2 | 5 |
Table 7.2: Phase 1 Computation Grid with Cyclic Partition (3 Threads, 6 Particles)
Section titled “Table 7.2: Phase 1 Computation Grid with Cyclic Partition (3 Threads, 6 Particles)”| Thread | Particle | Thread 0 Contributions | Thread 1 Contributions | Thread 2 Contributions |
|---|---|---|---|---|
| 0 | 0 | |||
| 1 | 1 | |||
| 2 | 2 | |||
| 0 | 3 | |||
| 1 | 4 | |||
| 2 | 5 |
Notice how the cyclic distribution balances the non-zero terms across all three threads much more evenly than the block distribution.
OpenMP Benchmark Results
Section titled “OpenMP Benchmark Results”The following table presents runtime results for simulating particles over timesteps on a multicore server:
Table 7.3: OpenMP Runtime Comparison (, timesteps)
Section titled “Table 7.3: OpenMP Runtime Comparison (n=400n = 400n=400, 100010001000 timesteps)”| Threads | Basic Solver | Reduced (Default Sched) | Reduced (Forces Cyclic) | Reduced (All Cyclic) |
|---|---|---|---|---|
| 1 | ||||
| 2 | ||||
| 4 | ||||
| 8 |
Key Takeaways:
Section titled “Key Takeaways:”- Algorithmic Advantage: The reduced solver is approximately faster than the basic solver across all thread counts due to halving the arithmetic operations.
- Scheduling Impact: For the reduced solver on 8 threads, using
schedule(cyclic)for the force calculation drops execution time from to (a improvement) due to superior load balancing. - False Sharing Tradeoff: Applying cyclic scheduling to all loops slightly degrades performance ( vs ) because strided array writes across threads induce cache coherence invalidations (false sharing).
7.6 Parallelizing with POSIX Threads (Pthreads)
Section titled “7.6 Parallelizing with POSIX Threads (Pthreads)”Implementing the -body solvers with POSIX Threads follows the same structural logic, with key implementation differences:
- Explicit Loop Scheduling: Without
#pragma omp for, threads compute their loop boundaries manually using a helper function:void Loop_schedule(int my_rank, int thread_count, int n, int is_cyclic,int* first_p, int* last_p, int* incr_p); - Explicit Synchronization: Pthreads does not provide implicit loop barriers. Code must invoke
pthread_barrier_wait(&barrier)between phases to prevent data races. - Condition Variable Fallback: If
pthread_barrier_tis unavailable on a platform, an equivalent barrier is constructed using a mutex and condition variable:void Barrier(void) {pthread_mutex_lock(&bar_mut);bar_count++;if (bar_count == thread_count) {bar_count = 0;pthread_cond_broadcast(&bar_cond);} else {while (pthread_cond_wait(&bar_cond, &bar_mut) != 0);}pthread_mutex_unlock(&bar_mut);}