N-Body Simulation with SIMD
Goal
Take your scalar N-body simulation from the previous exercise and accelerate it using std::simd.
The key insight is that if you have $N$ bodies, you can pack all their positions, velocities, and accelerations into a
single data-parallel type (which may or may not match a SIMD register) and compute interactions across lanes
simultaneously.
This approach works best when $N$ is small enough to fit in one or two SIMD registers (typically 4–16 bodies on x86).
For larger systems, you would chunk the data across multiple SIMD iterations.
However, this is complex enough, so we will limit ourselves to a single simd::vec<T, N> object per dimension.
If you implemented “Infinite Space”,
you want to disable it or use a git branch to work on the simpler solution.
Setup
Enable SIMD compilation
In
nbody/CMakeLists.txt, add a second build target that definesUSE_SIMD. Also, let’s enable-Ofastfor these targets, if you haven’t already done so. This is useful to get vector math functions from libmvec (glibc), since GCC 16 does not ship an implementation of the math overloads yet.add_runnable(nbody_scalar main.cpp) target_compile_options(nbody_scalar PRIVATE -Ofast) add_runnable(nbody_simd main.cpp) target_compile_options(nbody_simd PRIVATE -Ofast -DUSE_SIMD=1)Build both targets and compare performance later.
fast-math 😱
Many C++ experts shudder at the use of fast-math (
-Ofastimplies-ffast-math). And rightfully so. Be careful that you understand what you’re doing to your application.
Create simd_utils.h
Define SIMD helper utilities
Create
nbody/simd_utils.hcontaining the following building blocks:Concept. Define a concept that matches
std::simd::basic_vectypes:#include <simd> namespace simd = std::simd; template <typename T> concept simd_vec = std::same_as<T, simd::basic_vec<typename T::value_type, typename T::abi_type>>;Math overloads. Provide
sqrtandpowoverloads for SIMD vectors (they are in C++26, just not yet shipping):template <simd_vec V> constexpr V sqrt(const V& x) { return V([&](int i) { return std::sqrt(x[i]); }); } template <simd_vec V> constexpr V pow(const V& x, typename V::value_type y) { return V([&](int i) { return std::pow(x[i], y); }); }Rotate elements. Implement a function that shifts all lanes by one position using
std::simd::permute(the second argument defines an index mapping):template <simd_vec V> constexpr V rotate_elements(const V& x) { return simd::permute(x, [](int i, int size) { return (i + 1) % size; }); }Delta overload. (optional: if your code implemented infinite space) Add a
delta(V, V)overload for SIMD vectors. For plain floating-point SIMD, this is just subtraction:template <simd_vec V> requires std::floating_point<typename V::value_type> constexpr V delta(V a, V b) { return a - b; }Include
"simd_utils.h"where needed.
Pack bodies into SIMD registers
Rewrite
mainto initialize bodies as an array and conditionally pack themStore initial conditions in a
std::array<Body<float>, N>:auto bodies = init_bodies(std::array{ Body<float>{ {0, 0}, {}, {0, 0}, 2.f }, Body<float>{ {0, 1}, { 1, 0}, {0, 0}, 2.f }, Body<float>{ {0,-1}, {-1, 0}, {0, 0}, 2.f }, // ... more bodies });Write
init_bodiesas a template that returns either the originalstd::array(scalar mode) or a singleBody<V>whereV = std::simd::vec<float, N>(SIMD mode), controlled by#ifdef USE_SIMD. Use a generator constructor to build thesimd::vec:#ifdef USE_SIMD using V = simd::vec<T, N>; return Body<V> { { V([&](int i) { return init[i].r.x; }), V([&](int i) { return init[i].r.y; }) }, { V([&](int i) { return init[i].v.x; }), V([&](int i) { return init[i].v.y; }) }, { V([&](int i) { return init[i].a.x; }), V([&](int i) { return init[i].a.y; }) }, V([&](int i) { return init[i].m; }) }; #else return init; #endifEach lane of the SIMD vector holds one body’s coordinate. A
Body<simd::vec<float, 8>>represents eight bodies packed into seven data-parallel objects.
Visualize the memory layout of
Body<simd::vec<float, 4>>
- How does it depend on
-march/ CPU architecture?- Discuss caches, cache lines, and locality.
(OK, the data is small and cache issues don’t matter much here. But … can you extrapolate to different problems?)
Add SIMD overloads to simulation functions
Duplicate each simulation function with a
simd_vecoverloadYour existing functions operate on
std::span<Body<T>, N>. Add parallel versions that operate on a singleBody<V>:Update positions:
template <simd_vec V> constexpr void update_positions(const typename V::value_type dt, Body<V>& bodies) { // TODO }Update accelerations and velocities. This is the core loop. Instead of iterating over pairs $(i, j)$, you iterate over rotated copies of the same SIMD register:
template <simd_vec V> constexpr void update_a_and_v(const typename V::value_type dt, Body<V>& bodies) { // TODO }Energy functions. Use [
simd::reduce][ref-reduce] to sum across lanes:template <simd_vec V> constexpr typename V::value_type kinetic_energy(const Body<V>& bodies) { // TODO }For potential energy, keeping the
j > icondition isn’t obvious. But there’s an alternative: just compute more and make up for it in the end.template <simd_vec V> constexpr typename V::value_type potential_energy(const Body<V>& bodies) { // TODO }Do timestep. Simply dispatch to the SIMD versions:
template <simd_vec V> constexpr void do_timestep(typename V::value_type dt, Body<V>& bodies) { update_positions(dt, bodies); update_a_and_v(dt, bodies); }
Performance measurement
Benchmark scalar vs. SIMD
Build both targets and measure wall-clock time using
hyperfine. Critical: exclude any I/O from the timing window. Functions likestd::print,std::println, and SFML drawing calls must be outside the measured loop.However, for my code, the removal of all I/O allowed GCC to see that the program has no effect and it eliminated everything as dead code. You can use
vir-inspect.shto check what the compiler is doing to your C++ code.
- Use
hyperfineandperf staton the compiled binary.- Compare scalar and SIMD wall-clock times for the same number of timesteps and same body count.
- Check SIMD instruction counts:
schedtool -F -p 10 -a 1 -e \ perf stat -e \ fp_arith_inst_retired.scalar_single,\ fp_arith_inst_retired.128b_packed_single,\ fp_arith_inst_retired.256b_packed_single \ ./nbody_simd
Why this matters
Including I/O in benchmarks produces misleading numbers.
std::printlnand SFML rendering are orders of magnitude slower than physics computation and will completely dominate the timing. Always measure only the computational kernel.
Verify correctness
Cross-check energy conservation
Run both scalar and SIMD versions for the same initial conditions and step count. Compare final positions and total energy. They should agree within floating-point rounding tolerance.
Further exploration
Try different body counts
Experiment with $N = 4, 8, 12, 16$ bodies. Observe how speedup changes with problem size. At what point does the overhead of packing/unpacking outweigh the SIMD benefit?
Consider chunking: for $N >$ SIMD width, process groups of bodies in batches, accumulating partial forces before reducing.
Visualize with SFML
Adapt your SFML render loop to handle
Body<V>by iterating over lanes:const auto& [r, ..._, m] = bodies; for (int i = 0; i < V::size(); ++i) { sf::CircleShape point(m[i], 8); point.setPosition({r.x[i] * scale + offset, r.y[i] * scale + offset}); window.draw(point); }Remember: SFML drawing must be outside any timed benchmark loop.
Keep inherent parallelism
Fairly trivial, but in the snippet above, do you see the unnecessary serial execution for operations that are inherently data-parallel?
References
- Data-parallel types (SIMD) — cppreference (C++26)
- Experimental SIMD library — cppreference (
std::experimental::simd, TS v2) std::experimental::reduce— cppreferencestd::ranges::contiguous_range— cppreference- GCC
-Ofast— GCC documentation - GCC
-ffast-math— GCC documentation - Previous exercise: N-Body System Simulation