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 |
|---|---|---|---|
|
~1 µs |
none |
quick prototyping, one-off scripts, calling a system library |
|
~500 ns |
small |
portable bindings, prefer over |
|
~200 ns |
medium |
C++ codebases with rich types, class hierarchies |
CPython C API ( |
~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, addPy_MOD_GIL_NOT_USEDsupport 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 throughPyArg_ParseTupleis 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-spyorcProfilefirst; 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