Shared object files#

Disclaimer: we will be working in notebooks, because it’s a great place to teach in. It’s not a great place to write compiled code in, so we will write out the files we want to compile from the notebooks. In real life, just write out the files directly!

import sys

# Compilers:
CXX = (
    "clang++ -L/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/lib"
    if "darwin" in sys.platform
    else "g++"
)
CC = (
    "clang -L/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/lib"
    if "darwin" in sys.platform
    else "gcc"
)

What is meant by bindings?#

Bindings allow a function(alitiy) in a library to be accessed from Python.

We will start with this example:#

%%writefile simple.c

float square(float x) {
    return x*x;
}
Writing simple.c

Depending on your platform, you may normally use gcc (Linux) or clang (macOS) to compile. If you are on Windows, you might want to work in the Linux-as-a-Subsystem for this one example. There are better, cross platform ways to compile extensions that we’ll see soon.

!{CC} -shared -o simple.so simple.c

Desired usage in Python:#

y = square(x)

ctypes#

  • 0 dependencies

  • Shared libraries with C interfaces only

  • No help with creating/managing the SO’s.

C bindings are very easy. Just compile into a shared library, then open it in Python with the built in ctypes module:

import ctypes

lib = ctypes.cdll.LoadLibrary("./simple.so")

Warning: You can only load a file once; future loads just reuse the existing library (much like Python imports). So if you change the file, you need to restart the kernel.

Also, this may inherit the changes to Windows DLL path loading in Python 3.8+, so keep that in mind if you have trouble loading an so (dll).

Now, we need to set the argument types - they can’t be inferred from an SO.

lib.square.argtypes = (ctypes.c_float,)
lib.square.restype = ctypes.c_float
# %%timeit
lib.square(2.5)
6.25

Notice what was required and why:

  1. First we load the library, just handing the file we want to open.

  2. Next, we set .argtypes (argument types) with a iterable of types and .restype with the return type. We had to use the types from ctypes since compiled types are not the same as Python types. SO’s do not store signatures!

There is an alternate way to do this:

  1. Create a new C Function type, listing the return type, then the argument type(s) if there are any. Yes, this is different order and structure than Type Hinting in modern Python…

float_float_t = ctypes.CFUNCTYPE(ctypes.c_float, ctypes.c_float)

Now “cast” the function with your function type.

squarefunc = float_float_t(lib.square)

And this also works.

# %%timeit
squarefunc(2.0)
4.0

However, it sets up a little bit of extra machinery so it’s a hair slower. However, this is itself now a valid c_type and can be passed to code! You can even wrap Python functions (won’t be faster, but powerful, nevertheless)!

Suggestion: wrap your library in a class!

class SimpleLib:
    def __init__(self, file_to_load: str):
        self._lib = ctypes.cdll.LoadLibrary(file_to_load)
        self._lib.square.argtypes = (ctypes.c_float,)
        self._lib.square.restype = ctypes.c_float

    def square(self, x: float) -> float:
        return self._lib.square(x)
simple = SimpleLib("./simple.so")
simple.square(2)
4.0

There is a benefit to these types; they can wrap pure Python functions and make them fully callable from C, as well! Not fast, but can be done.

But what about C++ (and all other languages)?#

This wasn’t really language specific, it is just a property of compiled libraries. However, C++ (and possibly other libraries) do not by default provide a nice exported interface. In C++, because of features like overloading, the names are “mangled” in a compiler specific way. But you can manually export a nice interface:

Let’s try a slightly more advanced example, this time in C++:

%%writefile templates.cpp


template<class T>
T square(T x) {
    return x*x;
}


extern "C" {
    int square_int(int x) {
        return square<int>(x);
    }
    
    double square_double(double x) {
        return square<double>(x);
    }
}
Writing templates.cpp

This is not Python specific! If you want to load a SO (DLL) from any library, you need to do this.

!{CXX} templates.cpp -shared -o templates.so
from functools import singledispatchmethod


class TemplateLib(object):
    def __init__(self, file_to_load: str):
        self._lib = ctypes.cdll.LoadLibrary(file_to_load)

        self._lib.square_double.argtypes = (ctypes.c_double,)
        self._lib.square_double.restype = ctypes.c_double

        self._lib.square_int.argtypes = (ctypes.c_int,)
        self._lib.square_int.restype = ctypes.c_int

    @singledispatchmethod
    def square(self, arg):
        raise NotImplementedError("Not a registered type")

    @square.register
    def _(self, x: int):
        return self._lib.square_int(x)

    @square.register
    def _(self, x: float):
        return self._lib.square_double(x)
templates = TemplateLib("./templates.so")
templates.square(2)
4
templates.square(2.0)
4.0

NumPy tools#

Let’s briefly mention NumPy has adapters to help you with ctypes, called numpy.ctypeslib. You can convert to/from any object providing an __array_interface__, like NumPy arrays. It has a loader with more consistent defaults across operating systems. You also have a special pointer that can do bounds checking and such for array arguments.

CFFI#

  • The C Foreign Function Interface for Python

  • C only

  • Developed for PyPy, but available in CPython too

The same example as before:

from cffi import FFI

ffi = FFI()

ffi.cdef("float square(float);")

C = ffi.dlopen("./simple.so")

C.square(2.0)
4.0
C.square
<cdata 'float(*)(float)' 0x7f93205fb0f9>

Notice we were able to give a C header this time; no futzing around with C function types by hand.

Try it yourself#

Let’s try it now. We’ll take a few minutes to play with the code; we are also available to help with troubleshooting at this time. Double the elements in this array (for simplicity, do it in-place):

%%writefile array_square.c

void squares(float* arr, int size) {
    for(int i=0; i<size; i++)
        arr[i] = arr[i]*arr[i];
}
Writing array_square.c
!{CC} array_square.c -shared -o array_square.so
import numpy as np
import ctypes

alib = ctypes.cdll.LoadLibrary("./array_square.so")
...  # Prepare the function
Ellipsis

Remember to match the types! This is a 32 bit float, not a 64 bit double!

arr = np.array([1, 2, 3, 4, 5], dtype=np.float32)
...  # Call the function
Ellipsis
arr
array([1., 2., 3., 4., 5.], dtype=float32)

Mix and match with Numba and more#

We can mix ctypes (or cffi) with Numba, as well! Here’s another way to do the above problem:

import numpy as np

arr = np.random.randint(1, 10, size=10_000)
import numba
# Question: Why do I have to do this? Any thoughts?
csquare = lib.square


@numba.vectorize
def squares(x):
    return csquare(x)
%%timeit
squares(arr)
27.5 μs ± 9.66 μs per loop (mean ± std. dev. of 7 runs, 1 loop each)