Zero-copy coordinate buffers across the FFI boundary
The exact problem this page solves: a Python process on a memory-constrained gateway needs to hand tens of thousands of coordinate pairs to a compiled predicate every second without allocating a second copy of that array on every call — the copy itself, not the compiled code, is what shows up as the dominant cost once a binding is otherwise tuned. Within the Core Edge GIS Fundamentals guide, this page is the buffer-level technique referenced throughout Python/C FFI for hot-path geometry: whichever binding does the calling — ctypes, cffi, or PyO3 — the same buffer-protocol pattern removes the per-call copy underneath it.
Buffer strategy selection rationale
A naive FFI call marshals a Python list of (x, y) tuples into C representation on every invocation: each tuple is unpacked, each float is boxed and unboxed, and the whole array is rebuilt in a scratch buffer the binding allocates and frees per call. On a 256–512 MB gateway processing chunks of a few hundred points at a time, that allocate/free cycle happens tens of times a second and is squarely the kind of allocation churn that triggers the garbage collector pauses covered in the parent guide.
The fix is to never carry coordinates as a list of Python objects in the first place. Python’s buffer protocol lets an object expose a raw, contiguous memory region — a bytearray, a memoryview, a numpy array, or an array.array — that a C function can read directly through a pointer, with no per-element unpacking and no intermediate copy. ctypes.from_buffer and int.from_bytes-free packing via struct.pack('<dd', x, y) are two ways to get coordinates into that shape from the Python side; numpy’s __array_interface__ (and the __array_struct__ variant used by non-numpy C consumers) is the standard way a numeric library exposes its own buffer without either side needing to know about the other’s internals.
The complete implementation
The example below fills one preallocated buffer once per telemetry chunk using struct.pack’s array form, then hands the raw memory to a ctypes predicate through from_buffer — no list of tuples, no per-point Python object, and no reallocation between chunks:
import ctypes
import struct
lib = ctypes.CDLL("./libgeofence.so")
lib.contains_batch.argtypes = [
ctypes.c_void_p, # packed lat/lon buffer, 16 bytes per point
ctypes.c_int, # point count
ctypes.c_void_p, # output byte buffer, 1 byte per point
]
lib.contains_batch.restype = None
MAX_POINTS = 256
FRAME = "<dd" # little-endian double, double — matches struct Point2D { double, double; }
FRAME_SIZE = struct.calcsize(FRAME)
# Allocate once at worker start. Reused for every chunk; never rebuilt per call.
_coord_buf = bytearray(MAX_POINTS * FRAME_SIZE)
_result_buf = bytearray(MAX_POINTS)
def fill_coord_buffer(points):
"""Pack (lat, lon) pairs into the preallocated buffer in place."""
n = len(points)
if n > MAX_POINTS:
raise ValueError("chunk exceeds preallocated buffer capacity")
view = memoryview(_coord_buf)
for i, (lat, lon) in enumerate(points):
struct.pack_into(FRAME, view, i * FRAME_SIZE, lat, lon)
return n
def contains_batch(points):
"""Zero-copy call: the C side reads _coord_buf directly, no list marshalling."""
n = fill_coord_buffer(points)
coord_ptr = (ctypes.c_char * len(_coord_buf)).from_buffer(_coord_buf)
result_ptr = (ctypes.c_char * len(_result_buf)).from_buffer(_result_buf)
lib.contains_batch(
ctypes.cast(coord_ptr, ctypes.c_void_p),
n,
ctypes.cast(result_ptr, ctypes.c_void_p),
)
return bytes(_result_buf[:n])
struct.pack_into writes directly into the existing bytearray rather than returning a new one, which is what keeps this allocation-free across repeated calls. The .from_buffer() calls do not copy either — they construct a ctypes array object that shares memory with the bytearray, which is why _coord_buf and _result_buf must stay alive and untouched by other code for as long as the C side might still be reading or writing them.
For a numpy-fronted pipeline, the equivalent path goes through __array_interface__ instead of manual packing, since numpy arrays already expose a contiguous buffer with dtype and shape metadata a C function (or a ctypes array built with np.ctypeslib.as_ctypes) can consume without copying:
import numpy as np
import ctypes
xs = np.empty(MAX_POINTS, dtype=np.float64)
ys = np.empty(MAX_POINTS, dtype=np.float64)
def contains_batch_numpy(lat_array, lon_array, n):
xs[:n] = lat_array
ys[:n] = lon_array
xs_ptr = xs.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
ys_ptr = ys.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
# lib.contains_batch_xy(xs_ptr, ys_ptr, n, result_ptr) — same shared-buffer contract
xs.ctypes.data_as reads the array’s own buffer through __array_interface__ under the hood; the xs[:n] = lat_array line is still a copy, but it copies into the same preallocated numpy array every time rather than allocating a new array per chunk, which is the meaningful difference for GC pressure even though it isn’t a fully zero-copy path from the original source data.
Constraint validation table
| Constraint | Expected impact | Mitigation built into the code |
|---|---|---|
| RAM | A fresh scratch buffer per call fragments the heap over thousands of chunks on a 256–512 MB node | _coord_buf/_result_buf are allocated once at worker start and reused for the process’s lifetime |
| CPU / GC pressure | Per-point Python objects (tuples, boxed floats) feed generation-0 collections during bursts | struct.pack_into writes into existing memory; no Python object is created per coordinate |
| Latency | Buffer reallocation and marshalling add a tail-latency spike proportional to chunk size | Fixed-size preallocated buffers make the marshalling cost constant, not proportional to allocator behavior |
| Alignment / correctness | double fields read through an unaligned pointer can hard-fault on strict-alignment cores |
struct.pack_into with <dd framing keeps fields 8-byte aligned when the buffer itself starts on an 8-byte boundary |
Gotchas and edge cases
- Lifetime is the whole contract.
ctypes.from_bufferdoes not extend the lifetime of the underlyingbytearray— if_coord_bufis reassigned, resized, or garbage-collected while the C side still holds a pointer derived from it, the next read is undefined behavior. Never callfrom_bufferon a temporary; keep the backing object at module or worker scope, exactly as_coord_bufis here. bytearrayis resizable, which is a trap. Anything that appends to or reallocates abytearray(rather than writing in place withpack_intoor slice assignment) can move its backing memory, invalidating any pointer already handed to C. Preallocate to the maximum chunk size once and never call.append()/.extend()on the buffer afterward.- Alignment depends on where the buffer starts, not just the framing.
struct.pack_into('<dd', ...)guarantees the fields are laid out with no gaps, but if_coord_buf’s own start address isn’t 8-byte aligned, everydoubleinside it inherits that misalignment. CPython’sbytearrayallocator aligns to at leastvoid*size on mainstream platforms, but don’t assume this holds on every libc; verify withctypes.addressof(...) % 8 == 0during staging rather than trusting it silently. numpy’s__array_interface__describes the buffer, it doesn’t protect it. Slicing or reshaping anumpyarray can return a view that still points into the original buffer; passing that view’s pointer across the FFI boundary and then letting the original array go out of scope is the same use-after-free risk as thebytearraycase, just one layer further removed.- Endianness matters once, not per call.
struct.pack_into('<dd', ...)fixes little-endian framing explicitly; this only matters if the gateway’s compiled predicate assumes native-endiandoublelayout and the code is ever built for a big-endian target — rare in this guide’s ARM/RISC-V envelope, but worth the explicit<rather than relying on native (=) format for anything that might cross architectures.
Integration with the gateway pipeline
The preallocated buffers belong at worker scope, filled once per chunk inside the same ProcessPoolExecutor worker that owns the compiled predicate binding, so the buffer’s lifetime matches the worker process’s lifetime rather than any single call:
def worker_init():
global _coord_buf, _result_buf, lib
lib = ctypes.CDLL("./libgeofence.so")
lib.contains_batch.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p]
_coord_buf = bytearray(MAX_POINTS * FRAME_SIZE)
_result_buf = bytearray(MAX_POINTS)
def process_chunk(points):
return contains_batch(points)
Wiring the buffers into worker_init keeps them out of the async loop’s scope entirely, so a chunk’s coordinate data never touches the event loop’s memory — the loop only ever sees the small verdict bytes that come back, consistent with the process-isolation model in async execution for spatial workloads.
Related
- Python/C FFI for hot-path geometry — the call-boundary cost model this buffer technique removes the copy from.
- ctypes vs. cffi for hot-path geometry — the dispatch-mode comparison this buffer pattern applies to equally.
- PyO3 bindings for spatial operations —
PyReadonlyArray1as the Rust-side equivalent of borrowing a numpy buffer without copying.