← Back 15-618 • Spring 2026

Galaxy Simulations on a GPU

Andrews George Varghese (ageorgev) and Dhruv Kapur (dkapur)

15-618 Final Project Report

Summary

We implemented a 3D Barnes-Hut based N-Body Galaxy Simulator in CUDA on the GPU, targeting large simulations of as many as a million bodies in various configurations, including simulating regular Plummer-disk galaxy behaviors and spiral-galaxy formations. We developed, benchmarked and analyzed two CPU implementations and four GPU implementations with progressively better performance: (v-1) running the entire galaxy simulation in O(N^2) time, used for validation; (v0) which is the serial Barnes-Hut implementation on the CPU; (v1) where the largest bottle neck on CPU, the tree-walk and force calculations, are moved to the GPU; (v2) where we attempted moving the next biggest bottleneck, the octree construction, to GPU using Burtscher [2]; (v3) which redoes the octree construction using a different level-by-level approach; and finally (v4) where we optimize force calculation by minimizing warp divergence. CPU benchmarking was performed on a 13th Gen Intel(R) Core(TM) i5-13600K, while the GPU used is an NVIDIA RTX 3070. We plan on showcasing our optimizations, some of the intuitions behind them visually, and a few simulations of disc-shaped galaxies colliding to form spiral-shaped ones.

Plummer disc galaxy
Plummer distribution, from Wikipedia

Background

Problem

The N-body galaxy simulation problem evolves a system of N point masses under the effects of gravity. While exact calculations require computing the effects of interactions between every pair of bodies with computational complexity O(N^2), Barnes-Hut [1] approximates far away body clusters as a single point mass at their center of mass (CoM) to reduce the complexity. They define a cell as a region of space containing point masses, and divide all of space into cells using octrees. They start with a single cell covering all of the simulated space, and keep dividing the cell into smaller and smaller octants until every smallest cell has at most 1 point mass in it. They also define a parameter theta which decides at what relative size of cell to the distance from a mass do we approximate the entire cell as a single point. theta is in fact an approximation of the angle subtended by the cell at the mass we’re calculating forces for.

if cell_width / distance < theta 
then use cluster's COM as point mass
else recurse into cluster's 8 sub-cells

Key Data Structures

We need two main structures, one to represent the bodies that are provided as input, and the other to represent the octree that we construct and walk along for force calculations:

  1. Bodies array: we use a structure-of-arrays layout here, where position (x,y,z), velocity (vx, vy, vz), and mass (m) are stored in separate arrays within the same struct. This is done to allow for memory coalescing as consecutive warp threads access consecutive elements in each of these arrays simultaneously.
  1. The octree: this is the 8-ary tree we use to partition all of (simulated) space into cells, such that each cell has at most 1 point mass in it. Each internal node represents a cube of space and has up to 8 children, one per octant. Each node stores its cube’s center, half-width, CoM, total mass, body count, depth, and the eight child pointers. All of these are of course stored in the struct-of-arrays format. The children pointers are actually implemented as indices into the appropriate array.

Key Operations per Timestep

  1. Drawing bounding boxes (BBs): compute BBs of all bodies in a reduction operation that looks very much like the one we used in Assignment 2 for prefix sum
  1. Build tree root: reduce per-body BBs into a single large BB to obtain the extent of the entire space under simulation and create the octree’s root
  1. Tree building: build the octree layer by layer from top to bottom, partitioning the bodies into their appropriate cells
  1. CoM calculations: traverse the tree layer by layer from bottom to top, computing CoM for every node/body in the layer
  1. Force traversal: for each body, walk the entire tree, deciding at each visited node whether to descend further or not based on theta
  1. Integration: apply the forces to move the bodies, which we do with a simple integrator like Kick Drift, using equations v += at and x += vt, where x is position, v is velocity, and a is acceleration.
Reduction kernel for bounding box calculations, fewer threads as time progresses

Inputs and Outputs

