08 · FFI: Calling C from Python

Every Python library that runs fast has a C or C++ shared object behind it. NumPy, PyTorch, TensorFlow, Pillow, lxml, cryptography, ujson, orjson, pydantic v2 (Rust), even psutil — all shell out. Learning to write and ship a Python extension in C is the single most job-relevant “applied C” skill for an ML engineer at Zoho: you get to turn a slow tight loop in an internal service into a 20–100× speedup and land the PR. This file surveys the four FFI options, then walks you through the one you’ll actually use — a native CPython extension.

The four options ranked

Option

Overhead per call

Setup pain

When to use

ctypes (stdlib)

~1 µs

none

quick prototyping, one-off scripts, calling a system library

cffi (third-party, PyPy-friendly)

~500 ns

small

portable bindings, prefer over ctypes for anything real

pybind11 (C++ only)

~200 ns

medium

C++ codebases with rich types, class hierarchies

CPython C API (#include <Python.h>)

~100 ns

high

shipping a real Python package, maximum speed, tight integration

All four end up in the same place — a .so file the interpreter dlopens. They differ in how much of the plumbing they hide.

Option 1: ctypes in three lines

// mylib.c → compile: gcc -O2 -shared -fPIC mylib.c -o libmylib.so
#include <stdint.h>
int32_t add(int32_t a, int32_t b) { return a + b; }
import ctypes
lib = ctypes.CDLL("./libmylib.so")
lib.add.argtypes = [ctypes.c_int32, ctypes.c_int32]
lib.add.restype = ctypes.c_int32
print(lib.add(2, 3))   # 6

Good for gluing to an existing shared library. Bad for anything performance-critical: every call marshals arguments through Python objects, and every returned pointer needs a POINTER(c_something) incantation. Use it, but don’t build production on it.

Option 2: cffi

Written by the PyPy team, works on CPython. Two modes — ABI mode (like ctypes, dynamic) and API mode (compiles a small C shim at install time, cached). API mode is what serious projects use (cryptography, aiodns).

from cffi import FFI
ffi = FFI()
ffi.cdef("int32_t add(int32_t, int32_t);")
lib = ffi.dlopen("./libmylib.so")
print(lib.add(2, 3))

Good middle ground. Skip if you’re going straight to the C API.

Option 3: pybind11 (C++)

Header-only C++11 library that exposes C++ classes and functions to Python with minimal boilerplate. If your codebase is already C++, use this. It’s what PyTorch and pymilvus use. Not applicable for pure C — this file’s scope stops here.

Option 4: the native CPython C extension — the one you must know

A CPython extension is a .so (Linux/Mac) or .pyd (Windows) that CPython dlopens on import. It exposes a module init function and a table of methods. It gets full access to the Python object system — refcounts, GIL, buffer protocol, everything NumPy uses.

The minimum-viable module (~50 lines)

// quantize_module.c
#define PY_SSIZE_T_CLEAN
#include <Python.h>
#include <stdint.h>
#include <math.h>

static PyObject *py_quantize_int8(PyObject *self, PyObject *args) {
    Py_buffer in;
    if (!PyArg_ParseTuple(args, "y*", &in)) return NULL;    // fp32 bytes
    Py_ssize_t n = in.len / sizeof(float);
    const float *x = (const float *)in.buf;

    // find abs-max
    float amax = 0.0f;
    for (Py_ssize_t i = 0; i < n; i++) {
        float a = fabsf(x[i]);
        if (a > amax) amax = a;
    }
    float scale = amax > 0.0f ? amax / 127.0f : 1.0f;

    // allocate output bytes object
    PyObject *out = PyBytes_FromStringAndSize(NULL, n * sizeof(int8_t));
    if (!out) { PyBuffer_Release(&in); return NULL; }
    int8_t *q = (int8_t *)PyBytes_AsString(out);

    for (Py_ssize_t i = 0; i < n; i++) {
        float v = x[i] / scale;
        int r = (int)lrintf(v);
        if (r >  127) r =  127;
        if (r < -128) r = -128;
        q[i] = (int8_t)r;
    }

    PyBuffer_Release(&in);
    return Py_BuildValue("(Of)", out, scale);   // (bytes, scale)
}

static PyMethodDef Methods[] = {
    {"quantize_int8", py_quantize_int8, METH_VARARGS, "Symmetric per-tensor INT8 quant."},
    {NULL, NULL, 0, NULL},
};

static struct PyModuleDef moduledef = {
    PyModuleDef_HEAD_INIT, "quantize_ext", NULL, -1, Methods,
};

PyMODINIT_FUNC PyInit_quantize_ext(void) {
    return PyModule_Create(&moduledef);
}

setup.py

from setuptools import setup, Extension
setup(
    name="quantize_ext",
    ext_modules=[Extension(
        "quantize_ext",
        sources=["quantize_module.c"],
        extra_compile_args=["-O3", "-march=native", "-Wall", "-Wextra"],
    )],
)

Build and use:

python setup.py build_ext --inplace
python -c "import numpy as np, quantize_ext; \
  x = np.random.randn(4096).astype('float32'); \
  q, s = quantize_ext.quantize_int8(x.tobytes()); \
  print(len(q), s)"

That is the shape of every real CPython extension. NumPy is this plus 200,000 lines.

Rules that will bite you

  • Reference counting. Every PyObject * you receive is a borrowed reference; every one you return is a new reference. Getting this wrong causes leaks (over-return) or double-frees (under-return). Read Include/object.h and the CPython C API reference.

  • The GIL. Your code runs holding the Global Interpreter Lock unless you release it with Py_BEGIN_ALLOW_THREADS / Py_END_ALLOW_THREADS. Release it around long CPU-bound work so other Python threads can progress. Never touch Python objects while the GIL is released.

  • Free-threaded Python (PEP 703). CPython 3.13 shipped an experimental GIL-free build (python3.13t). 3.14 (Oct 2025) improved stability. As of 3.15/3.16 in 2026 it’s still opt-in. If you write extensions today, add Py_MOD_GIL_NOT_USED support only after single-threaded correctness is bulletproof.

  • Buffer protocol. Use Py_buffer (as above) or NumPy’s array C API to accept zero-copy input. Copying a 100 MB tensor through PyArg_ParseTuple is a common perf bug — accept a buffer instead.

Where you’d actually ship this at Zoho

  • Any internal pipeline where a Python hot loop over a large array is the bottleneck. Profile with py-spy or cProfile first; if the top frame is a NumPy call you’re already fast. If it’s your own Python loop, a 100-line C extension will often 20–100× it.

  • A shared string-parsing routine used across microservices — e.g. a custom log tokenizer, a domain-specific tokenizer for ML preprocessing.

  • A quantization or dequantization primitive that isn’t in NumPy — exactly the projects.md exercise.

The economics: one week of your time to write and test a C extension that saves 40% CPU on a service running 24/7 pays for itself in weeks. This is the pitch you’ll make to your tech lead.

Building wheels for distribution

Once the extension works locally, ship it as a wheel. Use cibuildwheel in GitHub Actions to build for Linux (manylinux), macOS (universal2 for x86+arm64), and Windows in one workflow. This is how every serious PyPI package with C code (numpy, cryptography, pillow) does it in 2026.

What most people get wrong about Python C extensions

They write the C code first and worry about packaging later. It’s the opposite. The hardest part of a Python C extension is not the C — it is (a) reference counting discipline and (b) building wheels for three OSes × N Python versions × two architectures. Set up cibuildwheel on day one, even for an internal package, so your CI catches ABI issues immediately. Also: never write your own vector types when NumPy already gives you PyArrayObject with contiguous memory and dtype metadata. Use NumPy’s C API when the input is a NumPy array; use the buffer protocol when it isn’t. Don’t reinvent.


Return to README.md · Next: 09_the_ml_serving_c_stack.md