Working with Compilers
Broadly speaking, interpreted languages tend to be slow, but are relatively easy to learn and use. Compiled languages generally deliver the maximum speed, but are more complex to learn and use effectively. Python can utilize libraries of compiled code that are appropriately prepared, or can invoke a compiler (standard or “just in time”) to compile snippets of code and incorporate it directly into the execution.
Wrapping Compiled Code in Python
Function libraries can be written in C/C++/Fortran and converted into Python-callable modules.
In order to wrap code written in a compiled language, you must have a compiler for the appropriate language installed on your system.
Windows
If you do not use Fortran, you can install MS Visual Studio. A community edition is available free for personal use and includes C and C++ compilers. If you might use Fortran, a good option is
MinGW-64. This may also provide good compatibility with a Python installation such as Miniforge, even if you do not expect to use Fortran. MinGW-64 provides several options for builds of the gcc
(Gnu Compiler Collection). The ucrt
build is recommended but may be a little rough around the edges, at least for Fortran users. The older mingw64
build may be more suitable. Either or both can be installed on the same system; the path will select the compiler used by Python or the IDE. A nice tutorial on installing MingGW-64 and using it with the free
VSCode IDE is
here. You must install VSCode extensions for C/C++ and, if appropriate, Fortran. To install the mingw64 version, simply substitute that name for ucrt in the pacman
instructions. For Fortran, after the basic toolchain is installed, run
pacman -S mingw-w64-x86_64-gcc-fortran
Now go to Settings and edit your system environment variables to add C:\msys2\mingw64\bin
to path
. Once that is done, you can use a command line or a local PowerShell, such as the Miniforge shell, to run f2py as shown below for Linux. After that move the resulting library to an appropriate location in your PYTHONPATH.
Mac OS
Install XCode from the Mac App Store for the C/C++ compilers, then if appropriate install gfortran from the
Wiki. MinGW-64 is also an option for macOS. Once installed you can run commands in a Terminal shell. In newer macOS versions the shell is zsh
and not bash
, but the commands shown for Linux should work without modification.
Linux
The gcc compiler should be installed by default but you may have to add the corresponding g++ and gfortran compilers. Refer to the documentation for your Linux distribution and package manager.
Wrapping Fortran
- If you have Fortran source code you can use f2py. It is included as part of NumPy. It can work for C as well, but requires some knowledge of Fortran interfaces to do so. It can wrap nearly all legacy Fortran 77 and some newer Fortran 90 constructs, in particular, modules. It must be used from a command line, which is simple on Linux and macOS but a little more complicated on Windows.
http://docs.scipy.org/doc/numpy-dev/f2py/
We will illustrate with a simple (and very incomplete) example of code to work with fractions.
Example
Download the example Fortran code fractions.f90 to try this yourself.
First create a Python signature file.
f2py fractions.f90 -m Fractions -h Fractions.pyf
We then use the signature file to generate the Python module.
f2py -c -m Fractions fractions.f90
The original Fortran source consisted of a module Fractions. Examining the signature file, we see that f2py has lower-cased it and created the Python module Fractions. Under Linux the module file is called Fractions.cpython-39-x86_64-linux-gnu.so but we can drop everything past the period when we import it.
>>> from Fractions import fractions
>>> fractions.adder(1,2,3,4)
array([10, 8], dtype=int32)
One significant weakness of f2py is limited support of the Fortran90+ standards, particularly derived types. One option is the f90wrap package.
It is also possible to wrap the Fortran code in C by various means, such as the F2003 ISO C binding features, then to use the Python-C interface packages, such as ctypes and CFFI, for Fortran. More details are available at fortran90.org for interfacing with C and Python.
Wrapping C
The [CFFI] (https://cffi.readthedocs.io/en/latest/overview.html) package can be used to wrap C code. CFFI (C Foreign Function Interface) wraps C libraries into Python code. To use it, prepare a shared (dynamic) library of functions. This requires a C compiler, and the exact steps vary depending on your operating system. Windows compilers produce a file called a DLL, Unix/Linux shared libraries end in .so
, and macOS shared libraries end in .dylib
.
CFFI is not a base package, but is often included in Python distributions such as Miniforge. It may also be included as an add-on for other installations such as system Pythons, since some other package such as a cryptography library may require it. Before installing CFFI, first attempt to import it
import cffi
If that fails you can pip install cffi
.
Much as for f2py, the user must prepare some form of signature for the C functions. For CFFI that usually means writing a header (.h
) file containing the function prototypes.
CFFI has several modes. We will work with the API (Application Programming Interface) since it is not too complicated and performs well.
See the documentation for a discussion of the various modes.
Example
Download the arith.c file and its corresponding arith.h header file, which implements some trivial arithmetic functions.
Now download the build_arith.py script
from cffi import FFI
ffibuilder = FFI()
ffibuilder.cdef("""
double sum(double x, double y);
double difference(double x, double y);
double product(double x, double y);
double division(double x, double y);
""")
# set_source() specifies the name of the Python extension module to
# create, as well as the original C source file. Conventionally, we
# name the extension module with a leading underscore. If we required
# external libraries, we would add the libraries argument with a list
# of the libraries in the form the linker would use to link them.
ffibuilder.set_source("_arithlib",
"""
#include "arith.h" // the C header of the library
""",
sources=['./arith.c'])
if __name__ == "__main__":
ffibuilder.compile(verbose=True)
We must repeat the function prototypes in the cdef
method. The set_source
method takes several arguments, not all of which are shown in this example. The first is the name of the shared library that will be generated. Next is all preprocessor statements within triple-double quotes. The sources
argument is a list of the source file or files. Note that if the path is not in the current search path, it must be specified. Our example shows a Unix-like path. Finally, we invoke the compiler to create the library. CFFI will use the default system library.
Run the script.
python build_arith.py
On Linux the name may be lengthy, such as _arithlib.cpython-39-x86_64-linux-gnu.so
. When importing we may use only the first part _arithlib
.
We now utilize it from the interpreter as follows:
>>>from _arithlib import ffi, lib
>>>lib.sum(11.1,12.8)
23.9
>>>lib.difference(11.1,12.8)
-1.700000000000001
>>>lib.product(11.1,12.8)
142.08
>>>lib.division(11.1,12.8)
0.8671874999999999
CFFI supports more advanced features. For example, structs can be wrapped into Python classes. See here for an example.
CFFI does not support C++ directly. Any C++ must be “C-like” and contain an extern C
declaration.
Wrapping C++
One of the most popular packages that deals directly with C++ is
PyBind11. Setting up the bindings is more complex than is the case for ctypes or CFFI, however, and the bindings are written in C++, not Python. Pybind11 will have to be installed through conda
or pip
.
One option is to use
CMake, since it can be configured to generate the fairly complex Makefile required, and it also works on Windows. A somewhat simpler method is to use the Python package invoke
. This can be installed through pip or through a manager such as a Linux operating system package manager. The Python header file Python.h
must also be accessible.
Example We will wrap the fractions.cxx file. It also requires the fractions.h header. These files implement a very incomplete Fractions class, similar to the Fortran example above.
- Write the bindings wrap_fractions.cxx.
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include "fractions.h"
namespace py = pybind11;
PYBIND11_MODULE(py_fractions,m) {
m.doc() = "Fractions module";
py::class_<Fraction>(m, "Fraction")
.def(py::init<int, int>())
.def("addFracs", &Fraction::addFracs);
}
- Compile
fractions.cxx
into a shared library. Invoke can be used for this, but a simple command line is also sufficient here.
g++ -O3 -Wall -Werror -shared -std=c++11 -fPIC fractions.cxx -o libfractions.so
Run the command
python -m pybind11 --includes
in order to determine the include path. On a particular system it returned
-I/usr/include/python3.11 -I/usr/include/pybind11
Take note of the include file paths, which will vary from one system to another. Move into Python and run invoke
import invoke
cpp_name="wrap_fractions.cxx"
extension_name="py_fractions"
invoke.run(
"g++ -O3 -Wall -Werror -shared -std=c++11 -fPIC fractions.cxx "
"-o libfractions.so "
)
invoke.run(
"g++ -O3 -Wall -Werror -shared -std=c++11 -fPIC "
"`python3 -m pybind11 --includes` "
"-I /usr/include/python3.9 -I . "
"{0} "
"-o {1}`python3.11-config --extension-suffix` "
"-L. -lfractions -Wl,-rpath,.".format(cpp_name, extension_name)
)
This will create a module whose name begins with py_fractions
(the rest of the name is specific to the platform on which it was created, and is ignored when importing). Test that it works:
>>> from py_fractions import Fraction
>>> f1=Fraction(5,8)
>>> f2=Fraction(11,13)
>>> f1.addFracs(f2)
[153, 104]
Pybind11 requires C++ code that adheres to the C++11 standard or higher. Another option is the Boost library Python bindings Boost.Python. Pybind11 is a fork of these bindings; the Boost version is more general, and can handle many older C++ codes, but it is more complex to use.
Cython
Cython is a package that allows Python code to be compiled into C code. Some rewriting is required because C requires statically-typed variables. Cython defines two additional keywords to declare functions, cdef
and cpdef
. cdef
is basically C and can produce the fastest code, but cdef
declared functions are not visible to Python code that imports the module. cpdef
is mix of C with dynamic bindings for passing of Python objects which makes it slower than cdef
(read the details).
Exercise: integrate.py
Suppose we start with
"""
Created on Mon Feb 3 14:05:03 2020
@author: Katherine Holcomb
"""
cpdef double f(double x):
return x**2-x
cpdef double integrate_f(double a, double b, int N):
cdef int i
cdef double s, dx
s = 0
dx = (b-a)/N
for i in range(N):
s += f(a+i*dx)
return s*dx
Save the above code as integrate_cyf.pyx
. Now create a setup.py
file:
from distutils.core import setup
from Cython.Build import cythonize
setup(
ext_modules = cythonize("integrate_cyf.pyx")
)
On the command line run python setup.py build_ext --inplace
to build the extension.
This will create a file integrate_cyf.c
in your local directory. In addition you will find a file called integrate_cyf.so
in unix or integrate_cyf.pyd
in Windows. Now you can import your module in your Python scripts.
import integrate_cyf as icyf
print(icyf.integrate_f(1.,51.,1000))
More detailed information describing the use of Cython can be found here.
Numba
Numba is available through the Miniforge Python distribution. It compiles selected functions using the LLVM compiler. Numba is accessed through a decorator. Decorators in Python are wrappers that modify the functions without the need to change the code.
Exercise: A well-known but slow way to compute pi is by a Monte Carlo method. Given a circle of unit radius inside a square with side length 2, we can estimate the area inside and outside the circle by throwing “darts” (random locations). Since the area of the circle is pi and the area of the square is 4, the ratio of hits inside the circle to the total thrown is pi/4.
Open the MonteCarloPi.py
script.
"""
Created on Mon Feb 3 14:41:44 2020
This program estimates the value of PI by running a Monte Carlo simulation.
NOTE: This is not how one would normally want to calculate PI, but serves
to illustrate the principle.
@author: Katherine Holcomb
"""
import sys
import random
def pi(numPoints):
"""Throw a series of imaginary darts at an imaginary dartboard of unit
radius and count how many land inside the circle."""
numInside=0
for i in range(numPoints):
x=random.random()
y=random.random()
if (x**2+y**2<1):
numInside+=1
pi=4.0*numInside/numPoints
return pi
def main():
# parse number of points from command line. Try 10^8
if (len(sys.argv)>1):
try:
numPoints=int(float((sys.argv[1])))
print('Pi (approximated): {}'.format(pi(numPoints)))
except:
print("Argument must be an integer.")
else:
print("USAGE:python MonteCarlo.py numPoints")
if __name__=="__main__":
main()
Running with $10^9$ points takes 6 minutes and 21 seconds on one particular system.
Now add another import
statement.
from numba import jit
Add the decorator above the pi function:
@jit
def pi(numPoints):
No other changes are required. The time is reduced to only 14.7 seconds!