Steven's Knowledge

Performance & Native Acceleration

Profiling Python and escaping it when needed -- cProfile/py-spy, NumPy vectorisation, numba/Cython, Rust/PyO3, and zero-copy C interop

Performance & Native Acceleration

Optimise only what you have measured. This page covers the profiling tools that find the real hotspot, and the escape hatches when Python itself is the bottleneck: vectorisation, JIT and native compilation, and the buffer protocol that lets you cross into C without copying data.

Performance Profiling and Native Acceleration

Optimise only what you have measured. The expert workflow is profile first, then act on the hotspot -- never guess.

import cProfile, pstats

with cProfile.Profile() as pr:
    run_workload()
pstats.Stats(pr).sort_stats("cumulative").print_stats(20)
ToolWhat it tells youWhen
cProfile + pstatsPer-function call counts and timeFirst pass, deterministic
py-spySampling profiler, no code changes, attaches to a live PIDProduction, can't reproduce locally
line_profilerTime per line inside a hot functionAfter you've found the hot function
memray / tracemallocWhere memory is allocatedLeaks and high RSS
scaleneCPU + memory + GPU, separates Python vs native timeMixed Python/native workloads

When profiling proves Python itself is the bottleneck (tight numeric loops, not I/O), escape to native code:

# 1. Vectorise first -- often the biggest win is staying in NumPy/Polars
import numpy as np
result = np.where(arr > 0, arr * 2, 0)   # C-speed loop, no Python per-element

# 2. Cython / numba for hot numeric kernels
from numba import njit
@njit                                     # JIT-compiles to machine code
def mandelbrot_escape(c: complex, max_iter: int) -> int:
    z = 0j
    for i in range(max_iter):
        z = z * z + c
        if (z.real * z.real + z.imag * z.imag) > 4.0:
            return i
    return max_iter

# 3. Rust via PyO3 + maturin for production-grade native extensions
#    (this is how pydantic-core, polars, and ruff are built)

The decision order: vectorise → cache/algorithm → numba/Cython → Rust/PyO3. Reaching for C before profiling, or before trying NumPy, is the classic premature-optimisation mistake.

C Interoperability and Zero-Copy

Python's performance escape hatch is calling into C, and the buffer protocol lets you do it without copying data. memoryview exposes another object's memory directly -- you can slice and reshape gigabytes without duplicating them:

data = bytearray(b"large payload ...")
view = memoryview(data)       # zero-copy window onto the same bytes
chunk = view[10:20]           # still zero-copy -- no new buffer allocated
chunk[0] = 0x41               # writes through to `data` (it's a view)
# Pass `view` to socket.send, file.write, struct -- they consume it without a copy.

For calling existing native libraries, ctypes (stdlib, no build step) loads a shared object and declares signatures at runtime:

import ctypes
libc = ctypes.CDLL("libc.so.6")
libc.strlen.restype = ctypes.c_size_t        # ALWAYS set argtypes/restype --
libc.strlen.argtypes = [ctypes.c_char_p]     # the default int return truncates pointers
libc.strlen(b"hello")                         # 5

The honest guidance on which tool:

NeedReach for
Call a system/C library occasionallyctypes -- zero build, runtime binding
A cleaner C interface, some build toolingcffi -- used by cryptography, PyPy-friendly
Speed up your own numeric Pythonnumba/Cython -- stay in Python-ish syntax
Production native extension / new moduleRust + PyO3/maturin -- memory-safe, how ruff & pydantic-core ship

The recurring theme across all of it: the boundary between Python and native code is where bugs and crashes live -- mismatched argtypes segfault, a freed buffer behind a memoryview corrupts memory, and the GIL must be released explicitly for native threads to run in parallel. Cross it deliberately, and only after profiling proves you must.

On this page