C Extensions & Performance

Cython, ctypes, and CFFI for maximum performance.

Why Write C Extensions?

Python is expressive and productive, but not always fast. When performance becomes critical, Python allows you to move hot paths into C or C++ while keeping a Pythonic API.

Use Cases
  • Numerical computation and tight loops
  • Low-level system or hardware access
  • Reuse existing C/C++ libraries
  • Memory-intensive operations
When NOT to Use
  • I/O-bound operations (use async)
  • Already fast enough code
  • Before profiling bottlenecks
  • Simple scripts or prototypes

The C Extension Landscape

ToolBest ForComplexityPerformance
CythonNumeric loops, NumPy integrationMediumExcellent
ctypesQuick wrapping, simple C APIsLowGood
CFFIComplex C APIs, maintainabilityMediumExcellent
C APIMaximum control, special needsHighMaximum
â„šī¸ Golden Rule: Choose the simplest tool that meets your performance needs. Profile first!

Deep Dive: Each Approach

Cython

Cython lets you gradually add static typing to Python code and compile it into C extensions. It's a superset of Python that supports optional static type declarations.

Pure Python
def sum_py(arr):
    total = 0
    for x in arr:
        total += x
    return total

# Slow: Python overhead
# on every iteration
Cython Optimized
cpdef long sum_c(int[:] arr):
    cdef long total = 0
    cdef Py_ssize_t i
    for i in range(arr.shape[0]):
        total += arr[i]
    return total

# Fast: C-level loop.
# 'long' for the accumulator, not 'int':
# a million 32-bit values overflow one.
✅ Key Features: Memory views, static typing, C function calls, GIL release, NumPy integration
Setup (setup.py)
from setuptools import setup
from Cython.Build import cythonize
import numpy as np

setup(
    ext_modules=cythonize("example.pyx"),
    include_dirs=[np.get_include()]
)

# Build: python setup.py build_ext --inplace
Measuring it, rather than guessing
import ctypes
import time

import numpy as np
from cffi import FFI

from fast import sum_c  # the Cython module built above

N = 1_000_000
arr = np.arange(N, dtype=np.int32)

def sum_py(a):
    total = 0
    for x in a:
        total += int(x)
    return total

lib_ct = ctypes.CDLL("./libsum.so")
lib_ct.sum_array.argtypes = (ctypes.POINTER(ctypes.c_int), ctypes.c_int)
lib_ct.sum_array.restype = ctypes.c_long
ptr = arr.ctypes.data_as(ctypes.POINTER(ctypes.c_int))

ffi = FFI()
ffi.cdef("long sum_array(const int*, int);")
lib_cf = ffi.dlopen("./libsum.so")
cptr = ffi.cast("int *", arr.ctypes.data)

impls = {
    "pure_python": lambda: sum_py(arr),
    "cython_typed": lambda: sum_c(arr),
    "ctypes": lambda: lib_ct.sum_array(ptr, N),
    "cffi": lambda: lib_cf.sum_array(cptr, N),
    "numpy": lambda: int(arr.sum()),
}

def fastest_of(fn, runs=5):
    """Best of N: the minimum is the least noisy estimate"""
    times = []
    for _ in range(runs):
        start = time.perf_counter()
        fn()
        times.append(time.perf_counter() - start)
    return min(times)

expected = int(arr.sum())
best = {}
for name, fn in impls.items():
    assert fn() == expected, name          # same answer, or the timing is meaningless
    best[name] = fastest_of(fn)

base = best["pure_python"]
for name, t in best.items():
    print(f"{name:14} {t * 1000:8.2f} ms   {base / t:6.0f}x")
Expected Output:
pure_python       71.19 ms        1x
cython_typed       0.25 ms      280x
ctypes             0.11 ms      659x
cffi               0.11 ms      624x
numpy              0.20 ms      364x

Summing a one-million-element int32 array, best of five runs each, on CPython 3.12 with gcc 14 and NumPy 2.5. Absolute times depend on your machine; the ordering is stable. Note that ctypes and cffi lead here only because the whole array crosses the boundary in one call. Call into C once per element and the per-call overhead reverses the result.

The GIL and True Parallelism

With GIL

Pure Python code runs on one CPU core at a time. Multithreading doesn't help CPU-bound tasks.

# Threads take turns
Thread1: █████.....
Thread2: .....█████
Without GIL

Native extensions can release the GIL, enabling true parallel execution on multiple cores.

# Parallel execution
Thread1: ██████████
Thread2: ██████████
Releasing the GIL in Cython
cdef double heavy_computation(double x, double y) nogil:
    # This can run in parallel with other threads
    cdef double result = 0.0
    cdef int i
    for i in range(1000000):
        result += x * y / (i + 1)
    return result

def parallel_work(data):
    # 1. Extract values while we still have the GIL
    cdef double x = data[0]
    cdef double y = data[1]
    cdef double result

    # 2. Release GIL safely
    with nogil:
        result = heavy_computation(x, y)

    # 3. Return result (Cython handles converting C double back to Python float)
    return result
✅ Real Impact: This is how NumPy, SciPy, and Pandas achieve high performance in multi-core environments.

Performance Best Practices

Do's
  • Profile before optimizing (use cProfile)
  • Focus on hot loops and bottlenecks
  • Benchmark with realistic data
  • Use memory views in Cython
  • Release GIL when possible
  • Consider NumPy vectorization first
Don'ts
  • Don't optimize prematurely
  • Don't assume - measure!
  • Don't ignore memory layout
  • Don't forget error handling
  • Don't sacrifice readability needlessly
  • Don't skip documentation

Key Takeaways

  • Profile first - not all performance problems need C extensions
  • Cython offers the best balance of power and productivity for numeric code
  • ctypes for quick wrapping, CFFI for maintainability
  • Native code can bypass the GIL for real parallelism on multiple cores
  • C API provides maximum control but maximum complexity
  • Consider NumPy/Numba as alternatives before writing C extensions

đŸ’ģ Practice Exercises

  1. Benchmark Challenge: Implement a matrix multiplication in pure Python, then optimize with Cython. Measure the speedup.
  2. FFI Practice: Wrap a simple C library (like zlib) using both ctypes and CFFI. Compare the code complexity.
  3. GIL Release: Create a Cython function that releases the GIL and verify parallel execution with threading.
  4. Real-world Application: Profile a slow function in your codebase and optimize it using the most appropriate tool.
What's Next?

You've learned C extensions! Now let's explore advanced data structures for solving complex algorithmic problems efficiently.

  • Custom Data Structures - Implement heaps, tries, and graphs
  • Algorithm Optimization - Choose the right data structure for performance
  • Memory Efficiency - Optimize space complexity in data structures