Skip to content

Tiling N-body forces

Each body accumulates the gravitational contribution of every other body. The plain implementation is already parallel over bodies. A GPU version can reuse positions within a workgroup by loading them into shared memory in tiles.

Bodies and gravitational accelerations

Origin: adapted from NVIDIA Warp's tile N-body example, Copyright © NVIDIA CORPORATION & AFFILIATES, Apache-2.0. The algorithm follows Lars Nyland, Mark Harris and Jan Prins, Fast N-Body Simulation with CUDA, GPU Gems 3 chapter 31. This teaching version calculates one acceleration field, uses seeded normal positions, and implements both ordinary and shared-memory kernels rather than Warp's tile API. It does not integrate trajectories.

Begin with the portable kernel

The pair interaction is r / (dot(r,r) + epsilon²)^(3/2) for unit masses. Softening keeps the denominator nonzero. The self contribution is zero because its displacement is zero.

@tack.kernel
def acceleration_plain(pos, acceleration, n):
    for i in range(n):
        a = tack.vec3(0.0, 0.0, 0.0)
        for j in range(n):
            a += interaction(pos[i], pos[j])
        acceleration[i] = a

Iteration i owns acceleration[i]. The inner loop is sequential. This costs O(n²) interactions and works on every backend.

Cooperate on each tile

Each 256-lane workgroup processes a tile of 256 positions. Every lane loads one position; every lane then reads all 256 positions from shared storage:

TILE = tack.constant(256)


