Skip to content

Communicate between blocks

The algorithms that take a PartitionedDataSet -- ghost cells, global ids, probes and particles across pieces, regions, sorts by voxel or label -- are written on tack.communication, a small layer for moving Tack arrays between blocks. This chapter is for writing one of your own: an algorithm whose answer on a piece needs another piece's data.

The model is DIY's (blocks, links, exchanges, collectives), with Tack arrays as the messages and plain dictionaries in place of callbacks. It runs the same in one process, with blocks on any of its devices, and over MPI ranks: an algorithm written on Blocks never asks which.

Blocks and worlds

A block is a unit of work on one device, with a global id: in tack.dataset, a piece. Blocks(devices, world) makes this process's blocks, one per device given (several may share a device); global ids run over every rank's blocks, contiguous by rank, as VTK numbers a partitioned dataset's pieces. blocks.local is the range of this rank's, blocks.count the number in all, blocks.rank_of(g) and blocks.device(g) where a block lives.

A world is the transport. World() is this process alone; World(comm) spans the ranks of an mpi4py communicator (Tack imports no MPI module itself). Every method takes and returns dictionaries keyed by global id, holding an entry for each of this rank's blocks. Every call is collective: each rank makes it, in the same order, even a rank with nothing to send -- the Blocks, Plan and Routing constructors included, which gather what every rank has.

Collectives combine small host values (numbers, NumPy arrays, anything picklable for all_gather) over every block: all_reduce by "sum", "min" or "max", all_gather into a list by global id, and exclusive_scan, each block's offset in a global numbering. They combine in order of global id, whichever rank holds which block, so a floating-point sum has the same bits however the blocks are shared out.

exchange sends messages -- each a dict of names to Tack arrays, on the sending block's device -- from any block to any block. It takes {source: {destination: message}} and returns {destination: {source: message}} for every block on this rank (an empty dict where nothing came), each message's arrays on the destination block's device. A sent array belongs to the receiver afterwards: where the receiver can address its memory, it gets that array itself, so the sender must not write to it again. link_by_bounds finds each block's neighbours by their boxes, touching or within a margin.

import numpy as np

import tack
from tack import communication as tc

tack.set_default_device("cpu")

world = tc.World()                                   # tc.World(MPI.COMM_WORLD) under mpiexec
blocks = tc.Blocks(["cpu", "cpu"], world)            # two blocks on this rank

# Block g holds g + 3 values: 10 g, 10 g + 1, ...
values = {g: tack.array(10.0 * g + np.arange(g + 3)) for g in blocks.local}

total = blocks.all_reduce({g: values[g].sum() for g in blocks.local})
largest = blocks.all_reduce({g: values[g].max() for g in blocks.local}, op="max")
sizes = blocks.all_gather({g: values[g].size for g in blocks.local})
starts, count = blocks.exclusive_scan({g: values[g].size for g in blocks.local})

every = [10.0 * g + np.arange(g + 3) for g in range(blocks.count)]
assert total == sum(v.sum() for v in every) and largest == every[-1].max()
assert sizes == [g + 3 for g in range(blocks.count)] and count == sum(sizes)
assert all(starts[g] == sum(sizes[:g]) for g in blocks.local)

# Each block sends its first value to the block before it, and its last to
# the block after it: neighbours from boxes, one exchange for every block.
boxes = {g: ((float(g),), (g + 1.0,)) for g in blocks.local}       # unit intervals
neighbours = blocks.link_by_bounds(boxes)                          # {g: [g - 1, g + 1]}
outgoing = {}
for g in blocks.local:
    first, last = values[g].numpy()[[0, -1]]
    outgoing[g] = {n: {"edge": tack.array([first if n < g else last])} for n in neighbours[g]}
incoming = blocks.exchange(outgoing)                 # {destination: {source: message}}

for g in blocks.local:
    for source, message in incoming[g].items():
        expected = every[source][-1] if source < g else every[source][0]
        assert message["edge"].numpy()[0] == expected
print(blocks, "total", total, "of", count, "values")

Plans: ghosts refreshed

Most exchanges repeat: the same elements of the same blocks, every step. A Plan keeps one: for each pair of blocks, the element indices the sender reads and those the receiver writes, in matching order (checked across ranks when the plan is made). plan.run(values) gathers, exchanges and scatters in place; {g: {name: array}} moves several arrays in one exchange. A block may send to itself, as a piece in a periodic box is its own neighbour.

