Source code for struphy.linear_algebra.schur_solver

from feectools.linalg.basic import IdentityOperator, LinearOperator, Vector
from feectools.linalg.block import BlockLinearOperator, BlockVector
from feectools.linalg.solvers import inverse
from line_profiler import profile
from scope_profiler import ProfileManager

from struphy.feec.mass import WeightedMassOperator
from struphy.linear_algebra.solver import SolverParameters


[docs] class SchurOperator(LinearOperator): r"""The operator :math:`A + c\, \mathbb B_W\, BC\, \mathbb B_V^\top` of :class:`SchurSolver`, applied lazily (without forming the sum as a matrix): :math:`A` and :math:`BC` are applied one after the other and the results are added. :math:`\mathbb B_V` and :math:`\mathbb B_W` are the boundary operators of :math:`A` if :math:`A` is a :class:`~struphy.feec.mass.WeightedMassOperator` (identities otherwise). The operator is thus the same as the matrix sum :math:`A + c\, BC` assembled into :math:`A` (whose boundary operators are applied to the whole sum). Unlike the matrix sum, it also works if :math:`BC` is not a matrix of the tensor-product spline space, e.g. a product of discrete derivatives and mass matrices on polar splines, or a matrix-free operator. Parameters ---------- A : LinearOperator First summand. BC : LinearOperator Second summand (without the factor :math:`c` and the boundary operators). scalar : float The factor :math:`c`, can be changed with :attr:`scalar` (e.g. :math:`-\Delta t^2` for a new time step). """ def __init__(self, A: LinearOperator, BC: LinearOperator, scalar: float = 1.0): assert A.domain == BC.domain assert A.codomain == BC.codomain self._A = A self._BC = BC self._scalar = scalar # boundary operators of A (the matrix sum applies them to BC too); skipped if they are identities self._bv = None self._bw = None if isinstance(A, WeightedMassOperator): bv, bw = A.boundary_ops if A.transposed: # the boundary operators of V act on the codomain side bv, bw = bw, bv if not isinstance(bv, IdentityOperator): self._bv = bv.T self._tmp_v = A.domain.zeros() if not isinstance(bw, IdentityOperator): self._bw = bw self._tmp_w = A.codomain.zeros() self._tmp = A.codomain.zeros() @property def A(self): """First summand :math:`A`.""" return self._A @property def BC(self): """Second summand :math:`BC`.""" return self._BC @property def scalar(self): """The factor :math:`c` of :math:`BC`.""" return self._scalar @scalar.setter def scalar(self, c): self._scalar = c @property def domain(self): return self._A.domain @property def codomain(self): return self._A.codomain @property def dtype(self): return self._A.dtype
[docs] def tosparse(self): raise NotImplementedError()
[docs] def toarray(self): raise NotImplementedError()
[docs] def transpose(self, conjugate=False): return SchurOperator( self._A.transpose(conjugate=conjugate), self._BC.transpose(conjugate=conjugate), self._scalar )
[docs] def dot(self, v, out=None): """``out = A v + c B_W BC B_V^T v`` (no temporaries are allocated if ``out`` is given).""" assert isinstance(v, Vector) assert v.space == self.domain if out is None: out = self.codomain.zeros() else: assert isinstance(out, Vector) assert out.space == self.codomain if out is v: v = v.copy() x = v if self._bv is None else self._bv.dot(v, out=self._tmp_v) if self._bw is None: y = self._BC.dot(x, out=self._tmp) else: self._BC.dot(x, out=self._tmp_w) y = self._bw.dot(self._tmp_w, out=self._tmp) y *= self._scalar self._A.dot(v, out=out) out += y return out
[docs] class SchurSolver: r"""Solves for :math:`x^{n+1}` in the block system .. math:: \begin{pmatrix} A & \Delta t\, B \\ \Delta t\, C & \mathbb I \end{pmatrix} \begin{pmatrix} x^{n+1} \\ y^{n+1} \end{pmatrix} = \begin{pmatrix} A & - \Delta t\, B \\ - \Delta t\, C & \mathbb I \end{pmatrix} \begin{pmatrix} x^n \\ y^n \end{pmatrix} using the Schur complement :math:`S = A - \Delta t^2 BC`, where :math:`\mathbb I` is the identity matrix and :math:`(x^n, y^n)` is given. The solution is .. math:: x^{n+1} = S^{-1} \left[ (A + \Delta t^2 BC) \, x^n - 2 \Delta t\, B \, y^n \right] \,. The caller passes :math:`B y^n` and computes :math:`y^{n+1} = y^n - \Delta t\, C (x^{n+1} + x^n)` itself. If :math:`A` and :math:`BC` are both :class:`~struphy.feec.mass.WeightedMassOperator` of the same spaces (e.g. :math:`BC` is an accumulated particle matrix), :math:`S` is assembled as a matrix (in a copy of :math:`A`). Otherwise (e.g. :math:`BC` is a product of discrete derivatives and mass matrices, or a matrix-free operator), :math:`S` is applied lazily as a :class:`SchurOperator`, which also works with polar splines. Parameters ---------- A : LinearOperator Upper left block :math:`A`. BC : LinearOperator Product :math:`BC` of the off-diagonal blocks (same domain and codomain as :math:`A`). solver_name : str Name of the iterative solver, passed to ``feectools.linalg.solvers.inverse`` (e.g. ``"pcg"``, ``"cg"``, ``"pbicgstab"``, ``"gmres"``). precond : LinearOperator, optional Preconditioner of the Schur complement :math:`S` (can be replaced later with :attr:`precond`). solver_params : SolverParameters, optional Solver controls (``tol``, ``maxiter``, ...); defaults to ``SolverParameters()``. """ def __init__( self, A: LinearOperator, BC: LinearOperator, solver_name: str, precond=None, # TODO: add Preconditioner base class solver_params: SolverParameters = None, ): assert isinstance(A, LinearOperator) assert isinstance(BC, LinearOperator) assert A.domain == BC.domain assert A.codomain == BC.codomain if solver_params is None: solver_params = SolverParameters() # linear operators self._A = A self._BC = BC # left- and right-hand side operators A - dt^2 BC and A + dt^2 BC (see update_operators): copies of A for the # matrix sum (allocated at the first solve), lazy SchurOperators otherwise (rebuilt when A or BC is replaced) self._schur = None self._rhs_m = None self._lazy_ops = None # initialize solver with dummy matrix A self._solver_name = solver_name kwargs = solver_params.__dict__.copy() kwargs.pop("info") if precond is not None: kwargs["pc"] = precond self._solver_kwargs = kwargs self._solver = inverse(A, solver_name, **kwargs) # right-hand side vector (avoids temporary memory allocation!) self._rhs = A.codomain.zeros() @property def A(self): """Upper left block :math:`A`.""" return self._A @property def BC(self): """Product :math:`BC` of the off-diagonal blocks.""" return self._BC @property def precond(self): """Preconditioner of the Schur system (None without preconditioner).""" return self._solver_kwargs.get("pc") @precond.setter def precond(self, pc): """Replace the preconditioner, e.g. by one of the Schur system for the current dt (built at the first solve).""" self._solver_kwargs.pop("pc", None) if pc is not None: self._solver_kwargs["pc"] = pc self._solver = inverse(self._A, self._solver_name, **self._solver_kwargs) @A.setter def A(self, a): """Upper left block :math:`A`.""" self._A = a # copies of the old A (with its boundary operators) for the matrix sum, rebuilt at the next solve self._schur = None self._rhs_m = None self._lazy_ops = None @BC.setter def BC(self, bc): """Product :math:`BC` of the off-diagonal blocks.""" self._BC = bc self._lazy_ops = None @property def matrix_sum(self) -> bool: """Whether :math:`S` is assembled as a matrix (:math:`A` and :math:`BC` are weighted mass operators of the same spaces) or applied lazily as a :class:`SchurOperator`.""" return ( isinstance(self._A, WeightedMassOperator) and isinstance(self._BC, WeightedMassOperator) and self._BC.domain is self._A.domain and self._BC.codomain is self._A.codomain )
[docs] def update_operators(self, dt: float): r"""Set the left- and right-hand side operators :math:`S = A - \Delta t^2 BC` (also the operator of the iterative solver) and :math:`A + \Delta t^2 BC` for the time step ``dt``. Returns ------- schur, rhs_m : LinearOperator The left- and right-hand side operators. """ if self.matrix_sum: if self._schur is None: self._schur = self._A.copy() self._rhs_m = self._A.copy() self._schur *= 0.0 self._schur += self._BC self._schur *= -(dt**2) self._schur += self._A self._rhs_m *= 0.0 self._rhs_m += self._BC self._rhs_m *= dt**2 self._rhs_m += self._A schur, rhs_m = self._schur, self._rhs_m else: if self._lazy_ops is None: self._lazy_ops = (SchurOperator(self._A, self._BC), SchurOperator(self._A, self._BC)) schur, rhs_m = self._lazy_ops schur.scalar = -(dt**2) rhs_m.scalar = dt**2 # use setter to update lhs matrix self._solver.linop = schur return schur, rhs_m
@profile @ProfileManager.profile("solve: SchurSolver") def __call__(self, xn, Byn, dt, out=None): """Solves the 2x2 block matrix linear system. Parameters ---------- xn : feectools.linalg.basic.Vector Solution from previous time step. Byn : feectools.linalg.basic.Vector The product B*yn. dt : float Time step size. out : feectools.linalg.basic.Vector, optional If given, the converged solution will be written into this vector (in-place). Returns ------- out : feectools.linalg.basic.Vector Converged solution. info : dict Convergence information. """ assert isinstance(xn, Vector) assert isinstance(Byn, Vector) assert xn.space == self._A.domain assert Byn.space == self._A.codomain # left- and right-hand side operators _, rhs_m = self.update_operators(dt) # right-hand side vector rhs = 2*dt*[ rhs_m/(2*dt) @ xn - Byn ] (in-place!) rhs = rhs_m.dot(xn, out=self._rhs) rhs /= 2 * dt rhs -= Byn rhs *= 2 * dt # solve linear system (in-place if out is not None) x = self._solver.dot(rhs, out=out) return x, self._solver._info
[docs] class SchurSolverFull: r"""Solves the block system .. math:: \begin{pmatrix} A & B \\ C & \mathbb I \end{pmatrix} \begin{pmatrix} x \\ y \end{pmatrix} = \begin{pmatrix} b_x \\ b_y \end{pmatrix} using the Schur complement :math:`S = A - BC`, where :math:`\mathbb I` is the identity matrix and :math:`(b_x, b_y)^\top` is given. The solution is .. math:: x &= S^{-1} \, (b_x - B b_y ) \,, \\ y &= b_y - C x \,. Parameters ---------- M : BlockLinearOperator Block matrix :math:`[[A, B], [C, \mathbb I]]`. solver_name : str Name of the iterative solver, passed to ``feectools.linalg.solvers.inverse`` (e.g. ``"pcg"``, ``"cg"``, ``"pbicgstab"``, ``"gmres"``), or ``"rpbicgstab"``: BiCGStab (``"bicgstab"``) for :math:`S P` with the preconditioner :math:`P` (right preconditioning), then :math:`x = P\, x'`. It needs two products with :math:`P` per iteration (``"pbicgstab"`` three), with the same stopping criterion (residual of :math:`S x`). **solver_params : Keyword arguments of the chosen solver; must contain ``pc`` (the preconditioner, None for none). """ def __init__(self, M, solver_name, **solver_params): assert isinstance(M, BlockLinearOperator) assert M.domain == M.codomain # solve square system # initialize solver with dummy matrix A self._solver_name = solver_name if solver_params["pc"] is None: solver_params.pop("pc") self._M = M self._A = M[0, 0] self._B = M[0, 1] self._C = M[1, 0] assert isinstance(M[1, 1], IdentityOperator) self._S = self._A - self._B @ self._C self._solver_params = solver_params self._build_solver() # right-hand side vector (avoids temporary memory allocation!) self._rhs = self._A.codomain.zeros() self._tmp_x = self._A.domain.zeros() def _build_solver(self): """The solver of the Schur system, with right preconditioning for ``"rpbicgstab"``.""" if self._solver_name == "rpbicgstab": params = {k: v for k, v in self._solver_params.items() if k != "pc"} self._right_pc = self._solver_params.get("pc") op = self._S if self._right_pc is None else self._S @ self._right_pc self._solver = inverse(op, "bicgstab", **params) else: self._right_pc = None self._solver = inverse(self._S, self._solver_name, **self._solver_params) @property def precond(self): """Preconditioner of the Schur system (None without preconditioner).""" return self._solver_params.get("pc") @precond.setter def precond(self, pc): """Replace the preconditioner, e.g. by one of the Schur system for the current dt.""" self._solver_params.pop("pc", None) if pc is not None: self._solver_params["pc"] = pc self._build_solver()
[docs] @profile @ProfileManager.profile("solve: SchurSolverFull") def dot(self, v, out=None): """Solves the 2x2 block matrix linear system. Parameters ---------- v : feectools.linalg.basic.Vector Left hand side of the system. out : feectools.linalg.basic.Vector, optional If given, the converged solution will be written into this vector (in-place). Returns ------- out : feectools.linalg.block.BLockVector Converged solution. info : dict Convergence information. """ assert isinstance(v, BlockVector) assert v.space == self._M.domain if out is None: out = self._M.codomain.zeros() else: assert out.space == self._M.codomain bx = v[0] by = v[1] # right-hand side vector rhs bx - B by rhs = self._B.dot(by, out=self._rhs) rhs *= -1 rhs += bx # solve linear system (in-place if out is not None) if self._right_pc is None: x = self._solver.dot(rhs, out=out[0]) else: xp = self._solver.dot(rhs, out=self._tmp_x) x = self._right_pc.dot(xp, out=out[0]) y = self._C.dot(x, out=out[1]) y *= -1 y += by return out
[docs] class SchurSolverFull3: r"""Solves the block system .. math:: \begin{pmatrix} A & B & D \\ C & \mathbb I & 0 \\ E & 0 & \mathbb I \end{pmatrix} \begin{pmatrix} x \\ y \\ z \end{pmatrix} = \begin{pmatrix} b_x \\ b_y \\ b_z \end{pmatrix} using the Schur complement :math:`S = A - BC - DE`, where :math:`\mathbb I` is the identity matrix and :math:`(b_x, b_y, b_z)^\top` is given. The solution is .. math:: x &= S^{-1} \, (b_x - B b_y - D b_z) \,, \\ y &= b_y - C x \,, \\ z &= b_z - E x \,. Parameters ---------- M : BlockLinearOperator Block matrix :math:`[[A, B, D], [C, \mathbb I, 0], [E, 0, \mathbb I]]`. solver_name : str Name of the iterative solver, passed to ``feectools.linalg.solvers.inverse`` (e.g. ``"pcg"``, ``"cg"``, ``"pbicgstab"``, ``"gmres"``). **solver_params : Keyword arguments of the chosen solver; must contain ``pc`` (the preconditioner, None for none). """ def __init__(self, M, solver_name, **solver_params): assert isinstance(M, BlockLinearOperator) assert M.domain == M.codomain # solve square system # initialize solver with dummy matrix A self._solver_name = solver_name if solver_params["pc"] is None: solver_params.pop("pc") self._M = M self._A = M[0, 0] self._B = M[0, 1] self._C = M[1, 0] self._D = M[0, 2] self._E = M[2, 0] NoneType = type(None) assert isinstance(M[1, 1], IdentityOperator) assert isinstance(M[2, 2], IdentityOperator) assert isinstance(M[1, 2], NoneType) assert isinstance(M[2, 1], NoneType) self._S = self._A - self._B @ self._C - self._D @ self._E self._solver_params = solver_params self._solver = inverse(self._S, solver_name, **solver_params) # right-hand side vector (avoids temporary memory allocation!) self._rhs = self._A.codomain.zeros() self._rhs2 = self._A.codomain.zeros() @property def precond(self): """Preconditioner of the Schur system (None without preconditioner).""" return self._solver_params.get("pc") @precond.setter def precond(self, pc): """Replace the preconditioner, e.g. by one of the Schur system for the current dt.""" self._solver_params.pop("pc", None) if pc is not None: self._solver_params["pc"] = pc self._solver = inverse(self._S, self._solver_name, **self._solver_params)
[docs] @profile @ProfileManager.profile("solve: SchurSolverFull3") def dot(self, v, out=None): """Solves the 3x3 block matrix linear system. Parameters ---------- v : feectools.linalg.basic.Vector Left hand side of the system. out : feectools.linalg.basic.Vector, optional If given, the converged solution will be written into this vector (in-place). Returns ------- out : feectools.linalg.block.BLockVector Converged solution. info : dict Convergence information. """ assert isinstance(v, BlockVector) assert v.space == self._M.domain if out is None: out = self._M.codomain.zeros() else: assert out.space == self._M.codomain bx = v[0] by = v[1] bz = v[2] # right-hand side vector rhs bx - B by rhs = self._B.dot(by, out=self._rhs) rhs *= -1 rhs -= self._D.dot(bz, out=self._rhs2) rhs += bx # solve linear system (in-place if out is not None) x = self._solver.dot(rhs, out=out[0]) y = self._C.dot(x, out=out[1]) y *= -1 y += by z = self._E.dot(x, out=out[2]) z *= -1 z += bz return out