Skip to content

Commit 1047986

Browse files
committed
Fault networks in parallel: the design note, the layout throughput test, and the gather's growth as a parameter (#670, #671)
docs/developer/design/fault-parallel-placement-2026-09.md is the governing note for parallel placement: the adapt-on-top frame, the three parallel strategies in the stack, what the gather moves and why, the measurements that showed it moving far too much, what PR #672 changed, the decision on long faults (faults as distributable objects, cut in the CAD along strike when one is too long for a rank, the strip along a cut plane gathered before the split), the rulings not to rebalance the child and to pre-refine the base, and what remains open. It is linked from the developer index (toctree and authority map), the conforming-surfaces subsystem note and the fault-networks page. The throughput test beside it holds everything fixed but where the faults sit relative to the seams: a 6 x 1 x 1 box at np=3, three faults one per slab, then one moved onto a seam, then all forced into one region, plus serial and GAMG baselines. Measured: per-region placement balances the mesh to 6% and gives a warm solve 1.8x serial where the old single gather gave 1.0x; one straddling fault costs 15%; the answer is layout-independent to four digits; and on this contact fixture the pressure Schur solve caps at 200 iterations in every configuration, so wall time is the pressure block's and GAMG beats the tail in wall time despite 26 velocity iterations against 4. _gather_regions takes the growth beyond the star as ``layers``. The placement was tried at ``layers=0`` (the star alone) and its own gate refused at np=2, 3 and 4: the carve drops cells beyond the marked vertices' star. Both the placement and the split keep one layer, and the note records the measurement. Underworld development team with AI support from Claude Code
1 parent 0d5b8bb commit 1047986

7 files changed

Lines changed: 416 additions & 20 deletions

File tree

docs/advanced/fault-networks.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,8 +292,12 @@ gather-first, so the cells gmsh fills into the carved cavity — the
292292
band and its graded surround — live on one rank; only the base's far
293293
field is balanced. On the crossing-patches fixture that is 8012 of
294294
8405 cells on one rank at np=4, and the solve is not faster than
295-
serial: parallel is a correctness mode for this path, not a speed-up,
296-
until the placed region is rebalanced.
295+
serial. Since PR #672 the gather is per region: each zone, or
296+
junction-connected cluster of zones, is placed and split by the rank that
297+
holds it, and a zone whose shell is already interior to a rank is not
298+
moved at all; a single long fault is still one rank's. The design and its
299+
measurements are in the developer note
300+
`fault-parallel-placement-2026-09.md`.
297301