@tack.kernel
def acceleration_tiled(pos, acceleration, n):
    for i in range(n):
        xyz = tack.shared(tack.f32, TILE * 3)  # folded to 768 scalar components
        lane = tack.thread_id()
        a = tack.vec3(0.0, 0.0, 0.0)
        for tile in range(n // TILE):
            q = pos[tile * TILE + lane]
            xyz[3 * lane], xyz[3 * lane + 1], xyz[3 * lane + 2] = q.x, q.y, q.z
            tack.barrier()                    # finish filling before reading
            for j in range(TILE):
                a += interaction(pos[i], [xyz[3 * j], xyz[3 * j + 1], xyz[3 * j + 2]])
            tack.barrier()                    # finish reading before reusing
        acceleration[i] = a

The first barrier ensures the tile is filled before any lane reads it. The second ensures every lane has finished reading before the next tile overwrites it. Both are outside lane-dependent conditions. Keep every lane participating; an early exit around either barrier violates the collective contract.

The shared array packs three coordinates per position. TILE * 3 is a constant expression, so Tack resolves it to 768 scalars before generating GPU code. It is workgroup storage, unlike a local_array, which would give each lane a private array and no reuse. TILE = 256 matches Tack's default 256-lane workgroup; changing the constant alone does not change that launch configuration.

Choose a capability, not a backend name

    if get_backend().supports_workgroups:
        if n % TILE:
            raise ValueError("the tiled example needs a multiple of 256 bodies")
        acceleration_tiled(pos, acceleration, n)
    else:
        acceleration_plain(pos, acceleration, n)

CPU has no workgroup execution model and runs the plain kernel. The GPU path requires a body count divisible by 256. This example rejects partial tiles; it does not silently drop remaining bodies or introduce padding. A general padded implementation must keep all lanes at both barriers and give padded bodies zero physical contribution.

Validate before timing

uv run python docs/examples/nbody.py --arch cpu --check
uv run python docs/examples/nbody.py --arch metal --check
uv run --with vtk python docs/examples/nbody.py --output nbody.png

The check compares the acceleration with a vectorized NumPy float64 reference. On a GPU it also compares the tiled result with the plain kernel. A CPU check alone does not execute shared memory or barriers. The VTK view shows an XY projection of 3-D positions and a subset of projected acceleration arrows.

For performance work, compile both kernels first, reuse their arrays, time warm calls at the same n, and record the selected device. GPU launches are queued, so call tack.sync() before reading the clock (see timing). Shared memory reduces repeated position loads within a workgroup, but it also adds barriers and uses resources. Measure the net effect; this tutorial promises no universal speedup. The arithmetic remains O(n²).

Complete program

Download nbody.py. The adapted code is Apache-2.0 licensed.

# Copyright (c) 2026 Kitware, Inc.
# SPDX-License-Identifier: Apache-2.0
"""All-pairs and tiled N-body forces, adapted from NVIDIA Warp's tile example.

Copyright (c) 2022 NVIDIA CORPORATION & AFFILIATES. Apache-2.0;
see LICENSE-APACHE-2.0.txt.
Source: https://github.com/NVIDIA/warp/blob/main/warp/examples/tile/example_tile_nbody.py
Algorithm: Lars Nyland, Mark Harris, Jan Prins, GPU Gems 3, chapter 31.
"""

import argparse

import numpy as np

import tack
from tack.runtime.dispatch import get_backend


@tack.func
def interaction(p, q):
    r = q - p
    inv = 1.0 / sqrt(r.dot(r) + 0.01)
    return r * inv * inv * inv


@tack.kernel
def acceleration_plain(pos, acceleration, n):
    for i in range(n):
        a = tack.vec3(0.0, 0.0, 0.0)
        for j in range(n):
            a += interaction(pos[i], pos[j])
        acceleration[i] = a


TILE = tack.constant(256)


@tack.kernel
def acceleration_tiled(pos, acceleration, n):
    for i in range(n):
        xyz = tack.shared(tack.f32, TILE * 3)  # folded to 768 scalar components
        lane = tack.thread_id()
        a = tack.vec3(0.0, 0.0, 0.0)
        for tile in range(n // TILE):
            q = pos[tile * TILE + lane]
            xyz[3 * lane], xyz[3 * lane + 1], xyz[3 * lane + 2] = q.x, q.y, q.z
            tack.barrier()                    # finish filling before reading
            for j in range(TILE):
                a += interaction(pos[i], [xyz[3 * j], xyz[3 * j + 1], xyz[3 * j + 2]])
            tack.barrier()                    # finish reading before reusing
        acceleration[i] = a


def run(arch="cpu", check=False):
    tack.set_default_device(arch)
    n = 256 if check else 512
    xyz = np.random.default_rng(42).normal(size=(n, 3)).astype(np.float32)
    pos = tack.empty((n,), tack.vec3(tack.f32))
    acceleration = tack.empty((n,), tack.vec3(tack.f32))
    tack.copyto(pos, xyz)
    if get_backend().supports_workgroups:
        if n % TILE:
            raise ValueError("the tiled example needs a multiple of 256 bodies")
        acceleration_tiled(pos, acceleration, n)
    else:
        acceleration_plain(pos, acceleration, n)
    result = acceleration.numpy()
    if check:
        r = xyz.astype(np.float64)[None, :, :] - xyz.astype(np.float64)[:, None, :]
        distance_sq = (r * r).sum(axis=-1) + 0.01
        reference = (r / distance_sq[..., None] ** 1.5).sum(axis=1)
        np.testing.assert_allclose(result, reference, atol=8e-4, rtol=3e-5)
        if get_backend().supports_workgroups:
            baseline = tack.empty((n,), tack.vec3(tack.f32))
            acceleration_plain(pos, baseline, n)
            np.testing.assert_allclose(result, baseline.numpy(), atol=8e-4, rtol=3e-5)
    return xyz, result


def save_figure(result, output):
    import vtkmodules.vtkRenderingFreeType
    import vtkmodules.vtkRenderingOpenGL2  # noqa: F401
    from vtkmodules.vtkCommonCore import VTK_TEXT_CENTERED, VTK_TEXT_TOP, vtkFloatArray, vtkPoints
    from vtkmodules.vtkCommonDataModel import vtkPolyData
    from vtkmodules.vtkFiltersCore import VTK_SCALE_BY_VECTOR, VTK_USE_VECTOR, vtkGlyph3D
    from vtkmodules.vtkFiltersGeneral import vtkVertexGlyphFilter
    from vtkmodules.vtkFiltersSources import vtkArrowSource
    from vtkmodules.vtkIOImage import vtkPNGWriter
    from vtkmodules.vtkRenderingCore import (
        VTK_NORMALIZED_VIEWPORT,
        vtkActor,
        vtkPolyDataMapper,
        vtkRenderer,
        vtkRenderWindow,
        vtkTextActor,
        vtkWindowToImageFilter,
    )

    pos, acceleration = result
    # Project both positions and forces into the x-y plane.
    xyz = pos.copy()
    xyz[:, 2] = 0
    # vtkFloatArray keeps the float32 positions: vtkPoints(data=numpy) would store doubles.
    bodies = vtkPolyData(points=vtkPoints(data=vtkFloatArray(xyz)))
    mapper = vtkPolyDataMapper()
    bodies >> vtkVertexGlyphFilter() >> mapper
    actor = vtkActor(mapper=mapper)
    actor.property.color = (0.15, 0.39, 0.92)
    actor.property.point_size = 4
    actor.property.render_points_as_spheres = True
    samples = vtkPolyData(points=vtkPoints(data=vtkFloatArray(np.ascontiguousarray(xyz[::8]))))
    scaled = acceleration / (np.linalg.norm(acceleration, axis=1).max() + 1e-12)
    scaled[:, 2] = 0
    # Attribute data has no settable vectors property: SetVectors makes them active.
    samples.point_data.SetVectors(vtkFloatArray(np.ascontiguousarray(scaled[::8])))
    glyphs = vtkGlyph3D(vector_mode=VTK_USE_VECTOR, scale_mode=VTK_SCALE_BY_VECTOR,
                        scale_factor=0.8, orient=True)
    arrow = vtkArrowSource(shaft_radius=0.04, tip_radius=0.15)
    # The glyph source is input port 1, which has no property or >> form.
    glyphs.SetSourceConnection(arrow.output_port)
    arrow_mapper = vtkPolyDataMapper(scalar_visibility=False)
    samples >> glyphs >> arrow_mapper
    arrows = vtkActor(mapper=arrow_mapper)
    arrows.property.color = (0.92, 0.35, 0.05)
    arrows.property.lighting = False
    renderer = vtkRenderer(background=(1, 1, 1))
    renderer.AddActor(actor)
    renderer.AddActor(arrows)
    camera = renderer.active_camera
    camera.position = (0, 0, 1)
    camera.focal_point = (0, 0, 0)
    camera.parallel_projection = True
    camera.parallel_scale = float(np.abs(xyz).max()) * 1.15
    title = vtkTextActor(input="Bodies and gravitational acceleration (x-y)", position=(0.5, 0.98))
    title.position_coordinate.coordinate_system = VTK_NORMALIZED_VIEWPORT
    text = title.text_property
    text.justification = VTK_TEXT_CENTERED
    text.vertical_justification = VTK_TEXT_TOP
    text.font_size = 20
    text.color = (0, 0, 0)
    renderer.AddViewProp(title)
    window = vtkRenderWindow(off_screen_rendering=True, multi_samples=0, size=(640, 640))
    window.AddRenderer(renderer)
    window.Render()
    capture = vtkWindowToImageFilter(input=window, read_front_buffer=False)
    writer = vtkPNGWriter(file_name=str(output))
    capture >> writer
    writer.Write()  # calling the writer, or updating it, writes raw pixels instead of a PNG
    window.Finalize()


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--arch", default="cpu", choices=["cpu", "metal", "cuda", "hip", "level_zero"])
    parser.add_argument("--check", action="store_true")
    parser.add_argument("--output", help="Save a figure (requires VTK)")
    args = parser.parse_args()
    result = run(args.arch, args.check)
    if args.output:
        save_figure(result, args.output)
    print("N-body: check passed" if args.check else "N-body: force calculation complete")