In this notebook, we'll build on the previous work where we used SymPy's code printers to generate code for evaluating expressions numerically. As a layer of abstraction on top of C code printers, which generate snippets of code we can copy into a C program, we can generate a fully compilable C library. On top of this, we will see how to use Cython to compile such a library into a Python extension module so its computational routines can be called directly from Python.
Learning Objectives
After this lesson, you will be able to:
%%cython
magic commandcodegen
function to output compilable C codecodegen
-generated code with Cython, compile it into an extension module, and call it from Pythonautowrap
function to do all of this behind the scenesautowrap
to make use of an external C library
In [ ]:
%matplotlib inline
import matplotlib.pyplot as plt
import numpy as np
import sympy as sym
sym.init_printing()
Cython is a compiler and a programming language used to generate C extension modules for Python.
The Cython language is a Python/C creole which is essentially Python with some additional keywords for specifying static data types. It looks something like this:
def cython_sum(int n):
cdef float s = 0.0
cdef int i
for i in range(n):
s += i
return s
The Cython compiler transforms this code into a "flavor" of C specific to Python extension modules. This C code is then compiled into a binary file that can be imported and used just like a regular Python module -- the difference being that the functions you use from that module can potentially be much faster and more efficient than an equivalent pure Python implementation.
Aside from writing Cython code for computations, Cython is commonly used for writing wrappers around existing C code so that the functions therein can be made available in an extension module as described above. We will use this technique to make the SymPy-generated C code accessible to Python for use in SciPy's odeint
.
As a quick demonstration of what Cython can offer, we'll walk through a simple example of generating numbers in the Fibonacci sequence. If you're not familiar with it already, the sequence is initialized with $F_0 = 0$ and $F_1 = 1$, then the remaining terms are defined recursively by:
$$ F_i = F_{i-1} + F_{i-2} $$Our objective is to write a function that computes the $n$-th Fibonacci number. Let's start by writing a simple iterative solution in pure Python.
In [ ]:
def python_fib(n):
a = 0.0
b = 1.0
for i in range(n):
tmp = a
a = a + b
b = tmp
return a
In [ ]:
[python_fib(i) for i in range(10)]
Let's see how long it takes to compute the 100th Fibonacci number.
In [ ]:
%timeit python_fib(100)
Now let's implement the same thing with Cython. Since Cython is essentially "Python with types," it is often fairly easy to make the move and see improvements in speed. It does come at the cost, however, of a separate compilation step.
There are several ways to ways to go about the compilation process, and in many cases, Cython's tooling makes it fairly simple. For example, Jupyter notebooks can make use of a %%cython
magic command that will do all of compilation in the background for us. To make use of it, we need to load the cython
extension.
In [ ]:
%load_ext cython
Now we can write a Cython function.
Note: the --annotate
(or -a
) flag of the %%cython
magic command will produce an interactive annotated printout of the Cython code, allowing us to see the C code that is generated.
In [ ]:
%%cython
def cython_fib(int n):
cdef double a = 0.0
cdef double b = 1.0
cdef double tmp
for i in range(n):
tmp = a
a = a + b
b = tmp
return a
In [ ]:
%timeit cython_fib(100)
To see a bit more about writing Cython and its potential performance benefits, see this Cython examples notebook.
Even better, check out Kurt Smith's Cython tutorial which is happening at the same time as this tutorial.
codegen()
Our main goal in using Cython is to wrap SymPy-generated C code into a Python extension module so that we can call the fast compiled numerical routines from Python.
SymPy's codegen
function takes code printing a step further: it wraps a snippet of code that numerically evaluates an expression with a function, and puts that function into the context of a file that is fully ready-to-compile code.
Here we'll revisit the water radiolysis system, with the aim of numerically computing the right hand side of the system of ODEs and integrating using SciPy's odeint
.
Recall that this system looks like:
$$ \begin{align} \frac{dy_0(t)}{dt} &= f_0\left(y_0,\,y_1,\,\dots,\,y_{13},\,t\right) \\ &\vdots \\ \frac{dy_{13}(t)}{dt} &= f_{13}\left(y_0,\,y_1,\,\dots,\,y_{13},\,t\right) \end{align} $$where we are representing our state variables $y_0,\,y_1,\dots,y_{13}$ as a vector $\mathbf{y}(t)$ that we called states
in our code, and the collection of functions on the right hand side $\mathbf{f}(\mathbf{y}(t))$ we called rhs_of_odes
.
Start by importing the system of ODEs and the matrix of state variables.
In [ ]:
from scipy2017codegen.chem import load_large_ode
rhs_of_odes, states = load_large_ode()
rhs_of_odes[0]
Now we'll use codegen
(under sympy.utilities.codegen
) to output C source and header files which can compute the right hand side (RHS) of the ODEs numerically, given the current values of our state variables. Here we'll import it and show the documentation:
In [ ]:
from sympy.utilities.codegen import codegen
#codegen?
We just have one expression we're interested in computing, and that is the matrix expression representing the derivatives of our state variables with respect to time: rhs_of_odes
. What we want codegen
to do is create a C function that takes in the current values of the state variables and gives us back each of the derivatives.
In [ ]:
[(cf, cs), (hf, hs)] = codegen(('c_odes', rhs_of_odes), language='c')
Note that we've just expanded the outputs into individual variables so we can access the generated code easily. codegen
gives us back the .c
filename and its source code in a tuple, and the .h
filename and its source in another tuple. Let's print the source code.
In [ ]:
print(cs)
There are several things here worth noting:
Let's address the first issue first. Similarly to what we did in the C printing exercises, let's use a MatrixSymbol
to represent our state vector instead of a matrix of individual state variable symbols (i.e. y[0]
instead of y0
). First, create the MatrixSymbol
object that is the same shape as our states matrix.
In [ ]:
y = sym.MatrixSymbol('y', *states.shape)
Now we need to replace the use of y0
, y1
, etc. in our rhs_of_odes
matrix with the elements of our new state vector (e.g. y[0]
, y[1]
, etc.). We saw how to do this already in the previous notebook. Start by forming a mapping from y0 -> y[0, 0]
, y1 -> y[1, 0]
, etc.
In [ ]:
state_array_map = dict(zip(states, y))
state_array_map
Now replace the symbols in rhs_of_odes
according to the mapping. We'll call it rhs_of_odes_ind
and use that from now on.
In [ ]:
rhs_of_odes_ind = rhs_of_odes.xreplace(state_array_map)
rhs_of_odes_ind[0]
Exercise: use codegen
again, but this time with rhs_of_odes_ind
which makes use of a state vector rather than a container of symbols. Check out the resulting code. What is different about the function signature?
[(cf, cs), (hf, hs)] = codegen(???)
In [ ]:
Solution
|
|
|
|
|
|
|
|
|
|
v
In [ ]:
[(cf, cs), (hf, hs)] = codegen(('c_odes', rhs_of_odes_ind), language='c')
print(cs)
So by re-writing our expression in terms of a MatrixSymbol
rather than individual symbols, the function signature of the generated code is cleaned up greatly.
However, we still have the issue of the auto-generated output variable name. To fix this, we can form a matrix equation rather than an expression. The name given to the symbol on the left hand side of the equation will then be used for our output variable name.
We'll start by defining a new MatrixSymbol
that will represent the left hand side of our equation -- the derivatives of each state variable.
In [ ]:
dY = sym.MatrixSymbol('dY', *y.shape)
Exercise: form an equation using sym.Eq
to equate the two sides of our system of differential equations, then use this as the expression in codegen
. Print out just the header source to see the function signature. What is the output argument called now?
ode_eq = sym.Eq(???)
[(cf, cs), (hf, hs)] = codegen(???)
print(???)
In [ ]:
Solution
|
|
|
|
|
|
|
|
|
|
v
In [ ]:
ode_eq = sym.Eq(dY, rhs_of_odes_ind)
[(cf, cs), (hf, hs)] = codegen(('c_odes', ode_eq), language='c')
print(hs)
Now we see that the c_odes
function signature is nice and clean. We pass it a pointer to an array representing the current values of all of our state variables and a pointer to an array that we want to fill with the derivatives of each of those state variables.
If you're not familiar with C and pointers, you just need to know that it is idiomatic in C to preallocate a block of memory representing an array, then pass the location of that memory (and usually the number of elements it can hold), rather than passing the array itself to/from a function. For our purposes, this is as complicated as pointers will get.
Just so we can compile this code and use it, we'll re-use the codegen
call above with to_files=True
so the .c
and .h
files are actually written to the filesystem, rather than having their contents returned in a string.
In [ ]:
codegen(('c_odes', ode_eq), language='c', to_files=True)
Now we want to wrap the function that was generated c_odes
with a Cython function so we can generate an extension module and call that function from Python. Wrapping a set of C functions involves writing a Cython script that specifies the Python interface to the C functions. This script must do two things:
The build system of Cython is able to take the Cython wrapper source code as well as the C library source code and compile/link things together into a Python extension module. We will write our wrapper code in a cell making use of the magic command %%cython_pyximport
, which does a few things for us:
modname.pyx
)modname.pyxbld
file for instructions on how to build thingsSo, it works similarly to the %%cython
magic command we saw at the very beginning, but things are a bit more complicated now because we have this external library c_odes
that needs to be compiled as well.
Note: The pyxbld
file contains code similar to what would be found in the setup.py
file of a package making use of Cython code for wrapping C libraries.
In either case, all that's needed is to tell setuptools/Cython:
We will call our extension module cy_odes
, so here we'll generate a cy_odes.pyxbld
file to specify how to build the module.
In [ ]:
%%writefile cy_odes.pyxbld
import numpy
# module name specified by `%%cython_pyximport` magic
# | just `modname + ".pyx"`
# | |
def make_ext(modname, pyxfilename):
from setuptools.extension import Extension
return Extension(modname,
sources=[pyxfilename, 'c_odes.c'],
include_dirs=['.', numpy.get_include()])
Now we can write our wrapper code.
To write the wrapper, we first write the function signature as specified by the C library. Then, we create a wrapper function that makes use of the C implementation and returns the result. This wrapper function becomes the interface to the compiled code, and it does not need to be identical to the C function signature. In fact, we'll make our wrapper function compliant with the odeint
interface (i.e. takes a 1-dimensional array of state variable values and the current time).
In [ ]:
%%cython_pyximport cy_odes
import numpy as np
cimport numpy as cnp # cimport gives us access to NumPy's C API
# here we just replicate the function signature from the header
cdef extern from "c_odes.h":
void c_odes(double *y, double *dY)
# here is the "wrapper" signature that conforms to the odeint interface
def cy_odes(cnp.ndarray[cnp.double_t, ndim=1] y, double t):
# preallocate our output array
cdef cnp.ndarray[cnp.double_t, ndim=1] dY = np.empty(y.size, dtype=np.double)
# now call the C function
c_odes(<double *> y.data, <double *> dY.data)
# return the result
return dY
Exercise: use np.random.randn
to generate random state variable values and evaluate the right-hand-side of our ODEs with those values.
random_vals = np.random.randn(???)
???
In [ ]:
Solution
|
|
|
|
|
|
|
|
|
|
v
In [ ]:
random_vals = np.random.randn(14)
cy_odes(random_vals, 0) # note: any time value will do
Now we can use odeint
to integrate the equations and plot the results to check that it worked. First we need to import odeint
.
In [ ]:
from scipy.integrate import odeint
A couple convenience functions are provided in the scipy2017codegen
package which give some reasonable initial conditions for the system and plot the state trajectories, respectively. Start by grabbing some initial values for our state variables and time values.
In [ ]:
from scipy2017codegen.chem import watrad_init, watrad_plot
y_init, t_vals = watrad_init()
Finally we can integrate the equations using our Cython-wrapped C function and plot the results.
In [ ]:
y_vals = odeint(cy_odes, y_init, t_vals)
watrad_plot(t_vals, y_vals)
As yet another layer of abstraction on top of codegen
, SymPy provides an autowrap
function that can automatically generate a Cython wrapper for the generated C code. This greatly simplifies the process of going from a SymPy expression to numerical computation, but as we'll see, we lose a bit of flexibility compared to manually creating the Cython wrapper.
Let's start by importing the autowrap
function and checking out its documentation.
In [ ]:
from sympy.utilities.autowrap import autowrap
#autowrap?
So autowrap
takes in a SymPy expression and gives us back a binary callable which evaluates the expression numerically. Let's use the Equality
formed earlier to generate a function we can call to evaluate the right hand side of our system of ODEs.
In [ ]:
auto_odes = autowrap(ode_eq, backend='cython', tempdir='./autowraptmp')
Exercise: use the main Jupyter notebook tab to head to the temporary directory autowrap
just created. Take a look at some of the files it contains. Can you map everything we did manually to the files generated?
Solution
|
|
|
|
|
|
|
|
|
|
v
autowrap
generates quite a few files, but we'll explicitly list a few here:
wrapped_code_#.c
: the same thing codegen
generatedwrapper_module_#.pyx
: the Cython wrapper codewrapper_module_#.c
: the cythonized codesetup.py
: specification of the Extension
for how to build the extension moduleExercise: just like we did before, generate some random values for the state variables and use auto_odes
to compute the derivatives. Did it work like before?
Hint: take a look at wrapper_module_#.pyx
to see the types of the arrays being passed in / created.
random_vals = np.random.randn(???)
auto_odes(???)
In [ ]:
Solution
|
|
|
|
|
|
|
|
|
|
v
In [ ]:
random_vals = np.random.randn(14, 1) # need a 2-dimensional vector
auto_odes(random_vals)
One advantage to wrapping the generated C code manually is that we get fine control over how the function is used from Python. That is, in our hand-written Cython wrapper we were able to specify that from the Python side, the input to our wrapper function and its return value are both 1-dimensional ndarray
objects. We were also able to add in the extra argument t
for the current time, making the wrapper function fully compatible with odeint
.
However, autowrap
just sees that we have a matrix equation where each side is a 2-dimensional array with shape (14, 1). The function returned then expects the input array to be 2-dimensional and it returns a 2-dimensional array.
This won't work with odeint
, so we can write a simple wrapper that massages the input and output and adds an extra argument for t
.
In [ ]:
def auto_odes_wrapper(y, t):
dY = auto_odes(y[:, np.newaxis])
return dY.squeeze()
Now a 1-dimensional input works.
In [ ]:
random_vals = np.random.randn(14)
auto_odes_wrapper(random_vals, 0)
Exercise: As we have seen previously, we can analytically evaluate the Jacobian of our system of ODEs, which can be helpful in numerical integration. Compute the Jacobian of rhs_of_odes_ind
with respect to y
, then use autowrap
to generate a function that evaluates the Jacobian numerically. Finally, write a Python wrapper called auto_jac_wrapper
to make it compatible with odeint
.
# compute jacobian of rhs_of_odes_ind with respect to y
???
# generate a function that computes the jacobian
auto_jac = autowrap(???)
def auto_jac_wrapper(y, t):
return ???
Test your wrapper by passing in the random_vals
array from above. The shape of the result should be (14, 14)
.
In [ ]:
Solution
|
|
|
|
|
|
|
|
|
|
v
In [ ]:
jac = rhs_of_odes_ind.jacobian(y)
auto_jac = autowrap(jac, backend='cython', tempdir='./autowraptmp')
def auto_jac_wrapper(y, t):
return auto_jac(y[:, np.newaxis])
auto_jac_wrapper(random_vals, 2).shape
Finally, we can use our two wrapped functions in odeint
and compare to our manually-written Cython wrapper result.
In [ ]:
y_vals = odeint(auto_odes_wrapper, y_init, t_vals, Dfun=auto_jac_wrapper)
watrad_plot(t_vals, y_vals)
autowrap
As of SymPy 1.1, autowrap
accepts a custom CodeGen
object, which is responsible for generating the code. The CodeGen
object in turn accepts a custom CodePrinter
object, meaning we can use these two points of flexibility to make use of customized code printing in an autowrapped function. The following example is somewhat contrived, but the concept in general is powerful.
In our set of ODEs, there are quite a few instances of $y_i^2$, where $y_i$ is one of the 14 state variables. As an example, here's the equation for $\frac{dy_3(t)}{dt}$:
In [ ]:
rhs_of_odes[3]
There is a library called fastapprox that provides computational routines things like powers, exponentials, logarithms, and a few others. These routines provide limited precision with respect to something like math.h
's equivalent functions, but they offer potentially faster computation.
The fastapprox library provides a function called fastpow
, with the signature fastpow(float x, float p)
. It it follows the interface of pow
from math.h
. In the previous notebook, we saw how to turn instances of $x^3$ into x*x*x
, which is potentially quicker than pow(x, 3)
. Here, let's just use fastpow
instead.
Exercise: implement a CustomPrinter
class that inherits from C99CodePrinter
and overrides the _print_Pow
function to make use of fastpow
. Test it by instantiating the custom printer and printing a SymPy expression $x^3$.
Hint: it may be helpful to run C99CodePrinter._print_Pow??
to see how it works
from sympy.printing.ccode import C99CodePrinter
class CustomPrinter(C99CodePrinter):
def _print_Pow(self, expr):
???
printer = CustomPrinter()
x = sym.symbols('x')
# print x**3 using the custom printer
???
In [ ]:
Solution
|
|
|
|
|
|
|
|
|
|
v
In [ ]:
from sympy.printing.ccode import C99CodePrinter
class CustomPrinter(C99CodePrinter):
def _print_Pow(self, expr):
return "fastpow({}, {})".format(self._print(expr.base),
self._print(expr.exp))
printer = CustomPrinter()
x = sym.symbols('x')
printer.doprint(x**3)
Now we can create a C99CodeGen
object that will make use of this printer. This object will be passed in to autowrap
with the code_gen
keyword argument, and autowrap
will use it in the code generation process.
In [ ]:
from sympy.utilities.codegen import C99CodeGen
gen = C99CodeGen(printer=printer)
However, for our generated code to use the fastpow
function, it needs to have a #include "fastpow.h"
preprocessor statement at the top. The code gen object supports this by allowing us to append preprocessor statements to its preprocessor_statements
attribute.
In [ ]:
gen.preprocessor_statements.append('#include "fastpow.h"')
One final issue remains, and that is telling autowrap
where to find the fastapprox library headers. These header files have just been downloaded from GitHub and placed in the scipy2017codegen
package, so it should be installed with the conda environment. We can find it by looking for the package directory.
In [ ]:
import os
import scipy2017codegen
package_dir = os.path.dirname(scipy2017codegen.__file__)
fastapprox_dir = os.path.join(package_dir, 'fastapprox')
Finally we're ready to call autowrap
. We'll just use ode_eq
, the Equality
we created before, pass in the custom CodeGen
object, and tell autowrap
where the fastapprox headers are located.
In [ ]:
auto_odes_fastpow = autowrap(ode_eq,
code_gen=gen,
backend='cython',
include_dirs=[fastapprox_dir],
tempdir='autowraptmp_custom')
If we navigate to the tmp directory created, we can view the wrapped_code_#.c
to see our custom printing in action.
As before, we need a wrapper function for use with odeint
, but aside from that, everything should be in place.
In [ ]:
def auto_odes_fastpow_wrapper(y, t):
dY = auto_odes_fastpow(y[:, np.newaxis])
return dY.squeeze()
y_vals, info = odeint(auto_odes_fastpow_wrapper, y_init, t_vals, full_output=True)
watrad_plot(t_vals, y_vals)
Exercise: generate an array of random state variable values, then use this array in the auto_odes_wrapper
and auto_odes_fastpow_wrapper
functions. Compare their outputs.
In [ ]:
Solution
|
|
|
|
|
|
|
|
|
|
v
In [ ]:
random_vals = np.random.randn(14)
dY1 = auto_odes_wrapper(random_vals, 0)
dY2 = auto_odes_fastpow_wrapper(random_vals, 0)
dY1 - dY2