The inputs are the N bodies, along with time step size dt, approximation parameter theta, gravitational constant G, and the number of steps for the simulation n_steps. We also pass a parameter softening used to handle precision issues when calculating inverse distances (since Newtonian gravity approaches a singularity as the distance between two bodies approaches zero, leading to numerical instability in the simulation.

The program outputs the positions of the N bodies after the n_steps time steps, with an option to output them after every k steps for the purpose of validation or visualization.

2 galaxies on the way to collide
The galaxies collide
Collision leading to a spiral

Profiling Results and Parallelizability

We first tested our serial CPU implementation of Barnes-Hut and observed that almost all the time (> 97\%) is spent on the force calculations:

Given this observation, we first focused on parallelizing the tree walk and force calculations on the GPU, hoping to use 1 GPU thread per body and parallelize the entire process. As force calculations at each body are independent of other bodies in a given time step, we can potentially leverage all threads in the GPU to run all N bodies simultaneously. This is thus, at least in theory, highly amenable to SIMT execution. Another great benefit of such a division of work is that each thread can independently perform integration on its own body once it has computed the force without having to wait for the other other threads to complete their force computations. However, as we later see, the workload is quite irregular and requires some optimizations to achieve good performance.

Once we optimized the force calculation step, the tree construction became the bottleneck (thanks to Amdahl’s Law of course). Here, the tree structure itself is the dependency constraint: bodies inserted into disjoint subtrees don’t contend on any shared resource except the tree’s root, while bodies in subtrees with significant overlap face larger contention. The workload is thus highly irregular, and required careful consideration to optimize to make it amenable to SIMT.

Moving the tree construction to the GPU, also requires moving the intermediate CoM calculation step to the GPU as well. This operation is again inherently tied to the tree structure, but when viewed as a layer-by-layer operation (i.e. operating on nodes of only a certain depth at any given time), it is parallelisable as bodies at the same depth don’t depend on each other for their CoM. It is, however, a serial computation across depths. Thus we process the CoM calculation depth-wise for maximum compatibility with SIMT execution.

There is also scope for significant spatial locality throughout the algorithm. Bodies that are spatially close tend to traverse the same portions of the tree. So if threads in a warp process such spatially close bodies, they can coalesce many of their loads and also minimise warp divergence by having near-identical traversal paths. In order to exploit this, we sort the bodies by their Morton-Code which linearizes 3D positions so that consecutive indices in the bodies array correspond to consecutive cells along a space-filling curve. This is an idea that we used in Assignment 4 to improve spatial locality of wires, and is also used by Karras [3].

Approach

Target platform and tools

  • Language/APIs: C++17, CUDA 12.4, Thrust (radix sort), Nsight Systems for profiling
  • Hardware: GPU: RTX 3070 (46 SMs, 5888 CUDA cores, 8 GB GDDR6); CPU: 13th Gen Intel(R) Core(TM) i5-13600K
  • Build: make with sm_86, -O3

Implementations

v1: CPU tree + GPU force

Breakdown of each step:

  1. Build entire octree on CPU using the bodies array
  1. Tree and bodies copied to GPU
  1. GPU launches one thread per body, where each thread walks the tree using a private stack of 256 integers. It uses the standard theta criterion while accumulating forces into the registers
  1. Updated bodies copied back to CPU
  1. GOTO step 1

In order to make the most use of the GPU, we map one thread per body, 256 threads per block, with ceil(N/256) blocks. Each thread’s stack is in per-thread global memory as it was too large to fit into the registers. However, load coalescing is poor, as each of the 32 threads in the warp process arbitrary bodies that may not be spatially close.

This gave us our first observation/idea: sort the bodies to exploit cache locality. We reordered the bodies based on a Morton-Code embedding of their positions. Morton-Code takes a 3-D floating point vector and converts it into a 63-bit integer, where bits 3i, 3i+1, 3i+2 correspond to the octant at depth i . Thus, sorting bodies by their Morton-Code provides a natural spatial ordering, leading to improved cache locality. We saw force calculation times drop from 640 ms to 384 ms on 100K bodies (RTX 2080) on using Morton-Codes, as mentioned in the mid-term report. We also kept the morton-ordered bodies across steps, which led to CPU tree build times also going down from 140ms to 98ms (GHC machine). Cache locality was a huge win!!

However, we also observed that now the tree construction became the bottleneck. On simulating 1M bodies in a Plummer disc, we observed tree construction taking ~185ms of 800ms total time, or 23%. We thus moved to optimizing the tree construction next.

v2: Tree construction on the GPU using Burtscher [2]

We moved tree construction to the GPU following the outline in Burtscher [2]:

  • One thread per body: each thread descends from the root, at each level performing atomicCAS(&children[octant][parent], EMPTY, LOCKED) to claim a slot, writing the body ref, then unlocking.
  • On collision (slot already holds a body): the locking thread allocates child nodes via atomicAdd(node_count, 1), sub-divides until the two bodies separate, and unlinks the lock. ⇒ Lock held until bodies separated
  • Bucket chains at depth 32 to handle near-coincident bodies

While we expected this to be fast as suggested in the paper, we were taken aback to find the 1M Plummer simulation now spent ~6500ms on tree construction, roughly 65x slower than the CPU-based construction (100ms on i5-13600k). Even on 8k bodies it took ~9s when we expect the entire simulation to complete in about 200ms. Thus it was obvious we were doing something seriously wrong, and analysis lead to the obvious culprit: our locks have huge contention.

This is funnily similar to the problem on the first exam, where a million threads all contended on a handful of locks. In our implementation, 1M threads (for N = 1M) all tried to lock one of the 8 children of the root node to insert themselves. And then they try to lock the up to 64 children at depth 2, and 512 children at depth 3 and so on. The contention and blocking of waiting threads led to extreme slowdowns. We had to get rid of the locking.

v3: Tree construction, one level at a time

In order to get rid of the contention created by locks, we drew inspiration from Lauterbach et al. [4] to instead construct the tree one level at a time. The pipeline now looks like:

  1. Bounding box and root init: as described earlier, using one reduction kernel to calculate individual body BBs, and one reduction kernel to obtain the overall space’s bounding box
  1. Compute morton codes of each position: using standard morton code algorithm, mapping each position to an integer corresponding to a point in the [0, 2^{21})^3 integer grid.
  1. Sort bodies by morton code position: using thrust::sort_by_key, which solves the problem using CUB radix sort. (this part has no contention and is BW bound). Then reorder the bodies using the sorted keys.
  1. Build tree level by level: run one thread for every cell that is active at the current depth being constructed. Each cell is a triple of (lo, hi, node_idx) which means its parent is node_idx and it contains bodies [lo, hi). Every thread does binary search on the Morton sorted array to find its children’s 8 octant boundaries, then for each non-empty sub-range, it moves the corresponding bodies into that octant’s cell. Cells with more than 1 body have to be further divided and are thus pushed into the next level’s work queue. Keep doing this for a max depth of 21, beyond which bodies in the same cell all have the same Morton code and must thus be chained together, much like a hash table. In order to implement the work queue, we use double-buffered arrays, each switching between roles of input and output to the build tree kernel.
The #(active threads) increases initially as we go deeper, and then reduces as the #(nodes) decreases
Using chaining beyond depth supported by Morton codes
  1. CoM: perform as described earlier, going up from the deepest level. Every body is assigned a thread, and only threads of bodies present at the current depth compute the CoM, as it is guaranteed all lower cells’ CoMs have been processed. Once done, go up a depth and repeat until you reach the root. At the root, exactly one thread will be active, tid = 0.
CoM calculation starts at the bottom level and works upwards to the root
  1. Force-calculations: using the same approach as in v1

And here we immediately saw huge improvements in the tree construction times. For example, with 1M bodies, construction times went down from ~1750ms in v1 to ~19ms in v3. A huge win!

Tree construction times for Plummer Discs, theta = 0.1

Bodiesv1 Tree (ms)v3 Tree (ms)Speedup
1K0.62.10.29x
10K7.63.22.38x
100K91.56.414.30x
1M1749.319.191.59x

We observe that v3 performs slightly worse in very small test cases, and can be attributed to overheads to constructing the tree on the GPU, including launching kernels, extra cudaMalloc, and data transfer.

By keeping #threads launched at every depth limited to the #cells at that level, we have effectively mapped well to the hardware constraints of having poor lock implementations. We have instead replaced the locks with atomicAdd which atomically fetches for each thread the next child’s position in a pre-allocated array.

Also, each thread now works on subdividing a unique cell that it has received in the current work queue with index in[tid], so all modifications it makes are to add children to this node by accessing children[oct][in[tid]] . Thus, threads make disjoint accesses to the shared data structure, and no locks are required.

v4: Reducing warp-divergence in the force calculation kernel

Run times for Plummer Discs, theta = 0.1

VersionTree (ms)CoM (ms)Force (ms)Total (ms)
v1 91.520.414941.015052.8
v3 6.41.122651.822798.5

Notice that the while the tree construction and CoM times has become negligible on the GPU, our force calculation times have gone up. While initially strange, Nsight measurements helped.

Analysing the performance of v3 on N=100k, theta = 0.1 showed us that the force kernel was heavily DRAM-bound, with compute TP at a meagre 11%. While the occupancy is quite high at 80%, we also tried to improve on that while optimizing the performance.

Looking at the code, we came up with some potential pain points:

  • Local memory stack creating too many and too frequent global memory transactions, making it memory bound
  • Threads within a warp all making the same loads when traversing the tree, leading to redundant traffic

To solve these, we use the fact that bodies spatially close to each other have to perform the same tree walk:

  1. Use a per-warp stack in shared memory, written to only by lane 0: this way, only 1 lane does all the stack push/pop operations, reducing the memory BW requirements by 32x. Further, now moving the stack from global per-thread memory to shared memory, we have inherently increased performance.

In order to perform the same tree walk across the entire warp, however, we need to slightly alter the original Barnes-Hut: usually, every body independently decides whether it should continue exploring the tree down, or stop at a particular node based on theta. To prevent this and ensure every lane in the warp walks the same path, we reduce the approximation by always exploring down the tree unless every lane in the warp agrees to approximate a particular node. So if only some lanes want to approximate, they are not allowed to and continue going down the tree. Thus, we have increased the accuracy of the simulation and increased the workload, but by doing so, we see huge gains in the force calculation performance.

The Nsight results show now the compute TP has gone up to 83%, and DRAM TP to < 1%. This clearly explains why v3 performed so poorly, and how our optimization helped improve the performance by eliminating global transactions that go to DRAM. By using a stack in the shared memory, we have greatly reduced latencies and improved performance.

Note however that we have made a small tradeoff. By these changes, we have reduced the occupancy slightly to ~75%. This comes partly from lane 0 doing a lot of work that other lanes do not. However the tradeoff is asymmetric in terms of performance gains. We leave the exploration of improving occupancy while maintaining performance for future work.

Results

Experimental Setup

  • Hardware: GPU: RTX 3070 (8 GB, sm_86, 46 SMs); CPU: 13th Gen Intel(R) Core(TM) i5-13600K
  • Inputs: Plummer-disk distributions at N = 10K, 100K, 1M. Generated using inverse-CDF sampling
  • Parameters: dt = 0.001, theta = 0.1, softening = 0.01, G = 1.0.
  • Measurement: wall-clock time via cudaEvents for per-kernel breakdown
  • Baseline: CPU Barnes-Hut (single-threaded)
  • Correctness: ./validate runs the same input through naive O(N^2) and Barnes–Hut, and reports maximum relative position/velocity error vs. naive

Plummer Disk, with 1k bodies, theta=0.1

MethodTree (ms)COM (ms)Force + Int (ms)Total (ms)Speedup vs Serial
Serial0.70.245.146.01.00x
v1: CPU Tree + GPU Force0.60.1234.7235.40.20x
v3: GPU Tree + Force2.10.527.6170.20.27x
v4: GPU Tree + Force warp opt2.00.55.1149.30.31x

Plummer Disk, with 10k bodies, theta=0.1

MethodTree (ms)COM (ms)Force + Int (ms)Total (ms)Speedup vs Serial
Serial7.31.78998.69007.61.00x
v1: CPU Tree + GPU Force7.61.7261.4270.733.27x
v3: GPU Tree + Force3.20.7785.5936.79.62x
v4: GPU Tree + Force warp opt3.20.750.2200.045.04x

Plummer Disk, with 100k bodies, theta=0.1

MethodTree (ms)COM (ms)Force + Int (ms)Total (ms)Speedup vs Serial
Serial91.720.5524100.1524212.31.00x
v1: CPU Tree + GPU Force91.520.414941.015052.834.82x
v3: GPU Tree + Force6.41.122651.822798.522.99x
v4: GPU Tree + Force warp opt5.91.11123.81280.8409.28x

We observe that across all large enough N, v4 performs the best, with force calculation kernel now well optimized. The optimizations we performed earlier have clearly improved the numbers, as already discussed in the Nsight discussion. However, for very small number of bodies like 1k, the serial implementation on the CPU performs best, as the GPU implementations have some constant overhead in launching kernels and transferring memory between device and host, which cannot be avoided. Also, such small N does not take advantage of the thousands of threads that the GPU can and should launch to obtain maximum benefit. The graph below also showcases the same (note the log scale on the Y-axis for easier visualization).

Total Run-Time Performance

We see that the speedup over the serial implementation itself also increases as the problem size grows. This shows that while we have made optimizations and tradeoffs and tried to adapt the workload to make it more GPU-friendly through our various optimizations, there are still some overheads that can only be overcome by increasing problem size to diminish their impact. We do not plot the speedup for 1M bodies, as the serial run time is too long to measure in a reasonable amount of time.

Impact of theta on Plummer Disk, N = 100k

ThetaSerial Total (ms)Serial Force (ms)v4 Total (ms)v4 Force (ms)v4 Speedup vs Serial
0.1525593.5525480.71377.81138.5381.47x
0.2100965.0100853.0607.7430.2166.13x
0.332513.832401.9384.4203.584.58x
0.414649.014537.1252.2105.258.08x
0.58256.88145.2224.669.236.76x
Total Runtime Trends
v4 speedup trends

theta decides when to approximate a cluster as a point mass. Increasing theta increases the approximation rate, reducing actual work performed by the force calculations. Thus we expect that as theta increases, total runtimes should drop, and we do see that trend in both the serial and GPU implementations. We also see that the speedup of v4 over serial drops, which is again expected as the total work to be done reduces. This means lesser parallelizable work is performed, and performance gains reduce (Amdahl’s Law).

Speedup limitations:

As discussed in previous sections, our code was primarily bottlenecked by being memory-bound. We have addressed that in v4 and believe the speedups are reasonable. We note, however, that the serial overhead is quite high when running on the GPU for small N, and would like to analyze and optimize this case further. We would also like to explore other potential tree-traversal optimizations, especially the one by Liu et al 2019, to see if we can push the occupancy even higher than in v4.

GPU choice

We believe our choice of optimizing barnes-hut for the GPU was a great choice. We were able to steadily and incrementally optimize different steps of the algorithm in an orderly fashion, using performance metrics and Nsight results. Our algorithms have been written to take advantage of the large number of bodies while keeping in mind contention on shared resources. Equivalent implementations using, say, OpenMP for multithreading cannot obtain speedups as high as 400x on reasonably accessible, consumer-grade machines. It would be interesting, however, to study and understand how workloads such as Barnes-Hut can be adopted to say an FPGA.

Correctness validation

./validate runs naive O(N^2) and Barnes–Hut on identical initial conditions and compares per-body final state. All v3 results have very high accuracy:

RunMax position errorMax velocity error
2K bodies, 20 steps, GPU v30.000%0.027%
2K bodies, 100 steps, GPU v30.002%0.060%
8K bodies, 20 steps, GPU v30.000%0.018%
2K bodies, 20 steps, CPU Barnes–Hut (reference)0.000%0.030%

We also ensure that energy and momentum are conserved across the simulation (law of conservation of energy/momentum).

In brief

We believe we have been fairly successful in optimizing our problem. We have shown that we can achieve significant speedups by moving the heaviest parts of Barnes-Hut to GPU with reasonable optimization efforts. While our original timeline included adaptive time stepping, the difficulty of optimizing the tree construction and tree walk itself consumed most of our time, barely leaving any time for the report and poster. We thus leave the implementation and optimization of adaptive time-stepping for future work.

Acknowledgements

Thank you to the Professors and TAs (and other staff involved) in ensuring this course was a great success. We truly learnt a lot from it, and the hard work has definitely helped ingrain the core ideas much better. We hope to take these ideas to wherever we go next and apply and share them so everyone writes better, faster code.

References

[1] A hierarchical O(N log N) force-calculation algorithm, Josh Barnes, Piet Hut 1986

[2] An Efficient CUDA Implementation of the Tree-Based Barnes Hut n-Body Algorithm, Martin Burtscher, Keshav Pingali 2011

[3] Maximizing Parallelism in the Construction of BVHs, Octrees, and k-d Trees, Tero Karras 2012

[4] Fast BVH Construction on GPUs, C. Lauterbach, M. Garland, S. Sengupta, D. Luebke, D. Manocha 2009

[5] Efficient GPU tree walks for effective distributed n-body simulations, Jianqiao Liu, Michael Robson, Thomas Quinn, and Milind Kulkarni 2019

Work distribution

We both split the work almost evenly, across different stages of the code writing, simulations and analysis. In particular, Dhruv and Andrew both worked on serial implementations of Barnes-Hut to get intuition of the algorithm. Dhruv worked extensively on v1, while Andrew worked on v2. We worked together to optimize v3, v4. We have split our workloads for the report and poster, handling different parts of the report and image creation in a 50-50 manner.

AI usage statement

We have used AI (Claude Opus 4.5/4.7) to help create the website (HTML). We have also used it to write the Python scripts which help us create inputs to the simulator (Plummer-disk, colliding galaxies), and to visualize our galaxy simulation outputs and convert them into gifs. For both of these use cases, we gave detailed prompts regarding the expected galaxy distributions we wanted to achieve, including some details about how the galaxies should collide. We reviewed the produced code and verified that the Physics matches that on public sources like Wikipedia. It has also been used to some extent in writing bash/Python scripts to run test cases that we provided for bench marking.


Milestone Report - April 14, 2026

Work completed so far

We started off a bit slow (due to the final exam), but were able to catch up and complete week 1's tasks in time. Timing analysis of the serial implementation on the CPU helped us identify the force calculations and integration as the bottleneck (as expected), taking ~98% of the serial running time. We also spent a little time reading about various common simulation scenarios, including uniformly distributed stars, plummer discs, and orbital velocities. Using these, we created a few simulation scenarios including one where 2 galaxies made of stars revolving around a black hole collide to form a new spiral-shaped galaxy.

The work of weeks 2 and 3 was compressed into ~1.2 weeks, as we spent about 4 days at Carnival. Our basic implementation on CUDA turned out to be relatively straightforward, with minimal changes to the serial approach giving encouraging performance. Instead of a quad-tree for 2D simulations, we implemented a 3D version using octrees (they are morally the same, potentially harder in simulations and spatial locality given the extra dimension). We changed the optimization exploration order a little bit. We felt that cache locality was the lowest hanging fruit in tree construction, so we explored that first. Using ideas we used in Assignments 3 and 4, we tried sorting the stars by their Morton Code order, aiming to bring spatially close stars closer in the bodies array. This yielded significant gains, and some measurements are provided in the Preliminary Results section. We further optimized this by moving the Morton Code sort to the GPU and sending back the sorted indices to the CPU to help improve cache locality during tree-building and center-of-mass calculations, taking advantage of CUB's linear-time implementation of Radix Sort.

A high level overview of the current best implementation:

1. Read bodies from file, create bodies array
2. Run simulation for n timesteps, in each time step:
3. Build Octree on CPU from the bodies array
4. Ship Octree and bodies array from CPU to GPU
5. Index-sort bodies (not the actual array) based on morton code
6. Each thread processes one body, traverses tree to compute forces
   on it and performs integration. No thread-level synchronization
7. Update bodies array to reflect the new morton ordering
8. Ship updated bodies array from GPU to CPU

There are 2 main data structures, the bodies array which contains the position, velocity and mass of each body, and the octree, which is efficient for force calculation. The CPU only reads from the bodies array and only writes to the octree, while the GPU only reads from the octree and writes to the bodies array. This separation of concerns makes the GPUs work easily parallelizable. As can be seen from the Preliminary results, morton code sorting greatly helps speedup the force computation. This is because nearby threads access spatially closer stars, which maximizes traversal overlap in the octree, minimizing warp divergence. Interestingly, when we write back the sorted bodies array to the CPU, we also see dramatic speedup in the tree construction and COM calculations because morton ordered bodies also greatly benefits cache locality during tree traversal.

Updated Timeline

We still think we're on track to complete the remaining deliverables as in the proposal. We have more clarity on the exact approaches we would like to explore:

Week ending on Tasks
~ Apr 1, 2026 Serial implementation, time analysis, simulation of galaxy merge (including data collection)
~ Apr 8, 2026 Basic CUDA implementation, Quad Tree/DS optimizations, construction optimization
~ Apr 14, 2026 Tree traversal optimizations, analysis of effect of degree of approximation in Barnes-Hut (decides when to treat a group of bodies as monopole)
~ Apr 23, 2026 1. Exploring Tree construction optimizations: (a) Optimized construction on GPU (Pingali et al, Karras 2012), (b) Pipelined construction on CPU overlapped with GPU force computation using a shared lock-free queue, (c) Online updates to tree (partial reconstructions) (nice to have)
2. Report writing
~ Apr 30, 2026 1. Adaptive time step implementation
2. Analysis of theta, the degree of approximation in BH
3. Tree walk optimizations (Liu et al)
4. Poster and simulations
Report Due
~ May 1, 2026 Poster Presentation

Poster Session Plans

We will primarily be showing graphs and analyses of the performance of various features in our code, highlighting how and why each specific optimization we tried affected performance. We will be using Nsight to obtain insights into the CUDA implementation.

We will also be showing the simulations (GIFs) of our fastest CUDA implementations create, including something cool like a few galaxies colliding to create a spiral galaxy like ours.

Issues

One issue we had until a few days back is how do we run larger simulations on GHC clusters (the star data is too large to fit on disc). We are currently running on Vast AI, and will finalize a stable testbed in the next 2-3 days.

We foresee issues in trying one of our ideas - the online tree reconstruction optimization, where every node updates its location in the tree as and when it finishes its force integration. The rebalancing required could be very involved, and it might not be feasible to achieve this on a single tree. We might need a duplicate tree which adds a lot of memory overhead for large body counts. We could also try relaxing the condition on how many bodies a leaf can have to potentially have leaves with many stars for a few iterations before reconstructing the full tree (but this increases approximation errors).

There may also be issues in adaptive time stepping with regards to efficiently using all the GPU threads. We are yet to come up with an approach to this, but the concerns we raised in the proposal remain valid: "while this reduces overall computation, each smallest timestep now has a smaller working set which could potentially underutilize the GPU. We will consider ideas like stream compaction and timestep batching for optimizations."

Preliminary Results

Runtime Comparisons

N=100K, steps=10 on GHC54 with CPU Intel(R) Xeon(R) CPU E5-2680 v3 @ 2.50GHz and GPU Nvidia RTX 2080.

Runtime comparison chart
Figure 1: Runtime comparison across methods
Method Tree Building (ms) COM Calc (ms) Force + Int (ms) Total (ms)
Naive---228134.0
Barnes Hut (CPU)139.740.412886.513067.2
Barnes Hut (GPU)140.540.9639.9821.9
Barnes Hut (GPU + Morton)138.940.8383.9564.2
Barnes Hut (GPU + Morton Writeback)97.523.8366.7488.6

steps=10 on GPU 3070Ti.

Scaling comparison chart
Figure 2: Scaling with problem size
Method 10K (ms) 100K (ms) 1M (ms) 10M (ms)
Barnes Hut (GPU)355.0858.911558.6166702.9
Barnes Hut (GPU + Morton)344.1775.98687.5117501.3
Barnes Hut (GPU + Morton Writeback)357.7716.05974.169535.0

Project Proposal - March 25, 2026

Background

N-body 2D galaxy simulations often use Barnes-Hut, an O(N log N)-complexity approximation where we create a quadtree of all particles and then approximate far-away particle interactions as monopoles while nearby particles interact as multipoles.

Barnes-Hut Pseudocode (from lectures):

for each time step in simulation:
    build tree structure
    compute (aggregate mass, center-of-mass) for interior nodes
    for each particle:
        traverse tree to accumulate gravitational forces
        update particle position based on gravitational forces

We will implement a relatively well-optimized CPU implementation of the algorithm and analyse its performance to identify the most time-consuming steps. We will then implement and optimize that portion in CUDA, potentially adding additional steps to the above pseudocode to help in the overall optimization process.

Theoretical analysis, along with a reading of existing literature, tell us that tree parsing and subsequent force calculations take the bulk of the computation time, and that is what we will be implementing in CUDA.

Once these implementations are satisfactorily completed, we will look at an interesting extension of Barnes-Hut, which involves simulating different particles at different timestep granularities, enabling us to spend more compute on the denser regions of our 2D galaxy. For example, this could greatly improve quality of simulations of galaxy mergers like this one by NASA.

The Challenge

Quadtree construction

Star locations are random, and hence generating a tree on the fly in the GPU would be grossly memory-inefficient, besides causing warp divergence. One idea we have to optimize this is to sort star locations using morton codes to improve locality, and then carefully construct the quadtree in an array in parallel using prefixes to predict where each region of stars will end up at. If, however, this construction is too slow, we could continue using a CPU implementation.

Tree traversal

Walking through the tree is highly dependent on each particle: particles in denser regions will walk down to more particles as compared to particles in sparser regions. This irregularity increases warp divergence and reduces arithmetic complexity per thread, and is something we will have to mitigate. One possibility is to use ideas from the 2019 work by Liu et al. "Efficient GPU tree walks for effective distributed n-body simulations."

Tree rebalancing

Rebalancing frequency depends on how fast the bodies we're simulating are moving. It involves potentially walking through the entire tree, and, at least initially, seems to reduce to just the quadtree construction step. We hope to identify optimizations in tree rebuilding, making partial update optimizations to speed up longer simulations.

Adaptive timesteps (Grudić et al.)

This is the extension part of our project, where each particle is simulated at potentially different granularity of timesteps. While this reduces overall computation, each smallest timestep now has a smaller working set which could potentially underutilize the GPU. We will consider ideas like stream compaction and timestep batching for optimizations.

Resources

We will be working entirely on GHC CPUs/GPUs, or on our personal PC which runs an Intel 13500K/32GB DDR5/RTX 2070 TI configuration.

While we will be starting from scratch in our implementations, we hope to draw on the following works:

Goals and Deliverables

Plan to Achieve

The above tasks are reasonable to achieve as they involve either a straightforward baseline implementation, or designing and implementing optimizations with sufficient references in the literature and past works.

Hope to Achieve

What if Things Go Awry

Then we will reduce the number of data structures and traversal formats we look at in the "PLAN TO ACHIEVE" section, focusing more on getting a reasonable working implementation.

Platform Choice

We will be using GHC CPU/GPU and C++/CUDA for most of our work. While the CPU implementation being the baseline is an obvious choice, we go for CUDA on NVIDIA GPUs as these are often the most readily available, highly parallelizable, general purpose accelerators. Thus not only do we get access to huge amounts of documentation and support from the internet and course staff, any results we achieve will be applicable to a wide range of users and not be tied to the availability of some very niche hardware.

As for our choice of C++, it is the language we're most comfortable with to write high performance code in, while also being fully extensible to write CUDA in.

The simulation visuals might be created using Python or any other language that makes it easier to generate videos/GIFs.

Schedule

Week ending on Tasks
~ Apr 1, 2026 Serial implementation, time analysis, simulation of galaxy merge (including data collection)
~ Apr 8, 2026 Basic CUDA implementation, Quad Tree/DS optimizations, construction optimization
~ Apr 14, 2026 Tree traversal optimizations, analysis of effect of degree of approximation in Barnes-Hut (decides when to treat a group of bodies as monopole)
~ Apr 23, 2026 Tree rebalancing optimization designs, adaptive time step adaptation, Report writing
~ Apr 30, 2026 Adaptive time step adaptation, report/poster writing

AI Usage Statement

We have used AI (Claude Opus 4.5) to create the HTML files for our website as we are not very familiar with HTML. All the content in the site itself has been created by us in its entirety.