.. _concepts:
PyOP2 Concepts
==============
Many numerical algorithms and scientific computations on unstructured meshes
can be viewed as the *independent application* of a *local operation*
everywhere on a mesh. This local operation is often called a computational
*kernel* and its independent application lends itself naturally to parallel
computation. An unstructured mesh can be described by *sets of entities*
(vertices, edges, cells) and the connectivity between those sets forming the
topology of the mesh.
PyOP2 is a domain-specific language (DSL) for the parallel executions of
computational kernels on unstructured meshes or graphs.
.. _sets:
Sets and mappings
-----------------
A mesh is defined by :class:`sets ` of entities and
:class:`mappings ` between these sets. Sets are used to represent
entities in the mesh (nodes in the graph) or degrees of freedom of data
(fields) living "on" the mesh (graph), while maps define the connectivity
between entities (links in the graph) or degrees of freedom, for example
associating an edge with its incident vertices. Sets of mesh entities may
coincide with sets of degrees of freedom, but this is not necessarily the case
e.g. the set of degrees of freedom for a field may be defined on the vertices
of the mesh and the midpoints of edges connecting the vertices.
.. note ::
There is a requirement for the map to be of *constant arity*, that is each
element in the source set must be associated with a constant number of
elements in the target set. There is no requirement for the map to be
injective or surjective. This restriction excludes certain kinds of mappings
e.g. a map from vertices to incident egdes or cells is only possible on a
very regular mesh where the multiplicity of any vertex is constant.
In the following we declare a :class:`~pyop2.Set` ``vertices``, a
:class:`~pyop2.Set` ``edges`` and a :class:`~pyop2.Map` ``edges2vertices``
between them, which associates the two incident vertices with each edge: ::
vertices = op2.Set(4)
edges = op2.Set(3)
edges2vertices = op2.Map(edges, vertices, 2, [[0, 1], [1, 2], [2, 3]])
.. _data:
Data
----
PyOP2 distinguishes three kinds of user provided data: data that lives on a
set (often referred to as a field) is represented by a :class:`~pyop2.Dat`,
data that has no association with a set by a :class:`~pyop2.Global` and data
that is visible globally and referred to by a unique identifier is declared as
:class:`~pyop2.Const`. Examples of the use of these data types are given in
the :ref:`par_loops` section below.
.. _data_dat:
Dat
~~~
Since a set does not have any type but only a cardinality, data declared on a
set through a :class:`~pyop2.Dat` needs additional metadata to allow PyOP2 to
interpret the data and to specify how much memory is required to store it. This
metadata is the *datatype* and the *shape* of the data associated with any
given set element. The shape is not associated with the :class:`~pyop2.Dat`
directly, but with a :class:`~pyop2.DataSet`. One can associate a scalar with
each element of the set or a one- or higher-dimensional vector. Similar to the
restriction on maps, the shape and therefore the size of the data associated
which each element needs to be uniform. PyOP2 supports all common primitive
data types supported by `NumPy`_. Custom datatypes are supported insofar as
the user implements the serialisation and deserialisation of that type into
primitive data that can be handled by PyOP2.
Declaring coordinate data on the ``vertices`` defined above, where two float
coordinates are associated with each vertex, is done like this: ::
dvertices = op2.DataSet(vertices, dim=2)
coordinates = op2.Dat(dvertices,
[[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]],
dtype=float)
.. _data_global:
Global
~~~~~~
In contrast to a :class:`~pyop2.Dat`, a :class:`~pyop2.Global` has no
association to a set and the shape and type of the data are declared directly
on the :class:`~pyop2.Global`. A 2x2 elasticity tensor would be defined as
follows: ::
elasticity = op2.Global((2, 2), [[1.0, 0.0], [0.0, 1.0]], dtype=float)
.. _data_const:
Const
~~~~~
Data that is globally visible and read-only to kernels is declared with a
:class:`~pyop2.Const` and needs to have a globally unique identifier. It does
not need to be declared as an argument to a :func:`~pyop2.par_loop`, but is
accessible in a kernel by name. A globally visible parameter ``eps`` would be
declared as follows: ::
eps = op2.Const(1, 1e-14, name="eps", dtype=float)
.. _data_mat:
Mat
~~~
In a PyOP2 context, a (sparse) matrix is a linear operator from one set to
another. In other words, it is a linear function which takes a
:class:`~pyop2.Dat` on one set :math:`A` and returns the value of a
:class:`~pyop2.Dat` on another set :math:`B`. Of course, in particular,
:math:`A` may be the same set as :math:`B`. This makes the operation of at
least some matrices equivalent to the operation of a particular PyOP2 kernel.
PyOP2 can be used to assemble :class:`matrices `, which are defined
on a :class:`sparsity pattern ` which is built from a pair of
:class:`DataSets ` defining the row and column spaces the
sparsity maps between and one or more pairs of maps, one for the row and one
for the column space of the matrix respectively. The sparsity uniquely defines
the non-zero structure of the sparse matrix and can be constructed purely from
those mappings. To declare a :class:`~pyop2.Mat` on a :class:`~pyop2.Sparsity`
only the data type needs to be given.
Since the construction of large sparsity patterns is a very expensive
operation, the decoupling of :class:`~pyop2.Mat` and :class:`~pyop2.Sparsity`
allows the reuse of sparsity patterns for a number of matrices without
recomputation. In fact PyOP2 takes care of caching sparsity patterns on behalf
of the user, so declaring a sparsity on the same maps as a previously declared
sparsity yields the cached object instead of building another one.
Defining a matrix of floats on a sparsity which spans from the space of
vertices to the space of vertices via the edges is done as follows: ::
sparsity = op2.Sparsity((dvertices, dvertices),
[(edges2vertices, edges2vertices)])
matrix = op2.Mat(sparsity, float)
.. _par_loops:
Parallel loops
--------------
Computations in PyOP2 are executed as :func:`parallel loops `
of a :class:`~pyop2.Kernel` over an *iteration set*. Parallel loops are the
core construct of PyOP2 and hide most of its complexity such as parallel
scheduling, partitioning, colouring, data transfer from and to device and
staging of the data into on chip memory. Computations in a parallel loop must
be independent of the order in which they are executed over the set to allow
PyOP2 maximum flexibility to schedule the computation in the most efficient
way. Kernels are described in more detail in :doc:`kernels`.
.. _loop-invocations:
Loop invocations
~~~~~~~~~~~~~~~~
A parallel loop invocation requires as arguments, other than the iteration set
and the kernel to operate on, the data the kernel reads and/or writes. A
parallel loop argument is constructed by calling the underlying data object
(i.e. the :class:`~pyop2.Dat` or :class:`~pyop2.Global`) and passing an
*access descriptor* and the mapping to be used when accessing the data. The
mapping is required for an *indirectly accessed* :class:`~pyop2.Dat` not
declared on the same set as the iteration set of the parallel loop. In the
case of *directly accessed* data defined on the same set as the iteration set
the map is omitted and only an access descriptor given.
Consider a parallel loop that translates the ``coordinate`` field by a
constant offset given by the :class:`~pyop2.Const` ``offset``. Note how the
kernel has access to the local variable ``offset`` even though it has not been
passed as an argument to the :func:`~pyop2.par_loop`. This loop is direct and
the argument ``coordinates`` is read and written: ::
op2.Const(2, [1.0, 1.0], dtype=float, name="offset");
translate = op2.Kernel("""void translate(double * coords) {
coords[0] += offset[0];
coords[1] += offset[1];
}""", "translate")
op2.par_loop(translate, vertices, coordinates(op2.RW))
.. _access-descriptors:
Access descriptors
~~~~~~~~~~~~~~~~~~
Access descriptors define how the data is accessed by the kernel and give
PyOP2 crucial information as to how the data needs to be treated during
staging in before and staging out after kernel execution. They must be one of
:data:`pyop2.READ` (read-only), :data:`pyop2.WRITE` (write-only),
:data:`pyop2.RW` (read-write), :data:`pyop2.INC` (increment),
:data:`pyop2.MIN` (minimum reduction) or :data:`pyop2.MAX` (maximum
reduction).
Not all of these descriptors apply to all PyOP2 data types. A
:class:`~pyop2.Dat` can have modes :data:`~pyop2.READ`, :data:`~pyop2.WRITE`,
:data:`~pyop2.RW` and :data:`~pyop2.INC`. For a :class:`~pyop2.Global` the
valid modes are :data:`~pyop2.READ`, :data:`~pyop2.INC`, :data:`~pyop2.MIN` and
:data:`~pyop2.MAX` and for a :class:`~pyop2.Mat` only :data:`~pyop2.WRITE` and
:data:`~pyop2.INC` are allowed.
.. _matrix-loops:
Loops assembling matrices
~~~~~~~~~~~~~~~~~~~~~~~~~
We declare a parallel loop assembling the ``matrix`` via a given ``kernel``
which we'll assume has been defined before over the ``edges`` and with
``coordinates`` as input data. The ``matrix`` is the output argument of this
parallel loop and therefore has the access descriptor :data:`~pyop2.INC` since
the assembly accumulates contributions from different vertices via the
``edges2vertices`` mapping. Note that the mappings are being indexed with the
:class:`iteration indices ` ``op2.i[0]`` and
``op2.i[1]`` respectively. This means that PyOP2 generates a :ref:`local
iteration space ` of size ``arity * arity`` with the
``arity`` of the :class:`~pyop2.Map` ``edges2vertices`` for any given element
of the iteration set. This local iteration space is then iterated over using
the iteration indices on the maps. The kernel is assumed to only apply to a
single point in that local iteration space. The ``coordinates`` are accessed
via the same mapping, but are a read-only input argument to the kernel and
therefore use the access descriptor :data:`~pyop2.READ`: ::
op2.par_loop(kernel, edges,
matrix(op2.INC, (edges2vertices[op2.i[0]],
edges2vertices[op2.i[1]])),
coordinates(op2.READ, edges2vertices))
You can stack up multiple successive parallel loops that add values to
a matrix, before you use the resulting values, you must explicitly
tell PyOP2 that you want to do so, by calling
:meth:`~pyop2.Mat.assemble` on the matrix. Note that executing a
:func:`~pyop2.solve` will do this automatically for you.
.. _reduction-loops:
Loops with global reductions
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
:class:`Globals ` are used primarily for reductions where a
given quantity on a field is reduced to a single number by summation or
finding the minimum or maximum. Consider a kernel computing the `L2 norm`_ of
the ``pressure`` field defined on the set of ``vertices`` as ``l2norm``. Note
that the :class:`~pyop2.Dat` constructor automatically creates an anonymous
:class:`~pyop2.DataSet` of dimension 1 if a :class:`~pyop2.Set` is passed as
the first argument. We assume ``pressure`` is the result of some prior
computation and only give the declaration for context. ::
pressure = op2.Dat(vertices, [...], dtype=float)
l2norm = op2.Global(dim=1, data=[0.0])
norm = op2.Kernel("""void norm(double * out, double * field) {
*out += field[0] * field[0];
}""", "norm")
op2.par_loop(pressure, vertices,
l2norm(op2.INC),
vertices(op2.READ))
.. _NumPy: http://docs.scipy.org/doc/numpy/reference/arrays.dtypes.html
.. _L2 norm: https://en.wikipedia.org/wiki/L2_norm#Euclidean_norm