Gmsh Mesh Partitioning¶
Domain decomposition: how Gmsh partitions meshes, the METIS graph partitioner, partition entities, ghost elements, and the pipeline to parallel OpenSees.
What Partitioning Is¶
Partitioning divides a mesh into $N$ non-overlapping subdomains (partitions). Each element belongs to exactly one partition. The goal is twofold:
- Load balancing — each partition should have roughly the same computational cost (element count, or weighted element count)
- Minimal interface — the boundary between partitions (shared nodes/faces) should be as small as possible, since inter-partition communication is expensive in parallel solvers
Original mesh Partitioned (N=3)
┌─────────────────────┐ ┌───────┬──────┬──────┐
│ │ │ │ │ │
│ │ ──► │ P1 │ P2 │ P3 │
│ │ │ │ │ │
└─────────────────────┘ └───────┴──────┴──────┘
↑ ↑
interfaces (shared nodes)
Partitioning is a post-meshing operation. You generate the full mesh first, then partition it.
METIS Graph Partitioner¶
Gmsh uses METIS as its primary partitioning algorithm. METIS converts the mesh into a graph (elements → nodes, adjacency → edges) and partitions the graph to minimize the edge cut while balancing node weights.
The mesh-to-graph conversion¶
Each mesh element becomes a graph node. Two graph nodes are connected by an edge if the corresponding elements share a face (3D) or an edge (2D). The weight of a graph node can be the computational cost of the element.
Mesh elements: Graph:
┌─────┬─────┐ (e1)───(e2)
│ e1 │ e2 │ │ │
├─────┼─────┤ → (e3)───(e4)
│ e3 │ e4 │
└─────┴─────┘
METIS then partitions this graph into $N$ subsets, minimizing the number of edges that cross partition boundaries (the "edge cut").
Two algorithms¶
| Option | Code | Method | Best for |
|---|---|---|---|
Mesh.MetisAlgorithm |
1 |
Recursive bisection | Small $N$ (< 8), quality focus |
Mesh.MetisAlgorithm |
2 |
K-way | Large $N$, speed focus |
Recursive bisection splits the graph in half, then splits each half, recursively. Produces high-quality partitions but cost grows with $N$.
K-way partitions directly into $N$ parts in a single pass. Faster for large $N$, slightly lower quality.
Objective function¶
METIS can optimize for two objectives:
gmsh.option.setNumber("Mesh.MetisObjective", 1)
# 1 = minimize edge-cut (default) — fewer shared faces between partitions
# 2 = minimize communication volume — total data exchanged between partitions
Edge-cut counts the number of edges crossing partition boundaries. Communication volume considers that a node shared by $k$ partitions requires $k-1$ communications, and minimizes the total. Communication volume is usually the better metric for parallel FEM, but edge-cut is the default and works well for most structural problems.
Load balancing weights¶
By default, all elements have equal weight. You can assign per-element-type weights to account for different computational costs:
# Hex elements cost more than tets (more integration points, larger stiffness matrix)
gmsh.option.setNumber("Mesh.PartitionHexWeight", 8)
gmsh.option.setNumber("Mesh.PartitionTetWeight", 4)
gmsh.option.setNumber("Mesh.PartitionTriWeight", 2)
gmsh.option.setNumber("Mesh.PartitionQuadWeight", 3)
gmsh.option.setNumber("Mesh.PartitionLineWeight", 1)
gmsh.option.setNumber("Mesh.PartitionPrismWeight", 6)
gmsh.option.setNumber("Mesh.PartitionPyramidWeight", 5)
# -1 = automatic (default)
Load imbalance tolerance¶
gmsh.option.setNumber("Mesh.MetisMaxLoadImbalance", -1)
# -1 = default (30 for K-way, 1 for Recursive)
# Higher values allow more imbalance in exchange for lower edge-cut
Additional METIS controls¶
gmsh.option.setNumber("Mesh.MetisEdgeMatching", 2)
# 1 = Random, 2 = Sorted Heavy-Edge (default) — coarsening strategy
gmsh.option.setNumber("Mesh.MetisMinConn", -1)
# -1 = default — minimize maximum connectivity of partitions
gmsh.option.setNumber("Mesh.MetisRefinementAlgorithm", 2)
# 1 = FM-based cut refinement, 2 = Greedy (default), 3 = Two-sided node FM
Partition Entities¶
After partitioning, Gmsh creates new discrete entities called partition entities. This is the key design decision: partitioning doesn't just label elements — it restructures the model topology.
Each partition entity:
- Has its own (dim, tag) — a regular entity in the model
- Belongs to one or more partitions (interface entities belong to multiple)
- Tracks its parent entity — the original entity it was carved from
- Has a full BRep boundary representation (if PartitionCreateTopology = 1)
gmsh.model.mesh.partition(3)
# The model now has new entities
entities = gmsh.model.getEntities()
for e in entities:
partitions = gmsh.model.getPartitions(e[0], e[1])
if len(partitions) > 0:
parentDim, parentTag = gmsh.model.getParent(e[0], e[1])
print(f"Entity {e} → partitions {partitions}, parent ({parentDim}, {parentTag})")
Partition topology¶
With PartitionCreateTopology = 1 (default), Gmsh creates a full BRep for the partitioned model:
- Partition volumes — each partition's portion of the original volume
- Partition surfaces — interior surfaces of a partition + interface surfaces shared between partitions
- Partition curves — edges of partition surfaces
- Partition points — vertices
Interface entities (surfaces between two partitions) belong to both partitions. This is how shared nodes are identified.
Original model: Partitioned (N=2):
┌───────────────┐ ┌───────┬───────┐
│ │ │ │ │
│ Volume (3,1) │ ──► │ (3,5) │ (3,6) │ ← partition volumes
│ │ │ P1 │ P2 │
└───────────────┘ └───────┴───────┘
│
(2,10) ← interface surface
partitions=[1,2] belongs to both
Configuration options¶
gmsh.option.setNumber("Mesh.PartitionCreateTopology", 1)
# 1 (default) — create full BRep for partition entities
# 0 — skip topology creation (lighter, but no boundary queries)
gmsh.option.setNumber("Mesh.PartitionCreatePhysicals", 1)
# 1 (default) — create physical groups for partitions based on existing PGs
# 0 — don't create partition physical groups
gmsh.option.setNumber("Mesh.PartitionSplitMeshFiles", 0)
# 0 (default) — write everything to one file
# 1 — write one .msh file per partition
Querying partition entities¶
# Get all entities and filter for partitioned ones
for dim, tag in gmsh.model.getEntities():
parts = gmsh.model.getPartitions(dim, tag)
if len(parts) == 0:
continue # not a partition entity
parentDim, parentTag = gmsh.model.getParent(dim, tag)
entity_type = gmsh.model.getType(dim, tag)
boundary = gmsh.model.getBoundary([(dim, tag)])
if len(parts) == 1:
# Interior entity — belongs to one partition
print(f"Interior: ({dim},{tag}) in P{parts[0]}, parent ({parentDim},{parentTag})")
else:
# Interface entity — shared between partitions
print(f"Interface: ({dim},{tag}) shared by P{parts}, parent ({parentDim},{parentTag})")
Ghost Elements¶
Ghost elements are copies of elements near partition boundaries. They belong to a neighboring partition but are stored as "ghosts" in the local partition so that each processor has the information it needs for assembly without communication.
Partition 1 Partition 2
┌──────────┐ ┌──────────┐
│ │ │ │
│ e1 e2 │ e3 e4 e3 e4 │ e5 e6 │
│ │ │ │
└──────────┘ └──────────┘
^^^^ ^^^^
ghost in P2 ghost in P1
Elements e3 and e4 are in the boundary region. Partition 1 owns them, but Partition 2 gets ghost copies (and vice versa for e3/e4 in P1's perspective).
Enabling ghost cells¶
Ghost cells are off by default. Enable them before partitioning:
Querying ghost elements¶
After partitioning with ghost cells, ghost entities appear in the model. Query them:
for dim, tag in gmsh.model.getEntities():
ghostTags, ghostPartitions = gmsh.model.mesh.getGhostElements(dim, tag)
if len(ghostTags) > 0:
print(f"Entity ({dim},{tag}) has {len(ghostTags)} ghost elements")
for etag, part in zip(ghostTags, ghostPartitions):
print(f" Element {etag} is ghost from partition {part}")
Why ghost elements matter¶
In parallel FEM, each processor assembles its local stiffness matrix. For elements adjacent to the partition boundary, the processor needs information about neighboring elements to:
- Correctly assemble shared DOFs
- Compute inter-element quantities (stress recovery, error estimation)
- Apply flux-based boundary conditions at partition interfaces
Without ghost elements, this information requires explicit communication. With ghost elements, each processor has a local copy of the boundary layer and can assemble without waiting.
Interface Handling¶
The partition interface is where the subdomains meet. Shared nodes at the interface need special treatment in parallel solvers.
Identifying interface nodes¶
Interface nodes are classified on partition entities that belong to multiple partitions:
interface_nodes = set()
for dim, tag in gmsh.model.getEntities():
parts = gmsh.model.getPartitions(dim, tag)
if len(parts) > 1: # interface entity
nodeTags, _, _ = gmsh.model.mesh.getNodes(dim, tag)
interface_nodes.update(int(t) for t in nodeTags)
print(f"Interface nodes: {len(interface_nodes)}")
Identifying interface elements¶
Elements on the interface are those with at least one node on an interface entity:
# Alternatively, use getBoundary on partition volumes to get interface surfaces
for dim, tag in gmsh.model.getEntities():
parts = gmsh.model.getPartitions(dim, tag)
if len(parts) == 1 and dim == 3: # interior partition volume
boundary = gmsh.model.getBoundary([(dim, tag)])
for bdim, btag in boundary:
bparts = gmsh.model.getPartitions(bdim, btag)
if len(bparts) > 1:
print(f"Partition {parts[0]}: interface surface ({bdim},{btag}) "
f"shared with partitions {bparts}")
Partition boundary data for solver setup¶
For parallel OpenSees, you need to know at each partition boundary: - Which nodes are shared - Which partitions share them - Which DOFs need to be communicated
This can be extracted by iterating partition entities with len(partitions) > 1.
Manual Partitioning¶
Instead of METIS, you can assign elements to partitions explicitly:
The elementTags and partitions arrays must be the same length. Each element is assigned to the specified partition.
Use cases¶
- Physics-based partitioning — separate soil and structure into different partitions for different solver strategies
- Manual load balancing — when element cost varies significantly and METIS weights aren't sufficient
- Predefined substructuring — when the domain decomposition follows a specific engineering logic (e.g., one partition per floor of a building)
Combining with physical groups¶
A practical pattern: use physical groups to define regions, then assign partitions based on region membership:
# Assume physical groups define structural regions
soil_entities = gmsh.model.getEntitiesForPhysicalGroup(3, soil_pg_tag)
struct_entities = gmsh.model.getEntitiesForPhysicalGroup(3, struct_pg_tag)
# Collect element tags per region
soil_elems = []
for tag in soil_entities:
_, etags, _ = gmsh.model.mesh.getElements(3, tag)
for arr in etags:
soil_elems.extend(arr)
struct_elems = []
for tag in struct_entities:
_, etags, _ = gmsh.model.mesh.getElements(3, tag)
for arr in etags:
struct_elems.extend(arr)
# Assign partitions
all_tags = soil_elems + struct_elems
all_parts = [1]*len(soil_elems) + [2]*len(struct_elems)
gmsh.model.mesh.partition(2, elementTags=all_tags, partitions=all_parts)
MSH4 Partitioned Format¶
The MSH4 file format natively supports partitioned meshes. When you write a partitioned mesh, the file contains additional sections.
$Entities section — partition entities¶
Partition entities appear alongside regular entities, with additional fields:
$Entities
numPoints numCurves numSurfaces numVolumes
...
$PartitionedEntities
numPartitions numGhostEntities
ghostEntityDim ghostEntityTag ghostPartition ...
numPoints numCurves numSurfaces numVolumes
pointTag parentDim parentTag numPartitions partitions... ...
...
$EndPartitionedEntities
Each partition entity records:
- Its own (dim, tag)
- The parent entity it came from (parentDim, parentTag)
- The partition(s) it belongs to
$Elements section — partition assignment¶
Elements are grouped by entity (including partition entities). Since each partition entity belongs to a specific partition, the element-to-partition mapping is implicit through the entity classification.
Split files¶
With Mesh.PartitionSplitMeshFiles = 1, Gmsh writes one .msh file per partition:
model_1.msh ← partition 1 elements + shared interface nodes
model_2.msh ← partition 2 elements + shared interface nodes
model_3.msh ← partition 3 elements + shared interface nodes
Each file is a self-contained mesh for its partition, including the interface nodes (duplicated across files). This is the format most parallel solvers expect.
Unpartitioning¶
To restore the original (unpartitioned) model:
OpenSees Parallel Pipeline¶
OpenSees provides two parallel frameworks: OpenSeesSP (single-interpreter, parallel equation solving) and OpenSeesMP (multiple interpreters, domain decomposition). Gmsh partitioning maps to both, but the workflow differs.
OpenSeesSP¶
In OpenSeesSP, the model is built on all processors, but the equation system is solved in parallel. The partitioning is handled internally by OpenSees — you don't typically use Gmsh partitioning here.
However, Gmsh partitioning can still be useful for pre-computing the domain decomposition and passing it to OpenSees via the numberer, to ensure the partitioning matches your engineering intent.
OpenSeesMP — the primary use case¶
In OpenSeesMP, each processor builds and owns a portion of the model. This is where Gmsh partitioning directly maps:
Gmsh partition 1 → Processor 0: builds elements + nodes for P1
Gmsh partition 2 → Processor 1: builds elements + nodes for P2
Gmsh partition N → Processor N-1: builds elements + nodes for PN
The workflow:
# Step 1: Partition in Gmsh (pre-processing, single process)
gmsh.model.mesh.generate(3)
gmsh.model.mesh.partition(num_processors)
# Step 2: Extract per-partition data
for proc in range(num_processors):
partition_id = proc + 1
# Find partition entities for this processor
nodes_for_proc = {}
elements_for_proc = []
for dim, tag in gmsh.model.getEntities():
parts = gmsh.model.getPartitions(dim, tag)
if partition_id in parts:
# Get nodes
ntags, ncoords, _ = gmsh.model.mesh.getNodes(dim, tag)
coords = ncoords.reshape(-1, 3)
for i, nt in enumerate(ntags):
nodes_for_proc[int(nt)] = coords[i]
# Get elements (only from entities owned by this partition)
if len(parts) == 1: # interior entity
etypes, etags, enodes = gmsh.model.mesh.getElements(dim, tag)
# ... collect elements
# Step 3: Write per-partition OpenSees script or data file
write_opensees_partition(proc, nodes_for_proc, elements_for_proc)
Interface nodes in OpenSeesMP¶
Shared nodes at partition boundaries need special handling. In OpenSeesMP, interface nodes exist on multiple processors. The framework uses send/recv or the Parallel domain to synchronize DOFs at shared nodes.
The critical data to extract from Gmsh:
# For each partition, identify which of its nodes are shared
shared_node_map = {} # node_tag → set of partition IDs
for dim, tag in gmsh.model.getEntities():
parts = gmsh.model.getPartitions(dim, tag)
if len(parts) > 1: # interface entity
ntags, _, _ = gmsh.model.mesh.getNodes(dim, tag)
for nt in ntags:
nt = int(nt)
if nt not in shared_node_map:
shared_node_map[nt] = set()
shared_node_map[nt].update(parts)
This map tells each processor which nodes it shares with which neighbors — essential for setting up the parallel communication pattern.
sendSelf / recvSelf¶
In OpenSees, every element and material implements sendSelf() and recvSelf() for parallel communication (see the OpenSees skill for details on classTags.h and FEM_ObjectBroker). The partitioning determines what gets sent where:
- Elements are created only on their owning processor
- Nodes at interfaces are created on all sharing processors
- Materials are local to each element (no sharing)
- Loads applied to interface nodes must be applied on all sharing processors (or on one and communicated)
Boundary conditions at partition interfaces¶
Boundary conditions on shared nodes need care:
- Displacement BCs (fix, fixX, etc.) — apply on all processors that own the node
- Loads — apply on one processor only (to avoid double-counting), or split proportionally
- Constraints (equalDOF, rigidLink) — both constrained and retained nodes must be on the same processor, or the constraint must span the interface using MP_Constraint
Practical recommendations for apeGmsh¶
- Partition after physical groups — PGs define material regions and BCs; partition within these regions, not across them when possible
- Use
PartitionCreatePhysicals = 1— this automatically creates partition-level physical groups from the original PGs, preserving the material/BC labels per partition - Extract interface data explicitly — don't rely on ghost cells for OpenSees; extract shared nodes and their partition membership directly
- Write per-partition input files — each processor reads its own file, which is simpler than having all processors parse a global file
- Validate partition quality — check that each partition has roughly equal element count and that the interface area is reasonable:
for p in range(1, N+1):
elem_count = 0
for dim, tag in gmsh.model.getEntities():
parts = gmsh.model.getPartitions(dim, tag)
if parts == [p] and dim == 3:
_, etags, _ = gmsh.model.mesh.getElements(dim, tag)
elem_count += sum(len(e) for e in etags)
print(f"Partition {p}: {elem_count} elements")
SimplePartition Plugin¶
Besides METIS, Gmsh offers a SimplePartition plugin for geometric (non-graph-based) partitioning:
gmsh.plugin.setNumber("SimplePartition", "NumSlicesX", 4)
gmsh.plugin.setNumber("SimplePartition", "NumSlicesY", 2)
gmsh.plugin.setNumber("SimplePartition", "NumSlicesZ", 1)
gmsh.plugin.run("SimplePartition")
This creates axis-aligned slices — a chessboard-like partition. No graph analysis, no optimization. Useful for:
- Simple geometries where spatial partitioning is obvious
- Debugging partitioning workflows (predictable, visual results)
- Cases where METIS isn't available
Not recommended for production structural analysis — it ignores element connectivity and can create poorly balanced partitions for irregular meshes.
Summary — Partitioning Data Flow¶
Mesh (unpartitioned)
│
│ gmsh.model.mesh.partition(N)
│ (METIS graph partitioning or manual assignment)
▼
Partition entities created
│
├── Interior entities: (dim, tag) → partitions=[p]
│ └── Elements owned exclusively by partition p
│
├── Interface entities: (dim, tag) → partitions=[p1, p2, ...]
│ └── Shared nodes at partition boundary
│
└── Ghost entities (optional): (dim, tag) → ghost elements from neighbor
│
│ Extract per-partition data
▼
Per-processor:
├── Nodes: own + shared interface nodes
├── Elements: own only (no ghosts for solver)
├── Shared nodes: node_tag → [partitions that share it]
├── BCs/Loads: from physical groups (partition-level PGs)
└── Materials: from physical groups (partition-level PGs)
│
│ Write per-partition solver input
▼
OpenSeesMP: each processor reads its partition file