OGS
AndersonAcceleration.cpp
Go to the documentation of this file.
1// SPDX-FileCopyrightText: Copyright (c) OpenGeoSys Community (opengeosys.org)
2// SPDX-License-Identifier: BSD-3-Clause
3
5
6#include <spdlog/fmt/ranges.h>
7
8#include <Eigen/LU>
9#include <algorithm>
10
11#include "AndersonWeights.h"
12#include "BaseLib/Error.h"
13#include "BaseLib/Logging.h"
16
17namespace NumLib
18{
19namespace detail
20{
21Eigen::VectorXd computeAndersonWeights(Eigen::MatrixXd G)
22{
23 int const history_size = static_cast<int>(G.rows());
24
25 // At least one residual is required: G is the Gram matrix of the stored
26 // steps, so maxCoeff() below is a reduction over a non-empty diagonal.
27 if (history_size < 1)
28 {
30 "Anderson acceleration: the mixing weights were requested for an "
31 "empty history (Gram matrix of size {:d}x{:d}).",
32 history_size, static_cast<int>(G.cols()));
33 }
34
35 // Unit weight on the newest stored step, i.e. the plain (damped) Picard
36 // update. Used whenever the stored history does not admit trustworthy
37 // mixing weights.
38 auto const newest_step_only = [history_size]()
39 {
40 Eigen::VectorXd theta = Eigen::VectorXd::Zero(history_size);
41 theta(history_size - 1) = 1.0;
42 return theta;
43 };
44
45 double const g_scale = G.diagonal().maxCoeff();
46 // Negated comparison, so that a NaN scale takes this branch as well.
47 if (!(g_scale > 0.0))
48 {
49 // All stored steps vanish; there is nothing left to mix.
50 return newest_step_only();
51 }
52 G /= g_scale;
53
54 Eigen::MatrixXd M(history_size + 1, history_size + 1);
55 M.topLeftCorner(history_size, history_size) = G;
56 M.topRightCorner(history_size, 1).setOnes();
57 M.bottomLeftCorner(1, history_size).setOnes();
58 M(history_size, history_size) = 0.0;
59
60 Eigen::VectorXd rhs_aa(history_size + 1);
61 rhs_aa.setZero();
62 rhs_aa(history_size) = 1.0;
63
64 Eigen::VectorXd const theta =
65 M.fullPivLu().solve(rhs_aa).head(history_size);
66
67 if (!theta.allFinite())
68 {
69 // A fallback silently changes the iterate the solver would otherwise
70 // take, so it is reported at INFO level rather than hidden in DBUG.
71 INFO(
72 "Anderson acceleration: the mixing weights came out non-finite. "
73 "Falling back to the plain Picard step for this iteration.");
74 return newest_step_only();
75 }
76
77 // The rank-revealing solve above never fails outright, so the weights have
78 // to be validated on their own merits. Note that an invertibility test on
79 // M would be the wrong check: it rejects healthy histories whose residual
80 // norms span many orders of magnitude (the normal situation for a
81 // converging iteration) while passing the ill-conditioned cases that
82 // actually do harm.
83 //
84 // (a) Descent: the mixture only earns its place if the residual norm it
85 // predicts is smaller than that of the plain step it would replace.
86 // For a degenerate history - linearly dependent steps, in particular
87 // duplicates - the minimizer is not unique and the solve returns an
88 // arbitrary one, which this test discards.
89 // Both norms are read off the same normalized G (divided by g_scale
90 // above), so the shared 1/g_scale factor cancels and the comparison is
91 // exactly the one on the unscaled residuals.
92 double const mixed_residual_norm_2 = theta.dot(G * theta);
93 double const newest_step_norm_2 = G(history_size - 1, history_size - 1);
94
95 // (b) No long lever arms: weights far outside [0, 1] mean the mixed
96 // iterate is a difference of near-identical vectors, i.e. dominated by
97 // cancellation. Healthy histories stay at |theta| ~ 1, whereas steps
98 // that agree to k digits produce weights of magnitude 10^k.
99 //
100 // The threshold is a heuristic, not a derived bound. A weight of
101 // magnitude 10^k sacrifices about k of the ~16 significant decimal
102 // digits of a double to cancellation; capping at 10^2 admits the
103 // modest lever arms of genuinely useful mixing (empirically |theta| up
104 // to ~10) while rejecting the 10^3-and-up weights that signal a
105 // degenerate history. It is deliberately loose: the descent test (a)
106 // is the primary guard, and this one only catches the cancellation
107 // cases that slip past it.
108 constexpr double max_weight = 1e2;
109
110 // (c) No exact cancellation of substantial steps: guards (a) and (b) both
111 // miss the case of exactly (or near-exactly) collinear stored steps
112 // whose magnitudes differ enough that cancelling them needs only
113 // modest weights - e.g. two steps in a 2x ratio need theta = (2, -1),
114 // well inside the max_weight cap, yet a rank-deficient G lets their
115 // combination be driven to an exactly-zero model residual regardless
116 // of how far either step actually is from the fixed point.
117 // That is trustworthy only when it is explained by a stored step that
118 // is already that small on its own (the ZeroResidualGetsFullWeight
119 // case, where G has a genuine zero diagonal entry): if every stored
120 // step still has a non-negligible norm, an exactly-zero mixed
121 // residual can only be an algebraic artefact of the steps happening
122 // to be parallel, not evidence of proximity to the fixed point.
123 constexpr double numerically_zero = 1e-8;
124 bool const exact_cancellation_of_substantial_steps =
125 mixed_residual_norm_2 < numerically_zero &&
126 G.diagonal().minCoeff() > numerically_zero;
127
128 // Negated comparison, so that a NaN norm takes the fallback as well.
129 if (!(mixed_residual_norm_2 < newest_step_norm_2) ||
130 theta.cwiseAbs().maxCoeff() > max_weight ||
131 exact_cancellation_of_substantial_steps)
132 {
133 // A fallback silently changes the iterate the solver would otherwise
134 // take, so it is reported at INFO level rather than hidden in DBUG.
135 INFO(
136 "Anderson acceleration: rejected the mixture of {:d} stored steps "
137 "(predicted residual {:g} vs. {:g} for the plain step, largest "
138 "weight {:g}). Falling back to the plain Picard step for this "
139 "iteration.",
140 history_size, mixed_residual_norm_2, newest_step_norm_2,
141 theta.cwiseAbs().maxCoeff());
142 return newest_step_only();
143 }
144
145 return theta;
146}
147
148} // namespace detail
149
151 : _depth(depth), _gram(depth, depth)
152{
154 {
155 _history.reserve(_depth);
156 }
157}
158
160{
161 for (auto const& entry : _history)
162 {
163 releaseHistoryEntry(entry);
164 }
165}
166
172
174 GlobalVector& x_new)
175{
176 namespace LinAlg = MathLib::LinAlg;
177
178 // A depth below min_mixing_depth admits no mixing (plain Picard); nothing
179 // is stored.
181 {
182 return;
183 }
184
185 // Additionally mixes the last _depth damped steps
186 // f_i = beta*(g(x_i) - x_i) (i.e. x_new - x_old computed after the beta
187 // relaxation already applied by the caller) to find the optimal theta
188 // minimising ||sum theta_i f_i|| s.t. sum theta_i = 1, then sets
189 // x_new = sum theta_i*(x_i + f_i).
190
191 // Whether the circular buffer is full and the oldest entry is about to be
192 // evicted (needed for the incremental Gram update).
193 bool const rotated = static_cast<int>(_history.size()) == _depth;
194 if (!rotated)
195 {
196 // The id out-params are unused: the provider allocates a fresh vector
197 // on every call and never re-fetches by id.
198 std::size_t x_id = 0u;
199 std::size_t f_id = 0u;
200 _history.push_back(
203 }
204 else
205 {
206 // Recycle the oldest entry as the newest one.
207 std::rotate(_history.begin(), _history.begin() + 1, _history.end());
208 }
209
210 auto const& newest = _history.back();
211
212 // x = x_old, f = x_new - x_old
213 LinAlg::copy(x_old, *newest.x);
214 LinAlg::copy(x_new, *newest.f);
215 LinAlg::axpy(*newest.f, -1.0, x_old);
216
217 // Actual window size, <= _depth while the buffer fills.
218 int const history_size = static_cast<int>(_history.size());
219
220 // Incrementally maintain the (history_size x history_size) Gram matrix
221 // G = F^T F whose columns are the stored damped steps f_0 ...
222 // f_{history_size-1}. All steps but the newest are unchanged from the
223 // previous iteration, so only the last row/column is recomputed -
224 // history_size dot products instead of a full
225 // history_size*(history_size+1)/2 rebuild. On a rotate the oldest entry
226 // (index 0) was evicted, so the cached block is first shifted up-left by
227 // one.
228 if (rotated)
229 {
230 _gram.topLeftCorner(history_size - 1, history_size - 1) =
231 _gram.block(1, 1, history_size - 1, history_size - 1).eval();
232 }
233 for (int i = 0; i < history_size; ++i)
234 {
235 double const d = LinAlg::dot(*_history[i].f, *newest.f);
236 _gram(i, history_size - 1) = d;
237 _gram(history_size - 1, i) = d;
238 }
239
240 // A single stored step needs no mixing: the sum-to-one constraint forces
241 // theta = (1), which just reproduces the damped step already held in x_new.
242 if (history_size < min_mixing_depth)
243 {
244 return;
245 }
246
247 // Solve G theta = e (least-squares) with the constraint sum theta_i = 1 via
248 // a simple Lagrange formulation:
249 //
250 // [ G 1 ] [ theta ] = [ 0 ]
251 // [ 1 0 ] [ lambda ] [ 1 ]
252 //
253 // The beta factor scales G by beta^2 and cancels in theta, so the weights
254 // are identical to the undamped case. The Anderson update is then:
255 // x_anderson = sum_i theta_i * (x_i + f_i)
256 // = sum_i theta_i * (x_i + beta*(g(x_i)-x_i))
257 // = sum_i theta_i * ((1-beta)*x_i + beta*g(x_i))
258 Eigen::MatrixXd const G = _gram.topLeftCorner(history_size, history_size);
259
260 Eigen::VectorXd const theta = detail::computeAndersonWeights(G);
261
262 // Accumulate the Anderson mixed iterate directly into x_new. Its previous
263 // value is no longer needed: the newest step was already extracted from it
264 // above, and it is not aliased by any history entry (those are independent
265 // copies).
266 x_new.setZero();
267 for (int i = 0; i < history_size; ++i)
268 {
269 // x_new += theta_i * (x_i + f_i)
270 LinAlg::axpy(x_new, theta(i), *_history[i].x);
271 LinAlg::axpy(x_new, theta(i), *_history[i].f);
272 }
273
274 DBUG("Picard/Anderson: history size {:d}, theta=[{:.4g}]", history_size,
275 fmt::join(theta.data(), theta.data() + history_size, ", "));
276}
277
279{
280 // Drop the just-added (now stale) history entry, since the iteration is
281 // being repeated. In the full-buffer case this is the recycled slot;
282 // releasing it by reference is safe because the provider tracks vectors by
283 // pointer, not by the (unused) id.
284 //
285 // The rotation and the Gram shift performed by accelerate() are not undone,
286 // and need not be: dropping the newest entry leaves the buffer holding the
287 // remaining entries in order, and the shifted top-left block of the Gram
288 // matrix is exactly their Gram matrix. The oldest entry stays evicted,
289 // which merely shortens the sliding window by one.
290 if (!_history.empty())
291 {
293 _history.pop_back();
294 }
295}
296
297} // namespace NumLib
#define OGS_FATAL(...)
Definition Error.h:10
MathLib::EigenVector GlobalVector
void INFO(fmt::format_string< Args... > fmt, Args &&... args)
Definition Logging.h:28
void DBUG(fmt::format_string< Args... > fmt, Args &&... args)
Definition Logging.h:22
void accelerate(GlobalVector const &x_old, GlobalVector &x_new)
static constexpr int min_mixing_depth
std::vector< HistoryEntry > _history
Circular buffer of history entries, oldest first (size <= _depth).
static void releaseHistoryEntry(HistoryEntry const &entry)
Returns entry's vectors to the global vector provider.
double dot(PETScVector const &a, PETScVector const &b)
Definition LinAlg.cpp:64
void copy(PETScVector const &x, PETScVector &y)
Definition LinAlg.cpp:30
void axpy(PETScVector &y, PetscScalar const a, PETScVector const &x)
Definition LinAlg.cpp:50
Eigen::VectorXd computeAndersonWeights(Eigen::MatrixXd G)
static NUMLIB_EXPORT VectorProvider & provider