Here a line of cells is cut into blocks of m cells, each held with a ghost cell at either end, and diffused by a kernel that reads a cell's two neighbours. Before each step, the plan copies each block's edge cells into its neighbours' ghosts; the blocks then give exactly the whole line's values. To sample the result, Routing sends points to the block that holds them and their answers back:

import numpy as np

import tack
from tack import communication as tc

tack.set_default_device("cpu")


@tack.kernel
def diffuse(u, out):
    for i in range(1, u.shape[0] - 1):
        out[i] = u[i] + 0.25 * (u[i - 1] - 2.0 * u[i] + u[i + 1])


world = tc.World()                                   # tc.World(MPI.COMM_WORLD) under mpiexec
blocks = tc.Blocks(["cpu"] * 2, world)
m = 50                                               # cells per block
n = m * blocks.count                                 # cells in all

# Block g owns cells [g m, (g + 1) m), held at 1 .. m with a ghost each side:
# 0 and m + 1, the neighbours' edge cells (or a fixed zero at the ends).
start = np.exp(-((np.arange(n) - n / 3) / 8.0) ** 2)
u = {g: tack.array(np.pad(start[g * m:(g + 1) * m], 1)) for g in blocks.local}
v = {g: tack.zeros(m + 2, tack.f64) for g in blocks.local}

# The ghost exchange: each block sends its edge cells, and the neighbour
# writes them into its ghost. Indices match in order, sends to receives.
sends = {g: {} for g in blocks.local}
receives = {g: {} for g in blocks.local}
for g in blocks.local:
    if g > 0:
        sends[g][g - 1], receives[g][g - 1] = [1], [0]
    if g < blocks.count - 1:
        sends[g][g + 1], receives[g][g + 1] = [m], [m + 1]
ghosts = tc.Plan(blocks, sends, receives)

for step in range(100):
    ghosts.run(u)                                    # ghosts from their owners
    for g in blocks.local:
        diffuse(u[g], v[g])
    u, v = v, u

# The same steps over the whole line, one array: the same bits.
whole, other = tack.array(np.pad(start, 1)), tack.zeros(n + 2, tack.f64)
for step in range(100):
    diffuse(whole, other)
    whole, other = other, whole
whole = whole.numpy()
for g in blocks.local:
    assert np.array_equal(u[g].numpy()[1:m + 1], whole[1 + g * m:1 + (g + 1) * m])

# Sample the result at points each block chooses: each point goes to the
# block whose cells hold it, which answers with its value there.
rng = np.random.default_rng(blocks.local.start)
points = {g: rng.uniform(0.0, n, (5, 1)) for g in blocks.local}
boxes = {g: ((g * m,), ((g + 1) * m,)) for g in blocks.local}


def holds(g, x):                                     # cells [g m, (g + 1) m)
    return (x[:, 0] >= g * m) & (x[:, 0] < (g + 1) * m)


routing = tc.Routing(blocks, points, boxes, holds)
arrived = routing.send()                             # {g: (points, values)}
answers = {}
for g in blocks.local:
    x, _ = arrived[g]
    cells = (np.floor(x[:, 0]) - g * m + 1).astype(np.int64)  # local index, after the ghost
    answers[g] = {"u": tack.array(u[g].numpy()[cells])}
sampled = routing.reply(answers, like={"u": tack.f64})          # in each block's order

for g in blocks.local:
    assert np.array_equal(sampled[g]["u"].numpy(), whole[1 + np.floor(points[g][:, 0]).astype(int)])
print("max", whole.max(), "sampled", sampled[blocks.local.start]["u"].numpy()[:3])

Routing: points to their blocks

Routing(blocks, positions, boxes, holds) sends each point to the block that holds it. Each block gives its points (an (n, d) host array), its box (None if it holds nothing), and a function holds(g, x) that block g is asked with the points inside its box: 0 where it does not hold a point, or a rank, True being 1. The point goes to the block of the highest rank, the lowest block on a tie -- so a mesh's cell that holds a point beats one that holds it only within a tolerance, and a point on a face two pieces share goes to the lower piece. routing.destination[g] is each point's block, -1 where none holds it.

