ctypes vs. cffi for hot-path geometry
The exact problem this page answers: given a Python process on a Linux gateway that must call a compiled geometry predicate hundreds of thousands of times a second, does ctypes or cffi cost less at the call site, and which one survives an OTA update without a rebuild. Within the Core Edge GIS Fundamentals guide, this is the detailed follow-up to Python/C FFI for hot-path geometry, which introduces both bindings alongside PyO3; this page stays narrowly on the two stdlib-adjacent options and the trade-offs that decide between them on a 256 MB–1 GB ARM gateway running Yocto.
Binding selection rationale
Both ctypes and cffi ultimately call the same kind of compiled function through the same kind of shared object, so the difference between them is entirely in how the call is dispatched, not in what gets executed once it arrives. ctypes is part of the standard library and, in exchange for that zero-dependency convenience, dispatches every call through libffi’s generic trampoline: at call time it reads the argtypes list, boxes each Python argument into the matching C representation, and invokes the function indirectly. That trampoline is a fixed per-call tax that does not shrink no matter how many times the same signature is called.
cffi offers two distinct modes, and the choice between them matters more than the choice between cffi and ctypes in the abstract. ABI mode (ffi.dlopen) dispatches through the same kind of libffi trampoline ctypes uses, so its per-call cost sits in the same neighborhood. API mode (ffi.set_source plus ffi.compile) generates and compiles a small CPython extension ahead of time; the generated code calls the target function directly, with no trampoline and no runtime argument-type inference, because the types were fixed at compile time. That difference shows up as a meaningfully lower per-call floor for API mode once the call rate climbs into the hundreds of thousands per second — exactly the regime a telemetry gateway runs a containment or distance predicate in.
Type marshalling follows the same split. ctypes and cffi ABI mode both re-validate argument types against the declared argtypes/cdef on every call, and both copy scalar values into freshly-boxed C representations rather than reusing a fixed memory layout. API mode does the type check once, at compile time, and the generated glue code copies values with the same discipline a hand-written C function would use. None of the three modes avoid the underlying copy of an array argument by value — that requires the buffer-protocol technique in zero-copy coordinate buffers across the FFI boundary — but API mode at least removes the trampoline overhead sitting on top of that copy.
A self-contained benchmark
The following script binds the same predicate — a bounding-circle containment test, dx*dx + dy*dy <= r*r — three ways: ctypes, cffi ABI mode, and cffi API mode, then times a batch of calls against each. Compile the shared predicate once with gcc -O2 -fno-exceptions -shared -fPIC -o libcircle.so circle.c:
/* circle.c — compile with: gcc -O2 -fno-exceptions -shared -fPIC -o libcircle.so circle.c */
int in_circle(double px, double py, double cx, double cy, double r) {
double dx = px - cx;
double dy = py - cy;
return (dx * dx + dy * dy) <= (r * r);
}
import time
import ctypes
from cffi import FFI
N = 200_000
PX, PY, CX, CY, R = 1.0002, 1.0001, 1.0, 1.0, 0.01
# --- ctypes: libffi trampoline, argtypes checked once, boxed per call ---
lib_ctypes = ctypes.CDLL("./libcircle.so")
lib_ctypes.in_circle.argtypes = [ctypes.c_double] * 5
lib_ctypes.in_circle.restype = ctypes.c_int
t0 = time.perf_counter_ns()
for _ in range(N):
lib_ctypes.in_circle(PX, PY, CX, CY, R)
ctypes_ns = time.perf_counter_ns() - t0
# --- cffi ABI mode: same trampoline family as ctypes, no build step ---
ffi_abi = FFI()
ffi_abi.cdef("int in_circle(double, double, double, double, double);")
lib_abi = ffi_abi.dlopen("./libcircle.so")
t0 = time.perf_counter_ns()
for _ in range(N):
lib_abi.in_circle(PX, PY, CX, CY, R)
abi_ns = time.perf_counter_ns() - t0
# --- cffi API mode: import a module built ahead of time by build_circle.py ---
# (build_circle.py runs ffi.set_source(...).compile() once, during the image build)
from _circle import lib as lib_api
t0 = time.perf_counter_ns()
for _ in range(N):
lib_api.in_circle(PX, PY, CX, CY, R)
api_ns = time.perf_counter_ns() - t0
print(f"ctypes: {ctypes_ns / N:.1f} ns/call")
print(f"cffi ABI: {abi_ns / N:.1f} ns/call")
print(f"cffi API: {api_ns / N:.1f} ns/call")
The build script that produces _circle follows the same shape as any other cffi API-mode module:
# build_circle.py — run during the image build, not on the gateway
from cffi import FFI
ffibuilder = FFI()
ffibuilder.cdef("int in_circle(double px, double py, double cx, double cy, double r);")
ffibuilder.set_source(
"_circle",
'#include "circle.h"',
sources=["circle.c"],
extra_compile_args=["-O2", "-fno-exceptions"],
)
if __name__ == "__main__":
ffibuilder.compile(verbose=True)
Run this on the actual target, not a build laptop — the absolute nanosecond figures are architecture-specific, but the ordering (API mode fastest, ctypes and ABI mode close together) holds consistently across ARM Cortex-A cores in field testing.
Constraint validation table
| Constraint | Expected impact | Mitigation built into the code |
|---|---|---|
| CPU / call-rate ceiling | The libffi trampoline in ctypes/ABI mode adds a fixed cost that scales with call count, not payload size |
API mode removes the trampoline; batching calls amortizes whichever mode is chosen |
| RAM | cffi API mode’s compiled module is a few KB larger than a bare .so binding, negligible even on a 256 MB node |
Both modes share the same C shared object; the difference is glue code size, not runtime heap |
| Build/deploy latency | API mode requires a compiler and a rebuild whenever the signature changes | Keep the build_circle.py step in the same Yocto recipe as the rest of the image so it never runs on-device |
| Power | Trampoline dispatch burns extra cycles per call, extra cycles mean extra time at a non-idle clock | Favor API mode on duty-cycled or solar-powered nodes where per-call CPU cost maps directly to battery draw |
Gotchas and edge cases
- ABI mode silently accepts a wrong signature.
ffi.dlopentrusts thecdefstring; if it disagrees with the real function signature, the mismatch is not caught until the call corrupts the stack or returns garbage.ctypeshas the identical failure mode viaargtypes. Neither mode type-checks against the actual compiled symbol. - API-mode builds don’t cross-compile for free.
ffibuilder.compile()invokes the compiler configured for the build host by default; producing an aarch64 module from an x86_64 build server needs the cross toolchain set throughCC/CFLAGSenvironment variables before the build script runs, mirroring how a Yocto recipe would invoke it. - Packaging drift between
ctypesand API mode. Actypesbinding only needs the.sopresent at runtime, so an OTA update that ships a new.soalone is enough. An API-mode module is a compiled Python extension tied to a specific CPython ABI tag; shipping a new predicate means rebuilding and repackaging the_circle*.soextension itself, not just swapping a library file. - GIL behavior is identical across all three modes here. None of
ctypes, ABI mode, or API mode release the GIL by default. For a call this short (tens of nanoseconds), that is irrelevant; for a longer native call the release has to be added explicitly regardless of which binding is chosen, as covered in the parent guide. - Float comparison at the boundary.
r * rcomputed on the Python side versus the C side can round differently if one side accidentally usesfloatand the otherdouble; keepc_doubleconsistent end to end or the containment verdict flips on points sitting exactly on the boundary.
Integration with the gateway pipeline
Whichever mode wins the benchmark on the target hardware, the calling convention into the rest of the pipeline stays the same: bind once at process start, then call from inside the chunk-processing worker rather than the async loop itself, so a batch of predicate calls never blocks ingestion. A ProcessPoolExecutor worker initializer is the natural place to load either binding:
def worker_init():
global _predicate
if USE_API_MODE:
from _circle import lib as _predicate
else:
import ctypes
_predicate = ctypes.CDLL("./libcircle.so")
_predicate.in_circle.argtypes = [ctypes.c_double] * 5
_predicate.in_circle.restype = ctypes.c_int
Because the binding is chosen once at worker startup and never touched again per call, switching from ctypes to API-mode cffi after a benchmark run is a one-line change at this initializer, not a rewrite of the processing loop.
Related
- Python/C FFI for hot-path geometry — the call-boundary cost model and the GIL/GC hazards both bindings share.
- PyO3 bindings for spatial operations — the Rust alternative when the call rate outgrows even API-mode
cffi. - Zero-copy coordinate buffers across the FFI boundary — removing the per-call array copy that sits underneath all three dispatch modes.