Add space filling curve (SFC) and hybrid SFC/graph partitioning algorithms for building parallel meshes - #4403
Draft
garth-wells wants to merge 71 commits into
Draft
Add space filling curve (SFC) and hybrid SFC/graph partitioning algorithms for building parallel meshes #4403garth-wells wants to merge 71 commits into
garth-wells wants to merge 71 commits into
Conversation
Avoids materialising and sorting a separate ghost-edges vector just to count distinct values; a single boost::unordered_flat_map pass over the edge array does it in one O(n) sweep instead of an O(n) filter plus an O(m log m) sort. Also flush the SCOTCH sub-timers so they show up in per-call timing reports. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
std::ranges::sort's generic path, when instantiated for a span whose size the compiler cannot bound at compile time, triggers a spurious array-bounds warning against the fixed-size row_sorted_storage scratch buffer it is sliced from. Replace the unreachable (no supported CellType has an entity with more than 4 vertices) generic-sort fallback with an explicit bound check, which also turns a latent buffer overflow into a defined error should a future entity type ever exceed 4 vertices. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Serial mesh creation: remove redundant copies and sorts in create_mesh (4.29 s -> 3.48 s for 6M tetrahedra, Release build): - `nodes2` was a second, identical concatenation of `cells1`, used only as a span by create_geometry. For a single cell type it is now `cells1.front()`, with no copy. - `create_topology` already builds the sorted, unique input global vertex indices but discarded them. A new `mesh::impl::create_topology` returns them alongside the Topology, and for 'P1 geometry' create_mesh uses them instead of radix sorting the whole cell array and calling unique. The public create_topology overloads are unchanged. - `extract_topology` is the identity for 'P1 geometry', so it is skipped: a new `mesh::is_vertex_dof_layout` drives the check and `cells1_v` becomes spans that alias `cells1`. Add geometric cell partitioners, which partition on cell position rather than on the dual graph edges. These are much cheaper than graph partitioning, and the cost is nearly independent of the rank count, at the price of a larger edge cut (measured 1.48x faster mesh creation at 40 ranks for 15.4M tetrahedra, with 24% more ghost cells): - `graph::partition_sfc` partitions points by Morton key, with splitters drawn from a gathered sample. No external library required. - `graph::geom_partition_fn` is `partition_fn` plus a coordinate per node, with implementations `graph::sfc::partitioner` and `graph::parmetis::geom_partitioner` (`ParMETIS_V3_PartGeomKway` and `ParMETIS_V3_PartGeom`). - `mesh::create_geometric_cell_partitioner` computes cell centroids and applies a `geom_partition_fn`, and returns an ordinary CellPartitionFunction, so create_mesh is unchanged. Pass num_threads to the dual graph build in create_cell_partitioner. It was defaulted to 1, so the partitioner's dual graph build could not be threaded whatever the caller asked for. Note: adding the defaulted num_threads parameter changes the mangled name of create_cell_partitioner, so this is an ABI break. Reinstall the Python module rather than relying on an incremental rebuild. Add demo_partition.py, which builds a mesh from distributed input data with each available partitioner and reports the cell imbalance and edge cut, and a C++ unit test asserting that global entity counts do not depend on the partitioner. Tests that imported create_cell_partitioner from dolfinx.cpp.mesh now use the pure-Python dolfinx.mesh interface, which carries the default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main's #4396 landed the mesh construction performance work that this branch also carried locally, so the shared parts conflicted. Resolutions: - graph/ordering.cpp: took main, which removes the GPS re-ordering code (#4391). The only local change was a std::ranges::distance tweak inside a function that main deletes. - graph/partitioners.cpp: took main's compute_destination_ranks, dropping the local 'Halo dest ranks' timers, and re-applied parmetis::geom_partitioner on top. - graph/partition.cpp: kept the <cstdint> and <limits> includes that partition_sfc needs. main's column-major packing of unmatched facets in create_boundary_vertices_fn and the create_mesh copy elimination on this branch touch the same lambda but different parts of it, and merged cleanly. Verified after merging: C++ unit tests pass on 1, 2 and 3 ranks; Python test suite passes (3085 passed, 3 CFFI tests deselected for a pre-existing setuptools issue); demo_partition runs via the demo test harness. Mesh creation timings are unchanged by the merge: 3.49 s serial for 6M tetrahedra, and 2.08 s (graph) / 1.46 s (SFC) at 40 ranks for 15.4M tetrahedra. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Explain the relationship between the two arrays returned by cube_block: `cells` holds global vertex indices, while a row of `x` holds the coordinates of the point whose global index is that row plus the number of points on lower ranks. The two distributions are independent, so a rank's cells generally refer to vertices whose coordinates are held by other ranks, and create_mesh (and the geometric partitioners) fetch them. Add `redistribute_cells`, which moves a given fraction of each rank's cells to a random rank, and report the comparison for input that is locality preserving, half randomised and fully randomised. Partition quality turns out to be insensitive to the input distribution, whereas the cost of graph partitioning is not, which is what ParMETIS GeomKway addresses. Report a mesh creation time alongside the quality measures, since otherwise the shuffle has no visible effect. The demo mesh is small and the time covers all of mesh creation, so the prose is explicit that only a hint of the effect is visible at this size. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured on the dual graph of a structured tetrahedral mesh, strategy::speed is around 20% faster than SCOTCH's own default strategy with no measurable change in the number of cut edges (20 ranks, 3.1M cells: 0.88-0.99 s and 148.8k cut edges for strategy::none, 0.69-0.77 s and 149.5k for strategy::speed; 40 ranks, 6M cells: 2.36 s/329.6k versus 2.19 s/328.3k). Note in passing that strategy::quality is both slower and cuts more edges than the default for these graphs, and that the imbalance parameter does not affect the run time appreciably (under 5% over 0.025 to 0.2). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The effect of the input cell distribution on partitioning cost is visible only on a large mesh, so use n = 128 (12.6M tetrahedra). Reduce n when there is nothing to partition (a single rank) and in CI, where the demo must run quickly, following the pattern in demo_cahn-hilliard. Add a DEMO_PARTITION_N override, as the full size is slow on few ranks. At n = 128 on 20 ranks the crossover that ParMETIS GeomKway exists for is clear: with locality-preserving input Kway is faster than GeomKway (2.35 s versus 2.58 s), whereas with a randomised input distribution GeomKway wins (4.78 s versus 5.99 s), Kway degrading 2.55x against GeomKway's 1.85x. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add graph::parmetis::repartitioner, which wraps ParMETIS_V3_AdaptiveRepart. Unlike a partitioner, it treats the current distribution of the graph as the current partition and computes a new partition that balances the load while limiting how many nodes have to move, which suits re-balancing a distributed mesh, e.g. after non-uniform refinement. It has the graph::partition_fn signature, so no new type is needed: the current partition of a node is the rank that holds it. This does mean the number of parts must equal the communicator size, which is checked. Note that PT-SCOTCH has no distributed equivalent to offer here. SCOTCH_dgraphRepart does not exist in PT-SCOTCH 7.0; its re-partitioning entry points (SCOTCH_graphRepart, SCOTCH_graphRemap) are sequential only, and would require gathering the whole dual graph onto one rank. Also remove the unused adaptive_repartition() and refine() helpers this replaces. They were never called, were written against an AdjacencyList interface that no longer exists, and passed vsize in the adjwgt argument of ParMETIS_V3_AdaptiveRepart. Measured on 20 ranks with 3.1M cells, starting from a deliberately skewed distribution (imbalance 3.5): re-partitioning rebalances to 1.014 while moving 74.5% of cells, against 81.8% for partitioning from scratch, and gives a 7% larger edge cut. The benefit is modest here because the starting distribution is a poor partition, so preserving it is worth little; the intended case of a good partition perturbed by refinement is not yet measured, and there is no unit test for this function yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Partition a randomly distributed mesh with the space-filling curve, which is insensitive to the input distribution, then re-partition the result with PT-SCOTCH, which now runs on a well-distributed input. This is the idea behind ParMETIS GeomKway, applied around any graph partitioner. On 20 ranks with 12.6M tetrahedra and a fully randomised input distribution, the time inside SCOTCH falls from 9.52 s to 2.25 s, a factor of 4.2, and the edge cut is slightly smaller (375596 against 385506). End-to-end mesh creation goes from 11.7 s to 7.1 s. Note that the reported total for the two-stage route is pessimistic: the first stage builds a complete intermediate mesh where only the cell destinations are needed, which is 3.2 s of the 7.1 s. Redistributing the cells without building a mesh is not possible through the public interface, so the demo pays for it. The second stage needs the first-stage cells in the original input vertex numbering, which the geometry's input_global_indices provides. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lint tools were not available in the environment used to develop the preceding commits, so these slipped through: - The GeometricPartitioningFunc alias declared the cell coordinates as npt.NDArray[np.floating], while the interface takes double only. Callable parameters are contravariant, so passing a partitioner from dolfinx.graph did not type check (mypy arg-type). - create_cell_partitioner was inserted at the head of three test import blocks rather than in sorted position (ruff I001). - A docstring line in demo_partition exceeded the doc line length (W505). Verified with the same invocations as the lint CI job: ruff check and ruff format over cpp/ and python/, gersemi, clang-format, and mypy over dolfinx, demo and test with the CI flags. All pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jørgen Schartum Dokken <dokken92@gmail.com>
Co-authored-by: Jørgen Schartum Dokken <dokken92@gmail.com>
…o garth/mesh-optimise-2
# Conflicts: # cpp/dolfinx/graph/partition.h # cpp/dolfinx/mesh/utils.cpp # cpp/dolfinx/mesh/utils.h # python/dolfinx/wrappers/dolfinx_wrappers/graph.h # python/dolfinx/wrappers/graph.cpp
redistribute_by_partitioner called the low-level partitioner without the cell_weights/edge_weights arguments added when main's weighted partitioner support was merged in, breaking the MPI demo test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Remove create_cell_partitioner, create_geometric_cell_partitioner and create_hybrid_cell_partitioner, and the CellPartitionFunction, GeometricPartitionFunction and HybridCellPartitionFunction type aliases, all now redundant with graph::partition_fn, graph::geom_partition_fn and graph::hybrid_partition_fn. The mesh dual graph is built once by the caller and passed to the partitioner as an AdjacencyList, rather than rebuilt inside it on every call. Also fixes the PR #4403 CI failure: GeometricPartitioner::__call__ and HybridPartitioner::__call__ in the Python bindings still passed a commg argument that a prior partition.h simplification had dropped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
redistribute_by_partitioner called GeometricPartitioner objects with the old (comm, nparts, commg, x) signature; commg was dropped when GeometricPartitioner::__call__ was updated to match graph::geom_partition_fn. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
model_to_mesh/read_from_msh's partitioner Callable type hint still described the pre-dual_graph-refactor CellPartitionFunction shape (cell_types, cells, max_facet_to_cell_links, ...), causing a mypy CI failure. Update to the current 6-argument graph::partition_fn shape (comm, nparts, dual_graph, cell_weights, edge_weights, ghosting). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extract split_and_build_node_disp from graph::parmetis::partitioner, repartitioner, geom_partitioner and geom_partitioner_kway, which all independently split comm (ParMETIS crashes on empty ranks) and built an identical node displacement array. Also drops the dead split_comm flag this made obsolete in partitioner. Use dolfinx::radix_sort, not std::ranges::sort, for the duplicate- vertex-index removal in mesh::impl::reorder_cells, matching the sort already used for the same idiom elsewhere in this refactor. Collapse GeometricPartitioner/HybridPartitioner into a single OpaquePartitioner<Fn> template with two aliases. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Fix stale ghosting doc comment on graph::geom_partition_fn. - Remove sfc.cpp's redundant partition_curve pass-through wrapper. - Widen create_box/create_rectangle/create_interval (and their impl::build_* helpers) to accept mesh::AnyCellPartitionFunction instead of only graph::partition_fn, so a geometric or hybrid partitioner can be used with the built-in mesh generators, not just create_mesh directly. Widen the matching Python bindings. - Drop the synthetic-argument padding in create_geom_partitioner_py/create_geometric_cell_partitioner: the Python geometric partitioner wrapper now matches graph::geom_partition_fn's natural 3-arg shape instead of being forced through the 5-arg hybrid-partitioner shape. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
hybrid_partition_fn was only used inside the HAS_PARMETIS-guarded geom_partitioner_parmetis_kway binding, so declaring it unconditionally tripped -Werror=unused-local-typedefs on builds without ParMETIS. Move the alias inside the #ifdef, next to its only use. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…sion - mesh::partition_cells: the partitioner is only invoked on ranks with commt != MPI_COMM_NULL (a possibly strict subset of comm, e.g. cells built on rank 0 only for create_interval/create_rectangle). A partitioner such as graph::parmetis::geom_partitioner, which requires nparts to equal the number of ranks calling it, can then throw on only those ranks, leaving the rest of comm blocked forever on the graph::build::distribute collective that follows. Catch the partitioner call and turn a data-dependent failure into a comm-wide decision via MPI_Allreduce before any rank proceeds. - refinement::uniform_refine lost its ghost_mode parameter when its partitioner argument was simplified from mesh::CellPartitionFunction (which bundled ghost mode) to a bare graph::partition_fn, silently hardcoding GhostMode::none regardless of what partitioner was supplied. Add an explicit ghost_mode parameter, mirroring refine(), and thread it through to create_mesh. Update the Python binding and pure-Python wrapper to match. - Fix stale "throws if ghosting true" doc comments on geom_partitioner and sfc::partitioner: graph::geom_partition_fn has no `ghosting` parameter at all. - Factor the repeated "does this AnyCellPartitionFunction hold a callable" std::visit check into mesh::has_partitioner. - Add missing <vector> include to graph/sfc.h (IWYU). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AdjacencyList._cpp_object is typed as a 3-way Union regardless of the Generic[Index] parameter, so mypy cannot narrow graph.adjacencylist(... dtype=np.int32)._cpp_object down to AdjacencyList_int32, even though that's the only variant it can be at runtime. Silence with the same type: ignore[arg-type] pattern already used elsewhere in graph.py for this class's untyped _cpp_object. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- mesh::partition_cells: merge the bool failed + int local_failed translation step into a single int failed, passed directly to MPI_Allreduce. - graph/partitioners.cpp: the comm-split half of partitioner(), repartitioner() and geom_partitioner_kway() was deduplicated into split_and_build_node_disp earlier in this PR, but each still repeated an identical "ghost, free pcomm, return" block afterwards. Factor it into finalise_partition, alongside split_and_build_node_disp. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A deadlocked demo (e.g. an MPI collective mismatch, or the documented demo_navier-stokes.py MUMPS hang) currently blocks the "Run Python demos" CI step for hours until the runner's own job timeout kicks in, with no indication of which demo is responsible. subprocess.run's timeout raises TimeoutExpired naming the command, giving a fast, attributable failure instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both demos used to build their gmsh-generated mesh with a partitioner built via _create_cell_partitioner_from_ghost_mode(GhostMode.shared_facet, 2), which bundled the ghost mode into the partitioner itself. The "Simplify interfaces" refactor moved to the plain create_cell_partitioner(), which carries no ghost mode -- that's now controlled separately by model_to_mesh's own ghost_mode argument, which defaults to GhostMode.none. Neither demo was updated to pass shared_facet explicitly, so both silently lost ghosting. For demo_axis.py this is a real bug, not just a quality regression: it computes an interior facet integral (dS(scatt_tag)) at line ~717, which requires shared_facet ghosting. Without it, only ranks whose local facet happens to be an inter-process facet raise "Cannot compute interior facet integral over interprocess facet"; the remaining rank(s) sail past that line and block forever on the next collective in a later call, hanging CI indefinitely (see https://github.com/FEniCS/dolfinx/actions/runs/33060291735/job/98477154522, reproduced locally as a subprocess.TimeoutExpired after the demo test timeout added in 71af260). demo_pml.py has no interior facet integral, so it degrades silently rather than hanging, but restoring shared_facet there too matches its original, evidently intentional, ghosting choice. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
It was a thin one-liner (call create_partitioner_cpp, or return nullptr if empty) whose null-check was already duplicated at every call site via has_value()/ternary guards. Call create_partitioner_cpp directly instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… directly Since ghost mode and the dual graph are now controlled at create_mesh call time rather than bundled into the partitioner, both overloads of create_cell_partitioner had degenerated into pure identity/pass-through wrappers: create_cell_partitioner() just returned the default graph partitioner (dolfinx::graph::partition_graph, already exposed as dolfinx.graph.partitioner()), and create_cell_partitioner(part) did nothing but round-trip an already Python-callable graph partitioner through C++ and back. Remove the Python function and its two C++ nanobind bindings (and the now-unused create_cell_partitioner_py wrapper they were the only caller of), and update every call site to pass a graph partitioner -- dolfinx.graph.partitioner(), partitioner_scotch(), etc. -- directly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Match the level of detail already given to its siblings create_geom_partitioner_py/create_hybrid_partitioner_py: state which dolfinx::graph type it wraps and how its signature differs from theirs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds space-filling curve (SFC) and hybrid SFC/graph partitioning, so that mesh construction cost can stay roughly flat as the rank count grows, instead of scaling with the (increasingly expensive) cost of graph partitioning. Also includes a general mesh-construction performance pass, a ParMETIS re-partitioner for rebalancing, and
demo_partition.py, a new demo comparing all available partitioners on the same input under varying degrees of input-distribution randomness.What's new
Geometric and hybrid partitioning types (
graph/partition.h)graph::partition_fn— graph-only partitioners (required dual graph, no coordinates).graph::geom_partition_fn— partitioners that use node positions and optionally the graph (e.g. for ghosting only); the graph argument isstd::optional.graph::hybrid_partition_fn— partitioners that need both the graph and node positions unconditionally, because the graph edges are part of the partitioning decision itself, not only used for ghosting.Space-filling curve partitioning (
graph/sfc.h{cpp})graph::partition_sfc_morton/partition_sfc_hilbert: partition points along a Morton or Hilbert curve. No external library required. Cost is nearly independent of rank count, at the price of a larger edge cut than graph partitioning.graph::sfc::partitioner: wraps either curve as ageom_partition_fn.ParMETIS additions (
graph/partitioners.h{cpp})graph::parmetis::geom_partitioner(ParMETIS_V3_PartGeom) andgeom_partitioner_kway(ParMETIS_V3_PartGeomKway, ahybrid_partition_fnsince it uses the graph edges as well as the coordinates).graph::parmetis::repartitioner(ParMETIS_V3_AdaptiveRepart): treats the current data distribution as the current partition and computes a new one that balances load while limiting how many nodes move — suited to rebalancing after non-uniform refinement, as opposed to partitioning from scratch.strategy::speed(see performance data below).Mesh-level wrappers (
mesh/partition.h{cpp})mesh::create_geometric_cell_partitioner: computes cell centroids and applies ageom_partition_fn; builds the mesh dual graph only when ghosting is requested (skipped otherwise, since a purely geometric partitioner does not need it for the partitioning decision itself).mesh::create_hybrid_cell_partitioner: as above but for ahybrid_partition_fn— always builds and supplies the dual graph, since these partitioners need it unconditionally.mesh/utils.h{cpp}into their own files.create_meshperformance and structuremesh::impl::partition_cells, shrinkingcreate_meshto a clearer sequence of named steps.demo_partition.pyGeomKwayembodies, but applicable around any graph partitioner.Performance data
create_meshcopy/sort removal: 4.29 s → 3.48 s, 6M tetrahedra, serial, Release build.strategy::speedvs. default: ~20% faster with no measurable change in cut edges (20 ranks/3.1M cells: 0.88–0.99 s → 0.69–0.77 s, 148.8k → 149.5k cut edges; 40 ranks/6M cells: 2.36 s → 2.19 s, 329.6k → 328.3k cut edges).strategy::qualitywas measured slower and worse (more cut edges) than the default.Scaling data (
demo_partition.py)Rank-count scaling, fixed mesh, fully randomised input
82,944 tetrahedra,
fraction=1.0(fully randomised). Time in seconds; edge cut in parentheses:(At 1 rank there is nothing to cut, hence 0.) At this mesh size, absolute times are dominated by fixed per-call overhead rather than by the cost of partitioning itself, so this mainly demonstrates correct behaviour at low rank counts. It is not a demonstration of asymptotic scaling — see below and the two-stage numbers in Performance data above for where the scaling advantage of the SFC/hybrid approach actually shows up.
Problem-size scaling at fixed rank count (20 ranks, fully randomised input)
At fixed rank count, all partitioners scale roughly linearly with cell count over this range (3.3–3.65x time for a 3.4x increase in cells) — none is dramatically worse than linear here. PT-SCOTCH remains the most expensive in absolute terms at both sizes, by a wide margin; the two-stage route is consistently faster than PT-SCOTCH alone at both sizes (7.96 s vs. 11.82 s, and 26.19 s vs. 41.02 s) and also has the lowest time ratio of the seven columns (3.29x vs. PT-SCOTCH alone's 3.47x), i.e. it scales somewhat more mildly with problem size here, not just faster in absolute terms — consistent with the SFC stage taming PT-SCOTCH's own scale-sensitive coarsening cost rather than only offering a one-off saving. The rank-count-independent cost advantage that motivates the SFC/hybrid approach more broadly is not fully visible in a same-rank-count comparison like this one, and would be expected to grow more pronounced at higher rank counts than tested here.
Known follow-up
Redistributing cells for the two-stage comparison currently goes through building a full intermediate mesh, most of whose cost (topology, geometry, ghost cells) is wasted since only the cell destinations are needed before the second stage. A
redistribute_by_partitionerhelper that calls the partitioner directly and exchanges only the cell rows was prototyped in the demo and measured at roughly half the cost of the full-mesh route for this step, but is not yet part of the public API.Filed #4402: for mixed-topology meshes, cell redistribution currently pays for one NBX neighbourhood-discovery round per cell type; the destination rank sets for different cell types typically overlap substantially, so this is a candidate for future optimisation. Does not affect single-cell-type meshes, which are unaffected (one cell type, one round).
Testing
C++ unit tests pass on 1, 2 and 3 ranks; Python test suite passes;
demo_partition.pyruns via the demo test harness across multiple rank counts and mesh sizes, including a 42.5M-tetrahedron mesh on 20 ranks.