Cython concepts and practice

Objectives

  • Explain how a compiled extension removes interpreter work from a numerical kernel.

  • Build and import a Cython extension.

  • Apply static types, C library calls, typed memoryviews, and nogil safely.

  • Benchmark an optimized kernel while checking its result.

Instructor note

  • 40 min teaching/type-along

  • 30 min exercises

  • Demonstrate the baseline build, annotation view, and one typing change live before learners begin the incremental exercise.

Cython translates Python-like .pyx source into C or C++, then a native compiler produces an extension module importable from Python. The extension boundary is useful when profiling finds a stable, loop-heavy kernel for which Python’s per-object and per-iteration work dominates.

Compilation alone is not a promise of speed. Untyped Cython code can still perform Python operations. The large gains come when Cython can replace those operations with C variables, C loops, direct memory access, and native library calls.

Start with a checked baseline

The running example approximates

\[ \int_0^{2\pi} \sin^2(x + \phi)\,dx = \pi. \]
"""Pure-Python baseline used throughout the module."""

from math import pi, sin


def integrate(n: int, phase: float = 0.0) -> float:
    """Approximate the integral of sin(x + phase)^2 over [0, 2*pi)."""
    if n <= 0:
        raise ValueError("n must be positive")

    step = 2.0 * pi / n
    total = 0.0
    for i in range(n):
        value = sin(i * step + phase)
        total += value * value
    return total * step

The known result gives us a correctness check. Time a workload large enough to rise above timer noise, repeat it, and report the environment with the result.

Translate, compile, import

The compiled version keeps the Python-callable interface but adds C types:

# cython: language_level=3

import cython
from libc.math cimport M_PI, sin


@cython.cdivision(True)
cpdef double integrate(Py_ssize_t n, double phase=0.0):
    """Typed implementation of the lesson's integration kernel."""
    cdef Py_ssize_t i
    cdef double step
    cdef double total = 0.0
    cdef double value

    if n <= 0:
        raise ValueError("n must be positive")

    step = 2.0 * M_PI / n
    with nogil:
        for i in range(n):
            value = sin(i * step + phase)
            total += value * value

    return total * step

Key changes are:

  • Py_ssize_t and double make loop state native values;

  • libc.math.sin calls the C math implementation instead of creating Python float objects in each iteration;

  • cpdef provides a Python wrapper and a C-level entry point;

  • with nogil marks a region containing no Python operations, allowing other threads to execute native work concurrently;

  • cdivision removes Python’s division semantics where the input checks make C division appropriate.

The build file uses cythonize and setuptools:

"""Build the Cython example in the current directory."""

from Cython.Build import cythonize
from setuptools import Extension, setup


setup(
    name="evita-integration-kernel",
    ext_modules=cythonize(
        [Extension("integrate_cython", ["integrate_cython.pyx"])],
        annotate=True,
        compiler_directives={"language_level": "3"},
    ),
)

Build and run the comparison from content/episodes/code:

$ python setup.py build_ext --inplace
$ python benchmark_integrate.py

Do not copy a speedup number from this page: the result depends on the CPU, compiler, workload size, Python version, and compiler flags. Check that both implementations produce the expected result before interpreting timing.

See where Python remains

The build enables Cython’s annotated HTML. Open integrate_cython.html; strong yellow highlighting indicates interaction with the Python runtime. Use annotation to target high-overhead lines, not to remove safety checks indiscriminately.

Warning

Integer and floating-point behavior can change when Python values become C values. C integers have finite ranges, C division differs from Python in some cases, and removing bounds checks can turn an indexing mistake into memory corruption. Add correctness tests before each optimization step.

Typed arrays without copies

A typed memoryview describes a buffer without tying the kernel to one particular array library. For a one-dimensional contiguous float64 buffer:

import cython
from libc.math cimport sin

@cython.boundscheck(False)
@cython.wraparound(False)
cpdef void sine_inplace(double[::1] values):
    cdef Py_ssize_t i
    with nogil:
        for i in range(values.shape[0]):
            values[i] = sin(values[i])

double[::1] requires unit stride. Validate shape, dtype, and contiguity at the Python boundary. Disable bounds and negative-index checks only after tests establish that every access is safe.

Incremental optimization exercise

Exercise

Make a working copy of integrate_cython.pyx and perform these experiments one at a time:

  1. Remove the C types and call Python’s math.sin; rebuild and time it.

  2. Restore types for function arguments, loop variables, and accumulators; rebuild and time it.

  3. Restore libc.math.sin; rebuild and time it.

  4. Inspect the annotated HTML after each version.

For each step, record correctness, best time from at least three repeats, and the lines whose annotation changed. Which change removes most Python interaction on your system?

Native integration exercise

Exercise

Extend the kernel with a typed amplitude argument so it integrates \(\left(a\sin(x+\phi)\right)^2\). Predict the exact integral, add an input/result test, then benchmark the change. Does adding one multiplication materially change the speedup ratio?

When Cython is the wrong first move

Prefer a higher-level change when a better algorithm or an existing NumPy/SciPy routine already solves the bottleneck. Cython adds a compiler toolchain, platform-specific build artifacts, and more specialized code. It earns that maintenance cost when a well-tested hot kernel cannot be expressed efficiently with existing native operations.

Keypoints

  • Cython is most valuable for measured, stable kernels dominated by Python loop and object overhead.

  • Compilation alone does little unless types and operations can become native.

  • Typed memoryviews expose array buffers without forcing a copy.

  • Release the GIL only in regions that perform no Python operations.

  • Preserve tests, inspect generated-code annotations, and benchmark on the target system.

See also

The Cython documentation covers extension types and compiler directives in depth. Continue with Dask and the optimization workflow.