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
nogilsafely.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
"""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_tanddoublemake loop state native values;libc.math.sincalls the C math implementation instead of creating Python float objects in each iteration;cpdefprovides a Python wrapper and a C-level entry point;with nogilmarks a region containing no Python operations, allowing other threads to execute native work concurrently;cdivisionremoves 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:
Remove the C types and call Python’s
math.sin; rebuild and time it.Restore types for function arguments, loop variables, and accumulators; rebuild and time it.
Restore
libc.math.sin; rebuild and time it.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?
Solution
The exact timings are machine-dependent. A correct report should show that merely translating dynamic Python changes little compared with eliminating Python objects inside the loop. Static numeric types and the C sin call should remove most highlighted interaction. The result should remain close to \(\pi\) in every correct version.
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?
Solution
The exact result is \(a^2\pi\). Add double amplitude=1.0, compute value = amplitude * sin(...), and compare the result to amplitude * amplitude * pi. The ratio may change slightly, but interpretation should focus on measured work and preserved correctness rather than expecting a fixed value.
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.