Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

The core of pystencils is an algebraic domain-specific language (DSL) designed to declaratively describe short-range array operations, so-called stencils. That language is based on a symbolic abstraction of discrete fields on numerical grids, which are used in writing numerical kernels as mathematical equations. Pystencils will then translate these to high-performing low-level code, which can be run on NumPy arrays. This lesson introduces the fundamental concepts of the pystencils DSL: domains modelled as patches, the fields defined on these, and finally stencil operators that evaluate equations and compute the values of these fields.

Lesson Goals

In the course of this lesson, you will

  • set up patches as the domain for your numerical fields;

  • declare fields to represent functions discretized on a patch’s grid;

  • write finite-difference stencil operators for numerically approximating derivatives on these fields;

  • investigate the low-level C++ code generated by pystencils for your operators;

  • measure the runtime of a 2D gradient operator, and evaluate its speed-up compared to a native NumPy implementation;

  • and, finally, parallelize the operator using OpenMP to make it even faster.

To begin, let’s import SymPy and pystencils.

Numerical Domains: The Patch

The primary application area of pystencils is the numerical solving of partial differential equations (PDEs). Such equations deal with functions (so-called fields) f:Ω→Cf: \Omega \to \mathcal{C} from some contiguous region Ω⊂Rd\Omega \subset \mathbb{R}^d to a value set C\mathcal{C}, as well as their derivatives (see Figure 1):

Function f and some of its possible derivatives, as mappings from a region \Omega to their respective value sets.

Figure 1:Function ff and some of its possible derivatives, as mappings from a region Ω\Omega to their respective value sets.

To get started, we are first interested in the domain Ω\Omega on which these functions are defined, for to solve a PDE numerically, we need to discretize Ω\Omega using one or more appropriate grids. While Ω\Omega (as in Figure 1) can be a region of arbitrary shape, to handle it numerically we need to impose some structure on it.

In pystencils, we employ a structured-grid approach to discretization, using regular cartesian grids. Our first assumption is that Ω\Omega is a multi-interval [xmin,xmax]\left[\boldsymbol{x^{\mathrm{min}}}, \boldsymbol{x^{\mathrm{max}}}\right] spanned by its lower and upper corners xmin,xmax∈Rd\boldsymbol{x^{\mathrm{min}}}, \boldsymbol{x^{\mathrm{max}}} \in \mathbb{R}^d (Figure 2):

Multi-intervals \left[\boldsymbol{x^{\mathrm{min}}}, \boldsymbol{x^{\mathrm{max}}}\right] in one, two, and three dimensions.

Figure 2:Multi-intervals [xmin,xmax]\left[\boldsymbol{x^{\mathrm{min}}}, \boldsymbol{x^{\mathrm{max}}}\right] in one, two, and three dimensions.

This dd-dimensional multi-interval is discretized using a cartesian grid of Nx×Ny×⋯N_x \times N_y \times \cdots points:

2D patch discretized using N_x \times N_y points.
The discretization results in a grid with horizontal spacing h_x = \frac{1}{N_x - 1}
and vertical spacing h_y = \frac{1}{N_y - 1}.

Figure 3:2D patch discretized using Nx×NyN_x \times N_y points. The discretization results in a grid with horizontal spacing hx=1Nx−1h_x = \frac{1}{N_x - 1} and vertical spacing hy=1Ny−1h_y = \frac{1}{N_y - 1}.

Such grid structures are represented in the language of pystencils by the class ps.grids.Patch. Each patch gets a lower and an upper corner, as well as its number of vertices; these parameters, as well as the patch itself, are symbolic (see lesson 1) mathematical objects. Here’s how to create a patch ranging from (x0,y0)(x_{0}, y_{0}) to (x1,y1)(x_1, y_1) with (Nx,Ny)(N_x, N_y) vertices:

Loading...

Defining Fields on Patches

Now that we’ve established the discretization of the domain Ω\Omega, let’s turn our attention to the codomain C\mathcal{C} and define the fields ff themselves. In C\mathcal{C}, pystencils primarily deals with tensors over the real numbers R\mathbb{R}, giving rise to:

Tensor fields, as algebraic objects, are represented by the ps.grids.TensorField class. We create a tensor field by giving it a name, underlying grid, and tensor shape:

Loading...

Here, we have created a scalar field uu (as denoted by the empty tensor shape ()), and placed it on the vertices of Ω\Omega. It is now a symbolic representation of a function from the set of grid points in Ω\Omega to the real numbers R\mathbb{R}.

Writing Stencil Operators

A stencil operator is a set of equations that compute new values for the entries u(x)u(\boldsymbol{x}) of a field uu on each node x∈Ω\boldsymbol{x} \in \Omega, depending on uu’s own entries, and the entries of other fields, only at the node x\boldsymbol{x} and its direct neighborhood. Pystencils’ domain-specific language is designed to define such stencil operators as symbolic equations. In the following, we will get to know this language by looking at several simple, but common, stencil operators.

Common examples of stencil operators are finite-difference stencils that approximate a function’s derivatives using local difference quotients. Consider our scalar field

u:vertices(Ω)→Ru: \mathsf{vertices} (\Omega) \to \mathbb{R}

defined above. Its first spatial derivative in xx-direction ∂xu\partial_x u can be approximated with a central-difference scheme:

∂xu(xi,yj)=u(xi+1,yj)−u(xi−1,yj)2hx.\partial_x u (x_i, y_j) = \frac{u(x_{i + 1}, y_{j}) - u(x_{i - 1}, y_{j})}{2 h_x}.

Stencil Expressions

Using pystencils, we write the right-hand side of this finite difference stencil as a stencil expression:

Loading...

Compare the code in the above cell with the finite difference equation (2). They express the same thing, but with slightly different syntax. In particular, instead of writing the node coordinates xi,yjx_{i}, y_{j} as symbols, we access fields using stencil notation.

Assembling an Operator

On its own, the above is just an expression - but we want to go a step further, and evaluate that expression on actual numerical data. To do so, we create an operator. A pystencils operator is a set of stencil equations, like (2), that can be compiled to executable code and then evaluated on data stored in multidimensional arrays. Writing a pystencils operator is similar to writing a Python function, with a few differences. Here’s an example; an operator that computes the gradient ∇u\nabla u of uu via central differences and stores the result in a vector field created for the purpose:

Output
Loading...

Okay, that’s a lot of new syntax. Let’s take it apart:

The result of defining compute_gradient is not a Python function, but an algebraic object; a collection of symbolic equations. Pystencils will automatically convert this operator object to executable code. We can even take a look under the hood, and see the code it generates, using ps.inspect:

Output
Loading...

As you can see, pystencils turns the operator into a 2D loop nest. It will also perform a number of optimizations, such as precomputing constant values and drawing loop-invariant code outside of the loop nest.

Notebook Cell
Loading...

From Algebra to Numerical Computation

Now that we have some operators, it is time to apply them to actual numerical data. So far, everything we have constructed with pystencils has been algebraic; all equations and no data. This was the symbolic language layer of pystencils. That layer is complemented by the numerical runtime system offered by pystencils, which we will take a look at next.

The first step toward numerical evaluation is to set up the data behind our fields and symbols. Every field needs an underlying data array, and every symbol a numerical value. To handle these, we use the ps.grids.PatchData data manager. To create the patch data manager, we pass it our patch; a dictionary with values for any free symbols we have used in our definitions so far; as well as a list of fields for which arrays should be created:

Let’s examine what we created here. Using Ω_data[x], you can access and view the data behind the symbol, or field, x. For instance, we can see that u is now backed by a NumPy array of shape (32, 32), which is exactly the number of vertices on the patch:

array([[0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], ..., [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.]], shape=(32, 32))

To run an operator on the data managed by Ω_data, simply call it with the data manager as its only argument. For instance, let’s run initialize_u and take a look at the resulting state of u.

Notebook Cell
<Figure size 640x480 with 1 Axes>
Notebook Cell
Notebook Cell
<Figure size 640x480 with 1 Axes>

If everything went right, you should see a vector field pointing from the troughs to the peaks of the cosine wave in yy-direction, with a slight tilt upward in xx-direction.

Performance Comparison and Optimization

Through its just-in-time compilation system, with its abilities to hardware-accelerate and optimize numerical operators on the fly, pystencils is able to outperform native Python, as well as purely NumPy-based, numerical code by a significant margin. Even without optimizations enabled, we can expect our gradient operator to run significantly faster than an equivalent NumPy-based implementation.

pystencils vs NumPy

Let’s see how much speed-up we can actually get in practice. The following cell contains a pure NumPy-implementation of the gradient operator:

We’re now going to benchmark gradient_numpy against our compute_gradient operator. For a reliable measurement, however, we’ll need data structures that are much larger than what we experimented with above.

Notebook Cell
1.1 ms ± 157 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
Notebook Cell
4.71 ms ± 90 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Depending on your hardware, you should be able to see the pystencils operator run several times faster than the NumPy equivalent.

Optimization: Thread-Parallel Execution using OpenMP

Let’s take this one step further. One major advantage of pystencils is that it allows us to apply various optimizations to the generated operator code. For instance, we can choose to use OpenMP to run our kernel with multiple threads in parallel, to share the work among several CPU cores. Let’s try that for compute_gradient, and see if we can get an even larger speed-up.

To enable OpenMP, we will first have to clear the generated code of our operator:

We can now reconfigure the code generator through the operator’s .config property. We enable OpenMP, using static scheduling and four threads:

Now, let’s regenerate the operator’s code, and see if, and where, the OpenMP directives were added (note: calling ps.inspect automatically causes the code to be generated):

Output
Loading...

As you should be able to see, a #pragma omp parallel with the given number of threads was added outside of the loop nest, followed by a #pragma omp for work-sharing construct.

Notebook Cell
Notebook Cell
553 μs ± 3.66 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

Conclusion

In this lesson, we have gotten to know some of the fundamental components of the pystencils kernel language: patches, fields, stencil notation, and operators. We have seen how to apply these in computing finite differences, and have observed how pystencils operators can achieve significant speed-up over purely NumPy-based code.

With these fundamentals in our pack, we can move on to apply pystencils to actual numerical problems. In the next lessons, we will use pystencils to numerically solve the Poisson and Advection-Diffusion equations; in doing so, we will further explore the features of pystencils’ DSL, and try out its capabilities for high-performance code generation, e.g. through GPU acceleration.