298302
## Limitations
299303

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
# Fault networks in parallel: what moves, why, and the decision (September 2026)
2+
3+
This note records how the fault-network machinery behaves in parallel,
4+
the measurements that established it, the design decision taken with
5+
Louis on 2 September 2026, and the test that keeps the numbers honest.
6+
It is the governing document for the parallel placement of faults; the
7+
subsystem description is `../subsystems/conforming-surfaces-and-fault-zones.md`
8+
and the multigrid side is `fault-patch-multigrid-2026-08.md`. Issues:
9+
#670 (the gather), #671 (the tail), PRs #669 and #672.
10+
11+
## The frame: adapt-on-top
12+
13+
The base mesh is the persistent, distributed object. It holds the bulk
14+
fields, it is refined in place by newest-vertex bisection co-partitioned
15+
with itself, and its coarse levels form the multigrid tail. A fault
16+
network is a *child* of that base: the band is placed into the base by
17+
surgery, the faults are split on the placed mesh, and the child is
18+
solved. The child is ephemeral. It is rebuilt when the base is adapted
19+
or rebalanced, and it carries no state of its own: the fault's history
20+
lives on the `FaultSurface` object, the bulk state on the base.
21+
22+
Two consequences follow. The child does not have to inherit the base's
23+
partition, only the two transfers must be partition-agnostic: bulk
24+
fields base to child and back, and the multigrid tail from the base's
25+
coarse levels to the child's finest. And the accepted trade for a fault
26+
is **extra load on the rank that owns it, in exchange for no
27+
communication** while it is solved.
28+
29+
## Three parallel strategies coexist in the stack
30+
31+
1. **The edge cut** (`line_cut`, 2-D): fully distributed. Each rank
32+
splits its own edges where the surface crosses them; every crossing
33+
is a pure function of the coordinates and the surface, every
34+
tolerance is a global length, and nothing moves.
35+
2. **Placement** (`place_sheet`, `place_thin_volume`, the 3-D band):
36+
*gather-first*. Placement deletes the base vertices in the way and
37+
asks gmsh to refill the cavity with the band's own mesh embedded in
38+
it. gmsh is serial and a cavity cannot be carved or filled across a
39+
partition seam, so the region around the band is moved onto one rank,
40+
the surgery runs there, and every rank rebuilds its chart
41+
collectively. The result is partition-independent by construction.
42+
3. **The split** (contact): redistribute the fault's thin cell star onto
43+
one rank, then split with serial topology, so every cut pair is born
44+
on one rank.
45+
46+
What the gather moves is **base mesh, never the fault**: the band is
47+
generated from a few patch coordinates on the surgery rank. The seam
48+
rule that forces the move is that no point the surgery deletes or
49+
creates may be shared, because the rebuild carries the old star forest
50+
over by renumbering. That needs exactly three layers of base cells on
51+
one rank: the cells the carve drops, their vertex star (the ring the
52+
fill attaches to), and one more layer so the ring's own points are
53+
unshared. A shell about three cells thick around the band. The third
54+
layer was tested for necessity: with the gather stopped at the star, the
55+
placer's own gate ("the gathered region touches a shared point; the
56+
gather mask under-reached") fires at np=2, 3 and 4 on the thin-volume
57+
suite, so the carve drops cells beyond the marked vertices' star and the
58+
layer stays. Two shells therefore touch at about six base cells'
59+
separation, and two zones closer than that are one region.
60+
61+
## What was wrong, measured
62+
63+
All on the crossing-patches fixture of `test_0863` (unit cube, far-field
64+
cells of about 0.126 after one refinement, band cells 0.08, width 0.04)
65+
unless stated; the wide box is the same patches in `[-0.5, 1.5]^3`.
66+
67+
**The fill is small; the gather was not.** The base has 5592 cells, the
68+
placed mesh 8405, of which 1663 are band: the fill adds about 1150 cells,
69+
all within a cell of the patches. But the gather moved 5087 of the 5592
70+
base cells onto one rank before any surgery, and at np=4 the surgery
71+
rank ended with 8012 of 8405 cells. The mark reached two cell widths
72+
beyond the carve *and then* `_gather_region` grew the star and the layer
73+
from it: the margin was paid twice.
74+
75+
| region on the base | cells | share |
76+
|---|---|---|
77+
| carve reach | 334 | 6% |
78+
| reach + 1 cell (the dropped cells) | 1313 | 23% |
79+
| reach + 2 cells (the old mark) | 3486 | 62% |
80+
| old mark + star + layer | 5592 | 100% |
81+
82+
The unit cube is four far-field cells across, so three layers from any
83+
band reach the walls: **that fixture measures correctness and can never
84+
show balance**. On the wide box the same patches gather 9831 cells under
85+
the old mark and 5046 under the new one (CROSS patches; 8961 and 4196 for
86+
the P_A/P_B pair), identical at np=2 and np=4.
87+
88+
**One target for the whole network, and no return.** The placer took the
89+
network as one assembly and gathered the union of every patch's region to
90+
the single rank holding the most of it; two faults each interior to a
91+
different rank were both moved to one of them. The moved cells stayed
92+
there. The extra load therefore landed on the rank that was already
93+
heaviest in that neighbourhood — anti-balanced — and was paid for with a
94+
redistribution: neither half of the accepted trade.
95+
96+
## What was done (PR #672)
97+
98+
- **The mark covers only what the carve drops** (victims plus the crossed
99+
cells' vertices, one cell diameter out), at every placement path. The
100+
gather's own star-and-layer growth supplies the seam freedom.
101+
- **Regions.** `_gather_regions` marks a chart of region ids, claims each
102+
region's star and layer, merges regions whose shells touch (a collective
103+
union-find), and sends each to the rank already holding most of it — or
104+
leaves it where it is when it is interior to one rank. One shell
105+
partition moves them all.
106+
- **Per-region surgery.** One region per connected component of the
107+
assembly; the owning ranks carve and fill their own components
108+
concurrently, each on its compacted share of the assembly and skin, and
109+
the collective rebuild sews them at once. Outcrop and ladder paths keep
110+
one region (their bowl, cap and extrusion are single-rank).
111+
- **The split follows the regions.** `split_faults(..., groups=...)`
112+
redistributes per group through the same gather; the network passes it
113+
the regions the placement reported (`info["embedded_regions"]`).
114+
- **The tail on a distributed band** (#671). The rotated prolongation
115+
zeroed its constrained rows by *local* block index where PETSc's
116+
`zeroRows` takes global ones, so every rank but the first zeroed the
117+
wrong rows; that is the "new nonzero caused a malloc" in `PCSetUp_MG`
118+
(PETSc repairs a zero Galerkin row with an identity diagonal that was
119+
never allocated). And the `cross_partition="auto"` rule replaced a
120+
co-partitioned transfer with 3 orphan columns by a cross-partition one
121+
with 16,791 of 96,009, which the repair then filled with nonsense. The
122+
rows now carry the ownership offset, and the rebuild is kept only where
123+
it leaves fewer orphans.
124+
125+
Measured after: two zones a domain apart at np=2 move 304 cells to two
126+
owners where 8451 moved to one; two faults in one network at np=2 are
127+
both interior to their ranks, nothing moves, 14,339 / 14,332 cells; at
128+
np=4 each region goes to a different rank. The contact solve on that
129+
distributed band converges on the geometric tail in one Newton step at
130+
np=2 and np=4, slips within 0.5% of serial (the gap fill's node count
131+
varies by one or two with the partition, so the meshes are not
132+
identical).
133+
134+
## The decision
135+
136+
Two designs were weighed:
137+
138+
1. **Cut the faults at the partition boundary and solve locally.** Best
139+
balance and no build communication, but every cost sits on the seam:
140+
a fill conforming to a polyhedral seam that the band skin crosses (two
141+
discrete surfaces to intersect consistently on two ranks), the split's
142+
pair nodes on shared points (the one seam problem the multigrid note
143+
lists as open), and a decomposition that changes with every rebalance.
144+
2. **Faults as separate objects, distributed at will.** The surgery stays
145+
serial per object, the pairs are rank-local by construction, an object
146+
can be sent where the load is light, and the only communication is its
147+
shell, once. Its limit is granularity: an object is a connected fault,
148+
so one long fault is one rank's load, proportional to its area.
149+
150+
**Ruling: the second is the primitive** — it is what is built — and the
151+
first's cut moves from the partition seam into the CAD when a long fault
152+
needs it. Cut a long fault along strike at chosen planes before meshing:
153+
OCC cuts the band by a plane exactly, so the two skins meet on a
154+
triangulated plane generated once, broadcast, and embedded verbatim by
155+
both owners (the fill already gates for verbatim constraints); the base
156+
cells straddling the plane are victims on both sides, so each rank's
157+
cavity ring closes onto the same plane triangulation and nothing
158+
discrete is intersected with anything discrete. The pieces are then
159+
ordinary objects. What still crosses ranks is the split: the fault trace
160+
runs through the cut plane, so the pair nodes there sit on shared
161+
points, and the strip along the plane — a few cells wide — is gathered
162+
to one of the two owners before splitting. Communication is then
163+
proportional to the cut planes, not the fault.
164+
165+
None of that is built, and it should not be until a model needs a fault
166+
longer than one rank comfortably holds. The first thing to test when it
167+
is: the split across one cut plane at np=3.
168+
169+
Two further rulings from the same discussion:
170+
171+
- **Do not rebalance the child after placement.** It fixes the solve
172+
imbalance by destroying co-partition everywhere, and every transfer
173+
then becomes a cross-partition search. Balance the base instead: refine
174+
it in the broad region where faults can exist (the lithosphere, say) in
175+
the *coarse* gmsh mesh, so the grading nests through the whole tail,
176+
and let the partitioner balance that.
177+
- **A rebalance of the base means a rebuild of the child.** That is
178+
consistent with the faults being ephemeral; what must be verified is
179+
that the base's own state (mesh variables, swarms) migrates cleanly
180+
through a redistribution of a live mesh, which has not been exercised.
181+
182+
## Still open
183+
184+
- **np=3 answer on the crossing fixture** (#671): the smallest piece's
185+
slip is 10% low. It no longer crashes; it is a wrong number.
186+
- **The cross-partition transfer builder** loses 17 to 45 percent of the
187+
coarse columns on a pair that is actually co-partitioned. It is only
188+
prevented from being chosen when it is worse.
189+
- **Slicing a long fault** (above).
190+
- **The seam-straddling gauge**: the weak-plane slip gauge omits a probe
191+
pair the rank does not own on both sides; unreachable while the
192+
gathered region holds the probes, and worth a collective count if a
193+
band is ever partitioned.
194+
195+
## The throughput test
196+
197+
The numbers above say what moves. The question that matters for a
198+
time-stepping model is what the movement *costs* in the solve, and the
199+
only fair measurement holds everything fixed except where the faults sit
200+
relative to the seams. `fault_parallel_layouts.py` (beside this note)
201+
does that on a 6 x 1 x 1 box whose np=3 partition is three slabs along x
202+
with seams near x = 2 and 4, each slab about fifteen base cells long.
203+
Four faults, 0.3 long and 0.2 tall, at least 1.5 apart so that no two
204+
shells touch: one alone, a junction-connected pair as one cluster
205+
(ligament 1.5; 1.0 degenerates the junction cut on this mesh), and one
206+
more that the layout moves:
207+
208+
- **local**: one fault per slab, the pair in the middle slab — nothing
209+
should move;
210+
- **straddle**: the fourth fault shifted onto the seam near x = 4 — it
211+
is gathered to the majority rank, that rank carries its shell, and the
212+
tail's transfers for the shell go cross-partition;
213+
- **gathered**: every fault forced into one region — what the code did
214+
before #672;
215+
- **serial** and **GAMG instead of the tail** on the same faults, as the
216+
baselines.
217+
218+
Two fixture lessons, both found the hard way: the 6:1 box meshes with
219+
cells up to 0.4 across, and a fault 0.4 tall in a unit-high box leaves
220+
0.28 of clearance, so the carve reached the floor and refused ("the
221+
cavity reached the domain wall"); and faults about eight cells apart
222+
merge into one region, since two three-cell shells touch at six.
223+
224+
The slips must agree across layouts to the fill's noise; the cost is in
225+
the build time, the cells moved, the per-rank imbalance, the iteration
226+
counts and the cold and warm solve times. "Warm" is a second full solve
227+
from zero with the tail and the rotation reused — a repeat from the
228+
converged solution takes no Newton step and measures nothing. Warm solve
229+
time is what a time-stepping model pays per step.
230+
231+
### Results (2 September 2026, 16-core workstation, 20,778-cell base)
232+
233+
The junior of the crossing pair is consumed whole by the ligament cut at
234+
1.5 on this mesh, so the fixture as run is three faults, one per slab,
235+
and the "cluster" is a single fault. Warm is the second full solve from
236+
zero; every solve took one Newton step.
237+
238+
| layout | regions | cells moved | cells per rank (max/mean) | cold s | warm s | velocity its | pressure its | slips A / B / D |
239+
|---|---|---|---|---|---|---|---|---|
240+
| serial, tail | 3 | 0 | 20778 (1.00) | 97.7 | 81.0 | 3 | 200 | 0.11182 / 0.13117 / 0.11110 |
241+
| np=3 local, tail | 3 | 122 | 6727 / 6733 / 7317 (1.06) | 52.3 | 45.8 | 4 | 200 | 0.11181 / 0.13176 / 0.11108 |
242+
| np=3 straddle, tail | 3 | 834 | 5624 / 6733 / 8438 (1.22) | 60.6 | 52.9 | 4 | 200 | 0.11181 / 0.13114 / 0.10926 (D moved) |
243+
| np=3 gathered (pre-#672), tail | 1 | 5440 | 3514 / 13208 / 4054 (1.91) | 89.4 | 78.3 | 4 | 200 | 0.11182 / 0.13116 / 0.11111 |
244+
| np=3 local, GAMG | 3 | 122 | 6727 / 6733 / 7317 (1.06) | 24.9 | 17.6 | 26 | 200 | 0.11182 / 0.13176 / 0.11108 |
245+
246+
What the table says:
247+
248+
- **The answer is layout-independent.** A and D agree to four digits
249+
across serial, local, straddle and gathered; B to 0.5%, which is the
250+
gap fill's node-count noise between partitions. The straddle's D is a
251+
different fault position and legitimately a different number.
252+
- **Per-region placement is what makes np=3 worth running.** The old
253+
single gather left one rank with 63% of the mesh and a warm solve of
254+
78 s against 81 s serial: no parallel gain at all. Per region, the
255+
mesh is balanced to 6% and the warm solve is 46 s, 1.8 times serial
256+
and 1.7 times the old gather.
257+
- **A straddling fault costs about 15%** (53 s against 46 s): 834 cells
258+
moved, the owner at 1.22 of the mean, and that shell's transfers
259+
cross-partition. That is the price of one non-local fault on three
260+
ranks, and it is the number the design decision rests on.
261+
- **The pressure block is the wall-clock gate on this fixture, not the
262+
velocity block.** Every configuration hits the pressure Schur solve's
263+
200-iteration cap, so wall time is dominated by pressure iterations,
264+
each of which applies the velocity preconditioner. That is why GAMG,
265+
at 26 velocity iterations against the tail's 4, is 2.6 times faster
266+
here: its application is cheaper and the cap is the same. The tail's
267+
advantage in velocity iterations is real and invisible in wall time
268+
until the pressure solve converges. This is the #625 pressure cap,
269+
measured again on a contact fixture; it is not a placement matter.
270+
271+
The script beside this note (`fault_parallel_layouts.py`) regenerates
272+
the table:
273+
274+
mpirun -np 3 python -u fault_parallel_layouts.py -uw_layout local|straddle|gathered [-uw_tail 0]

0 commit comments

Comments
 (0)