send(values) moves the points, and any values with them, and returns what arrived at each block, in order of origin block and then of the points there. reply(answers, like) sends an answer for each arrived point back, in that order, and returns one per original point (zero where the point went nowhere). Coordinates travel as the bits of f64 values, so the block decides on exactly the point it was given. probe across pieces is this pattern, and particle advection runs it in rounds, routing each particle after every stretch it takes.

Sorting over blocks

sort_by_key(blocks, keys, values) sorts every block's integer keys together, carrying values along: block 0 ends with the smallest keys and the last block with the largest, each ascending, and every copy of a key ends on one block. That last property is what it is for: matching. Give each point a key -- a hash of its exact position, a voxel, a halo label, a global id -- sort, and the copies of each key meet on one block, which matches or reduces them there; the values say where each came from. It is a stable sample sort (DIY's): equal keys keep the order of their blocks, then their order within a block. Heavily repeated keys can leave blocks uneven.

import numpy as np

import tack
from tack import communication as tc

tack.set_default_device("cpu")

world = tc.World()                                   # tc.World(MPI.COMM_WORLD) under mpiexec
blocks = tc.Blocks(["cpu"] * 3, world)

# Each block's keys, some on several blocks; the value says where each came
# from: (block << 40) | index, the identity Tack's dataset algorithms use.
keys, origin = {}, {}
for g in blocks.local:
    k = np.random.default_rng(g).integers(0, 40, 25 + 5 * g)
    keys[g] = tack.array(k, tack.i64)
    origin[g] = {"origin": tack.array((g << 40) | np.arange(k.size), tack.i64)}

ordered, carried = tc.sort_by_key(blocks, keys, origin)

# Block 0 has the smallest keys, the last block the largest, each ascending,
# and every copy of a key is on one block: count them where they landed.
mine = {g: ordered[g].numpy() for g in blocks.local}
every = blocks.all_gather(mine)
given = [np.random.default_rng(g).integers(0, 40, 25 + 5 * g) for g in range(blocks.count)]
assert np.array_equal(np.concatenate(every), np.sort(np.concatenate(given)))
held = [set(part.tolist()) for part in every]
assert all(not (held[a] & held[b]) for a in range(len(held)) for b in range(a))

# Each value still names its key's block and index.
for g in blocks.local:
    o = carried[g]["origin"].numpy()
    came = [given[s][i] for s, i in zip(o >> 40, o & ((1 << 40) - 1))]
    assert np.array_equal(came, mine[g])
    print("block", g, "holds", mine[g].size, "keys,", mine[g].min(), "to", mine[g].max())

Ghost cells match boundary points this way, generate_global_ids numbers matched points, and voxel_downsample and halo_catalog bring each voxel's or halo's points together.

Blocks from a dataset's pieces

An algorithm over a PartitionedDataSet makes one block per piece, in the pieces' order, in the dataset's world:

blocks = tc.Blocks([piece.device for piece in pieces], pieces.world)
mine = dict(zip(blocks.local, pieces))           # global id -> this rank's piece

A piece's global id is then its index among every rank's pieces, the numbering ghost marks, global ids and statistics use. Positions are relative to each piece's origin, so compare boxes and points across pieces only where the pieces share one, as Tack's algorithms check they do (partitioned.common_origin).

Over MPI

The examples above run under mpiexec unchanged but for the world:

from mpi4py import MPI

world = tc.World(MPI.COMM_WORLD)
mpiexec -n 3 python plan.py

Each rank makes only its own blocks (here two each, so three ranks give six blocks and a longer line); the checks pass on any number of ranks. Messages between ranks are staged through host memory, or, with World(comm, gpu_aware=True) and an MPI that reads device memory, handed to MPI as the device arrays themselves. Make each rank's device (tack.device(f"cuda:{local_rank}")) before importing mpi4py.MPI, which starts MPI on the device current then. World(comm) communicates on a duplicate of comm, so its messages never meet the application's; make one per run.

Run and check

uv run python docs/examples/validate.py --arch cpu
uv run pytest packages/tack-core/tests/test_communication.py

validate.py runs this chapter's three examples in one process. The test suite runs every pattern on threads that stand in for ranks, and under mpiexec -n 3 staged and GPU-aware (--extra mpi); the reference for each call is the API page, and what it guarantees is in the Runtime API contract.