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
The buffer protocol's lifetime contract, kept and broken Two sequences over the same shared buffer. In the kept contract, Python allocates the buffer once, exports a memoryview, C reads through the pointer, and the view is released before the buffer is resized. In the broken contract, the buffer is grown while a memoryview is still exported: CPython raises a BufferError on resize, which is the good case, but a C pointer captured before the export and held across the boundary has no such protection and silently reads freed pages. The exported view is a lock; a raw pointer is not contract kept allocate once memoryview exported · C reads it view released resize now legal contract broken allocate once C holds a raw double* it captured bytearray grows → realloc moves it → pointer reads freed pages CPython refuses to resize while a view is exported, which is why the top row is safe and why the bottom row must never capture a bare pointer. Rule: the buffer's size is fixed for the life of the process, or the view is the only thing that ever crosses the boundary.
Zero copy means the two runtimes share a lifetime as well as a pointer — and only one of them is capable of enforcing it.

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.

Two buffers, two very different memory profiles

The copying path is not slow because copying is expensive in itself — a memcpy of a few hundred kilobytes is microseconds. It is expensive because it doubles the peak, and peak is the number that gets a process killed on a 256 MB node. A 2 MB coordinate batch handed across by value exists twice for the duration of the call, and if two workers do it at once, four times.

Peak memory for a 2 MB coordinate batch, copied versus shared Two memory profiles for the same 2 megabyte coordinate batch. In the copying path the Python array, a ctypes staging array and the C-side copy all exist at once, peaking at 6 megabytes with two memcpy operations. In the zero-copy path a single buffer is allocated once and both sides address the same pages through the buffer protocol, peaking at 2 megabytes with no copies at all. Same batch, same call — three copies or one buffer by value Python array · 2 MB ctypes staging · 2 MB C-side copy · 2 MB peak 6 MB memcpy → memcpy → zero copy one buffer · 2 MB peak 2 MB · no memcpy Python holds a memoryview of these pages… …and C holds a double* into the same pages The saving scales with concurrency: four workers copying is 24 MB of transient peak, four workers sharing is 8 MB.
Zero copy is a peak-memory technique first and a throughput technique second — which is why it matters most on exactly the nodes where throughput is not the binding constraint.

The discipline that makes it safe is ownership: allocate the buffer once, on the Python side, at a size fixed by the batch policy, and never resize it while a call is in flight. Resizing a bytearray can move it, which invalidates every pointer C is holding without any diagnostic at all — the same failure the borrow checker prevents in the PyO3 path, reappearing here as a rule a human has to keep.

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.