L05 · Galactic Collision
Two spiral galaxies, 16,000 gravitating bodies, 2 billion years, 4 CPU cores, 3 minutes, all in Python compiled to SIMD machine code. The tidal tails Toomre & Toomre explained in 1972 appear on their own, and then the galaxies merge.

Movie: output/collision.mp4 (1280×720, 20 s, H.264). Gold = galaxy 1, azure = galaxy 2.
Brightness is the log (asinh) stretch of stellar surface density on black.
1. The question
Some galaxy pairs, like the Antennae (NGC 4038/9), the Mice (NGC 4676) and the Tadpole, have thin "tails" of stars more than 100 kpc long. In 1972 Alar and Juri Toomre showed these are tidal features. Gravity alone, acting during a close prograde passage, flings out the far side of each disk. Two questions:
- Physics: starting from two stable disk galaxies on a parabolic orbit, can a self-consistent N-body model reproduce tails, a bridge and the eventual merger? And does the merger timescale disagree with the point-mass (Kepler) prediction the way dynamical friction says it should?
- Computation: gravity is all-pairs, O(N²). How far can plain Python go, and which specific engineering steps (compilation, SIMD, threads, memory layout, precision) buy the speed?
2. The model
Gravity. Direct summation over all pairs with Plummer softening ε (G = 1):
$$\ddot{\mathbf r}_i=\sum_{j}\frac{m_j(\mathbf r_j-\mathbf r_i)}{\left(|\mathbf r_j-\mathbf r_i|^2+\varepsilon^2\right)^{3/2}},\qquad \varepsilon = 0.08 \;(280\ \text{pc})$$
The particles are not stars. Each one is a Monte Carlo sample of a smooth, collisionless phase-space density: a disk particle weighs 6×10⁶ M☉ and a halo particle 5×10⁷ M☉. Softening suppresses the unphysical close two-body encounters that such heavy "super-stars" would otherwise have.
Integrator. Kick-drift-kick leapfrog, fixed step dt = 0.05 (0.89 Myr):
$$\mathbf v_{n+\frac12}=\mathbf v_n+\tfrac{\Delta t}{2}\mathbf a(\mathbf x_n),\quad \mathbf x_{n+1}=\mathbf x_n+\Delta t\,\mathbf v_{n+\frac12},\quad \mathbf v_{n+1}=\mathbf v_{n+\frac12}+\tfrac{\Delta t}{2}\mathbf a(\mathbf x_{n+1})$$
Each galaxy (identical, 8,000 particles):
| component | profile | parameters (N-body units) |
|---|---|---|
| stellar disk, 5,000 particles | Σ(R) ∝ e^(−R/R_d), ρ(z) ∝ sech²(z/z₀) | M_d = 1, R_d = 1, z₀ = 0.1, cut at 6 R_d |
| dark halo, 3,000 particles (live) | Plummer ρ ∝ (1 + r²/b²)^(−5/2) | M_h = 5, b = 2 |
- Halo velocities are drawn exactly from the isotropic Plummer distribution function (Aarseth, Hénon & Wielen 1974).
- Disk rotation curve v_c(R) is measured from the particles: the code takes the mean radial pull on massless test rings. That includes softening and the disk's own gravity.
- Disk dispersions come from a Toomre stability parameter Q = σ_R κ / (3.36 G Σ) = 1.3, the epicyclic ratio σ_φ = σ_R κ / 2Ω, and the isothermal-sheet relation σ_z² = π G Σ z₀.
Encounter. Two point masses on a parabolic orbit (e = 1), pericentre r_p = 5 (17.5 kpc), starting separation 20 (70 kpc). Galaxy 1 (gold) has its disk in the orbital plane, spinning prograde. Galaxy 2 (azure) is tilted 60°. The parabola also gives the analytic time to pericentre (Barker's equation), which is used as a check.
Units (G = 1): length 3.5 kpc, mass 3×10¹⁰ M☉, so velocity 192 km/s and time 17.8 Myr. Run: N = 16,000, 2,400 steps, 2.14 Gyr, one frame every 4 steps (600 frames).
3. M&S concepts you'll learn here
- N-body as Monte Carlo: particles sample a collisionless fluid. Softening and N set the resolution, and two-body relaxation is a numerical artefact to keep small.
- Direct summation vs. hierarchical methods: why O(N²) caps you at ~10⁵ bodies, and how Barnes–Hut, FMM and TreePM get to 10¹².
- Symplectic integration: leapfrog conserves a nearby "shadow" Hamiltonian, so its energy error stays bounded forever. Explicit Euler's error ratchets up every orbit.
- Equilibrium initial conditions: sampling a distribution function (Plummer), Jeans/epicyclic approximations, and Toomre Q for disk stability.
- Verification ladder: analytic two-body problem, then conservation laws, then a model-to-model comparison (N-body vs. Kepler parabola), then qualitative validation against Toomre & Toomre morphology.
- Dynamical friction: live halos turn orbital energy into random motion, so a parabolic (unbound!) encounter ends in a merger.
- Performance engineering: interpreter overhead, JIT compilation, SIMD vectorisation (and what blocks it), multithreading, memory layout (SoA vs AoS) and mixed precision, each one measured separately.
- Scientific visualisation: SPH-style adaptive kernel smoothing and the asinh stretch that SDSS used for its colour images.
4. Tech stack
| tool | since | role here |
|---|---|---|
| Python 3.12 | L01 | glue, the "pure Python" rung of the ladder |
| NumPy | L01 | state arrays, the broadcasting rung, histograms |
| Matplotlib | L01 | all figures, and the MP4 through FFMpegWriter |
| SciPy | L02 | Rotation (disk orientation, camera), gaussian_filter, cKDTree (adaptive smoothing) |
| SimPy / Mesa / NetworkX | L03–L04 | not needed here. Gravity is continuous-time physics, not events or agents |
| ★ Numba | L05 | @njit compiles the force loop to LLVM machine code. fastmath + error_model="numpy" unlock AVX SIMD, and parallel=True + prange spreads it over threads. The same Python source runs at 4.9×10⁶ or 7.6×10⁹ pair interactions/s |
★ ffmpeg (via imageio-ffmpeg) |
L05 | H.264 MP4 (yuv420p, plays in any browser) |
multiprocessing (stdlib) |
L04 | 4 worker processes render movie frames while the main process streams them to ffmpeg |
Numba matters because it is the shortest path from "physics loop written like the equation" to
"compiled, vectorised, multithreaded". You don't rewrite anything in C, and you don't switch to array
tricks that blow up memory. The same pattern (a tight inner loop in @njit) returns in L06
(lattice Boltzmann) and L10 (ray-marched shading).
5. Industry & scientific tools
| tool | what it is | who uses it | how this project maps to it |
|---|---|---|---|
| GADGET-4 | TreePM + FMM gravity, SPH hydro, hierarchical time steps, MPI | cosmology groups (Millennium, IllustrisTNG heritage) | our KDK leapfrog is GADGET's integrator. It replaces direct sum with tree/PM and gives each particle its own power-of-two time step |
| AREPO | moving-mesh hydro + TreePM gravity | IllustrisTNG, Auriga galaxy simulations | same gravity story, plus gas physics (star formation, feedback) we leave out |
| SWIFT | task-based FMM gravity + SPH, very scalable | EAGLE-XL, FLAMINGO (Durham, Leiden) | the FMM we mention as the O(N) upgrade; its task scheduler is the grown-up version of prange |
| REBOUND | N-body library (WHFast symplectic, IAS15, tree) | planetary dynamics, exoplanets | our Kepler/leapfrog verification is its bread and butter; WHFast is a symplectic splitting like leapfrog |
| AMUSE | Python framework coupling many astro codes (Bonsai, Gadget2, PhiGRAPE, SeBa) | star-cluster, galaxy and multi-physics studies | "rebuild it here": the same ICs could drive a GPU tree code (Bonsai) from Python |
| galpy | galactic-dynamics library (potentials, orbits, DFs) | Milky Way dynamics | where you'd get proper equilibrium disk/halo DFs instead of our epicyclic approximations |
| NBODY6++GPU / PeTar | direct-sum Hermite + regularisation, GPU | star clusters (collisional) | here O(N²) is still the right choice, because every close encounter matters |
| LAMMPS / GROMACS | molecular dynamics | materials, biochemistry | the MD cousins: same velocity-Verlet (= leapfrog) integrator, with cut-off short-range forces and PME (a PM method) for long range |
6. Results
The encounter

After the first pericentre (0.29 Gyr) each disk throws out a long, curved tidal tail from its far side and a bridge of stars toward its partner. That is the Toomre & Toomre (1972) signature. The prograde, in-plane gold disk makes a long, wide tail that curls back on itself. The tilted azure disk makes a narrow tail plus a diffuse fan, because its stars feel the tide out of their own plane. The two sets of debris also trade stars, so there is gold around the azure galaxy and azure around the gold one. The galaxies climb to apocentre (71 kpc at 1.03 Gyr), fall back, pass close again at ~1.7 Gyr, and merge into one hot, roughly spheroidal remnant. The tails keep expanding the whole time, because much of their material is unbound.

Left: the separation of the two nuclei (median position of each galaxy's innermost disk particles). Until pericentre the N-body orbit follows the analytic Kepler parabola. Measured pericentre: 0.29 Gyr at 16.3 kpc. Kepler prediction: 0.28 Gyr at 17.5 kpc. After that the curves split: the point-mass parabola escapes to infinity, but the live halos have taken orbital energy through dynamical friction, so the galaxies turn round at 71 kpc and merge. The nuclei stay within 2 kpc of each other from 1.97 Gyr on. Total energy is still conserved (right panel). It just moved from the orbit into the internal random motion of stars and dark matter.
Right: total energy error of the 16,000-body run (float32 forces, fixed dt). It stays at ~10⁻⁴ through the encounter. After the merger it creeps up to 5.6×10⁻⁴, because the merged nucleus is denser and its orbital times get closer to the fixed dt = 0.89 Myr. An earlier run at dt = 0.71 Myr stayed below 1.2×10⁻⁴ all the way. So even a symplectic integrator needs the time step to resolve the fastest orbits, and that is why production codes give every particle its own time step.
Verification

| check | expected | got | pass |
|---|---|---|---|
| two-body Kepler period (m₁ = 1, m₂ = 0.5, a = 1, e = 0.5, 500 steps/orbit) | 2π√(a³/GM) = 5.130199 | 5.131472 (error 2.5×10⁻⁴ = 0.025%) | < 1% ✔ |
| leapfrog energy error, 20 orbits | bounded (no growth) | max 4.2×10⁻⁴, identical peak in first and last orbit | ✔ |
| explicit Euler energy error, 20 orbits, same dt | drifts | 0.80 after 20 orbits (orbit spirals outward) | ✔ (>100× leapfrog) |
| collision run energy conservation | ≪ 1% | max 5.6×10⁻⁴ (0.056%) | ✔ |
| first pericentre vs. Kepler parabola (point masses) | 0.28 Gyr, 17.5 kpc | 0.29 Gyr, 16.3 kpc | ✔ within 4% / 7% (extended halos are not point masses) |
Why symplectic matters. Leapfrog does not conserve the true energy H exactly. It exactly conserves a "shadow" Hamiltonian H̃ = H + O(Δt²), because each kick and each drift is itself an exact Hamiltonian flow. So the energy error oscillates, spiking at every pericentre where the step is coarsest, but it never accumulates. Euler is not symplectic. It adds a little energy on each pericentre passage, and the staircase in the plot is those passages stacking up. After 20 orbits the Euler "planet" has lost 80% of its binding energy and is on a much larger orbit. For galaxies (≈10–100 orbits) and planetary systems (10⁹ orbits) that is the difference between physics and garbage. Leapfrog is also time-reversible and costs exactly one force evaluation per step, the same as Euler. (The small period error comes from leapfrog's O(Δt²) phase error, which shows up as slow apsidal precession of the orbit.)
Performance ladder

Same physics, same machine (8-core / 16-thread x86 CPU with AVX2), capped at 4 threads, measured on a quiet machine. Each point is the best of 3 samples. Rates are pair interactions per second at the largest N timed for each rung:
| rung | largest N timed | pair interactions/s | speed-up vs pure Python | gain over previous rung |
|---|---|---|---|---|
| pure Python (lists) | 512 | 4.9×10⁶ | 1× | – |
| NumPy broadcasting | 2,048 | 3.3×10⁷ | 7× | 7× |
Numba @njit |
8,192 | 3.2×10⁸ | 65× | 10× (vs NumPy) |
+ fastmath, error_model="numpy" (SIMD) |
16,384 | 1.47×10⁹ | 300× | 4.6× |
+ parallel=True, 4 threads |
32,768 | 5.0×10⁹ | 1,030× | 3.4× |
| + float32 pair maths (production kernel) | 65,536 | 7.6×10⁹ | 1,558× | 1.5× |
| parallel, AoS (N, 3) layout, float64 | 32,768 | 1.25×10⁹ | 255× | 4.0× slower than SoA |
What each rung teaches:
- Pure Python pays the interpreter on every
+and*: ~5×10⁶ pairs/s. - NumPy broadcasting moves the loops into C, but it builds (3, N, N) temporaries. The work becomes memory-bound and the RAM use is O(N²) (N = 4096 would need > 1 GB). It is only 7× faster, and its throughput actually falls once N > 128, when those temporaries stop fitting in cache.
@njitcompiles the same Python function to machine code: 65× in one line. The first call pays 0.1–0.6 s of JIT compilation, which is why the benchmark warms up before it times anything.fastmath=True, error_model="numpy": the compiled loop was still scalar. Numba's default Python error model puts aZeroDivisionErrorcheck inside the loop, and strict IEEE forbids reordering the sum. Remove both and LLVM emits AVXvsqrtpd/vdivpdon 4 doubles at once (checked withaccel_simd.inspect_asm()), which gives 4.6×. Before this fix, the serialfastmathrung was no faster than plain@njit.parallel=True+prangesplits the i-loop across 4 threads with no race conditions, because each thread owns its own rows. That gives 3.4× on 4 threads, but only once N ≳ 1000. At N = 64 it is no faster than one thread, because waking the threads costs as much as the work. On a saturated machine it is worse: a parallel region is only as fast as its slowest thread. In one run where other jobs had all 16 hardware threads busy, the 4-thread rung fell behind the single-threaded SIMD rung for N below ~10⁴, and the full run took 12.4 min instead of 3.- float32 does the pair maths in single precision (8 lanes per AVX register) but keeps the sums and the state in float64. This is the GPU-code trick (Bonsai, and the GPU Gems N-body kernel). It is the production kernel.
- Dashed line, AoS layout: the same parallel kernel reading an (N, 3) array-of-structs instead of separate x/y/z arrays (structure-of-arrays). The strided loads defeat vectorisation, and it runs 4.0× slower: 4 threads of AoS are no faster than 1 thread of SoA. Memory layout is a performance decision.
The collision itself ran at 6.5×10⁹ pair interactions/s (39.6 ms per step at N = 16,000, including the Python-side integrator and bookkeeping).
Why production codes don't do this: O(N²) vs. Barnes–Hut, FMM, TreePM
The right panel is the whole story. Cost grows as N², so at our best measured 7.6×10⁹ pairs/s:
| N | one force evaluation (direct sum, 4 cores) | typical use |
|---|---|---|
| 1.6×10⁴ (this run) | 0.034 s (measured: 0.040 s per full step) | galaxy-pair toy model |
| 10⁶ | ~2 min | a single modern galaxy simulation |
| 10¹⁰ | ~400 years | a cosmological run (Millennium had 10¹⁰, FLAMINGO 3×10¹¹) |
And a run needs 10⁴–10⁵ steps. Production codes change the algorithm, not the constant factor:
- Barnes–Hut tree (1986), O(N log N): put particles in an octree. A distant cell of many particles is replaced by its centre of mass (plus quadrupole) whenever it subtends less than an opening angle θ. The force error (~0.1–1%) is controlled by θ and is comparable to the discreteness noise that is already there.
- Fast Multipole Method (Greengard & Rokhlin 1987), O(N): cells talk to cells, not just to particles, through multipole→local expansions. SWIFT and GADGET-4 use it.
- TreePM: long-range forces go through a mesh with FFTs (particle-mesh, O(N + M log M), periodic cosmology for free) and the tree handles only short-range corrections. GADGET-2/4 and AREPO use it.
- Codes also add individual (hierarchical) time steps, because dense centres need tiny dt and the outskirts don't.
Direct summation still wins where every encounter matters and accuracy is paramount: collisional star clusters (NBODY6++GPU, PeTar, with GPUs doing exactly our inner loop) and planetary systems. For a collisionless galaxy the particles are statistical samples anyway, so the approximate tree force costs nothing in fidelity.
7. Run it
From the repo root:
uv run python L05_galactic_collision/sim.py # full run: 188 s on a quiet 8-core machine
uv run python L05_galactic_collision/sim.py --quick # smoke test: 5,000 bodies, 0.7 Gyr, no benchmark, ~35 s
# (note: --quick overwrites output/ with its smaller versions)
Output goes to output/: hero.png, collision.mp4, sequence.png, collision_diagnostics.png,
verify_kepler_energy.png, benchmark.png. The run prints the benchmark table in Markdown.
Timing breakdown (final run): benchmark 23 s, verification 1 s, collision 96 s (2,400 steps), stills + MP4 68 s
(600 frames rendered by 4 processes). When other jobs saturate the CPU, expect 2–4× longer (we measured 12.4 min).
8. Code tour (sim.py)
| function | what it does |
|---|---|
accel |
the kernel: direct-sum softened gravity on structure-of-arrays. It is plain Python, so it is also the pure-Python rung |
accel_jit, accel_simd, accel_par |
the same function compiled three ways (njit, + fastmath/numpy error model, + parallel) |
accel_aos, accel_numpy |
the array-of-structs and NumPy-broadcasting comparison rungs |
potential, energy |
O(N²/2) softened potential energy (parallel reduction) + kinetic |
gravity |
wraps a kernel into force(x). The float32 option is the mixed-precision mode |
leapfrog, euler |
integrators as generators that yield after every step |
verify |
Kepler period + leapfrog vs. Euler energy, asserts, and the figure |
benchmark |
times every rung at N = 64 … 65,536, then prints the table and draws the chart |
plummer, galaxy |
equilibrium ICs: Plummer DF sampling, exponential disk, measured rotation curve, Toomre-Q dispersions |
collision_ics |
orients the two disks (scipy Rotation) and puts them on the parabolic orbit |
collide |
the production run: stores frames, nucleus separation, energy |
render |
adaptive-smoothing asinh image, one hue per galaxy (kNN widths via cKDTree) |
zoom_schedule, make_movie, make_stills, diagnostics |
camera, MP4 (4-process frame pool feeding FFMpegWriter), hero/sequence, diagnostic plots |
9. Simplifications & ceilings
- O(N²) direct sum: ~3×10⁴ bodies is the practical ceiling for minutes of runtime on 4 cores. Upgrade path: tree/FMM (above).
- Low N: disk particles of 6×10⁶ M☉ and halo particles 8× heavier cause some numerical disk heating (two-body relaxation). That is part of why the disks look smooth and hot rather than grand-design spirals. Real studies use 10⁶–10⁸ particles and equal-mass or better-resolved components.
- Stars and dark matter only: no gas, star formation or feedback, so no starburst, no blue star-forming knots in the tails, no central black holes. Colour means which galaxy a star came from, not stellar age, and brightness assumes a constant mass-to-light ratio.
- Approximate equilibrium: no bulge, a Plummer halo (compact and truncated, not NFW), no asymmetric-drift correction, and the halo DF ignores the disk's potential. Each disk relaxes a little during the approach. galpy / GalIC / MakeNewDisk do this properly.
- One global time step with float32 forces. Energy is conserved to 6×10⁻⁴ here, and the error only starts to creep once the dense merger remnant forms. A cuspy halo or a close black-hole pair would need hierarchical steps and higher precision.
- One realisation, one orbit: the morphology depends on inclinations, pericentre and mass ratio. No sweep or ensemble is run (see Go further).
- Camera zoom is a data-driven heuristic (1.8 × the 90th-percentile extent, smoothed), not physics.
10. Go further
- Write Barnes–Hut in Numba. Build an octree, walk it with opening angle θ, then plot force error vs θ and find the N
where the tree beats
accel_par. It should be around a few thousand. - Go to the GPU. Port
acceltonumba.cudaor CuPy with shared-memory tiling (Nyland et al. 2007). Expect 10¹⁰–10¹¹ pairs/s and 10⁵ bodies in real time. - Rebuild it in an industry tool. Generate the same galaxies with GalIC or MakeNewDisk and run them in GADGET-4 or SWIFT. Or drive the GPU tree code Bonsai from AMUSE in Python with 10⁶ particles and compare the tails.
- Toomre's atlas / match the Antennae. Sweep disk inclinations and pericentre (a
multiprocessingpool as in L04) and compare against the Identikit models of real interacting pairs (Barnes & Hibbard 2009, Privon et al. 2013). - Add physics. Use an NFW halo + bulge from galpy distribution functions, then SPH gas to see merger-driven inflows and starbursts (Mihos & Hernquist 1996).
11. References
- Toomre, A. & Toomre, J. (1972). Galactic bridges and tails. ApJ 178, 623.
- Barnes, J. & Hernquist, L. (1992). Dynamics of interacting galaxies. ARA&A 30, 705.
- Barnes, J. (1988). Encounters of disk/halo galaxies. ApJ 331, 699.
- Dubinski, J., Mihos, J. C. & Hernquist, L. (1996). Using tidal tails to probe dark matter halos. ApJ 462, 576.
- Hernquist, L. (1993). N-body realizations of compound galaxies. ApJS 86, 389.
- Aarseth, S., Hénon, M. & Wielen, R. (1974). A comparison of numerical methods for the study of star cluster dynamics. A&A 37, 183.
- Binney, J. & Tremaine, S. (2008). Galactic Dynamics, 2nd ed. Princeton (Toomre Q, epicycles, dynamical friction).
- Barnes, J. & Hut, P. (1986). A hierarchical O(N log N) force-calculation algorithm. Nature 324, 446.
- Greengard, L. & Rokhlin, V. (1987). A fast algorithm for particle simulations. J. Comput. Phys. 73, 325.
- Springel, V. et al. (2021). Simulating cosmic structure formation with the GADGET-4 code. MNRAS 506, 2871.
- Schaller, M. et al. (2024). SWIFT: a modern highly-parallel gravity and SPH solver. MNRAS 530, 2378.
- Hairer, E., Lubich, C. & Wanner, G. (2003). Geometric numerical integration illustrated by the Störmer–Verlet method. Acta Numerica 12, 399.
- Dehnen, W. & Read, J. (2011). N-body simulations of gravitational dynamics. EPJ Plus 126, 55.
- Nyland, L., Harris, M. & Prins, J. (2007). Fast N-body simulation with CUDA. GPU Gems 3, ch. 31.
- Lam, S. K., Pitrou, A. & Seibert, S. (2015). Numba: a LLVM-based Python JIT compiler. LLVM-HPC workshop.
- Lupton, R. et al. (2004). Preparing red-green-blue images from CCD data. PASP 116, 133 (the asinh stretch).
- Rein, H. & Liu, S.-F. (2012). REBOUND. A&A 537, A128. Portegies Zwart, S. & McMillan, S. (2018). Astrophysical Recipes: the art of AMUSE. IOP.