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.

Per-call copy versus zero-copy buffer protocol A comparison of marshalling a coordinate list by value on every call against sharing one contiguous buffer through the buffer protocol, across allocation behavior, GC pressure, lifetime rules, and when each approach is the better choice. Per-call copy • list of (x, y) tuples • new scratch buffer per call • boxes every float twice • feeds GC generation-0 churn • simplest to write correctly Best when: call rate is low Zero-copy buffer • memoryview / numpy array • one buffer, reused across calls • C reads the pointer directly • no per-call Python objects • owner/lifetime must be explicit Best when: high call rate, tight RAM
The buffer protocol trades a stricter lifetime contract for the removal of every per-call copy.

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_buffer does not extend the lifetime of the underlying bytearray — if _coord_buf is reassigned, resized, or garbage-collected while the C side still holds a pointer derived from it, the next read is undefined behavior. Never call from_buffer on a temporary; keep the backing object at module or worker scope, exactly as _coord_buf is here.
  • bytearray is resizable, which is a trap. Anything that appends to or reallocates a bytearray (rather than writing in place with pack_into or 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, every double inside it inherits that misalignment. CPython’s bytearray allocator aligns to at least void* size on mainstream platforms, but don’t assume this holds on every libc; verify with ctypes.addressof(...) % 8 == 0 during staging rather than trusting it silently.
  • numpy’s __array_interface__ describes the buffer, it doesn’t protect it. Slicing or reshaping a numpy array 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 the bytearray case, 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-endian double layout 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.