Notes on stencils
Numba provides the @stencil decorator to represent stencil computations. This document explains how this feature is implemented in the several different modes available in Numba. Currently, calls to the stencil from non-jitted code is supported as well as calls from jitted code, either with or without the parallel=True option.
The stencil decorator
The stencil decorator itself just returns a StencilFunc
object.
This object encapsulates the original stencil kernel function
as specified in the program and the options passed to the
stencil decorator. Also of note is that after the first compilation
of the stencil, the computed neighborhood of the stencil is
stored in the StencilFunc
object in the neighborhood
attribute.
Handling the three modes
As mentioned above, Numba supports the calling of stencils
from inside or outside a @jit
compiled function, with or
without the parallel=True option.
Outside jit context
StencilFunc
overrides the __call__
method so that calls
to StencilFunc
objects execute the stencil:
def __call__(self, *args, **kwargs):
result = kwargs.get('out')
new_stencil_func = self._stencil_wrapper(result, None, *args)
if result is None:
return new_stencil_func.entry_point(*args)
else:
return new_stencil_func.entry_point(*args, result)
First, the presence of the optional out
parameter is checked. If it is present then the output array is
stored in result
. Then, the call to _stencil_wrapper
generates the stencil function given the result and argument types
and finally the generated stencil function is executed and its result
returned.
Jit without parallel=True
When constructed, a StencilFunc
inserts itself into the typing
context’s set of user functions and provides the _type_me
callback. In this way, the standard Numba compiler is able to
determine the output type and signature of a StencilFunc
.
Each StencilFunc
maintains a cache of previously seen combinations
of input argument types and keyword types. If previously seen,
the StencilFunc
returns the computed signature. If not previously
computed, the StencilFunc
computes the return type of the stencil
by running the Numba compiler frontend on the stencil kernel and
then performing type inference on the Numba IR (IR) to get the scalar
return type of the kernel. From that, a Numpy array type is constructed
whose element type matches that scalar return type.
After computing the signature of the stencil for a previously
unseen combination of input and keyword types, the StencilFunc
then creates the stencil function itself.
StencilFunc
then installs the new stencil function’s definition
in the target context so that jitted code is able to call it.
Thus, in this mode, the generated stencil function is a stand-alone function called like a normal function from within jitted code.
Jit with parallel=True
When calling a StencilFunc
from a jitted context with parallel=True
,
a separate stencil function as generated by Creating the stencil function
is not used. Instead, parfors (Stage 5b: Perform Automatic Parallelization) are
created within the current function that implements the stencil.
This code again starts with the stencil kernel and does a similar kernel
size computation but then rather than standard Python looping syntax,
corresponding parfors are created so that the execution of the stencil
will take place in parallel.
The stencil to parfor translations can also be selectively disabled
by setting parallel={'stencil': False}
, among other sub-options
described in Stage 5b: Perform Automatic Parallelization.
Creating the stencil function
Conceptually, a stencil function is created from the user-specified
stencil kernel by adding looping code around the kernel, transforming
the relative kernel indices into absolute array indices based on the
loop indices, and replacing the kernel’s return
statement with
a statement to assign the computed value into the output array.
To accomplish this transformation, first, a copy of the stencil kernel IR is created so that subsequent modifications of the IR for different stencil signatures will not effect each other.
Then, an approach similar to how GUFunc’s are created for parfors
is employed. In a text buffer, a Python function is created with
a unique name. The input array parameter is added to the function
definition and if the out
argument type is present then an
out
parameter is added to the stencil function definition.
If the out
argument is not present then first an output array
is created with numpy.zeros
having the same shape as the
input array.
The kernel is then analyzed to compute the stencil size and the
shape of the boundary (or the neighborhood
stencil decorator
argument is used for this purpose if present).
Then, one for
loop for each dimension of the input array is
added to the stencil function definition. The range of each
loop is controlled by the stencil kernel size previously computed
so that the boundary of the output image is not modified but instead
left as is. The body of the innermost for
loop is a single
sentinel
statement that is easily recognized in the IR.
A call to exec
with the text buffer is used to force the
stencil function into existence and an eval
is used to get
access to the corresponding function on which run_frontend
is
used to get the stencil function IR.
Various renaming and relabeling is performed on the stencil function
IR and the kernel IR so that the two can be combined without conflict.
The relative indices in the kernel IR (i.e., getitem
calls) are
replaced with expressions where the corresponding loop index variables
are added to the relative indices. The return
statement in the
kernel IR is replaced with a setitem
for the corresponding element
in the output array.
The stencil function IR is then scanned for the sentinel and the
sentinel replaced with the modified kernel IR.
Next, compile_ir
is used to compile the combined stencil function
IR. The resulting compile result is cached in the StencilFunc
so that
other calls to the same stencil do not need to undertake this process
again.
Exceptions raised
Various checks are performed during stencil compilation to make sure
that user-specified options do not conflict with each other or with
other runtime parameters. For example, if the user has manually
specified a neighborhood
to the stencil decorator, the length of
that neighborhood must match the dimensionality of the input array.
If this is not the case, a ValueError
is raised.
If the neighborhood has not been specified then it must be inferred
and a requirement to infer the kernel is that all indices are constant
integers. If they are not, a ValueError
is raised indicating that
kernel indices may not be non-constant.
Finally, the stencil implementation detects the output array type
by running Numba type inference on the stencil kernel. If the
return type of this kernel does not match the type of the value
passed to the cval
stencil decorator option then a ValueError
is raised.