core 1.5.0
Shared MPI block decomposition, asynchronous ghost-layer exchange, SDF geometry, load balancing (header-only C++20)
Loading...
Searching...
No Matches
graph_amg.hpp
Go to the documentation of this file.
1// core — smoothed-aggregation algebraic multigrid (SA-AMG) for a general assembled sparse SPD
2// operator on an irregular graph. Mesh-agnostic: it names no octree / momentum / AMR types and
3// assumes nothing beyond a CSR (diagonal + off-diagonal). The motivating consumer is the voro
4// energy-minimisation mesh optimiser, whose Gauss–Newton Hessian H = 2γ JᵀJ lives on the Voronoi
5// cell-adjacency graph (a "Laplacian-squared", SPD, no geometric grid hierarchy) — so a geometric
6// multigrid does not apply and only ALGEBRAIC coarsening gives mesh-independent iteration counts,
7// i.e. an O(N) solve. But nothing here is specific to that operator.
8//
9// This is the HOST ORACLE per docs/DEVICE_RESIDENCY_PLAN.md: plain std::vector, no Kokkos, so it
10// builds and runs in the dependency-free core test suite and pins the mesh-independence contract
11// (CG iteration count flat as N grows) that the later device + MPI ports must reproduce.
12//
13// Why a NEW class and not core::amr::MomentumMG: that one is hard-wired to BlockOctree (its
14// hierarchy is octree coarsening via Morton ancestry) and its Galerkin triple-product is
15// specialised to a piecewise-constant *injection* prolongator. Smoothed aggregation's prolongator
16// P = (I − ω D⁻¹A) P₀ is a general sparse matrix, so the coarse operator A_c = PᵀAP is a general
17// sparse triple product — a different computation on a different (graph) hierarchy.
18//
19// Pipeline (smoothed aggregation, Vaněk et al. 1996):
20// 1. strength-of-connection on the NODAL graph (block size s = ndofPerNode: 3 for Voronoi seed
21// positions, 4 with power weights) — aggregation is nodal so the tentative prolongator keeps
22// the s near-nullspace modes (the uniform per-component "translation" modes of H) per node;
23// 2. greedy aggregation (root pass + assign-leftovers pass + new-aggregate pass);
24// 3. tentative prolongator P₀ — piecewise constant over aggregates, one coarse block per
25// aggregate, columns normalised to unit 2-norm;
26// 4. prolongator smoothing P = (I − (ω/λ_max) D⁻¹A) P₀ (λ_max = spectral radius of D⁻¹A);
27// 5. Galerkin coarse operator A_c = PᵀAP (general sparse RAP);
28// 6. V-cycle with a Chebyshev polynomial smoother (GPU-parallel, near-Gauss–Seidel, no
29// colouring / no extra MPI comms — the decision recorded against colored-GS) or damped
30// Jacobi, used as a CG preconditioner.
31#ifndef PECLET_CORE_SOLVER_GRAPH_AMG_HPP
32#define PECLET_CORE_SOLVER_GRAPH_AMG_HPP
33
34#include <algorithm>
35#include <cmath>
36#include <cstdint>
37#include <cstdio>
38#include <map>
39#include <vector>
40
42
43namespace peclet::core::solver {
44
46
52struct HostCsrOp {
53 Index n = 0;
54 std::vector<double> diag;
55 std::vector<Index> start;
56 std::vector<Index> nbr;
57 std::vector<double> coef;
58
60 void apply(const std::vector<double>& x, std::vector<double>& y) const {
61 for (Index i = 0; i < n; ++i) {
62 double s = diag[static_cast<std::size_t>(i)] * x[static_cast<std::size_t>(i)];
63 for (Index k = start[static_cast<std::size_t>(i)]; k < start[static_cast<std::size_t>(i) + 1];
64 ++k)
65 s += coef[static_cast<std::size_t>(k)] *
66 x[static_cast<std::size_t>(nbr[static_cast<std::size_t>(k)])];
67 y[static_cast<std::size_t>(i)] = s;
68 }
69 }
70};
71
72struct AmgParams {
73 int ndofPerNode = 1;
74 double theta = 0.05;
75 double smoothOmega = 4.0 / 3.0;
76 int maxLevels = 25;
78 int pre = 1, post = 1;
79 int chebDegree = 2;
80 double jacobiOmega = 0.6;
81 int coarseSweeps = 0;
84 int eigIters = 15;
85};
86
89class GraphAMG {
90 public:
91 void build(const HostCsrOp& A, const AmgParams& prm = {}) {
92 prm_ = prm;
93 levels_.clear();
94 levels_.emplace_back();
95 levels_.back().A = A;
96 for (int L = 0; L + 1 < prm_.maxLevels; ++L) {
97 finalizeSmoother(levels_[static_cast<std::size_t>(L)]);
98 if (levels_[static_cast<std::size_t>(L)].A.n <= prm_.coarsest)
99 break;
100 Level next;
101 if (!coarsen(levels_[static_cast<std::size_t>(L)], next))
102 break; // no further coarsening possible (already minimal)
103 levels_.push_back(std::move(next));
104 }
105 finalizeSmoother(levels_.back());
106 for (auto& lv : levels_)
107 allocScratch(lv);
108 }
109
111 void apply(const std::vector<double>& r, std::vector<double>& z) const {
112 Level& l0 = levels_[0];
113 l0.b = r;
114 std::fill(l0.x.begin(), l0.x.end(), 0.0);
115 vcycle(0);
116 z = l0.x;
117 }
118
119 int numLevels() const { return static_cast<int>(levels_.size()); }
120 Index size(int L = 0) const { return levels_[static_cast<std::size_t>(L)].A.n; }
121
124 double operatorComplexity() const {
125 double nnz0 = static_cast<double>(levels_[0].A.coef.size() + levels_[0].A.diag.size());
126 double tot = 0;
127 for (auto& lv : levels_)
128 tot += static_cast<double>(lv.A.coef.size() + lv.A.diag.size());
129 return (nnz0 > 0) ? tot / nnz0 : 0.0;
130 }
131
132 // Hierarchy access for the device mirror (GraphAMGDevice): setup stays here on the host, the
133 // device class copies each level's CSR/transfer/smoother data into Views once at build.
134 struct Level {
136 // Prolongation to the next-coarser level (this level → L+1), row-CSR: fine row i has entries
137 // (Pcol, Pval) over coarse DOFs. Empty on the coarsest level.
138 std::vector<Index> Pstart, Pcol;
139 std::vector<double> Pval;
140 Index nc = 0; // number of coarse DOFs (columns of P)
141 // Smoother data:
142 std::vector<double> invDiag; // 1/diag (0 where diag == 0)
143 double lmax = 1.0; // spectral radius estimate of D⁻¹A on this level
144 // Scratch (mutable — apply() is logically const):
145 mutable std::vector<double> x, b, res, t0, t1;
146 };
147
148 const std::vector<Level>& levels() const { return levels_; }
149 const AmgParams& params() const { return prm_; }
150
151 private:
152 AmgParams prm_;
153 mutable std::vector<Level> levels_;
154
155 static void allocScratch(Level& lv) {
156 const std::size_t n = static_cast<std::size_t>(lv.A.n);
157 lv.x.assign(n, 0.0);
158 lv.b.assign(n, 0.0);
159 lv.res.assign(n, 0.0);
160 lv.t0.assign(n, 0.0);
161 lv.t1.assign(n, 0.0);
162 }
163
164 // 1/diag + power-iteration estimate of λ_max(D⁻¹A) for the Chebyshev / Jacobi smoother bounds.
165 void finalizeSmoother(Level& lv) {
166 const std::size_t n = static_cast<std::size_t>(lv.A.n);
167 lv.invDiag.assign(n, 0.0);
168 for (std::size_t i = 0; i < n; ++i) {
169 const double d = lv.A.diag[i];
170 lv.invDiag[i] = (std::fabs(d) > 1e-300) ? 1.0 / d : 0.0;
171 }
172 // v ← D⁻¹A v (Rayleigh quotient in the standard inner product approximates ρ(D⁻¹A)). Seed with
173 // a high-frequency ±1 hash pattern, NOT a smooth vector: the dominant eigenvector of D⁻¹A is
174 // the highest-frequency mode, so a smooth seed overlaps it weakly and power iteration
175 // underestimates λ_max — which would let a higher-degree Chebyshev amplify the modes above the
176 // (too-low) bound.
177 std::vector<double> v(n, 0.0), w(n, 0.0);
178 for (std::size_t i = 0; i < n; ++i) {
179 // splitmix64 bit-mixing → a well-decorrelated ±1 vector (O(1) overlap with the top mode, so
180 // power iteration converges in a handful of steps). A poorly-mixed hash — e.g. i·odd & 1,
181 // which only preserves i's low bit — gives a degenerate, non-dominant pattern that never
182 // converges.
183 std::uint64_t z = static_cast<std::uint64_t>(i) + 0x9E3779B97F4A7C15ULL;
184 z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ULL;
185 z = (z ^ (z >> 27)) * 0x94D049BB133111EBULL;
186 z = z ^ (z >> 31);
187 v[i] = (z & 1ULL) ? 1.0 : -1.0;
188 }
189 double nrm = std::sqrt(dot(v, v));
190 if (nrm == 0.0) {
191 lv.lmax = 1.0;
192 return;
193 }
194 for (auto& e : v)
195 e /= nrm;
196 double lam = 1.0;
197 for (int k = 0; k < prm_.eigIters; ++k) {
198 lv.A.apply(v, w);
199 for (std::size_t i = 0; i < n; ++i)
200 w[i] *= lv.invDiag[i];
201 lam = dot(v, w); // v is unit-norm ⇒ Rayleigh quotient
202 const double wn = std::sqrt(dot(w, w));
203 if (wn == 0.0)
204 break;
205 for (std::size_t i = 0; i < n; ++i)
206 v[i] = w[i] / wn;
207 }
208 lv.lmax = (lam > 0.0) ? lam : 1.0;
209 }
210
211 // Build the coarse level (aggregation → tentative P₀ → smoothed P → Galerkin A_c). Returns false
212 // if the graph cannot be coarsened further (one aggregate).
213 bool coarsen(Level& lv, Level& next) {
214 const Index n = lv.A.n;
215 const int s = prm_.ndofPerNode;
216 const Index nNodes = n / s;
217
218 // --- 1. nodal strength-of-connection (Frobenius norm of each s×s block) ---
219 // blockFro[I] : map J -> ‖A_block(I,J)‖_F² (accumulated), plus the diagonal block norm.
220 std::vector<std::map<Index, double>> blk(static_cast<std::size_t>(nNodes));
221 std::vector<double> ddiag(static_cast<std::size_t>(nNodes), 0.0); // ‖diagonal block‖_F²
222 for (Index i = 0; i < n; ++i) {
223 const Index I = i / s;
224 ddiag[static_cast<std::size_t>(I)] +=
225 lv.A.diag[static_cast<std::size_t>(i)] * lv.A.diag[static_cast<std::size_t>(i)];
226 for (Index k = lv.A.start[static_cast<std::size_t>(i)];
227 k < lv.A.start[static_cast<std::size_t>(i) + 1]; ++k) {
228 const Index J = lv.A.nbr[static_cast<std::size_t>(k)] / s;
229 const double c = lv.A.coef[static_cast<std::size_t>(k)];
230 if (J == I)
231 ddiag[static_cast<std::size_t>(I)] += c * c;
232 else
233 blk[static_cast<std::size_t>(I)][J] += c * c;
234 }
235 }
236 // Strong set per node: J strong to I iff ‖A(I,J)‖_F ≥ θ·√(‖A(I,I)‖_F·‖A(J,J)‖_F).
237 std::vector<std::vector<Index>> strong(static_cast<std::size_t>(nNodes));
238 const double th2 = prm_.theta * prm_.theta;
239 // (blk/ddiag hold SQUARED Frobenius norms, so square the criterion: ‖S‖_F² ≥ θ²·‖D_I‖_F·‖D_J‖_F
240 // = θ²·√(ddiag_I·ddiag_J).)
241 for (Index I = 0; I < nNodes; ++I)
242 for (auto& [J, fro2] : blk[static_cast<std::size_t>(I)])
245 strong[static_cast<std::size_t>(I)].push_back(J); // (blk iterates J in sorted order)
246 auto isStrong = [&](Index I, Index J) {
247 const auto& s = strong[static_cast<std::size_t>(I)];
248 return std::binary_search(s.begin(), s.end(), J);
249 };
250
251 // --- 2. greedy aggregation ---
252 std::vector<Index> agg(static_cast<std::size_t>(nNodes), -1);
253 Index nAgg = 0;
254 // Pass 1 — roots: a fully-unaggregated node with a fully-unaggregated strong neighbourhood
255 // seeds a new aggregate consisting of itself + those neighbours.
256 for (Index I = 0; I < nNodes; ++I) {
257 if (agg[static_cast<std::size_t>(I)] != -1)
258 continue;
259 bool ok = true;
260 for (Index J : strong[static_cast<std::size_t>(I)])
261 if (agg[static_cast<std::size_t>(J)] != -1) {
262 ok = false;
263 break;
264 }
265 if (!ok)
266 continue;
267 agg[static_cast<std::size_t>(I)] = nAgg;
268 for (Index J : strong[static_cast<std::size_t>(I)])
270 ++nAgg;
271 }
272 // Pass 2 — attach each leftover to the adjacent aggregate it is most strongly tied to.
273 for (Index I = 0; I < nNodes; ++I) {
274 if (agg[static_cast<std::size_t>(I)] != -1)
275 continue;
276 Index best = -1;
277 double bestw = -1.0;
278 for (Index J : strong[static_cast<std::size_t>(I)]) {
279 const Index a = agg[static_cast<std::size_t>(J)];
280 if (a < 0)
281 continue;
282 const double w = blk[static_cast<std::size_t>(I)][J];
283 if (w > bestw) {
284 bestw = w;
285 best = a;
286 }
287 }
288 if (best >= 0)
289 agg[static_cast<std::size_t>(I)] = best;
290 }
291 // Pass 3 — remaining nodes form fresh aggregates from their still-unaggregated strong clusters
292 // (a fully isolated node becomes a singleton aggregate).
293 for (Index I = 0; I < nNodes; ++I) {
294 if (agg[static_cast<std::size_t>(I)] != -1)
295 continue;
296 agg[static_cast<std::size_t>(I)] = nAgg;
297 for (Index J : strong[static_cast<std::size_t>(I)])
298 if (agg[static_cast<std::size_t>(J)] == -1)
300 ++nAgg;
301 }
302 if (nAgg <= 1 || nAgg == nNodes)
303 return false; // no useful coarsening
304
305 // --- 3. tentative prolongator P₀ (block piecewise-constant, unit-2-norm columns) ---
306 // Each aggregate → s coarse DOFs; fine DOF (I,d) maps to coarse DOF (agg(I)·s + d) with weight
307 // 1/√|agg(I)| (the QR normalisation of the constant near-nullspace mode over the aggregate).
308 std::vector<Index> cnt(static_cast<std::size_t>(nAgg), 0);
309 for (Index I = 0; I < nNodes; ++I)
310 ++cnt[static_cast<std::size_t>(agg[static_cast<std::size_t>(I)])];
311 std::vector<double> invSqrtCnt(static_cast<std::size_t>(nAgg));
312 for (Index a = 0; a < nAgg; ++a)
313 invSqrtCnt[static_cast<std::size_t>(a)] =
314 1.0 / std::sqrt(static_cast<double>(cnt[static_cast<std::size_t>(a)]));
315 const Index ncoarse = nAgg * s;
316
317 // --- 4. FILTERED prolongator smoothing P = (I − (ω/λ_max) D_F⁻¹ A^F) P₀ ---
318 // A^F is the filtered operator: off-diagonal entries between nodes that are NOT strongly
319 // connected are dropped and LUMPED into the diagonal (row sums, hence the constant nullspace,
320 // preserved). Filtering is the standard SA fill-control: smoothing along only the strong graph
321 // keeps P — and therefore the Galerkin coarse operator — sparse, so operator complexity stays
322 // bounded (unfiltered smoothing densifies the coarse levels and, through the lmax-dependent
323 // step, makes the whole hierarchy hypersensitive / erratic). P₀ row for fine DOF i=(I,d) is a
324 // single entry (agg(I)·s + d, invSqrtCnt).
325 const double sc = prm_.smoothOmega / lv.lmax;
326 auto p0col = [&](Index i) -> Index {
327 const Index I = i / s, d = i % s;
328 return agg[static_cast<std::size_t>(I)] * s + d;
329 };
330 auto p0val = [&](Index i) -> double {
331 return invSqrtCnt[static_cast<std::size_t>(agg[static_cast<std::size_t>(i / s)])];
332 };
333 std::vector<Index> Pstart(static_cast<std::size_t>(n) + 1, 0), Pcol;
334 std::vector<double> Pval;
335 std::map<Index, double> row;
336 for (Index i = 0; i < n; ++i) {
337 const Index I = i / s;
338 // Filtered diagonal: original diagonal + lumped weak off-diagonals.
339 double dF = lv.A.diag[static_cast<std::size_t>(i)];
340 for (Index k = lv.A.start[static_cast<std::size_t>(i)];
341 k < lv.A.start[static_cast<std::size_t>(i) + 1]; ++k) {
342 const Index J = lv.A.nbr[static_cast<std::size_t>(k)] / s;
343 if (J != I && !isStrong(I, J))
344 dF += lv.A.coef[static_cast<std::size_t>(k)]; // lump weak connection into the diagonal
345 }
346 row.clear();
347 row[p0col(i)] += p0val(i); // P₀ contribution
348 // − sc·(1/dF)·(A^F P₀)_i, with A^F_ii = dF and A^F_ij kept only for strong (or intra-node) j.
349 const double f = (std::fabs(dF) > 1e-300) ? (-sc / dF) : 0.0;
350 row[p0col(i)] += f * dF * p0val(i); // diagonal term ⇒ P₀_i·(1 − sc)
351 for (Index k = lv.A.start[static_cast<std::size_t>(i)];
352 k < lv.A.start[static_cast<std::size_t>(i) + 1]; ++k) {
353 const Index j = lv.A.nbr[static_cast<std::size_t>(k)];
354 const Index J = j / s;
355 if (J != I && !isStrong(I, J))
356 continue; // weak: already lumped into dF
357 row[p0col(j)] += f * lv.A.coef[static_cast<std::size_t>(k)] * p0val(j);
358 }
359 for (auto& [c, val] : row)
360 if (val != 0.0) {
361 Pcol.push_back(c);
362 Pval.push_back(val);
363 }
364 Pstart[static_cast<std::size_t>(i) + 1] = static_cast<Index>(Pcol.size());
365 }
366
367 // --- 5. Galerkin coarse operator A_c = PᵀAP (general sparse RAP) ---
368 // AP_i,: = diag_i·P_i,: + Σ_k coef_k·P_{nbr_k,:} (fine row → coarse columns)
369 // A_c[c1][c2] += Σ_i P[i][c1]·AP[i][c2]
370 std::vector<std::map<Index, double>> Ac(static_cast<std::size_t>(ncoarse));
371 std::map<Index, double> apRow;
372 for (Index i = 0; i < n; ++i) {
373 apRow.clear();
374 const double di = lv.A.diag[static_cast<std::size_t>(i)];
375 for (Index k = Pstart[static_cast<std::size_t>(i)];
377 apRow[Pcol[static_cast<std::size_t>(k)]] += di * Pval[static_cast<std::size_t>(k)];
378 for (Index k = lv.A.start[static_cast<std::size_t>(i)];
379 k < lv.A.start[static_cast<std::size_t>(i) + 1]; ++k) {
380 const Index j = lv.A.nbr[static_cast<std::size_t>(k)];
381 const double c = lv.A.coef[static_cast<std::size_t>(k)];
382 for (Index kk = Pstart[static_cast<std::size_t>(j)];
384 apRow[Pcol[static_cast<std::size_t>(kk)]] += c * Pval[static_cast<std::size_t>(kk)];
385 }
386 for (Index k = Pstart[static_cast<std::size_t>(i)];
388 const Index c1 = Pcol[static_cast<std::size_t>(k)];
389 const double p1 = Pval[static_cast<std::size_t>(k)];
390 auto& r1 = Ac[static_cast<std::size_t>(c1)];
391 for (auto& [c2, apv] : apRow)
392 r1[c2] += p1 * apv;
393 }
394 }
395
396 // Flatten A_c into the coarse HostCsrOp (drop structural zeros).
397 next.A.n = ncoarse;
398 next.A.diag.assign(static_cast<std::size_t>(ncoarse), 0.0);
399 next.A.start.assign(static_cast<std::size_t>(ncoarse) + 1, 0);
400 next.A.nbr.clear();
401 next.A.coef.clear();
402 for (Index c = 0; c < ncoarse; ++c) {
403 for (auto& [c2, val] : Ac[static_cast<std::size_t>(c)]) {
404 if (c2 == c) {
405 next.A.diag[static_cast<std::size_t>(c)] = val;
406 } else if (val != 0.0) {
407 next.A.nbr.push_back(c2);
408 next.A.coef.push_back(val);
409 }
410 }
411 next.A.start[static_cast<std::size_t>(c) + 1] = static_cast<Index>(next.A.nbr.size());
412 }
413
414 // Stash the transfer on the fine level.
415 lv.Pstart = std::move(Pstart);
416 lv.Pcol = std::move(Pcol);
417 lv.Pval = std::move(Pval);
418 lv.nc = ncoarse;
419 return true;
420 }
421
422 // ---- V-cycle ----
423 void vcycle(int L) const {
424 Level& lv = levels_[static_cast<std::size_t>(L)];
425 if (L + 1 == static_cast<int>(levels_.size())) {
426 coarseSolve(lv);
427 return;
428 }
429 smooth(lv, prm_.pre);
430 // res = b − A x
431 lv.A.apply(lv.x, lv.res);
432 for (std::size_t i = 0; i < lv.res.size(); ++i)
433 lv.res[i] = lv.b[i] - lv.res[i];
434 // restrict residual to coarse RHS (Pᵀ res), start coarse x at 0.
435 Level& cl = levels_[static_cast<std::size_t>(L + 1)];
436 std::fill(cl.b.begin(), cl.b.end(), 0.0);
437 for (Index i = 0; i < lv.A.n; ++i)
438 for (Index k = lv.Pstart[static_cast<std::size_t>(i)];
439 k < lv.Pstart[static_cast<std::size_t>(i) + 1]; ++k)
440 cl.b[static_cast<std::size_t>(lv.Pcol[static_cast<std::size_t>(k)])] +=
441 lv.Pval[static_cast<std::size_t>(k)] * lv.res[static_cast<std::size_t>(i)];
442 std::fill(cl.x.begin(), cl.x.end(), 0.0);
443 vcycle(L + 1);
444 // prolong + correct: x += P x_c
445 for (Index i = 0; i < lv.A.n; ++i) {
446 double s = 0.0;
447 for (Index k = lv.Pstart[static_cast<std::size_t>(i)];
448 k < lv.Pstart[static_cast<std::size_t>(i) + 1]; ++k)
449 s += lv.Pval[static_cast<std::size_t>(k)] *
450 cl.x[static_cast<std::size_t>(lv.Pcol[static_cast<std::size_t>(k)])];
451 lv.x[static_cast<std::size_t>(i)] += s;
452 }
453 smooth(lv, prm_.post);
454 }
455
456 // Smoother: 4th-kind Chebyshev of degree prm_.chebDegree (default), or damped Jacobi when
457 // chebDegree == 0. Both read only the previous iterate per matvec and are symmetric polynomials
458 // in D⁻¹A ⇒ a valid SPD CG preconditioner with pre == post.
459 void smooth(const Level& lv, int sweeps) const {
460 if (sweeps <= 0)
461 return;
462 if (prm_.chebDegree <= 0) {
463 for (int s = 0; s < sweeps; ++s)
464 jacobiSweep(lv);
465 return;
466 }
467 for (int s = 0; s < sweeps; ++s)
468 chebSweep(lv);
469 }
470
471 void jacobiSweep(const Level& lv) const {
472 // Spectrally-scaled damped Jacobi: step = ω/λ_max(D⁻¹A). Scaling by λ_max is essential — a
473 // fixed ω is only stable when ω·λ_max < 2, which fails on operators with a large D⁻¹A spectrum
474 // (e.g. the Gauss-Newton Hessian JᵀJ), where an unscaled sweep diverges. ω≈1 is a decent
475 // smoother; the optimal high-frequency-damping value is 4/3 (== the 4th-kind Chebyshev degree-1
476 // step).
477 const double step = prm_.jacobiOmega / lv.lmax;
478 lv.A.apply(lv.x, lv.res); // res = A x
479 for (std::size_t i = 0; i < lv.x.size(); ++i)
480 lv.x[i] += step * lv.invDiag[i] * (lv.b[i] - lv.res[i]);
481 }
482
483 // One 4th-kind Chebyshev smoothing pass of degree k on A x = b (in place), Jacobi (D) prec.
484 // (Lottes 2022 / Phillips & Fischer 2022.) Unlike the 1st-kind form it needs ONLY an upper bound
485 // λ on ρ(D⁻¹A) — no lower-bound guess to get wrong on the Galerkin coarse levels — and its first
486 // step is exactly the optimal 4/(3λ) damped-Jacobi step. Guaranteed a good, symmetric smoother.
487 void chebSweep(const Level& lv) const {
488 const int k = prm_.chebDegree;
489 const double lam =
490 1.1 * lv.lmax; // safety over-estimate: λ must bound the true spectral radius
491 auto& r = lv.res;
492 auto& d = lv.t0;
493 auto& Ad = lv.t1;
494 lv.A.apply(lv.x, r); // r = b − A x
495 for (std::size_t i = 0; i < r.size(); ++i)
496 r[i] = lv.b[i] - r[i];
497 std::fill(d.begin(), d.end(), 0.0);
498 for (int i = 1; i <= k; ++i) {
499 const double c1 = (2.0 * i - 3.0) / (2.0 * i + 1.0);
500 const double c2 = (8.0 * i - 4.0) / ((2.0 * i + 1.0) * lam);
501 for (std::size_t j = 0; j < d.size(); ++j)
502 d[j] = c1 * d[j] + c2 * lv.invDiag[j] * r[j];
503 for (std::size_t j = 0; j < lv.x.size(); ++j)
504 lv.x[j] += d[j];
505 if (i < k) {
506 lv.A.apply(d, Ad); // r −= A d
507 for (std::size_t j = 0; j < r.size(); ++j)
508 r[j] -= Ad[j];
509 }
510 }
511 }
512
513 // Coarsest level: a short unpreconditioned CG (the level is tiny, ≤ coarsest DOFs) — robust even
514 // if A_c is only semidefinite (a consistent RHS still converges on the range).
515 void coarseSolve(Level& lv) const {
516 if (prm_.coarseSweeps > 0) { // single-level / cheap-bottom mode: smoother sweeps, no CG
517 smooth(lv, prm_.coarseSweeps);
518 return;
519 }
520 const Index n = lv.A.n;
521 auto& x = lv.x;
522 auto& r = lv.res;
523 auto& p = lv.t0;
524 auto& Ap = lv.t1;
525 lv.A.apply(x, r);
526 for (Index i = 0; i < n; ++i)
527 r[static_cast<std::size_t>(i)] =
528 lv.b[static_cast<std::size_t>(i)] - r[static_cast<std::size_t>(i)];
529 p = r;
530 double rr = dot(r, r);
531 const double rr0 = rr;
532 const int maxit = static_cast<int>(std::min<Index>(n, 200));
533 for (int k = 0; k < maxit && rr > 1e-24 * rr0; ++k) {
534 lv.A.apply(p, Ap);
535 const double pAp = dot(p, Ap);
536 if (pAp <= 0.0)
537 break;
538 const double alpha = rr / pAp;
539 for (Index i = 0; i < n; ++i) {
540 x[static_cast<std::size_t>(i)] += alpha * p[static_cast<std::size_t>(i)];
541 r[static_cast<std::size_t>(i)] -= alpha * Ap[static_cast<std::size_t>(i)];
542 }
543 const double rrn = dot(r, r);
544 const double beta = rrn / rr;
545 for (Index i = 0; i < n; ++i)
546 p[static_cast<std::size_t>(i)] =
547 r[static_cast<std::size_t>(i)] + beta * p[static_cast<std::size_t>(i)];
548 rr = rrn;
549 }
550 }
551
552 static double dot(const std::vector<double>& a, const std::vector<double>& b) {
553 double s = 0.0;
554 for (std::size_t i = 0; i < a.size(); ++i)
555 s += a[i] * b[i];
556 return s;
557 }
558};
559
562struct CgResult {
563 int iters = 0;
564 double res0 = 0.0;
565 double res = 0.0;
566};
567
568inline CgResult amgPcg(const HostCsrOp& A, std::vector<double>& x, const std::vector<double>& b,
569 const GraphAMG& M, int maxIters = 500, double tol = 1e-8) {
570 const Index n = A.n;
571 const std::size_t nn = static_cast<std::size_t>(n);
572 std::vector<double> r(nn), z(nn), p(nn), Ap(nn);
573 auto dot = [&](const std::vector<double>& u, const std::vector<double>& v) {
574 double s = 0.0;
575 for (std::size_t i = 0; i < nn; ++i)
576 s += u[i] * v[i];
577 return s;
578 };
579 A.apply(x, Ap);
580 for (std::size_t i = 0; i < nn; ++i)
581 r[i] = b[i] - Ap[i];
582 CgResult R;
583 R.res0 = std::sqrt(dot(r, r));
584 if (R.res0 == 0.0)
585 return R;
586 M.apply(r, z);
587 p = z;
588 double rz = dot(r, z);
589 double rnorm = R.res0;
590 int it = 0;
591 for (; it < maxIters; ++it) {
592 A.apply(p, Ap);
593 const double pAp = dot(p, Ap);
594 if (pAp <= 0.0)
595 break;
596 const double alpha = rz / pAp;
597 for (std::size_t i = 0; i < nn; ++i) {
598 x[i] += alpha * p[i];
599 r[i] -= alpha * Ap[i];
600 }
601 rnorm = std::sqrt(dot(r, r));
602 if (rnorm <= tol * R.res0) {
603 ++it;
604 break;
605 }
606 M.apply(r, z);
607 const double rzNew = dot(r, z);
608 const double beta = rzNew / rz;
609 for (std::size_t i = 0; i < nn; ++i)
610 p[i] = z[i] + beta * p[i];
611 rz = rzNew;
612 }
613 R.iters = it;
614 R.res = rnorm;
615 return R;
616}
617
618} // namespace peclet::core::solver
619
620#endif // PECLET_CORE_SOLVER_GRAPH_AMG_HPP
Smoothed-aggregation AMG hierarchy usable as z = M⁻¹ r (one symmetric V-cycle from a zero initial gue...
Definition graph_amg.hpp:89
void apply(const std::vector< double > &r, std::vector< double > &z) const
z = M⁻¹ r: one V-cycle (correction scheme) from a zero initial guess.
void build(const HostCsrOp &A, const AmgParams &prm={})
Definition graph_amg.hpp:91
const AmgParams & params() const
const std::vector< Level > & levels() const
Index size(int L=0) const
double operatorComplexity() const
Total nonzeros / finest nonzeros — the grid+operator complexity (should stay ~<2 for a healthy hierar...
CgResult amgPcg(const HostCsrOp &A, std::vector< double > &x, const std::vector< double > &b, const GraphAMG &M, int maxIters=500, double tol=1e-8)
PECLET_HD Real dot(Vec3< Real > a, Vec3< Real > b)
Definition portable.hpp:70
Kokkos::View< T *, MemSpace > View
1D device array.
Definition view.hpp:26
std::int64_t Index
Signed index type for grids and particles (supersedes block_decomposer's long int IndxT).
Definition types.hpp:15
int coarseSweeps
coarsest level: 0 ⇒ a near-exact CG solve; >0 ⇒ this many smoother sweeps instead (with maxLevels=1 t...
Definition graph_amg.hpp:81
double jacobiOmega
damped-Jacobi smoother relaxation (used when chebDegree == 0)
Definition graph_amg.hpp:80
int chebDegree
Chebyshev smoother polynomial degree; 0 ⇒ damped-Jacobi smoother.
Definition graph_amg.hpp:79
double theta
strength-of-connection threshold (fraction of √(A_ii·A_jj))
Definition graph_amg.hpp:74
int ndofPerNode
block size for nodal aggregation (Voronoi positions ⇒ 3, +weight ⇒ 4)
Definition graph_amg.hpp:73
Index coarsest
stop coarsening once a level has ≤ this many DOFs
Definition graph_amg.hpp:77
int post
smoother sweeps per level (pre == post keeps the V-cycle symmetric)
Definition graph_amg.hpp:78
int maxLevels
hierarchy depth cap
Definition graph_amg.hpp:76
double smoothOmega
prolongator-smoothing damping (× 1/λ_max)
Definition graph_amg.hpp:75
int eigIters
power-iteration steps for the per-level λ_max(D⁻¹A) estimate
Definition graph_amg.hpp:84
PCG on A x = b preconditioned by an already-built GraphAMG (one V-cycle per iteration).
A general assembled sparse operator in CSR form: the diagonal is stored separately and the CSR (start...
Definition graph_amg.hpp:52
std::vector< double > coef
size nnz (off-diagonal values)
Definition graph_amg.hpp:57
std::vector< Index > start
size n+1, off-diagonal row offsets
Definition graph_amg.hpp:55
std::vector< Index > nbr
size nnz (off-diagonal column indices)
Definition graph_amg.hpp:56
std::vector< double > diag
size n
Definition graph_amg.hpp:54
void apply(const std::vector< double > &x, std::vector< double > &y) const
y = A x.
Definition graph_amg.hpp:60