Python/C FFI for Hot-Path Geometry
Within the Core Edge GIS Fundamentals guide, this page marks the point where a Python geometry loop stops being fast enough and the work has to cross into compiled code. A gateway ingesting GPS fixes, LiDAR returns, or sensor polygons at field rates spends most of its cycles on small, repetitive geometric tests — point-in-polygon, bounding-box overlap, distance-to-segment — and a pure-Python implementation of any of those pays object-creation and interpreter-dispatch overhead on every single call. On a desktop that overhead is noise. On a 256 MB–2 GB ARM or RISC-V gateway sharing a core with a cellular modem, it is the difference between keeping up with a telemetry stream and falling behind it until a queue overflows. The fix is a foreign function interface (FFI): a narrow, well-defined boundary across which Python hands a batch of coordinates to compiled C, C++, or Rust, and gets verdicts back without re-entering the interpreter for every point.
This page covers the three practical ways to build that boundary — ctypes, cffi, and PyO3 — the cost model that governs every call across it, and the two runtime hazards (the GIL and the garbage collector) that turn a fast compiled predicate into a slow one if the Python side is careless. It assumes the same deployment envelope as the rest of this guide: Linux or Yocto on an ARM Cortex-A or RISC-V SoC, 256 MB to 2 GB of RAM, and a compiled toolchain available either on-device or via cross-compilation. Containment and distance predicates are the running examples below, but the same boundary carries coordinate reprojection too — the transform math itself is covered separately under coordinate reference systems at the edge; this page only concerns itself with how any of that work gets from Python into compiled code and back.
Constraint mapping
The boundary is cheap in theory and expensive in practice, and every place it gets expensive traces back to one of the hardware limits catalogued for this guide. Map the symptom to the lever before touching any code:
| Constraint | Where it bites | Symptom under load | Lever |
|---|---|---|---|
| GIL held during the call | ctypes/cffi ABI-mode calls don’t release the GIL unless told to |
Other coroutines and threads stall for the duration of a long native call | Release the GIL explicitly for calls over roughly a millisecond — Py_BEGIN_ALLOW_THREADS in a C extension, or py.allow_threads in PyO3 |
| Call-boundary marshalling | Every call re-validates argtypes, boxes Python objects, and copies scalars |
Overhead dominates once call rate exceeds tens of thousands per second on small geometries | Batch: one call per chunk of coordinates, never one call per point |
| GC churn in the hot loop | Building a fresh tuple or dict per result triggers generation-0 collections | Latency spikes correlate with collection cycles, visible via gc.callbacks |
Write results into a preallocated buffer; disable or freeze the collector for the loop’s duration |
| RAM ceiling / duplicate copies | Marshalling by value copies the whole coordinate array at the boundary | RSS grows with buffer size times copy count on a 256–512 MB node | Cross the boundary once with a shared buffer instead of per-call copies (see the zero-copy companion page) |
| ABI / cross-compile mismatch | Struct layout or calling convention differs between the build host and the target | Segfault, or silently swapped fields, only on the ARM/RISC-V target | Pin the target triple and sysroot; verify sizeof/offsets at import time |
| Thermal/CPU throttle | The compiled predicate is still O(n) work, just faster per unit | Sustained clock reduction lengthens every chunk proportionally | Keep the predicate allocation-free so degraded clocks scale linearly, not superlinearly |
The GIL and the garbage collector are the two constraints unique to this page rather than to the hardware directly — they are runtime-level costs that only show up because Python is the caller, and they are easy to miss because a desktop benchmark rarely triggers either one.
Three ways across the boundary
ctypes ships in the standard library and needs no build step: it loads a shared object at runtime with ctypes.CDLL and calls into it through libffi’s generic calling trampoline. That trampoline is what makes ctypes slow relative to a native extension — every call pays a runtime cost to marshal Python objects into C types according to argtypes, even when those types never change between calls. For code that already ships as a shared library — libgeos_c.so, a vendor’s sensor SDK — ctypes is the lowest-friction way to reach it from Python with zero extra compilation on the gateway.
cffi can be used the same way ctypes is (its ABI mode, via ffi.dlopen), but its more interesting mode compiles a small, purpose-built C extension ahead of time (API mode, via ffi.set_source and ffi.compile). That extension binds directly to the target function with no runtime type-checking trampoline, closing most of the gap to a hand-written C extension. The trade is a build step: API mode needs a C compiler available wherever the wheel is built, which on Yocto means baking the module into an image recipe rather than pip install-ing it on the device.
PyO3 goes further by generating the extension module itself, in Rust, compiled with maturin. There is no separate shared library to load and no argtypes to declare — the function signature is checked at compile time, and Rust’s ownership model rules out an entire class of use-after-free and aliasing bugs that plague hand-written C bindings. The cost is a heavier toolchain (a Rust cross-compiler for aarch64 or the target’s architecture) and a build pipeline most gateway teams are less familiar with than gcc.
Ordered by typical per-call overhead once a binding is warmed up, cffi ABI mode tracks ctypes closely (both pay the libffi trampoline), cffi API mode is meaningfully cheaper because the call is direct, and a PyO3 extension function is cheaper still because it is indistinguishable, at the call site, from any other compiled Python extension. None of that ordering matters at the scale of one call — it matters at the scale of a million calls a minute, which is the regime a telemetry gateway actually runs in. The deeper, benchmarked comparison between ctypes and cffi lives in ctypes vs. cffi for hot-path geometry; the PyO3 build and threading model has its own walkthrough in PyO3 bindings for spatial operations; and the technique that removes the marshalling copy entirely, regardless of which binding you pick, is covered in zero-copy coordinate buffers across the FFI boundary.
Implementation: a ctypes binding to a libgeos-style predicate
The most common on-device use of ctypes is binding directly to libgeos_c, the C API that underlies most desktop geometry libraries and is already present on many embedded Linux images. The binding below creates one reentrant context, builds a prepared geometry once, and runs a batch of containment tests against a coordinate buffer without allocating a Python object per point:
import ctypes
lib = ctypes.CDLL("libgeos_c.so.1")
# Declare the signatures once, at import time — this is what lets ctypes
# skip re-inferring argument types on every call.
lib.GEOS_init_r.restype = ctypes.c_void_p
lib.GEOSCoordSeq_create_r.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_uint]
lib.GEOSCoordSeq_create_r.restype = ctypes.c_void_p
lib.GEOSCoordSeq_setX_r.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint, ctypes.c_double]
lib.GEOSCoordSeq_setY_r.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint, ctypes.c_double]
lib.GEOSGeom_createLinearRing_r.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
lib.GEOSGeom_createLinearRing_r.restype = ctypes.c_void_p
lib.GEOSGeom_createPolygon_r.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint]
lib.GEOSGeom_createPolygon_r.restype = ctypes.c_void_p
lib.GEOSPrepare_r.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
lib.GEOSPrepare_r.restype = ctypes.c_void_p
lib.GEOSPreparedContains_r.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p]
lib.GEOSPreparedContains_r.restype = ctypes.c_byte
_ctx = lib.GEOS_init_r() # one context per process, never shared across threads
def make_ring(coords):
seq = lib.GEOSCoordSeq_create_r(_ctx, len(coords), 2)
for i, (x, y) in enumerate(coords):
lib.GEOSCoordSeq_setX_r(_ctx, seq, i, x)
lib.GEOSCoordSeq_setY_r(_ctx, seq, i, y)
return lib.GEOSGeom_createLinearRing_r(_ctx, seq)
def prepare_polygon(shell_coords):
"""Call once per geofence load, never per point."""
ring = make_ring(shell_coords)
poly = lib.GEOSGeom_createPolygon_r(_ctx, ring, None, 0)
return lib.GEOSPrepare_r(_ctx, poly)
def contains_batch(prepared, points):
"""Hot path: one C call per point, zero Python-side geometry objects."""
hits = bytearray(len(points))
for i, (x, y) in enumerate(points):
seq = lib.GEOSCoordSeq_create_r(_ctx, 1, 2)
lib.GEOSCoordSeq_setX_r(_ctx, seq, 0, x)
lib.GEOSCoordSeq_setY_r(_ctx, seq, 0, y)
pt = lib.GEOSGeom_createPoint_r(_ctx, seq) if hasattr(lib, "GEOSGeom_createPoint_r") else None
hits[i] = lib.GEOSPreparedContains_r(_ctx, prepared, pt)
return hits
prepare_polygon belongs in provisioning, not the loop — building the prepared geometry once and reusing it for every subsequent test is what makes the batch cheap. Note that contains_batch still allocates a fresh coordinate sequence per point; that allocation, not the FFI call itself, is usually the first thing worth removing once profiling points at this function, and the zero-copy companion page covers the buffer-protocol technique that avoids it. The equivalent, allocation-free approach in compiled C rather than through libgeos_c is the ray-casting predicate documented in implementing polygon containment checks in C++, which is often the better choice for a single well-known geofence shape where pulling in all of GEOS is unnecessary weight.
Implementation: cffi ABI mode vs. API mode
cffi gives a choice between the low-friction path and the fast path, and a gateway build usually wants both: ABI mode for anything loaded at runtime from a vendor .so, API mode for the project’s own hot-path predicate. ABI mode looks almost identical to the ctypes example above:
from cffi import FFI
ffi = FFI()
ffi.cdef("""
int point_in_polygon(double lat, double lon,
const double *ring, int n, double epsilon);
""")
lib = ffi.dlopen("./libgeofence.so")
result = lib.point_in_polygon(51.5, -0.12, ring_ptr, len(ring), 1.5e-5)
API mode compiles a small extension ahead of time instead of relying on the libffi trampoline. The build step lives in a separate script, run once during the image build rather than on the device:
# build_predicate.py — run during image build, not on the gateway
from cffi import FFI
ffibuilder = FFI()
ffibuilder.cdef("int point_in_polygon(double lat, double lon, const double *ring, int n, double epsilon);")
ffibuilder.set_source(
"_predicate",
'#include "geofence_containment.h"',
sources=["geofence_containment.c"],
extra_compile_args=["-O2", "-fno-exceptions"],
)
if __name__ == "__main__":
ffibuilder.compile(verbose=True)
The application code then imports the compiled module directly, the same way it would import any other extension:
from _predicate import lib # generated by build_predicate.py at build time
is_inside = lib.point_in_polygon(51.5, -0.12, ring_ptr, len(ring), 1.5e-5)
The difference that matters operationally: ABI mode needs nothing but the shared object at runtime, so it survives an OTA update that only replaces the .so. API mode needs the extension rebuilt and repackaged whenever the C signature changes, which means it belongs in the same Yocto recipe or bitbake layer as the rest of the image rather than being dropped onto the device independently.
Configuration & tuning
- Compile flags. Build the shared predicate with
-O2 -fno-exceptions(add-fno-rttifor C++); exceptions and RTTI pull in unwind tables and vtable lookups that a hot loop never needs and that inflate binary size on a space-constrained image. - Struct packing. When a
ctypes.Structureor acfficdefstruct mirrors a C struct exactly, set_pack_ = 1(or the C side’s__attribute__((packed))) so field offsets match on both sides regardless of the compiler’s default alignment. Packing removes padding but can slow field access on architectures that fault or emulate unaligned loads — treat it as a correctness fix for cross-language layout, not a free performance win. - Buffer alignment. A buffer handed to a compiled predicate should sit on at least an 8-byte boundary for
doubleaccess; some Cortex-M and RISC-V cores without a hardware unaligned-access unit will hard-fault otherwise. Allocate withposix_memalignon the C side rather than trusting whatever addressctypes.create_string_bufferhappens to return. - Releasing the GIL. A C extension wraps a long-running call in
Py_BEGIN_ALLOW_THREADS/Py_END_ALLOW_THREADS; a PyO3 function wraps it inpy.allow_threads(|| ...). Do this for anything that takes longer than roughly a millisecond, otherwise the async loop covered in async execution for spatial workloads stalls for the call’s full duration. - GC posture. Call
gc.disable()around a tight batch loop andgc.collect()once at the chunk boundary rather than letting the collector interrupt mid-batch; after warmup,gc.freeze()keeps long-lived reference objects (prepared geometries, cached contexts) out of every future generation scan.
Verification & field diagnostics
Confirm the boundary is actually cheap on the target hardware, not just in a desktop benchmark — ARM cache latency and clock behavior differ enough from a development laptop that assumptions carried over from x86 routinely fail:
- Microbenchmark on-device. Time a batch of calls with
time.perf_counter_nsin a loop on the actual gateway before trusting any number measured elsewhere; cache and branch-predictor behavior on a Cortex-A53 is not the same shape as on a workstation. - Watch loop heartbeat during native calls. If the async loop’s heartbeat (see the async execution guide) goes quiet while a native call runs, the GIL is being held longer than intended — the call needs the
allow_threadstreatment above. - Diff struct layout across targets. At import time, log
ctypes.sizeof()for each shared struct and compare it against the value recorded when the extension was built for that target; a mismatch means the cross-compile toolchain diverged from what was assumed. - Check pointer alignment. Assert
address % 8 == 0on buffers passed into the predicate during a staging run; a failure here that only appears on the field device and not in CI points at an allocator difference between the dev and target C libraries. - Track GC pause timing. Register a
gc.callbackshook that logs collection duration; a burst ofcontains_batch-style calls that also spikes GC pause time is a sign that per-call Python objects are still being created somewhere in the hot path.
Failure modes and recovery
- GIL held too long. A native call runs past its expected duration without releasing the GIL. Detect: the async loop’s heartbeat misses a beat during the call. Recover: wrap the call in
allow_threads/Py_BEGIN_ALLOW_THREADS, or move the work to a process pool where GIL contention cannot reach the loop at all. - ABI mismatch after a cross-compile. The build host’s toolchain silently disagrees with the target’s about struct layout or calling convention. Detect: the startup
sizeofcheck above trips, or fields come back swapped rather than crashing outright. Recover: pin the target triple and sysroot explicitly rather than relying on the host’s default compiler. - Buffer misalignment. A pointer handed across the boundary isn’t aligned for the field type it points to. Detect: a bus error or hard fault on the target that never reproduces on x86. Recover: allocate with an alignment-aware call on the C side rather than trusting the Python-side buffer’s address.
- GC pause spikes under burst load. Result construction inside the hot loop still creates Python objects the collector has to scan. Detect: rising collection duration in the
gc.callbackslog correlating with call bursts. Recover: write results into a preallocated buffer and apply thegc.disable()/gc.freeze()pattern from the tuning section. - Marshalling overhead dominates at small batch sizes. Calling once per point instead of once per chunk turns the per-call constant cost into the whole budget. Detect: throughput collapses as call rate rises even though the compiled predicate’s own cost is unchanged. Recover: batch calls and, where the binding allows it, adopt the buffer-protocol pattern in the zero-copy companion page.
Related
- ctypes vs. cffi for hot-path geometry — a benchmarked comparison of per-call overhead, marshalling, and Yocto packaging.
- PyO3 bindings for spatial operations — building and cross-compiling a Rust extension with
maturin. - Zero-copy coordinate buffers across the FFI boundary — passing coordinate arrays without a per-call copy.
- Implementing polygon containment checks in C++ — the allocation-free compiled predicate this page’s ctypes binding wraps.
- Async execution for spatial workloads — why a long native call without a released GIL stalls the ingestion loop.