Mesh partitioning#
Download sources
This demo illustrates how to:
Create a mesh from cell and geometry data that is already distributed across MPI ranks
Select the partitioner that distributes the cells
Measure the quality of the resulting partition
Partitioners#
When create_mesh is called, the
input cells are redistributed across ranks by a partitioner. The
partitioner decides which rank will own each cell, and the choice
involves a trade-off between the cost of computing the partition and its
quality.
Three families are used here:
Graph partitioners work on the mesh dual graph, in which each cell is a node and cells sharing a facet are connected by an edge. They aim to divide the nodes into equally sized parts while cutting as few edges as possible. A graph partitioning function can be passed to
create_meshdirectly, as itspartitionerargument. Three are available, depending on how DOLFINx was built: ParMETISKway(multilevel k-way partitioning), PT-SCOTCH and KaHIP.Geometric partitioners work on the positions of the cells. Cells are ordered along a space-filling curve through the mesh and the curve is cut into equal pieces. This is much cheaper than graph partitioning, and the cost barely grows with the number of ranks, but more edges are cut. Such a partitioner is called with a coordinate for each cell (the mean of its vertex positions) rather than the graph, so a custom one must first be wrapped with
create_geometric_cell_partitionerbefore it can be passed tocreate_mesh; the built-in ones below (Morton,Hilbert,Geom) already come wrapped.ParMETIS provides
Geom, which uses a space-filling curve alone. DOLFINx provides two curves that require no external library, both purely geometric. A Morton (‘Z-order’) curve is cheap to evaluate, but jumps a long way in space whenever a high bit of the key changes, so consecutive cells on the curve are not always neighbours. A Hilbert curve has no such jumps: successive points on it are always neighbours, which gives more compact partitions and a smaller edge cut.A geometric partitioner just needs to map each cell’s centroid to a destination rank, so a simple one is easy to write by hand – see
slab_partitionerfurther below for an example.Hybrid partitioners also use the graph edges as part of the partitioning decision itself, rather than only to determine ghost cells, but are otherwise called the same way as a geometric partitioner and wrapped with
create_hybrid_cell_partitioner. ParMETIS providesGeomKway, which uses the space-filling curve to redistribute the graph and then applies k-way partitioning to it; it already comes wrapped.
Which of ParMETIS, PT-SCOTCH and KaHIP are present depends on the build,
so the demo checks dolfinx.has_parmetis,
dolfinx.has_ptscotch and dolfinx.has_kahip and
uses those that are available.
Measures of partition quality#
Two quantities are reported for each partition:
Imbalance, the largest number of cells owned by any rank divided by the average. A perfectly balanced partition has an imbalance of 1. Work per rank in an assembly loop is proportional to its cell count, so the slowest rank sets the pace.
Edge cut, the number of facets that are shared by two cells owned by different ranks. Each cut facet is a place where data must be communicated, so a smaller cut means less communication later. Cut facets are exactly the mesh ‘inter-process’ facets, so the cut can be read off the created mesh. Note that this requires the mesh to be built with ghost cells, i.e. with
GhostMode.shared_facet.
import os
import time
from collections.abc import Callable
from mpi4py import MPI
import numpy as np
import numpy.typing as npt
from dolfinx import common, graph, has_kahip, has_parmetis, has_ptscotch
from dolfinx.fem import coordinate_element
from dolfinx.mesh import (
CellType,
GhostMode,
Mesh,
compute_cell_centroids,
create_geometric_cell_partitioner,
create_mesh,
)
Distributed input data#
Mesh data is often read from file in parallel, with each rank holding an arbitrary block of the cells and of the geometry. Here the same situation is created without a file: each rank builds the block of a structured cube mesh of tetrahedra that corresponds to its slice of the global cell range.
The two returned arrays describe the mesh in different index spaces, and understanding how they relate is the key to the input format:
cellsholds, for each local cell, the global indices of its four vertices. ‘Global’ means an index into the global list of points, and the same point has the same index on every rank. Each cell appears on exactly one rank, so the local blocks together list every cell once.xholds the coordinates of the points that this rank supplies. The global index of a point is not stored: it is implied by position, being the row index plus the number of points held by all lower ranks. The local blocks together supply every point once.
The two distributions are independent. Row j of x is not the
position of vertex j of the local cells, and a rank’s cells will
generally refer to vertices whose coordinates are held by other ranks.
create_mesh resolves this: after
the partitioner has decided cell ownership, each rank fetches the
coordinates of the vertices it needs from whichever rank holds them. The
same applies to the geometric partitioners, which must fetch coordinates
before they can compute cell positions.
def cube_block(comm: MPI.Comm, n: int) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.float64]]:
"""Local block of the cells and points of a unit cube of tetrahedra.
Each hexahedral cell of an ``n x n x n`` grid is split into six
tetrahedra. Cells and points are shared out over the ranks of
``comm`` in contiguous blocks, independently of each other.
Args:
comm: MPI communicator to distribute the mesh data over.
n: Number of divisions in each direction.
Returns:
Tuple of ``(cells, x)``, where ``cells`` has shape
``(num_cells, 4)`` and row ``i`` holds the global indices of the
four vertices of local cell ``i``, and ``x`` has shape
``(num_points, 3)`` and row ``j`` holds the coordinates of the
point with global index ``j + offset``, where ``offset`` is the
number of points held by all lower ranks.
The two are not aligned with one another: the vertex indices in
``cells`` refer to the global point numbering, and the
corresponding coordinates are in general held by other ranks.
"""
# Cells: this rank's slice of the n^3 hexahedra
c0, c1 = common.local_range(comm.rank, n**3, comm.size)
i = np.arange(c0, c1, dtype=np.int64)
iz, j = np.divmod(i, n * n)
iy, ix = np.divmod(j, n)
# The eight corners of each hexahedron, in lexicographic vertex
# numbering
v = [
(iz + dz) * (n + 1) ** 2 + (iy + dy) * (n + 1) + ix + dx
for dz in (0, 1)
for dy in (0, 1)
for dx in (0, 1)
]
tets = [(0, 1, 3, 7), (0, 1, 7, 5), (0, 5, 7, 4), (0, 3, 2, 7), (0, 6, 4, 7), (0, 2, 6, 7)]
cells = np.stack([v[k] for t in tets for k in t], axis=1).reshape(-1, 4)
# Points: this rank's slice of the (n + 1)^3 grid points
p0, p1 = common.local_range(comm.rank, (n + 1) ** 3, comm.size)
p = np.arange(p0, p1, dtype=np.int64)
pz, q = np.divmod(p, (n + 1) ** 2)
py, px = np.divmod(q, n + 1)
x = np.stack([px, py, pz], axis=1).astype(np.float64) / n
return np.ascontiguousarray(cells), np.ascontiguousarray(x)
Randomising the input distribution#
The blocks above are locality preserving: consecutive cells of the structured grid are numbered consecutively, so a rank’s block covers a compact region of the cube. Input read from file may be far less well behaved, with cells arriving in an order unrelated to their position.
This matters for partitioning cost. A graph partitioner coarsens the
dual graph, and coarsening is communication-bound when a cell’s
neighbours are held by unrelated ranks. GeomKway exists for exactly
this case: it uses the space-filling curve to redistribute the graph
so that it has spatial locality, and only then applies k-way
partitioning.
The function below moves a given fraction of each rank’s cells to a
random rank, so the input distribution can be varied from fully
locality preserving (fraction = 0) to fully random
(fraction = 1). Only the cells are moved; the point distribution is
untouched, which the input format allows since the two are independent.
def redistribute_cells(
comm: MPI.Comm, cells: npt.NDArray[np.int64], fraction: float, seed: int = 1
) -> npt.NDArray[np.int64]:
"""Move a fraction of the cells on each rank to a random rank.
The mesh described by the cells is unchanged: cells are only moved
between ranks, so every cell still appears exactly once.
Args:
comm: MPI communicator holding the cells.
cells: Local cells, with shape ``(num_cells, 4)``.
fraction: Fraction of the local cells to move to a rank chosen
uniformly at random, in ``[0, 1]``. Cells not selected stay
on the calling rank.
seed: Random number generator seed.
Returns:
The cells now held by the calling rank.
"""
if fraction <= 0.0 or comm.size == 1:
return cells
# A per-rank seed avoids every rank drawing the same random stream.
rng = np.random.default_rng(seed + comm.rank)
dest = np.full(len(cells), comm.rank)
move = rng.random(len(cells)) < fraction
dest[move] = rng.integers(0, comm.size, np.count_nonzero(move))
recv = comm.alltoall([cells[dest == r] for r in range(comm.size)])
return np.ascontiguousarray(np.concatenate(recv), dtype=np.int64)
Partition quality#
The imbalance is computed from the number of cells owned by each rank. The edge cut is the global number of inter-process facets: a facet is listed as inter-process on the rank that owns it when its two cells are owned by different ranks, so summing the local counts counts each cut facet once. This assumes exactly two cells per facet, true for the tetrahedral mesh used here; meshes with branching facets (T-joints, 1D graph structures) can have more, and are not counted by this measure.
def partition_quality(msh: Mesh) -> tuple[float, int]:
"""Compute the imbalance and edge cut of a mesh partition.
Args:
msh: Mesh to measure.
Returns:
Cell imbalance (largest over average number of owned cells) and
the global number of cut facets.
"""
comm = msh.comm
tdim = msh.topology.dim
cell_map = msh.topology.index_map(tdim)
max_owned = comm.allreduce(cell_map.size_local, MPI.MAX)
imbalance = max_owned * comm.size / cell_map.size_global
# Inter-process facets are available once facets and the facet-to-cell
# connectivity have been computed
msh.topology.create_entities(tdim - 1)
msh.topology.create_connectivity(tdim - 1, tdim)
cut = comm.allreduce(len(msh.topology.interprocess_facets()), MPI.SUM)
return imbalance, cut
A simple, user-defined geometric partitioner#
A geometric partitioner is just a function taking (comm, nparts, x),
where x is one row of coordinates per cell, and returning one
destination rank per row. slab_partitioner below is about as simple
as that can be: it cuts the domain into nparts slabs along the x
axis and assigns each cell to the slab its centroid falls in. Wrapping
it with create_geometric_cell_partitioner makes it usable as
create_mesh’s partitioner
argument, exactly like the built-in partitioners above.
def slab_partitioner(
comm: MPI.Comm,
nparts: int,
x: npt.NDArray[np.float64],
node_weights: npt.NDArray[np.int32] | None,
) -> npt.NDArray[np.int32]:
"""Partition cells into slabs of the domain, ordered by `x`-coordinate.
Cuts the domain into `nparts` equal-width slabs along the first
coordinate axis and assigns each cell to the slab its centroid
falls in. This balances cell counts well only when cells are
spread evenly along `x`, and -- unlike a space-filling curve -- it
ignores the other coordinates entirely, so it keeps neighbouring
cells together in only one direction.
Args:
comm: Unused; a geometric partitioner is called collectively,
but does not need the communicator itself to compute a
per-row destination.
nparts: Number of parts to divide the domain into.
x: Cell centroids, with shape ``(num_cells, gdim)``.
node_weights: Unused; this partitioner does not support node
weights.
Returns:
Destination rank for each row of `x`.
"""
dest = np.floor(x[:, 0] * nparts).astype(np.int32)
return np.clip(dest, 0, nparts - 1)
Creating a mesh with each partitioner#
The coordinate element is the
same in each case; only the partitioner and the input cell distribution
differ. The geometric and hybrid partitioners need cell positions to
partition on; create_mesh computes
these itself, as the mean of each cell’s vertex positions in the
coordinate array x it is given, and passes them to the partitioner.
A mesh creation time is reported alongside the quality measures, since the cost of partitioning is the reason to prefer a cheaper partitioner. It is a wall-clock time for a small mesh, so treat it as indicative only.
comm = MPI.COMM_WORLD
# Divisions per direction (6*n^3 tetrahedra). Large by default, since
# input-distribution effects only show up on a big mesh; reduced for a
# single rank (nothing to partition) or in CI (must run quickly).
_small = comm.size == 1 or "CI" in os.environ or "GITHUB_ACTIONS" in os.environ
n = 24 if _small else 128
cells0, x = cube_block(comm, n)
cmap = coordinate_element(CellType.tetrahedron, 1)
ghost_mode = GhostMode.none
partitioners = {}
if has_parmetis:
partitioners["ParMETIS Kway"] = graph.partitioner_parmetis()
partitioners["ParMETIS GeomKway"] = graph.partitioner_parmetis_hybrid(1.02, [1, 0, 5])
partitioners["ParMETIS Geom"] = graph.partitioner_parmetis_geom
if has_ptscotch:
partitioners["PT-SCOTCH"] = graph.partitioner_scotch()
if has_kahip:
partitioners["KaHIP"] = graph.partitioner_kahip()
# The space-filling curve partitioners are built into DOLFINx, so they are
# always available
partitioners["SFC Morton"] = graph.partition_morton
partitioners["SFC Hilbert"] = graph.partition_hilbert
# A hand-written geometric partitioner needs wrapping before it can be
# passed to create_mesh, unlike the built-ins above
partitioners["Slab (custom)"] = create_geometric_cell_partitioner(slab_partitioner)
if comm.rank == 0:
print(f"Mesh: {6 * n**3} tetrahedra on {comm.size} rank(s)")
# Repeat the comparison for input distributions ranging from fully
# locality preserving to fully random
for fraction in (0.0, 0.5, 1.0):
cells = redistribute_cells(comm, cells0, fraction)
if comm.rank == 0:
print(f"\nFraction of cells moved to a random rank: {fraction}")
print(f"{'Partitioner':<20}{'imbalance':>12}{'edge cut':>12}{'time (s)':>12}")
for name, partitioner in partitioners.items():
comm.Barrier()
t = time.perf_counter()
msh = create_mesh(comm, cells, cmap, x, partitioner=partitioner, ghost_mode=ghost_mode)
comm.Barrier()
elapsed = comm.allreduce(time.perf_counter() - t, MPI.MAX)
imbalance, cut = partition_quality(msh)
if comm.rank == 0:
print(f"{name:<20}{imbalance:>12.3f}{cut:>12}{elapsed:>12.3f}")
Two-stage partitioning#
A graph partitioner is much more expensive on a randomly distributed input, because its coarsening phase has to communicate with unrelated ranks (see above). The space-filling curve partitioner does not care about the input distribution at all, which suggests a two-stage scheme: first partition with the curve, which cheaply gives the cells spatial locality, then re-partition the result with the graph partitioner, which now runs on a well-distributed input.
This is the same idea as ParMETIS GeomKway, but applied around any
graph partitioner. It is shown here with PT-SCOTCH, which is the most
sensitive to the input distribution.
The first stage only needs the redistributed cells to feed to the
second stage, not a mesh, so it calls the cell partitioner directly and
exchanges the cell-vertex rows itself, using graph.distribute, rather
than going through :func:create_mesh. This avoids paying for
topology, geometry and ghost cells for a mesh that would otherwise be
discarded immediately, and graph.distribute scales better than an
all-to-all over the whole communicator. Since sfc below does not
ghost, every cell is sent to exactly one rank, so it still appears
exactly once, in the same vertex numbering it started with.
sfc is a geometric partitioner, called directly with cell centroids
computed here with :func:compute_cell_centroids – the same step
:func:create_mesh performs internally before calling it.
def redistribute_by_partitioner(
comm: MPI.Comm,
cell_type: CellType,
cells: npt.NDArray[np.int64],
x: npt.NDArray[np.float64],
partitioner: Callable,
) -> npt.NDArray[np.int64]:
"""Redistribute cells to the ranks assigned by a geometric partitioner.
Args:
comm: MPI communicator the cells and ``x`` are distributed over.
cell_type: Cell type of ``cells``.
cells: Local cells, with shape ``(num_cells, num_vertices)``.
x: Geometry ('node') coordinates, as passed to :func:`create_mesh`.
partitioner: Geometric cell partitioning function, i.e. one
taking cell centroids rather than the graph -- see
:func:`create_mesh`. Called here directly, rather than
through :func:`create_mesh`, with its
``(comm, nparts, x, node_weights)`` signature, returning one
destination rank per centroid.
Returns:
The cells assigned to this rank, in the same vertex numbering as
the input ``cells``.
"""
centroid = compute_cell_centroids(comm, [cell_type], [cells.reshape(-1)], comm, x)
dest = graph.adjacencylist(partitioner(comm, comm.size, centroid, None))._cpp_object
recv, _, _, _ = graph.distribute(comm, cells, dest) # type: ignore[arg-type]
return recv
def scotch_partitioner_time() -> float:
"""Cumulative time spent in the SCOTCH partitioner on this rank."""
try:
return common.timing("Compute graph partition (SCOTCH)")[1].total_seconds()
except RuntimeError:
return 0.0
if has_ptscotch and comm.size > 1:
cells_random = redistribute_cells(comm, cells0, 1.0)
scotch = partitioners["PT-SCOTCH"]
sfc = partitioners["SFC Hilbert"]
def timed(cells: npt.NDArray[np.int64], partitioner: Callable) -> tuple[Mesh, float, float]:
"""Create a mesh, with the elapsed and the SCOTCH time."""
comm.Barrier()
t, t_scotch = time.perf_counter(), scotch_partitioner_time()
msh = create_mesh(comm, cells, cmap, x, partitioner=partitioner, ghost_mode=ghost_mode)
comm.Barrier()
return (
msh,
comm.allreduce(time.perf_counter() - t, MPI.MAX),
comm.allreduce(scotch_partitioner_time() - t_scotch, MPI.MAX),
)
# SCOTCH alone, on the random input -- repeats the fraction = 1.0
# row above, deliberately, so this comparison stands on its own.
msh1, t1, t1_scotch = timed(cells_random, scotch)
# Curve first, then SCOTCH on its output. Stage 1 redistributes
# cells only (not a full mesh, which would be discarded immediately).
comm.Barrier()
t = time.perf_counter()
cells_sfc = redistribute_by_partitioner(comm, CellType.tetrahedron, cells_random, x, sfc)
comm.Barrier()
t2a = comm.allreduce(time.perf_counter() - t, MPI.MAX)
msh2b, t2b, t2b_scotch = timed(cells_sfc, scotch)
if comm.rank == 0:
print("\nPartitioning a randomly distributed mesh with PT-SCOTCH")
header = f"{'Route':<28}{'imbalance':>12}{'edge cut':>12}"
print(header + f"{'total (s)':>12}{'SCOTCH (s)':>12}")
imb1, cut1 = partition_quality(msh1)
imb2, cut2 = partition_quality(msh2b)
if comm.rank == 0:
print(f"{'SCOTCH alone':<28}{imb1:>12.3f}{cut1:>12}{t1:>12.3f}{t1_scotch:>12.3f}")
row = f"{'SFC, then SCOTCH':<28}{imb2:>12.3f}{cut2:>12}"
print(row + f"{t2a + t2b:>12.3f}{t2b_scotch:>12.3f}")
print(f"{' stage 1 (SFC)':<28}{'':>12}{'':>12}{t2a:>12.3f}")
print(f"{' stage 2 (SCOTCH)':<28}{'':>12}{'':>12}{t2b:>12.3f}")
On a single rank there is nothing to partition: the imbalance is 1 and the edge cut is zero. Run the demo with, for example
mpirun -n 12 python3 demo_partition.py
to compare the partitioners. The graph partitioners cut the fewest
facets, and the curve-based Geom and the DOLFINx space-filling curve
cut roughly 20-45% more, in exchange for being much cheaper to compute –
a trade that is worth making when the partitioning cost itself dominates,
since graph partitioning becomes more expensive as the number of ranks
grows while the curve partitioners barely do. All of them give an
imbalance within a couple of percent of perfect, with the curve
partitioners slightly ahead as they divide the cells by count.
Among the graph partitioners, PT-SCOTCH typically achieves the smallest cut, at the cost of a slightly larger imbalance and of being substantially slower than ParMETIS. Its result varies a little between runs, so the numbers it reports are not reproducible in the way the others are.
The cut of a curve-based partition depends on how well the curve happens to align with the mesh and the number of parts. Running this demo on 8 ranks is an instructive special case: a Morton curve through a cube divides naturally into 8 octants, so the curve partitioners produce nearly optimal cube-shaped parts and cut fewer facets than the graph partitioners. Rank counts that are not a power of eight, such as the 12 above, are more representative.
Effect of the input distribution#
Comparing the three tables shows that partition quality is largely insensitive to how the input cells are spread over the ranks: each partitioner reports much the same imbalance and edge cut whether the input is locality preserving or fully random. This is expected, since every partitioner sees the same mesh either way.
The cost is not insensitive, and this is what GeomKway addresses.
With a random input distribution a cell’s neighbours are held by
unrelated ranks, so the coarsening phase of a graph partitioner has to
communicate much more, whereas GeomKway first uses the space-filling
curve to restore locality. The mesh here is small and the reported time
covers all of mesh creation rather than partitioning alone, so only a
hint of this is visible; the gap between Kway and GeomKway widens
with the number of cells and of ranks. The curve-only partitioners are
almost unaffected, as they never look at the graph.