|
| 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