-
Notifications
You must be signed in to change notification settings - Fork 35
[spec] Solver interface implementation
Note: this blueprint is out of date, the Solver interface has since been implemented using petsc4py
As discussed in Issue #68 we should provide an interface to solvers that meets the following constraints:
- The interface should allow the use of different solver backends
- There must be a way to pass options to the solver.
- The interface should be exposed to Fluidity users using a similar interface to that provided by Dolfin
The user instantiates a PyOP2 solver object as:
solver = Solver()
The Solver
object instantiates an instance of the backend library solver in
its constructor. For now we only have PETSc, but we should ensure that it is
easy to add other linear algebra backends. It is expected that the solver
library may either be chosen in the OP2 Config, although the choice of OP2
backend may restrict the choice of linear algebra backends.
Solver options can be set by updating the parameters, for example:
solver.parameters.update({'type' : 'cg', 'preconditioner' : ..., ...})
When it comes to solving a system, the user uses:
# A is an op2.Mat object, x and b are op2.Dat objects
solver.solve(A, x, b)
Subsequently, the user might enquire about the solve:
solver.get_iteration_number() # Return the number of iterations executed
solver.get_converged_reason() # Return the reason the iteration stopped
These are modelled on PETSc's convergence reasons, which is probably adequate for now, but could be revisited in the future.
For each solver backend, a Python class will be created. The correct class for the chosen backend will be instantiated by the op2.Solver
class when it is constructed. The interface for the backend objects involves defining the following methods:
-
__init__(self)
: Performs any backend-specific initialisation -
__dealloc__(self)
: Performs backend-specific cleanup -
set_parameters(self, parameters)
: Takes a dict of parameters and sets these parameters in the linear algebra backend accordingly. Parameters include'linear_solver'
(the solver algorithm, e.g.'cg'
or'gmres'
),'preconditioner'
(the preconditioner, e.g.'jacobi'
or'ilu'
), tolerances including'relative_tolerance'
,'absolute_tolerance'
, and'divergence_tolerance'
, and'maximum_iterations'
, the number of iterations to stop at if convergence is not achieved. -
solve(self, A, x, b)
: Solves the systemAx = b
using the backend. -
get_converged_reason(self)
: Returns the reason the iteration terminated in the previous solve. It is an error to call this method if there was no previous solve. -
get_iteration_number(self)
: Returns the number of iterations executed in the previous solve. It is an error to call this method if there was no previous solve.
The class ksp_solver
is an example implementation of a backend that uses the PETSc KSP libraries. It is implemented as a Cython interface over a C glue layer that abstracts away a lot of the data types used in PETSc. The C layer shields the Cython implementation from having to be aware of the structure of PETSc data structures, which would otherwise need duplicating into a Cython header file.
A bare minimum C interface would contain the following functions:
void* create_ksp()
{
KSP ksp;
KSPCreate(PETSC_COMM_WORLD,&ksp);
KSPSetFromOptions(ksp);
return (void*)ksp;
}
void destroy_ksp(void *ksp)
{
#if (PETSC_VERSION_MAJOR == 3 && PETSC_VERSION_MINOR >= 2)
KSPDestroy((KSP*)(&ksp));
#else
KSPDestroy((KSP)ksp);
#endif
}
int ksp_get_converged_reason(void *ksp)
{
KSPConvergedReason reason;
KSPGetConvergedReason((KSP)ksp, &reason);
return (int)reason;
}
int ksp_get_iteration_number(void *ksp)
{
int its;
KSPGetIterationNumber((KSP)ksp, &its);
return its;
}
void ksp_set_type(void *ksp, const char *type)
{
KSPSetType((KSP)ksp, type);
}
void* ksp_get_pc(void *ksp)
{
PC pc;
KSPGetPC((KSP)ksp, &pc);
return (void*)pc;
}
void pc_set_type(void *pc, const char *type)
{
PCSetType((PC)pc, type);
}
void ksp_set_tolerances(void *ksp, double rtol, double atol, double dtol, int maxits)
{
KSPSetTolerances((KSP)ksp, rtol, atol, dtol, maxits);
}
void ksp_solve(void *ksp, op_mat mat, op_dat x, op_dat b)
{
assert( mat && b && x );
Vec p_b = op_create_vec(b);
Vec p_x = op_create_vec(x);
Mat A = (Mat) mat->mat;
KSPSetOperators((KSP)ksp,A,A,DIFFERENT_NONZERO_PATTERN);
KSPSolve((KSP)ksp,p_b,p_x);
op_destroy_vec(p_b, b);
op_destroy_vec(p_x, x);
}
Assuming we then create a Cython header file called _op_lib_petsc.pxd
for all these functions, we can then implement the ksp_solver
object as:
cimport _op_lib_petsc as petsc
class ksp_solver:
cdef void * _ksp
cdef void * _pc
def __cinit__(self):
self._ksp = petsc.create_ksp()
self._pc = petsc.ksp_get_pc(self._ksp)
def __dealloc__(self):
petsc.destroy_ksp(self._ksp)
def set_parameters(self, parameters):
ksptype = parameters['linear_solver']
pctype = parameters['preconditioner']
rtol = parameters['relative_tolerance']
atol = parameters['absolute_tolerance']
dtol = parameters['divergence_tolerance']
maxits = parameters['maximum_iterations']
petsc.ksp_set_type(self._ksp, ksptype)
petsc.pc_set_type(self._pc, pctype)
petsc.ksp_set_tolerances(self._ksp, rtol, atol, dtol, maxits)
def solve(self, A, x, b):
cdef op_mat cA
cdef op_dat cx, cb
cA = A._c_handle
cx = x._c_handle
cb = b._c_handle
petsc.ksp_solve(self._ksp, cA._handle, cx._handle, cb._handle)
def get_converged_reason(self):
return petsc.ksp_get_converged_reason(self._ksp)
def get_iteration_number(self):
return petsc.ksp_get_iteration_number(self._ksp)
An op2.Solver
object that makes use of the ksp_solver
class would look like the following (noting that this doesn't include a method for configuring different backends, but illustrates how the Solver
object would interface with the backend solver object):
_DEFAULT_SOLVER_PARAMETERS = {'linear_solver': 'cg',
'preconditioner': 'jacobi',
'relative_tolerance': 1.0e-5,
'absolute_tolerance': 1.0e-50,
'divergence_tolerance': 1.0e+4,
'maximum_iterations': 10000 }
class Solver(object):
def __init__(self, parameters=None):
self.parameters = parameters or _DEFAULT_SOLVER_PARAMETERS.copy()
self._ksp_solver = core.ksp_solver()
def solve(self, A, x, b):
self._ksp_solver.set_parameters(self.parameters)
self._ksp_solver.solve(A, x, b)
reason = self._ksp_solver.get_converged_reason()
its = self._ksp_solver.get_iteration_number()
print "Converged reason", reason
print "Iterations", its
An implementation of the PETSc backend in this form for the sequential OP2 backend is in the following two branches:
- For the OP2-Common side of things: https://github.com/OP2/OP2-Common/tree/cpp-matrices-solve-options
- On the PyOP2 side: https://github.com/OP2/PyOP2/tree/solver-object
Note that this implementation is presently tangled up in the cpp-matrices implementation, but there is no definite need for all the code in cpp-matrices - this was the easiest place to build this experimental implementation quickly.
- For our use case, we only need to access a small subset of PETSc's functionality.
- PETSc4py covers a much larger set of PETSc, but it is much more complicated than the simple PETSc implementation proposed here.
- Additionally, PETSc4py doesn't provide a way to directly access the matrix data, which is a requirement for PyOP2.
- Thus in order to make use of PETSc4py, it would have to be forked. This is slightly scary, since there is little documentation for PETSc4py, and it mirrors all of PETSc's data structures, so we'd be restricted to a specific PETSc version in our fork, and having to keep maintaining our fork for newer versions of PETSc.
- Summary: using PETSc4py looks like much harder work for our use case.
Since we have decided not to integrate the matrix support into the OP2-Common master branch, we need to move all the matrix functionality into PyOP2. The above PETSc implementation theoretically has dependencies on code that is only in the cpp-matrices branch, but this could all be moved into PyOP2 so that we could make this work with the master branch.
The main change that we would need to be aware of is the removal of the op_sparsity
and op_mat
data types. The change in PyOP2 would probably involve the linear algebra backend interface being used to construct sparsity and matrix objects that it supports instead.
At the moment, the core interface implements the core.op_sparsity
with the following members and methods:
-
cdef core.op_sparsity _handle
. This is a handle to a C-layerop_sparsity
object, which has the following structure:
typedef struct {
op_map *rowmaps; /* row maps */
op_map *colmaps; /* column maps */
int nmaps; /* number of maps */
int dim[2];
size_t nrows; /* number of rows */
size_t ncols; /* number of columns */
int *d_nnz; /* vector of number of nonzeros per row in
* diagonal subblock */
int *o_nnz; /* vector of number of nonzeros per row in
* off-diagonal subblock */
int total_nz; /* total nonzeros in the pattern sum(d_nnz + o_nnz) */
int *rowptr; /* csr row pointer (accumulation of d_nnz and o_nnz) */
int *colidx; /* csr column indices for each row */
char const *name; /* name of dataset */
} op_sparsity_core;
typedef op_sparsity_core * op_sparsity;
-
__cinit__(self,sparsity)
: Takes anop2.Sparsity
object and uses it to instantiate the coreop_sparsity
to be stored inself._handle
. This involves picking out the pointers to the C-Layer maps referenced in the sparsity, and then calls the C-layerop_decl_sparsity_core
. The main function ofop_decl_sparsity_core
is to perform some argument checking, and then construct the sparsity pattern by callingop_build_sparsity_pattern
- this constructs therowptr
andcolidx
arrays. -
total_nz(self)
: returns the total number of nonzero elements in the sparsity pattern. -
rowptr(self)
: returns a numpy array wrapping therowptr
member of the_handle
-
colidx(self)
: returns a numpy array wrapping thecolidx
member of the_handle
The core.op_mat
is implemented with the following methods and members:
-
cdef core.op_mat _handle
. This is a handle to a C-layerop_mat
object, which has the following structure:
typedef struct
{
int index; /* index */
int dim[2], /* dimension of data */
size; /* size of each element in dataset */
void* mat; /* handle to matrix object from 3rd party library */
void* mat_array; /* handle to matrix value from 3rd party library */
char const *type, /* datatype */
*name; /* name of matrix */
op_sparsity sparsity; /* sparsity structure */
char *data; /* matrix entries (device) */
char *lma_data; /* matrix entries in LMA form (device) */
} op_mat_core;
typedef op_mat_core * op_mat;
-
_nnzeros
: This stores the total number of nonzeroes in the matrix - this is a multiple of the sparsitytotal_nz
property, since the matrix entries may have dimension greater than 1 -
__cinit__(self, mat)
: Takes anop2.Mat
object and initialises a C-layerop_mat
object from it by callingop_decl_mat
. This creates either anMPIAIJ
orSeqAIJ
PETSc matrix object with the given sparsity, depending on whether there is more than one MPI process. -
zero(self)
: Zeroes all entries in the matrix -
zero_rows(self, rows, v)
: Zeroes all the rows in therows
list and sets the diagonal entry tov
. -
assemble(self)
: Calls the PETSc matrix assembly begin and end routines to assemble the matrix throighcore.op_mat_assemble
. This is called by the OpenCL backend to put the matrix in the assembled statement, even though it does not callMatSetValues
by any path. -
array(self)
: property that returns the value array of the matrix wrapped in a numpy array. As far as I can determine, the semantics of this mean that PETSc no longer considers itself the owner of the array: http://www.mcs.anl.gov/petsc/petsc-current/docs/manualpages/Mat/MatGetArray.html#MatGetArray -
restore_array(self)
: Relinquishes ownership of the array data afterarray()
has been called and there is no longer any need to access the array data. -
cptr(self)
: Returns a C pointer to_handle
(anop_mat
) so that the sequential backend can pass it toop_mat_addto
-
values(self)
: Returns a dense numpy array containing the matrix values.
In addition to the objects presented to the Python interface, there are also addto
functions that are exposed such that the sequential backend can call them in the generated code. These are:
-
op_mat_addto_scalar( op_mat mat, const void* value, int row, int col )
: Add thevalue
into the entry at (row
,col
) in the matrixmat
. Calls the PETScMatSetValues
interface. -
op_mat_addto( op_mat mat, const void* values, int nrows, const int *irows, int ncols, const int *icols )
: Adds a vector ofvalues
to the entries pointed to byirows
andicols
intomat
. Calls the PETScMatSetValues
interface. -
op_mat_assemble( op_mat mat )
: Calls the PetscMatAssemblyBegin
andMatAssemblyEnd
interfaces to assemble the matrix after the addtos have been performed.
These functions are exposed to the generated code through mat_utils.cxx
, which Instant compiles and links with the generated code:
#include "op_lib_mat.h"
#include "mat_utils.h"
void addto_scalar(op_mat mat, const void *value, int row, int col)
{
op_mat_addto_scalar(mat, value, row, col);
}
void addto_vector(op_mat mat, const void *values,
int nrows, const int *irows,
int ncols, const int *icols)
{
op_mat_addto(mat, values, nrows, irows, ncols, icols);
}
void assemble_mat(op_mat mat)
{
op_mat_assemble(mat);
}
The PyOP2 Mat
object constructs an underlying core.op_mat
object when its _c_handle
is accessed:
@property
def _c_handle(self):
if self._lib_handle is None:
self._lib_handle = core.op_mat(self)
return self._lib_handle
The PyOP2 Sparsity
object behaves similarly:
@property
def _c_handle(self):
if self._lib_handle is None:
key = (self._rmaps, self._cmaps, self._dims)
self._lib_handle = _sparsity_cache.get(key) or core.op_sparsity(self)
_sparsity_cache[key] = self._lib_handle
return self._lib_handle
The op_sparsity
and op_mat
declarations in op_lib_core.pyx
need to be replaced with linear algebra backend-specific interfaces that contain code necessary to interact with the library without depending on any code specific to the cpp-matrices branch. As with the solver interface, the correct interface object will need to be instantiated depending on the OP2 configuration.
For example: we may begin by replacing op_mat
and op_sparsity
with a generic sparsity
Python class, and a PETSc-specific petsc_mat
class. The Python sparsity
class could be generic since the current op_sparsity
used in the cpp-matrices branch doesn't have any PETSc-specific functionality. The petsc_mat
class will present the same interface as the current core.op_mat
, but will call methods to create the matrix and manipulate it through a small C stub, in a similar fashion to the stub for accessing PETSc's KSP*
methods in the example above.
The mat_utils.cxx
stub will need changing so that instead of operating on op_mat
objects, it accepts and works with PETSc Mat
objects instead. Similarly, the cptr
method of petsc_mat
should return a pointer to the PETSc Mat
rather than an op_mat
. The op_mat_addto*
functions will also take a PETSc Mat
instead of an op_mat
. These should be relatively simple changes, since the current implementations that take op_mat
objects have to unbox the Mat
objects from the op_mat
s.
Additionally, the PyOP2 Mat
object will need to construct a petsc_mat
instead of a core.op_mat
:
@property
def _c_handle(self):
if self._lib_handle is None:
self._lib_handle = petsc.petsc_mat(self)
return self._lib_handle
However, the PyOP2 Sparsity
object may not need another object at the linear algebra backend layer, since its members may be generic across backends. In this case, the sparsity-building functions that are presently written in C in the cpp-matrices branch may be ported into the Python Sparsity
class implementation.