Coverage for pySDC / implementations / problem_classes / generic_spectral.py: 68%
249 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-27 07:06 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-27 07:06 +0000
1from pySDC.core.problem import Problem, WorkCounter
2from pySDC.helpers.spectral_helper import SpectralHelper
3import numpy as np
4from pySDC.core.errors import ParameterError
5from pySDC.helpers.fieldsIO import Rectilinear
8class GenericSpectralLinear(Problem):
9 """
10 Generic class to solve problems of the form M u_t + L u = y, with mass matrix M, linear operator L and some right
11 hand side y using spectral methods.
12 L may contain algebraic conditions, as long as (M + dt L) is invertible.
14 Note that the `__getattr__` method is overloaded to pass requests on to the spectral helper if they are not
15 attributes of this class itself. For instance, you can add a BC by calling `self.spectral.add_BC` or equivalently
16 `self.add_BC`.
18 You can port problems derived from this more or less seamlessly to GPU by using the numerical libraries that are
19 class attributes of the spectral helper. This class will automatically switch the datatype using the `setup_GPU` class method.
21 Attributes:
22 spectral (pySDC.helpers.spectral_helper.SpectralHelper): Spectral helper
23 work_counters (dict): Dictionary for counting work
24 cached_factorizations (dict): Dictionary of cached matrix factorizations for solving
25 L (sparse matrix): Linear operator
26 M (sparse matrix): Mass matrix
27 diff_mask (list): Mask for separating differential and algebraic terms
28 Pl (sparse matrix): Left preconditioner
29 Pr (sparse matrix): Right preconditioner
30 """
32 def setup_GPU(self):
33 """switch to GPU modules"""
34 import cupy as cp
35 from pySDC.implementations.datatype_classes.cupy_mesh import cupy_mesh, imex_cupy_mesh
36 from pySDC.implementations.datatype_classes.mesh import mesh, imex_mesh
38 self.dtype_u = cupy_mesh
40 GPU_versions = {
41 mesh: cupy_mesh,
42 imex_mesh: imex_cupy_mesh,
43 }
45 self.dtype_f = GPU_versions[self.dtype_f]
47 if self.comm is not None:
48 from pySDC.helpers.NCCL_communicator import NCCLComm
50 if not isinstance(self.comm, NCCLComm):
51 self.__dict__['comm'] = NCCLComm(self.comm)
53 def __init__(
54 self,
55 bases,
56 components,
57 comm=None,
58 Dirichlet_recombination=True,
59 left_preconditioner=True,
60 solver_type='cached_direct',
61 solver_args=None,
62 preconditioner_args=None,
63 useGPU=False,
64 max_cached_factorizations=12,
65 spectral_space=True,
66 real_spectral_coefficients=False,
67 heterogeneous=False,
68 debug=False,
69 ):
70 """
71 Base class for problems discretized with spectral methods.
73 Args:
74 bases (list of dictionaries): 1D Bases
75 components (list of strings): Components of the equations
76 comm (mpi4py.Intracomm or None): MPI communicator
77 Dirichlet_recombination (bool): Use Dirichlet recombination in the last axis as right preconditioner
78 left_preconditioner (bool): Reverse the Kronecker product if yes
79 solver_type (str): Solver for linear systems
80 solver_args (dict): Arguments for linear solver
81 useGPU (bool): Run on GPU or CPU
82 max_cached_factorizations (int): Number of matrix decompositions to cache before starting eviction
83 spectral_space (bool): If yes, the solution will not be transformed back after solving and evaluating the RHS, and is expected as input in spectral space to these functions
84 real_spectral_coefficients (bool): If yes, allow only real values in spectral space, otherwise, allow complex.
85 heterogeneous (bool): If yes, perform memory intensive sparse matrix operations on CPU
86 debug (bool): Make additional tests at extra computational cost
87 """
88 solver_args = {} if solver_args is None else solver_args
90 preconditioner_args = {} if preconditioner_args is None else preconditioner_args
91 preconditioner_args['drop_tol'] = preconditioner_args.get('drop_tol', 1e-3)
92 preconditioner_args['fill_factor'] = preconditioner_args.get('fill_factor', 100)
94 self._makeAttributeAndRegister(
95 'max_cached_factorizations',
96 'useGPU',
97 'solver_type',
98 'solver_args',
99 'preconditioner_args',
100 'left_preconditioner',
101 'Dirichlet_recombination',
102 'comm',
103 'spectral_space',
104 'real_spectral_coefficients',
105 'heterogeneous',
106 'debug',
107 localVars=locals(),
108 )
109 self.spectral = SpectralHelper(comm=comm, useGPU=useGPU, debug=debug)
111 if useGPU:
112 self.setup_GPU()
113 if self.solver_args is not None:
114 if 'rtol' in self.solver_args.keys():
115 self.solver_args['tol'] = self.solver_args.pop('rtol')
117 for base in bases:
118 self.spectral.add_axis(**base)
119 self.spectral.add_component(components)
121 self.spectral.setup_fft(real_spectral_coefficients)
123 super().__init__(init=self.spectral.init_forward if spectral_space else self.spectral.init)
125 self.work_counters[solver_type] = WorkCounter()
126 self.work_counters['factorizations'] = WorkCounter()
128 self.setup_preconditioner(Dirichlet_recombination, left_preconditioner)
130 self.cached_factorizations = {}
132 if self.heterogeneous:
133 self.__heterogeneous_setup = False
135 self.logger.debug('Finished GenericSpectralLinear __init__')
137 def heterogeneous_setup(self):
138 if self.heterogeneous and not self.__heterogeneous_setup:
140 CPU_only = ['BC_line_zero_matrix', 'BCs']
141 both = ['Pl', 'Pr', 'L', 'M']
143 self.logger.debug(f'Starting heterogeneous setup. Moving {CPU_only} and copying {both} to CPU')
145 if self.useGPU:
146 for key in CPU_only:
147 self.logger.debug(f'Moving {key} to CPU')
148 setattr(self.spectral, key, getattr(self.spectral, key).get())
150 for key in both:
151 self.logger.debug(f'Copying {key} to CPU')
152 setattr(self, f'{key}_CPU', getattr(self, key).get())
153 else:
154 for key in both:
155 setattr(self, f'{key}_CPU', getattr(self, key))
157 self.logger.debug('Done with heterogeneous setup')
158 self.__heterogeneous_setup = True
160 def __getattr__(self, name):
161 """
162 Pass requests on to the helper if they are not directly attributes of this class for convenience.
164 Args:
165 name (str): Name of the attribute you want
167 Returns:
168 request
169 """
170 return getattr(self.spectral, name)
172 def _setup_operator(self, LHS):
173 """
174 Setup a sparse linear operator by adding relationships. See documentation for ``GenericSpectralLinear.setup_L`` to learn more.
176 Args:
177 LHS (dict): Equations to be added to the operator
179 Returns:
180 sparse linear operator
181 """
182 operator = self.spectral.get_empty_operator_matrix()
183 for line, equation in LHS.items():
184 self.spectral.add_equation_lhs(operator, line, equation)
185 return self.spectral.convert_operator_matrix_to_operator(operator)
187 def setup_L(self, LHS):
188 """
189 Setup the left hand side of the linear operator L and store it in ``self.L``.
191 The argument is meant to be a dictionary with the line you want to write the equation in as the key and the relationship between components as another dictionary. For instance, you can add an algebraic condition capturing a first derivative relationship between u and ux as follows:
193 ```
194 Dx = self.get_differentiation_matrix(axes=(0,))
195 I = self.get_Id()
196 LHS = {'ux': {'u': Dx, 'ux': -I}}
197 self.setup_L(LHS)
198 ```
200 If you put zero as right hand side for the solver in the line for ux, ux will contain the x-derivative of u afterwards.
202 Args:
203 LHS (dict): Dictionary containing the equations.
204 """
205 self.L = self._setup_operator(LHS)
206 self.logger.debug('Set up L matrix')
208 def setup_M(self, LHS):
209 '''
210 Setup mass matrix, see documentation of ``GenericSpectralLinear.setup_L``.
211 '''
212 diff_index = list(LHS.keys())
213 self.diff_mask = [me in diff_index for me in self.components]
214 self.M = self._setup_operator(LHS)
215 self.logger.debug('Set up M matrix')
217 def setup_preconditioner(self, Dirichlet_recombination=True, left_preconditioner=True):
218 """
219 Get left and right preconditioners.
221 Args:
222 Dirichlet_recombination (bool): Basis conversion for right preconditioner. Useful for Chebychev and Ultraspherical methods. 10/10 would recommend.
223 left_preconditioner (bool): If True, it will interleave the variables and reverse the Kronecker product
224 """
225 N = np.prod(self.init[0][1:])
226 if self.useGPU:
227 from cupy_backends.cuda.libs.cusparse import CuSparseError as MemError
228 else:
229 MemError = MemoryError
231 if left_preconditioner:
232 self.logger.debug(f'Setting up left preconditioner with {N} local degrees of freedom')
234 # reverse Kronecker product
235 if self.spectral.useGPU:
236 import scipy.sparse as sp
237 else:
238 sp = self.spectral.sparse_lib
240 R = sp.lil_matrix((self.ncomponents * N,) * 2, dtype=int)
242 for j in range(self.ncomponents):
243 for i in range(N):
244 R[i * self.ncomponents + j, j * N + i] = 1
246 self.Pl = self.spectral.sparse_lib.csc_matrix(R, dtype=complex)
248 self.logger.debug('Finished setup of left preconditioner')
249 else:
250 Id = self.spectral.sparse_lib.eye(N)
251 Pl_lhs = {comp: {comp: Id} for comp in self.components}
252 self.Pl = self._setup_operator(Pl_lhs)
254 if Dirichlet_recombination and type(self.axes[-1]).__name__ in ['ChebychevHelper', 'UltrasphericalHelper']:
255 self.logger.debug('Using Dirichlet recombination as right preconditioner')
256 _Pr = self.spectral.get_Dirichlet_recombination_matrix(axis=-1)
257 else:
258 _Pr = self.spectral.sparse_lib.eye(N)
260 Pr_lhs = {comp: {comp: _Pr} for comp in self.components}
261 operator = self._setup_operator(Pr_lhs)
263 try:
264 self.Pr = operator @ self.Pl.T
265 except MemError:
266 self.logger.debug('Setting up right preconditioner on CPU due to memory error')
267 self.Pr = self.spectral.sparse_lib.csc_matrix(operator.get() @ self.Pl.T.get())
269 def solve_system(self, rhs, dt, u0=None, *args, skip_itransform=False, **kwargs):
270 """
271 Do an implicit Euler step to solve M u_t + Lu = rhs, with M the mass matrix and L the linear operator as setup by
272 ``GenericSpectralLinear.setup_L`` and ``GenericSpectralLinear.setup_M``.
274 The implicit Euler step is (M - dt L) u = M rhs. Note that M need not be invertible as long as (M + dt*L) is.
275 This means solving with dt=0 to mimic explicit methods does not work for all problems, in particular simple DAEs.
277 Note that by putting M rhs on the right hand side, this function can only solve algebraic conditions equal to
278 zero. If you want something else, it should be easy to overload this function.
279 """
281 sp = self.spectral.sparse_lib
283 self.heterogeneous_setup()
285 if self.spectral_space:
286 rhs_hat = rhs.copy()
287 if u0 is not None:
288 u0_hat = u0.copy().flatten()
289 else:
290 u0_hat = None
291 else:
292 rhs_hat = self.spectral.transform(rhs)
293 if u0 is not None:
294 u0_hat = self.spectral.transform(u0).flatten()
295 else:
296 u0_hat = None
298 # apply inverse right preconditioner to initial guess
299 if u0_hat is not None and 'direct' not in self.solver_type:
300 if not hasattr(self, '_Pr_inv'):
301 self._PR_inv = self.linalg.splu(self.Pr.astype(complex)).solve
302 u0_hat[...] = self._PR_inv(u0_hat)
304 rhs_hat = (self.M @ rhs_hat.flatten()).reshape(rhs_hat.shape)
305 rhs_hat = self.spectral.put_BCs_in_rhs_hat(rhs_hat)
306 rhs_hat = self.Pl @ rhs_hat.flatten()
308 if dt not in self.cached_factorizations.keys() or not self.solver_type.lower() == 'cached_direct':
309 if self.heterogeneous:
310 M = self.M_CPU
311 L = self.L_CPU
312 Pl = self.Pl_CPU
313 Pr = self.Pr_CPU
314 else:
315 M = self.M
316 L = self.L
317 Pl = self.Pl
318 Pr = self.Pr
320 A = M + dt * L
321 A = Pl @ self.spectral.put_BCs_in_matrix(A) @ Pr
323 # if A.shape[0] < 200e20:
324 # import matplotlib.pyplot as plt
326 # # M = self.spectral.put_BCs_in_matrix(self.L.copy())
327 # M = A # self.L
328 # im = plt.spy(M)
329 # plt.show()
331 if 'ilu' in self.solver_type.lower():
332 if dt not in self.cached_factorizations.keys():
333 if len(self.cached_factorizations) >= self.max_cached_factorizations:
334 to_evict = list(self.cached_factorizations.keys())[0]
335 self.cached_factorizations.pop(to_evict)
336 self.logger.debug(f'Evicted matrix factorization for {to_evict=:.6f} from cache')
337 iLU = self.linalg.spilu(
338 A, **{**self.preconditioner_args, 'drop_tol': dt * self.preconditioner_args['drop_tol']}
339 )
340 self.cached_factorizations[dt] = self.linalg.LinearOperator(A.shape, iLU.solve)
341 self.logger.debug(f'Cached incomplete LU factorization for {dt=:.6f}')
342 self.work_counters['factorizations']()
343 M = self.cached_factorizations[dt]
344 else:
345 M = None
346 info = 0
348 if self.solver_type.lower() == 'cached_direct':
349 if dt not in self.cached_factorizations.keys():
350 if len(self.cached_factorizations) >= self.max_cached_factorizations:
351 self.cached_factorizations.pop(list(self.cached_factorizations.keys())[0])
352 self.logger.debug(f'Evicted matrix factorization for {dt=:.6f} from cache')
354 if self.heterogeneous:
355 import scipy.sparse as sp
357 cpu_decomp = sp.linalg.splu(A)
359 if self.useGPU:
360 from cupyx.scipy.sparse.linalg import SuperLU
362 solver = SuperLU(cpu_decomp).solve
363 else:
364 solver = cpu_decomp.solve
365 else:
366 solver = self.spectral.linalg.factorized(A)
368 self.cached_factorizations[dt] = solver
369 self.logger.debug(f'Cached matrix factorization for {dt=:.6f}')
370 self.work_counters['factorizations']()
372 _sol_hat = self.cached_factorizations[dt](rhs_hat)
373 self.work_counters[self.solver_type]()
374 self.logger.debug(f'Used cached matrix factorization for {dt=:.6f}')
376 elif self.solver_type.lower() == 'direct':
377 _sol_hat = sp.linalg.spsolve(A, rhs_hat)
378 elif 'gmres' in self.solver_type.lower():
379 _sol_hat, _ = sp.linalg.gmres(
380 A,
381 rhs_hat,
382 x0=u0_hat,
383 **self.solver_args,
384 callback=self.work_counters[self.solver_type],
385 callback_type='pr_norm',
386 M=M,
387 )
388 elif self.solver_type.lower() == 'cg':
389 _sol_hat, info = sp.linalg.cg(
390 A, rhs_hat, x0=u0_hat, **self.solver_args, callback=self.work_counters[self.solver_type]
391 )
392 elif 'bicgstab' in self.solver_type.lower():
393 _sol_hat, info = self.linalg.bicgstab(
394 A,
395 rhs_hat,
396 x0=u0_hat,
397 **self.solver_args,
398 callback=self.work_counters[self.solver_type],
399 M=M,
400 )
401 else:
402 raise NotImplementedError(f'Solver {self.solver_type=} not implemented in {type(self).__name__}!')
404 if info != 0:
405 self.logger.warn(f'{self.solver_type} not converged! {info=}')
407 sol_hat = self.spectral.u_init_forward
408 sol_hat[...] = (self.Pr @ _sol_hat).reshape(sol_hat.shape)
410 if self.spectral_space:
411 return sol_hat
412 else:
413 sol = self.spectral.u_init
414 sol[:] = self.spectral.itransform(sol_hat).real
416 if self.spectral.debug:
417 self.spectral.check_BCs(sol)
419 return sol
421 def setUpFieldsIO(self):
422 Rectilinear.setupMPI(
423 comm=self.comm.commMPI if self.useGPU else self.comm,
424 iLoc=[me.start for me in self.local_slice(False)],
425 nLoc=[me.stop - me.start for me in self.local_slice(False)],
426 )
428 def getOutputFile(self, fileName):
429 self.setUpFieldsIO()
431 coords = [me.get_1dgrid() for me in self.spectral.axes]
432 assert np.allclose([len(me) for me in coords], self.spectral.global_shape[1:])
434 fOut = Rectilinear(np.float64, fileName=fileName)
435 fOut.setHeader(nVar=len(self.components), coords=coords)
436 fOut.initialize()
437 return fOut
439 def processSolutionForOutput(self, u):
440 if self.spectral_space:
441 return np.array(self.itransform(u).real)
442 else:
443 return np.array(u.real)
446def compute_residual_DAE(self, stage=''):
447 """
448 Computation of the residual that does not add u_0 - u_m in algebraic equations.
450 Args:
451 stage (str): The current stage of the step the level belongs to
452 """
454 # get current level and problem description
455 L = self.level
457 # Check if we want to skip the residual computation to gain performance
458 # Keep in mind that skipping any residual computation is likely to give incorrect outputs of the residual!
459 if stage in self.params.skip_residual_computation:
460 L.status.residual = 0.0 if L.status.residual is None else L.status.residual
461 return None
463 # check if there are new values (e.g. from a sweep)
464 # assert L.status.updated
466 # compute the residual for each node
468 # build QF(u)
469 res_norm = []
470 res = self.integrate()
471 mask = L.prob.diff_mask
472 for m in range(self.coll.num_nodes):
473 res[m][mask] += L.u[0][mask] - L.u[m + 1][mask]
474 # add tau if associated
475 if L.tau[m] is not None:
476 res[m] += L.tau[m]
477 # use abs function from data type here
478 res_norm.append(abs(res[m]))
480 # find maximal residual over the nodes
481 if L.params.residual_type == 'full_abs':
482 L.status.residual = max(res_norm)
483 elif L.params.residual_type == 'last_abs':
484 L.status.residual = res_norm[-1]
485 elif L.params.residual_type == 'full_rel':
486 L.status.residual = max(res_norm) / abs(L.u[0])
487 elif L.params.residual_type == 'last_rel':
488 L.status.residual = res_norm[-1] / abs(L.u[0])
489 else:
490 raise ParameterError(
491 f'residual_type = {L.params.residual_type} not implemented, choose '
492 f'full_abs, last_abs, full_rel or last_rel instead'
493 )
495 # indicate that the residual has seen the new values
496 L.status.updated = False
498 return None
501def compute_residual_DAE_MPI(self, stage=None):
502 """
503 Computation of the residual using the collocation matrix Q
505 Args:
506 stage (str): The current stage of the step the level belongs to
507 """
508 from mpi4py import MPI
510 L = self.level
512 # Check if we want to skip the residual computation to gain performance
513 # Keep in mind that skipping any residual computation is likely to give incorrect outputs of the residual!
514 if stage in self.params.skip_residual_computation:
515 L.status.residual = 0.0 if L.status.residual is None else L.status.residual
516 return None
518 # compute the residual for each node
520 # build QF(u)
521 res = self.integrate(last_only=L.params.residual_type[:4] == 'last')
522 mask = L.prob.diff_mask
523 res[mask] += L.u[0][mask] - L.u[self.rank + 1][mask]
524 # add tau if associated
525 if L.tau[self.rank] is not None:
526 res += L.tau[self.rank]
527 # use abs function from data type here
528 res_norm = abs(res)
530 # find maximal residual over the nodes
531 if L.params.residual_type == 'full_abs':
532 L.status.residual = self.comm.allreduce(res_norm, op=MPI.MAX)
533 elif L.params.residual_type == 'last_abs':
534 L.status.residual = self.comm.bcast(res_norm, root=self.comm.size - 1)
535 elif L.params.residual_type == 'full_rel':
536 L.status.residual = self.comm.allreduce(res_norm / abs(L.u[0]), op=MPI.MAX)
537 elif L.params.residual_type == 'last_rel':
538 L.status.residual = self.comm.bcast(res_norm / abs(L.u[0]), root=self.comm.size - 1)
539 else:
540 raise NotImplementedError(f'residual type \"{L.params.residual_type}\" not implemented!')
542 # indicate that the residual has seen the new values
543 L.status.updated = False
545 return None
548def get_extrapolated_error_DAE(self, S, **kwargs):
549 """
550 The extrapolation estimate combines values of u and f from multiple steps to extrapolate and compare to the
551 solution obtained by the time marching scheme. This function can be used in `EstimateExtrapolationError`.
553 Args:
554 S (pySDC.Step): The current step
556 Returns:
557 None
558 """
559 u_ex = self.get_extrapolated_solution(S)
560 diff_mask = S.levels[0].prob.diff_mask
561 if u_ex is not None:
562 S.levels[0].status.error_extrapolation_estimate = (
563 abs((u_ex - S.levels[0].u[-1])[diff_mask]) * self.coeff.prefactor
564 )
565 # print([abs(me) for me in (u_ex - S.levels[0].u[-1]) * self.coeff.prefactor])
566 else:
567 S.levels[0].status.error_extrapolation_estimate = None