Skip to content

Commit 99b64eb

Browse files
authored
Document why the multigrid coarse level must be small (#644) (#666)
* Document why the MG coarse level must be small (#644, #643) bknight1 hit an OOM at Pawsey: the FMG coarse solve is redundant+LU, which replicates the coarse factorization on every rank, and his coarsest level still carried 14k velocity DOFs - ~300 MB/rank replicated, killed at the 900 MB cap. Agreed on the issue that this is not a bug but a documentation gap. The premise the docs never stated: multigrid is cheap because the bottom of the hierarchy is solved exactly at negligible cost, and that is only true if the bottom is genuinely tiny. Nothing in the method makes it so and nothing warns when it is not. A 14k-DOF coarsest level means the hierarchy was built from too fine a base with too few levels, so the premise never held - the fix is another level underneath, not a different bottom solve. Doubling the base cell size and adding one refinement level lands on the same finest grid with every coarser level ~4x smaller in 2-D (8x in 3-D); bknight1 measured that as 683 -> 416 MB/rank for +11% linear-solve time, with the finest grid and the SNES iteration sequence unchanged. Also records that this is PETSc's own choice, not ours: PCSetUp_MG selects PCREDUNDANT above one rank and PCLU at one rank, its source comment reading "coarse solve is (redundant) LU by default" (src/ksp/pc/impls/mg/mg.c), which I confirmed by inspection at np = 1, 2 and 4. We set it explicitly only so a bundle cannot inherit a sibling's leftover options (#468). Deleting the keys and letting PETSc decide would change nothing - which is the point, because it puts the attention back on the hierarchy. Two corrections to what was there. The page claimed the default coarse solve was a bare `lu` in both the bundle listing and the parallel section, and told users to set redundant themselves for large partitions - it has been the default all along. And the SVD path is documented as what it actually is: keyed on VERIFIED rotation null modes, not on a problem being "rotated", because a rotated problem with Dirichlet walls has none and a blanket dense SVD coarse solve cost most of ~0.8 s per V-cycle application in #622. Adds a snippet to check the hierarchy you actually built (verified to run) and a rule of thumb: low thousands of DOFs at the bottom, and if it reaches five figures add a level. docs-build: succeeded, no warnings on this page. Underworld development team with AI support from Claude Code * Adversarial review of my own #666: five defects, two of them wrong advice Reviewed the docs change by running its claims rather than re-reading them. Five findings, fixed here. 1. TELESCOPE CANNOT WORK HERE. The page offered `mg_coarse_pc_type = "telescope"` for large partitions, and my rewrite promoted it to its own subsection. PCTELESCOPE has no DMPlex support and every UW3 mesh is a DMPlex, so the snippet aborts the solve: "Support for DMPLEX is currently not available", PETSc error 56, reproduced at np=2. It is now a warning that it is unavailable - which strengthens the point, since it means there is NO bottom-solve escape hatch from an over-large coarse level. 2. THE SVD SNIPPET WAS INERT. `geometric_mg_bundle(coarse="svd")` builds an MGSettings and discards it; nothing is applied. It read as a user action and did nothing. Removed - the solver already selects SVD from the verified rotation-mode count. 3. THE OVERRIDE PROMISE IS FALSE ON THE ROTATED PATH - the one path that wants SVD. That route writes its bundle under the velocity sub-PC's own prefix, applies it, then deletes the keys; `solver.petsc_options[...]` sits under the SNES prefix and never reaches it. Verified the override DOES survive on the standard path (fieldsplit_velocity_mg_coarse_pc_type = svd after solve), so the promise is now scoped to that path and the rotated exception is a warning. 4. "LANDS ON THE SAME FINEST GRID" OVERCLAIMED. Measured, cellSize 0.10/ref2 gives 3,872 finest cells against 0.20/ref3 at 4,224 - 9% MORE, not equal, while the coarsest drops to 27%. #644's own numbers go the other way (227k vs 233k). Now stated as a few percent either side, with both measurements. 5. THE RULE OF THUMB HAD NO np OR MEMORY BUDGET IN IT, yet those are what set the threshold - the replicated factor is paid per rank against a per-rank cap. Rewritten around that arithmetic, calibrated on #644's 14k DOFs -> ~300 MB/rank at 5.16x fill. Claims that survived the review, all verified rather than asserted: the dm_hierarchy snippet works on a bare mesh (no solver needed); the per-level growth is exactly 4x in 2-D and 8.00x in 3-D (66/264/1056, 184/1472/11776); PETSc's own source picks redundant above one rank and lu at one. docs-build: succeeded, no warnings on this page. Underworld development team with AI support from Claude Code
1 parent 0b2e3ea commit 99b64eb

1 file changed

Lines changed: 155 additions & 12 deletions

File tree

docs/advanced/multigrid-preconditioning.md

Lines changed: 155 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ pc_mg_galerkin = "both" # RAP coarse operators (see below)
7171
mg_levels_ksp_type = "chebyshev"
7272
mg_levels_pc_type = "sor"
7373
mg_levels_ksp_max_it = 4
74-
mg_coarse_pc_type = "lu" # direct coarse solve
74+
mg_coarse_pc_type = "redundant" # direct coarse solve, on every rank
75+
mg_coarse_redundant_pc_type = "lu"
7576
```
7677

7778
**Why Galerkin coarse operators?** Underworld3 does not install
@@ -92,23 +93,165 @@ This is the key reason to prefer FMG when you adapt:
9293
GAMG on the next solve, so nothing breaks — you simply lose the geometric path
9394
until a hierarchy is available again.
9495

95-
## Parallel coarse solve
96+
## The coarse level must be small, or the bottom solve stops being free
9697

97-
The default coarse solver is a serial direct solve (`mg_coarse_pc_type = "lu"`),
98-
which is the fast, simple choice in serial and for modest core counts. For large
99-
parallel partitions, replicate or gather the (small) coarse grid instead:
98+
Multigrid is cheap because each level costs a fraction of the one above it, and
99+
the bottom of the hierarchy is solved **exactly** at negligible cost. The word
100+
doing the work in that sentence is *negligible*. A direct (or SVD) coarse solve
101+
is only free if the coarsest level is genuinely tiny; nothing in the method makes
102+
it so, and nothing warns you when it is not.
103+
104+
### What the default does, and why
105+
106+
The bundle sets a redundant direct solve:
107+
108+
```python
109+
mg_coarse_pc_type = "redundant"
110+
mg_coarse_redundant_pc_type = "lu"
111+
```
112+
113+
`redundant` copies the whole coarse system to **every rank** and factors it
114+
there. For a genuinely small system that is the right trade: communicating
115+
across ranks to solve a few thousand unknowns collectively costs more than
116+
solving the same thing everywhere. It is also np-safe, which a bare `lu` is not
117+
— a serial LU cannot factor a distributed matrix and fails at `np > 1` with
118+
`DIVERGED_LINEAR_SOLVE` after zero iterations. In serial, `redundant` + `lu` is
119+
identical to `lu`.
120+
121+
This is not an Underworld idiosyncrasy, and it is worth being clear about that
122+
before blaming the choice for a memory problem. **PETSc makes the same choice on
123+
its own.** With nothing set under `mg_coarse_*`, `PCSetUp_MG` selects
124+
`PCREDUNDANT` when the communicator has more than one rank and `PCLU` when it
125+
does not — its own source comment reads "coarse solve is (redundant) LU by
126+
default" (`src/ksp/pc/impls/mg/mg.c`). Confirmed by inspection at `np` = 1, 2
127+
and 4 on PETSc 3.25.
128+
129+
Underworld sets it explicitly rather than inheriting it, so a bundle applied
130+
after another cannot pick up a sibling's leftover options (#468) — but the value
131+
is the one PETSc would have picked anyway. Deleting these keys and letting PETSc
132+
decide would change nothing here; the leverage is in the hierarchy, not the
133+
bottom solve.
134+
135+
### The failure mode: replicated memory
136+
137+
Because the factorization is replicated, its memory is paid **per rank**, and
138+
the total scales with the coarse system size *times* the core count. That is
139+
harmless for a small bottom level and fatal for a large one.
140+
141+
Measured on Setonix (issue #644), a production model at `np = 30` under a
142+
900 MB/rank cgroup cap:
143+
144+
| Base mesh | Refinement | Coarsest velocity DOFs | Peak RSS/rank | Linear solve |
145+
|---|---|---|---|---|
146+
| 10 km | 2 | 14,195 | **683 MB** — OOM killed | ~100 s |
147+
| 20 km | 3 | ~1,900 | **416 MB** | 111 s (+11%) |
148+
149+
Both configurations have essentially the same *finest* grid (233k vs 227k
150+
velocity DOFs) and converge identically — the same SNES iteration sequence. The
151+
only real difference is how far down the hierarchy goes. Shrinking the coarsest
152+
level alone removed 261 MB/rank, which matches the gather-plus-fill arithmetic
153+
(~45 MB for the gathered copy, ~232 MB for the factor at a 5.16x fill ratio).
154+
155+
### The fix is the hierarchy, not the coarse solver
156+
157+
The instinct on an OOM is to reach for a different bottom solve. That treats the
158+
symptom. A 14,000-DOF coarsest level means the hierarchy was built from too fine
159+
a base with too few levels — the bottom of the ladder never got small, so the
160+
"solve the coarse problem exactly, for free" premise never held.
161+
162+
Keep the finest resolution you need and add levels underneath it:
163+
164+
```python
165+
# OOM: fine base, few levels -> the bottom is still large
166+
mesh = uw.meshing.UnstructuredSimplexBox(..., cellSize=0.10, refinement=2)
167+
168+
# same finest grid, one more level, a much smaller bottom
169+
mesh = uw.meshing.UnstructuredSimplexBox(..., cellSize=0.20, refinement=3)
170+
```
171+
172+
Each refinement level divides cell size by two, so the level above has 4x the
173+
cells in 2-D and 8x in 3-D — measured exactly, 66 / 264 / 1056 and
174+
184 / 1472 / 11776 on a `refinement=2` box. Doubling the base cell size and
175+
adding one level therefore lands on the same finest grid while making every
176+
coarser level — the bottom one included — about 4x (or 8x) smaller.
177+
178+
"The same finest grid" is approximate, because the base mesh is unstructured and
179+
gmsh does not produce exactly 4x the cells for half the target size. Measured on
180+
a unit box, `cellSize=0.10, refinement=2` gives 3,872 finest cells against
181+
`cellSize=0.20, refinement=3` at 4,224 — 9% more, while the coarsest level drops
182+
to 27% of its former size. The production model in #644 landed the other way:
183+
227k against 233k. Expect a few percent either side, and check rather than
184+
assume.
185+
186+
Check what you actually built rather than assuming; `dm_hierarchy[0]` is the
187+
coarsest level and `[-1]` is the mesh you solve on:
100188

101189
```python
102-
# redundant: copy the coarse grid to every rank and solve it there
103-
stokes.petsc_options["fieldsplit_velocity_mg_coarse_pc_type"] = "redundant"
104-
stokes.petsc_options["fieldsplit_velocity_mg_coarse_redundant_pc_type"] = "lu"
190+
for i, dm in enumerate(mesh.dm_hierarchy):
191+
cells = uw.mpi.comm.allreduce(dm.getStratumSize("depth", mesh.dim))
192+
uw.pprint(f"level {i}: {cells} cells")
193+
```
105194

106-
# or telescope onto a sub-communicator for very large runs
107-
# stokes.petsc_options["fieldsplit_velocity_mg_coarse_pc_type"] = "telescope"
195+
There is no single DOF number that is "small enough", because the quantity that
196+
has to fit is the replicated factor **per rank**, against your per-rank memory
197+
budget — the core count is in the arithmetic, not just the coarse size. The #644
198+
measurement calibrates it: ~14k velocity DOFs cost ~300 MB/rank at a 5.16x LU
199+
fill, so a 900 MB/rank cap is already lost before the rest of the solver is
200+
counted, while ~1.9k DOFs cost little enough to disappear.
201+
202+
As a working rule, keep the coarsest level in the low thousands of DOFs, and
203+
treat five figures as a bug in the hierarchy rather than a tuning question. If
204+
you are near the line, estimate it rather than guessing: the factor grows faster
205+
than the DOF count, and you pay it on every rank at once.
206+
207+
### When the coarse operator has a null space: SVD
208+
209+
One case does require a different bottom solve, and the solver handles it for
210+
you. A **rotated** velocity block whose domain leaves rigid rotations free — a
211+
closed circle (one mode), a spherical shell (three) — hands those modes to the
212+
Galerkin-coarsened coarse operator, because the rotations survive the
213+
constraint. `redundant`/LU hits a zero pivot on a singular coarse operator and
214+
fails with `SUBPC_ERROR` (outer reason `-11`), so the rotated path selects SVD
215+
instead.
216+
217+
It is deliberately **not** a blanket choice for every rotated problem. It keys
218+
on the count of *verified rotation* modes, because a rotated problem with
219+
Dirichlet walls, or a split-fault contact box, has none — and there an SVD is
220+
pure cost. A 3-D P2 coarse level is a **dense** factorization: measured, it
221+
accounted for most of ~0.8 s per V-cycle application at healthy iteration counts
222+
(#622).
223+
224+
```{warning}
225+
There is no `petsc_options` override for the coarse solve **on the rotated
226+
path**. That route writes its multigrid bundle under the velocity sub-PC's own
227+
prefix, applies it, and deletes the keys again; anything you set through
228+
`solver.petsc_options[...]` lives under the solver's SNES prefix and never
229+
reaches it. The override below works on the standard path only.
108230
```
109231

110-
Because `"auto"` does not overwrite options you set yourself, you can keep
111-
`preconditioner = "auto"` and just override the coarse solver as above.
232+
Because SVD is dense, it is affordable only on a genuinely small coarse level —
233+
which makes the sizing guidance above a hard requirement on this path rather
234+
than a preference.
235+
236+
### Overriding the coarse solve yourself
237+
238+
On the standard (non-rotated) path, `"auto"` does not overwrite options you set
239+
yourself, so you can keep `preconditioner = "auto"` and change just the bottom
240+
solve:
241+
242+
```python
243+
stokes.preconditioner = "auto"
244+
stokes.petsc_options["fieldsplit_velocity_mg_coarse_pc_type"] = "svd"
245+
```
246+
247+
```{note}
248+
`PCTELESCOPE` — the usual PETSc answer to "replicating onto every rank is too
249+
many copies", which gathers the coarse system onto a sub-communicator instead —
250+
**is not available here**. It has no DMPlex support, and every Underworld mesh
251+
is a DMPlex, so requesting it aborts the solve with `Support for DMPLEX is
252+
currently not available` (PETSc error 56). There is no bottom-solve escape hatch
253+
from an over-large coarse level: the hierarchy is the lever.
254+
```
112255

113256
## Benchmark: FMG vs GAMG on a deforming adaptive mesh
114257

0 commit comments

Comments
 (0)