core
Shared MPI block decomposition + asynchronous ghost-layer exchange (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
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 private:
133 struct Level {
134 HostCsrOp A;
135 // Prolongation to the next-coarser level (this level → L+1), row-CSR: fine row i has entries
136 // (Pcol, Pval) over coarse DOFs. Empty on the coarsest level.
137 std::vector<Index> Pstart, Pcol;
138 std::vector<double> Pval;
139 Index nc = 0; // number of coarse DOFs (columns of P)
140 // Smoother data:
141 std::vector<double> invDiag; // 1/diag (0 where diag == 0)
142 double lmax = 1.0; // spectral radius estimate of D⁻¹A on this level
143 // Scratch (mutable — apply() is logically const):
144 mutable std::vector<double> x, b, res, t0, t1;
145 };
146
147 AmgParams prm_;
148 mutable std::vector<Level> levels_;
149
150 static void allocScratch(Level& lv) {
151 const std::size_t n = static_cast<std::size_t>(lv.A.n);
152 lv.x.assign(n, 0.0);
153 lv.b.assign(n, 0.0);
154 lv.res.assign(n, 0.0);
155 lv.t0.assign(n, 0.0);
156 lv.t1.assign(n, 0.0);
157 }
158
159 // 1/diag + power-iteration estimate of λ_max(D⁻¹A) for the Chebyshev / Jacobi smoother bounds.
160 void finalizeSmoother(Level& lv) {
161 const std::size_t n = static_cast<std::size_t>(lv.A.n);
162 lv.invDiag.assign(n, 0.0);
163 for (std::size_t i = 0; i < n; ++i) {
164 const double d = lv.A.diag[i];
165 lv.invDiag[i] = (std::fabs(d) > 1e-300) ? 1.0 / d : 0.0;
166 }
167 // v ← D⁻¹A v (Rayleigh quotient in the standard inner product approximates ρ(D⁻¹A)). Seed with
168 // a high-frequency ±1 hash pattern, NOT a smooth vector: the dominant eigenvector of D⁻¹A is the
169 // highest-frequency mode, so a smooth seed overlaps it weakly and power iteration underestimates
170 // λ_max — which would let a higher-degree Chebyshev amplify the modes above the (too-low) bound.
171 std::vector<double> v(n, 0.0), w(n, 0.0);
172 for (std::size_t i = 0; i < n; ++i) {
173 // splitmix64 bit-mixing → a well-decorrelated ±1 vector (O(1) overlap with the top mode, so
174 // power iteration converges in a handful of steps). A poorly-mixed hash — e.g. i·odd & 1,
175 // which only preserves i's low bit — gives a degenerate, non-dominant pattern that never
176 // converges.
177 std::uint64_t z = static_cast<std::uint64_t>(i) + 0x9E3779B97F4A7C15ULL;
178 z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ULL;
179 z = (z ^ (z >> 27)) * 0x94D049BB133111EBULL;
180 z = z ^ (z >> 31);
181 v[i] = (z & 1ULL) ? 1.0 : -1.0;
182 }
183 double nrm = std::sqrt(dot(v, v));
184 if (nrm == 0.0) {
185 lv.lmax = 1.0;
186 return;
187 }
188 for (auto& e : v)
189 e /= nrm;
190 double lam = 1.0;
191 for (int k = 0; k < prm_.eigIters; ++k) {
192 lv.A.apply(v, w);
193 for (std::size_t i = 0; i < n; ++i)
194 w[i] *= lv.invDiag[i];
195 lam = dot(v, w); // v is unit-norm ⇒ Rayleigh quotient
196 const double wn = std::sqrt(dot(w, w));
197 if (wn == 0.0)
198 break;
199 for (std::size_t i = 0; i < n; ++i)
200 v[i] = w[i] / wn;
201 }
202 lv.lmax = (lam > 0.0) ? lam : 1.0;
203 }
204
205 // Build the coarse level (aggregation → tentative P₀ → smoothed P → Galerkin A_c). Returns false
206 // if the graph cannot be coarsened further (one aggregate).
207 bool coarsen(Level& lv, Level& next) {
208 const Index n = lv.A.n;
209 const int s = prm_.ndofPerNode;
210 const Index nNodes = n / s;
211
212 // --- 1. nodal strength-of-connection (Frobenius norm of each s×s block) ---
213 // blockFro[I] : map J -> ‖A_block(I,J)‖_F² (accumulated), plus the diagonal block norm.
214 std::vector<std::map<Index, double>> blk(static_cast<std::size_t>(nNodes));
215 std::vector<double> ddiag(static_cast<std::size_t>(nNodes), 0.0); // ‖diagonal block‖_F²
216 for (Index i = 0; i < n; ++i) {
217 const Index I = i / s;
218 ddiag[static_cast<std::size_t>(I)] += lv.A.diag[static_cast<std::size_t>(i)] *
219 lv.A.diag[static_cast<std::size_t>(i)];
220 for (Index k = lv.A.start[static_cast<std::size_t>(i)];
221 k < lv.A.start[static_cast<std::size_t>(i) + 1]; ++k) {
222 const Index J = lv.A.nbr[static_cast<std::size_t>(k)] / s;
223 const double c = lv.A.coef[static_cast<std::size_t>(k)];
224 if (J == I)
225 ddiag[static_cast<std::size_t>(I)] += c * c;
226 else
227 blk[static_cast<std::size_t>(I)][J] += c * c;
228 }
229 }
230 // Strong set per node: J strong to I iff ‖A(I,J)‖_F ≥ θ·√(‖A(I,I)‖_F·‖A(J,J)‖_F).
231 std::vector<std::vector<Index>> strong(static_cast<std::size_t>(nNodes));
232 const double th2 = prm_.theta * prm_.theta;
233 // (blk/ddiag hold SQUARED Frobenius norms, so square the criterion: ‖S‖_F² ≥ θ²·‖D_I‖_F·‖D_J‖_F
234 // = θ²·√(ddiag_I·ddiag_J).)
235 for (Index I = 0; I < nNodes; ++I)
236 for (auto& [J, fro2] : blk[static_cast<std::size_t>(I)])
239 strong[static_cast<std::size_t>(I)].push_back(J); // (blk iterates J in sorted order)
240 auto isStrong = [&](Index I, Index J) {
241 const auto& s = strong[static_cast<std::size_t>(I)];
242 return std::binary_search(s.begin(), s.end(), J);
243 };
244
245 // --- 2. greedy aggregation ---
246 std::vector<Index> agg(static_cast<std::size_t>(nNodes), -1);
247 Index nAgg = 0;
248 // Pass 1 — roots: a fully-unaggregated node with a fully-unaggregated strong neighbourhood
249 // seeds a new aggregate consisting of itself + those neighbours.
250 for (Index I = 0; I < nNodes; ++I) {
251 if (agg[static_cast<std::size_t>(I)] != -1)
252 continue;
253 bool ok = true;
254 for (Index J : strong[static_cast<std::size_t>(I)])
255 if (agg[static_cast<std::size_t>(J)] != -1) {
256 ok = false;
257 break;
258 }
259 if (!ok)
260 continue;
261 agg[static_cast<std::size_t>(I)] = nAgg;
262 for (Index J : strong[static_cast<std::size_t>(I)])
264 ++nAgg;
265 }
266 // Pass 2 — attach each leftover to the adjacent aggregate it is most strongly tied to.
267 for (Index I = 0; I < nNodes; ++I) {
268 if (agg[static_cast<std::size_t>(I)] != -1)
269 continue;
270 Index best = -1;
271 double bestw = -1.0;
272 for (Index J : strong[static_cast<std::size_t>(I)]) {
273 const Index a = agg[static_cast<std::size_t>(J)];
274 if (a < 0)
275 continue;
276 const double w = blk[static_cast<std::size_t>(I)][J];
277 if (w > bestw) {
278 bestw = w;
279 best = a;
280 }
281 }
282 if (best >= 0)
283 agg[static_cast<std::size_t>(I)] = best;
284 }
285 // Pass 3 — remaining nodes form fresh aggregates from their still-unaggregated strong clusters
286 // (a fully isolated node becomes a singleton aggregate).
287 for (Index I = 0; I < nNodes; ++I) {
288 if (agg[static_cast<std::size_t>(I)] != -1)
289 continue;
290 agg[static_cast<std::size_t>(I)] = nAgg;
291 for (Index J : strong[static_cast<std::size_t>(I)])
292 if (agg[static_cast<std::size_t>(J)] == -1)
294 ++nAgg;
295 }
296 if (nAgg <= 1 || nAgg == nNodes)
297 return false; // no useful coarsening
298
299 // --- 3. tentative prolongator P₀ (block piecewise-constant, unit-2-norm columns) ---
300 // Each aggregate → s coarse DOFs; fine DOF (I,d) maps to coarse DOF (agg(I)·s + d) with weight
301 // 1/√|agg(I)| (the QR normalisation of the constant near-nullspace mode over the aggregate).
302 std::vector<Index> cnt(static_cast<std::size_t>(nAgg), 0);
303 for (Index I = 0; I < nNodes; ++I)
304 ++cnt[static_cast<std::size_t>(agg[static_cast<std::size_t>(I)])];
305 std::vector<double> invSqrtCnt(static_cast<std::size_t>(nAgg));
306 for (Index a = 0; a < nAgg; ++a)
307 invSqrtCnt[static_cast<std::size_t>(a)] =
308 1.0 / std::sqrt(static_cast<double>(cnt[static_cast<std::size_t>(a)]));
309 const Index ncoarse = nAgg * s;
310
311 // --- 4. FILTERED prolongator smoothing P = (I − (ω/λ_max) D_F⁻¹ A^F) P₀ ---
312 // A^F is the filtered operator: off-diagonal entries between nodes that are NOT strongly
313 // connected are dropped and LUMPED into the diagonal (row sums, hence the constant nullspace,
314 // preserved). Filtering is the standard SA fill-control: smoothing along only the strong graph
315 // keeps P — and therefore the Galerkin coarse operator — sparse, so operator complexity stays
316 // bounded (unfiltered smoothing densifies the coarse levels and, through the lmax-dependent
317 // step, makes the whole hierarchy hypersensitive / erratic). P₀ row for fine DOF i=(I,d) is a
318 // single entry (agg(I)·s + d, invSqrtCnt).
319 const double sc = prm_.smoothOmega / lv.lmax;
320 auto p0col = [&](Index i) -> Index {
321 const Index I = i / s, d = i % s;
322 return agg[static_cast<std::size_t>(I)] * s + d;
323 };
324 auto p0val = [&](Index i) -> double {
325 return invSqrtCnt[static_cast<std::size_t>(agg[static_cast<std::size_t>(i / s)])];
326 };
327 std::vector<Index> Pstart(static_cast<std::size_t>(n) + 1, 0), Pcol;
328 std::vector<double> Pval;
329 std::map<Index, double> row;
330 for (Index i = 0; i < n; ++i) {
331 const Index I = i / s;
332 // Filtered diagonal: original diagonal + lumped weak off-diagonals.
333 double dF = lv.A.diag[static_cast<std::size_t>(i)];
334 for (Index k = lv.A.start[static_cast<std::size_t>(i)];
335 k < lv.A.start[static_cast<std::size_t>(i) + 1]; ++k) {
336 const Index J = lv.A.nbr[static_cast<std::size_t>(k)] / s;
337 if (J != I && !isStrong(I, J))
338 dF += lv.A.coef[static_cast<std::size_t>(k)]; // lump weak connection into the diagonal
339 }
340 row.clear();
341 row[p0col(i)] += p0val(i); // P₀ contribution
342 // − sc·(1/dF)·(A^F P₀)_i, with A^F_ii = dF and A^F_ij kept only for strong (or intra-node) j.
343 const double f = (std::fabs(dF) > 1e-300) ? (-sc / dF) : 0.0;
344 row[p0col(i)] += f * dF * p0val(i); // diagonal term ⇒ P₀_i·(1 − sc)
345 for (Index k = lv.A.start[static_cast<std::size_t>(i)];
346 k < lv.A.start[static_cast<std::size_t>(i) + 1]; ++k) {
347 const Index j = lv.A.nbr[static_cast<std::size_t>(k)];
348 const Index J = j / s;
349 if (J != I && !isStrong(I, J))
350 continue; // weak: already lumped into dF
351 row[p0col(j)] += f * lv.A.coef[static_cast<std::size_t>(k)] * p0val(j);
352 }
353 for (auto& [c, val] : row)
354 if (val != 0.0) {
355 Pcol.push_back(c);
356 Pval.push_back(val);
357 }
358 Pstart[static_cast<std::size_t>(i) + 1] = static_cast<Index>(Pcol.size());
359 }
360
361 // --- 5. Galerkin coarse operator A_c = PᵀAP (general sparse RAP) ---
362 // AP_i,: = diag_i·P_i,: + Σ_k coef_k·P_{nbr_k,:} (fine row → coarse columns)
363 // A_c[c1][c2] += Σ_i P[i][c1]·AP[i][c2]
364 std::vector<std::map<Index, double>> Ac(static_cast<std::size_t>(ncoarse));
365 std::map<Index, double> apRow;
366 for (Index i = 0; i < n; ++i) {
367 apRow.clear();
368 const double di = lv.A.diag[static_cast<std::size_t>(i)];
369 for (Index k = Pstart[static_cast<std::size_t>(i)];
371 apRow[Pcol[static_cast<std::size_t>(k)]] += di * Pval[static_cast<std::size_t>(k)];
372 for (Index k = lv.A.start[static_cast<std::size_t>(i)];
373 k < lv.A.start[static_cast<std::size_t>(i) + 1]; ++k) {
374 const Index j = lv.A.nbr[static_cast<std::size_t>(k)];
375 const double c = lv.A.coef[static_cast<std::size_t>(k)];
376 for (Index kk = Pstart[static_cast<std::size_t>(j)];
378 apRow[Pcol[static_cast<std::size_t>(kk)]] += c * Pval[static_cast<std::size_t>(kk)];
379 }
380 for (Index k = Pstart[static_cast<std::size_t>(i)];
382 const Index c1 = Pcol[static_cast<std::size_t>(k)];
383 const double p1 = Pval[static_cast<std::size_t>(k)];
384 auto& r1 = Ac[static_cast<std::size_t>(c1)];
385 for (auto& [c2, apv] : apRow)
386 r1[c2] += p1 * apv;
387 }
388 }
389
390 // Flatten A_c into the coarse HostCsrOp (drop structural zeros).
391 next.A.n = ncoarse;
392 next.A.diag.assign(static_cast<std::size_t>(ncoarse), 0.0);
393 next.A.start.assign(static_cast<std::size_t>(ncoarse) + 1, 0);
394 next.A.nbr.clear();
395 next.A.coef.clear();
396 for (Index c = 0; c < ncoarse; ++c) {
397 for (auto& [c2, val] : Ac[static_cast<std::size_t>(c)]) {
398 if (c2 == c) {
399 next.A.diag[static_cast<std::size_t>(c)] = val;
400 } else if (val != 0.0) {
401 next.A.nbr.push_back(c2);
402 next.A.coef.push_back(val);
403 }
404 }
405 next.A.start[static_cast<std::size_t>(c) + 1] = static_cast<Index>(next.A.nbr.size());
406 }
407
408 // Stash the transfer on the fine level.
409 lv.Pstart = std::move(Pstart);
410 lv.Pcol = std::move(Pcol);
411 lv.Pval = std::move(Pval);
412 lv.nc = ncoarse;
413 return true;
414 }
415
416 // ---- V-cycle ----
417 void vcycle(int L) const {
418 Level& lv = levels_[static_cast<std::size_t>(L)];
419 if (L + 1 == static_cast<int>(levels_.size())) {
420 coarseSolve(lv);
421 return;
422 }
423 smooth(lv, prm_.pre);
424 // res = b − A x
425 lv.A.apply(lv.x, lv.res);
426 for (std::size_t i = 0; i < lv.res.size(); ++i)
427 lv.res[i] = lv.b[i] - lv.res[i];
428 // restrict residual to coarse RHS (Pᵀ res), start coarse x at 0.
429 Level& cl = levels_[static_cast<std::size_t>(L + 1)];
430 std::fill(cl.b.begin(), cl.b.end(), 0.0);
431 for (Index i = 0; i < lv.A.n; ++i)
432 for (Index k = lv.Pstart[static_cast<std::size_t>(i)];
433 k < lv.Pstart[static_cast<std::size_t>(i) + 1]; ++k)
434 cl.b[static_cast<std::size_t>(lv.Pcol[static_cast<std::size_t>(k)])] +=
435 lv.Pval[static_cast<std::size_t>(k)] * lv.res[static_cast<std::size_t>(i)];
436 std::fill(cl.x.begin(), cl.x.end(), 0.0);
437 vcycle(L + 1);
438 // prolong + correct: x += P x_c
439 for (Index i = 0; i < lv.A.n; ++i) {
440 double s = 0.0;
441 for (Index k = lv.Pstart[static_cast<std::size_t>(i)];
442 k < lv.Pstart[static_cast<std::size_t>(i) + 1]; ++k)
443 s += lv.Pval[static_cast<std::size_t>(k)] *
444 cl.x[static_cast<std::size_t>(lv.Pcol[static_cast<std::size_t>(k)])];
445 lv.x[static_cast<std::size_t>(i)] += s;
446 }
447 smooth(lv, prm_.post);
448 }
449
450 // Smoother: 4th-kind Chebyshev of degree prm_.chebDegree (default), or damped Jacobi when
451 // chebDegree == 0. Both read only the previous iterate per matvec and are symmetric polynomials
452 // in D⁻¹A ⇒ a valid SPD CG preconditioner with pre == post.
453 void smooth(const Level& lv, int sweeps) const {
454 if (sweeps <= 0)
455 return;
456 if (prm_.chebDegree <= 0) {
457 for (int s = 0; s < sweeps; ++s)
458 jacobiSweep(lv);
459 return;
460 }
461 for (int s = 0; s < sweeps; ++s)
462 chebSweep(lv);
463 }
464
465 void jacobiSweep(const Level& lv) const {
466 // Spectrally-scaled damped Jacobi: step = ω/λ_max(D⁻¹A). Scaling by λ_max is essential — a fixed
467 // ω is only stable when ω·λ_max < 2, which fails on operators with a large D⁻¹A spectrum (e.g. the
468 // Gauss-Newton Hessian JᵀJ), where an unscaled sweep diverges. ω≈1 is a decent smoother; the
469 // optimal high-frequency-damping value is 4/3 (== the 4th-kind Chebyshev degree-1 step).
470 const double step = prm_.jacobiOmega / lv.lmax;
471 lv.A.apply(lv.x, lv.res); // res = A x
472 for (std::size_t i = 0; i < lv.x.size(); ++i)
473 lv.x[i] += step * lv.invDiag[i] * (lv.b[i] - lv.res[i]);
474 }
475
476 // One 4th-kind Chebyshev smoothing pass of degree k on A x = b (in place), Jacobi (D) prec.
477 // (Lottes 2022 / Phillips & Fischer 2022.) Unlike the 1st-kind form it needs ONLY an upper bound
478 // λ on ρ(D⁻¹A) — no lower-bound guess to get wrong on the Galerkin coarse levels — and its first
479 // step is exactly the optimal 4/(3λ) damped-Jacobi step. Guaranteed a good, symmetric smoother.
480 void chebSweep(const Level& lv) const {
481 const int k = prm_.chebDegree;
482 const double lam = 1.1 * lv.lmax; // safety over-estimate: λ must bound the true spectral radius
483 auto& r = lv.res;
484 auto& d = lv.t0;
485 auto& Ad = lv.t1;
486 lv.A.apply(lv.x, r); // r = b − A x
487 for (std::size_t i = 0; i < r.size(); ++i)
488 r[i] = lv.b[i] - r[i];
489 std::fill(d.begin(), d.end(), 0.0);
490 for (int i = 1; i <= k; ++i) {
491 const double c1 = (2.0 * i - 3.0) / (2.0 * i + 1.0);
492 const double c2 = (8.0 * i - 4.0) / ((2.0 * i + 1.0) * lam);
493 for (std::size_t j = 0; j < d.size(); ++j)
494 d[j] = c1 * d[j] + c2 * lv.invDiag[j] * r[j];
495 for (std::size_t j = 0; j < lv.x.size(); ++j)
496 lv.x[j] += d[j];
497 if (i < k) {
498 lv.A.apply(d, Ad); // r −= A d
499 for (std::size_t j = 0; j < r.size(); ++j)
500 r[j] -= Ad[j];
501 }
502 }
503 }
504
505 // Coarsest level: a short unpreconditioned CG (the level is tiny, ≤ coarsest DOFs) — robust even
506 // if A_c is only semidefinite (a consistent RHS still converges on the range).
507 void coarseSolve(Level& lv) const {
508 if (prm_.coarseSweeps > 0) { // single-level / cheap-bottom mode: smoother sweeps, no CG
509 smooth(lv, prm_.coarseSweeps);
510 return;
511 }
512 const Index n = lv.A.n;
513 auto& x = lv.x;
514 auto& r = lv.res;
515 auto& p = lv.t0;
516 auto& Ap = lv.t1;
517 lv.A.apply(x, r);
518 for (Index i = 0; i < n; ++i)
519 r[static_cast<std::size_t>(i)] =
520 lv.b[static_cast<std::size_t>(i)] - r[static_cast<std::size_t>(i)];
521 p = r;
522 double rr = dot(r, r);
523 const double rr0 = rr;
524 const int maxit = static_cast<int>(std::min<Index>(n, 200));
525 for (int k = 0; k < maxit && rr > 1e-24 * rr0; ++k) {
526 lv.A.apply(p, Ap);
527 const double pAp = dot(p, Ap);
528 if (pAp <= 0.0)
529 break;
530 const double alpha = rr / pAp;
531 for (Index i = 0; i < n; ++i) {
532 x[static_cast<std::size_t>(i)] += alpha * p[static_cast<std::size_t>(i)];
533 r[static_cast<std::size_t>(i)] -= alpha * Ap[static_cast<std::size_t>(i)];
534 }
535 const double rrn = dot(r, r);
536 const double beta = rrn / rr;
537 for (Index i = 0; i < n; ++i)
538 p[static_cast<std::size_t>(i)] =
539 r[static_cast<std::size_t>(i)] + beta * p[static_cast<std::size_t>(i)];
540 rr = rrn;
541 }
542 }
543
544 static double dot(const std::vector<double>& a, const std::vector<double>& b) {
545 double s = 0.0;
546 for (std::size_t i = 0; i < a.size(); ++i)
547 s += a[i] * b[i];
548 return s;
549 }
550};
551
554struct CgResult {
555 int iters = 0;
556 double res0 = 0.0;
557 double res = 0.0;
558};
559
560inline CgResult amgPcg(const HostCsrOp& A, std::vector<double>& x, const std::vector<double>& b,
561 const GraphAMG& M, int maxIters = 500, double tol = 1e-8) {
562 const Index n = A.n;
563 const std::size_t nn = static_cast<std::size_t>(n);
564 std::vector<double> r(nn), z(nn), p(nn), Ap(nn);
565 auto dot = [&](const std::vector<double>& u, const std::vector<double>& v) {
566 double s = 0.0;
567 for (std::size_t i = 0; i < nn; ++i)
568 s += u[i] * v[i];
569 return s;
570 };
571 A.apply(x, Ap);
572 for (std::size_t i = 0; i < nn; ++i)
573 r[i] = b[i] - Ap[i];
574 CgResult R;
575 R.res0 = std::sqrt(dot(r, r));
576 if (R.res0 == 0.0)
577 return R;
578 M.apply(r, z);
579 p = z;
580 double rz = dot(r, z);
581 double rnorm = R.res0;
582 int it = 0;
583 for (; it < maxIters; ++it) {
584 A.apply(p, Ap);
585 const double pAp = dot(p, Ap);
586 if (pAp <= 0.0)
587 break;
588 const double alpha = rz / pAp;
589 for (std::size_t i = 0; i < nn; ++i) {
590 x[i] += alpha * p[i];
591 r[i] -= alpha * Ap[i];
592 }
593 rnorm = std::sqrt(dot(r, r));
594 if (rnorm <= tol * R.res0) {
595 ++it;
596 break;
597 }
598 M.apply(r, z);
599 const double rzNew = dot(r, z);
600 const double beta = rzNew / rz;
601 for (std::size_t i = 0; i < nn; ++i)
602 p[i] = z[i] + beta * p[i];
603 rz = rzNew;
604 }
605 R.iters = it;
606 R.res = rnorm;
607 return R;
608}
609
610} // namespace peclet::core::solver
611
612#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
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)
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