10 min read 5 sections

Arena Allocation for Per-Event Spatial Scratch

Every geofence evaluation needs temporary space: an array of candidate ids, the projected coordinates of a point, a list of intersection parameters, the vertex slice of whichever ring is being walked. Allocating that space per evaluation is the default in Python and the reason a pipeline with excellent per-call benchmarks has a P99 dominated by garbage collection. At 25,000 evaluations/sec with 3.9 candidates each, the naive implementation produces roughly 300,000 short-lived objects a second, and the generational collector’s promotion behaviour turns that into a gen-2 pause every four seconds. This page sits under memory-constrained spatial processing within Core Architecture & Latency Constraints.

Concept and specification

An arena is a block of memory allocated once and handed out by bumping a pointer, with no individual frees — the whole arena is reset at a known safe point. For a request-response or event-processing loop, that safe point is the end of each event, which makes the arena’s lifetime exactly the scratch’s lifetime and removes the bookkeeping entirely.

In Python the arena is not raw memory but a set of preallocated NumPy buffers plus an offset. Handing out scratch means returning a view into a buffer, which allocates a small view object but not the underlying data; resetting means setting the offset back to zero. The view objects are gen-0 garbage that never survives a collection, which is the cheap case the generational collector is designed for.

The arena must be sized to the worst event, not the average, because an overflow has to fall back to ordinary allocation and the fallback is exactly the behaviour being eliminated. A safety factor of about 1.5 over the observed P99.9 is the usual compromise.

Parameter Typical value Effect if wrong
Arena size per worker 256 KB – 2 MB Too small: frequent fallback; too large: wasted RSS per worker
Reset point end of each event Later resets extend lifetime into gen-1
Overflow policy fall back and count Silently growing the arena hides a pathological event
Alignment 64 bytes (cache line) Misaligned views cost a few percent on vectorised kernels
Arenas per process one per worker task Sharing across tasks requires locking and defeats the purpose
Parameter at a glance: Typical value, Effect if wrong A row per parameter, a column per option, so a single axis can be compared across options in one sweep. Parameter at a glance: Typical value, Effect if wrong the same trade-offs, read across instead of down Typical value Effect if wrong Arena size per worker 256 KB – 2 MB Too small: frequent fallback; too large: wasted RSS per worker Reset point end of each event Later resets extend lifetime into gen-1 Overflow policy fall back and count Silently growing the arena hides a pathological event Alignment 64 bytes (cache line) Misaligned views cost a few percent on vectorised kernels Arenas per process one per worker task Sharing across tasks requires locking and defeats the purpose
The parameter table read across rather than down — each row is one axis of the decision, colour-keyed so a single trade-off can be followed across Typical value, Effect if wrong.

The one-arena-per-worker rule is what keeps the design lock-free. An arena shared between concurrently running coroutines would need synchronisation on every bump, and since asyncio coroutines interleave at await points, an arena held across an await could be reset by another task mid-use. The rule that makes this safe is stronger and simpler: never hold an arena view across an await.

Step-by-step implementation

1. Preallocate typed buffers, not a byte blob. Distinct buffers for the distinct shapes of scratch avoid reinterpreting bytes and keep NumPy’s dtype checks meaningful.

python
from __future__ import annotations
import numpy as np

class SpatialArena:
    """Per-worker scratch. Views are valid only until the next reset(), and
    must never be held across an await."""

    __slots__ = ("_f64", "_i64", "_off_f", "_off_i", "overflows", "high_water")

    def __init__(self, floats: int = 65_536, ints: int = 16_384) -> None:
        self._f64 = np.empty(floats, dtype=np.float64)
        self._i64 = np.empty(ints, dtype=np.int64)
        self._off_f = 0
        self._off_i = 0
        self.overflows = 0
        self.high_water = 0

    def floats(self, n: int) -> np.ndarray:
        end = self._off_f + n
        if end > self._f64.size:
            self.overflows += 1
            return np.empty(n, dtype=np.float64)     # fall back, keep going
        view = self._f64[self._off_f:end]
        self._off_f = end
        self.high_water = max(self.high_water, end)
        return view

    def ints(self, n: int) -> np.ndarray:
        end = self._off_i + n
        if end > self._i64.size:
            self.overflows += 1
            return np.empty(n, dtype=np.int64)
        view = self._i64[self._off_i:end]
        self._off_i = end
        return view

    def reset(self) -> None:
        self._off_f = 0
        self._off_i = 0

2. Reset at exactly one place. A single try/finally around the per-event body guarantees the reset happens even when the body raises, and having one call site makes the invariant auditable.

python
async def handle_event(arena: SpatialArena, ev, index, fences) -> list:
    try:
        cand = arena.ints(64)
        n = index.query_into(ev.x, ev.y, cand)        # fills, returns count
        xs = arena.floats(n)
        ys = arena.floats(n)
        return evaluate(cand[:n], xs, ys, fences)     # no await inside
    finally:
        arena.reset()

3. Push the arena into the kernels rather than returning fresh arrays. A function that returns np.empty(...) allocates regardless of the arena; the calling convention has to become “write into this buffer”. That is the invasive part of the change and the reason it is worth doing once, at the boundaries that matter, rather than everywhere.

4. Instrument overflow and high water. Overflow is not an error but it is a signal: a rising overflow count means an event shape the arena was not sized for, and the high-water mark is what to size the next arena from. Both are single integers and belong in the metrics from Prometheus metrics for queue depth and P99 latency.

5. Never let a view escape the event. A view stored in a result object, appended to a list that outlives the event, or captured by a closure will be silently overwritten by the next event. This is the one genuinely dangerous property of the pattern, and the defence is a convention — arena views are consumed within the event and anything returned is a copy — enforced in review and, in staging, by filling the arena with NaN on reset so escapees fail loudly.

Benchmark and verification

Measured at 25,000 evaluations/sec on one worker, 3.9 candidates per evaluation:

Allocation strategy Allocation rate Gen-2 interval P99 evaluation RSS
Fresh objects per evaluation 340 MB/s 4 s 31 ms 810 MB
Object pool with free lists 96 MB/s 19 s 14 ms 690 MB
Arena, reset per event 11 MB/s 71 s 9 ms 640 MB
Arena + gc.freeze() at startup 11 MB/s 74 s 8 ms 640 MB
Allocation rate, Gen-2 interval, P99 evaluation — 4 options Each panel scales on its own, so Allocation rate, Gen-2 interval, P99 evaluation are compared across 4 options without sharing an axis they do not share a unit with. Allocation rate, Gen-2 interval, P99 evaluation — 4 options Allocation strategy — the table above, drawn to scale Fresh objects per evaluation Object pool with free lists Arena, reset per event Arena + gc.freeze() at startup Allocation rate 340 MB/s 96 MB/s 11 MB/s 11 MB/s Gen-2 interval 4 s 19 s 71 s 74 s P99 evaluation 31 ms 14 ms 9 ms 8 ms
Allocation rate, Gen-2 interval and 1 more for Fresh objects per evaluation, Object pool with free lists, Arena, reset per event and 1 more, drawn from the measurements in the table above. Each panel carries its own scale, so the shape of the gap is comparable even where the units are not.

The gen-2 interval is the number that matters operationally, because a gen-2 collection on a heap of this size is a 20–40 ms stop-the-world pause and appears directly in the P99. Moving it from every four seconds to every seventy-one removes it from the tail almost entirely: the P99 falls 3.4× while the mean barely moves, which is the signature of a GC problem rather than a compute one.

The gc.freeze() row is nearly free and worth taking. Calling it after the index is loaded moves every long-lived object into a permanent generation the collector does not traverse, so each gen-2 pass has less to scan. It composes with the arena rather than replacing it — the arena removes the garbage, freeze shrinks the survivors.

Verify with tracemalloc rather than with RSS. Take two snapshots sixty seconds apart under load and compare; with the arena in place, the top allocation sites should be the view objects and nothing in the geometry path. Any geometry construction appearing in the diff is a kernel that still returns fresh arrays and has not been converted.

Failure modes and edge cases

Failure mode Signature Mitigation
View held across an await Data silently changes mid-computation Never await inside an arena scope; assert in review
View escapes into a result Downstream sees the next event’s data Copy anything returned; NaN-fill on reset in staging
Arena shared across tasks Non-deterministic corruption under concurrency One arena per worker, created with the worker
Sized to the average event Frequent fallback; benefit disappears Size from the P99.9 high-water mark plus 50%
Reset missing on the error path Offset grows until every request overflows Reset in a finally, at one call site
Arena per request rather than per worker Allocation simply moved, not removed Bind the arena to the worker’s lifetime
Failure mode at a glance: Signature, Mitigation A row per failure mode, a column per option, so a single axis can be compared across options in one sweep. Failure mode at a glance: Signature, Mitigation the same trade-offs, read across instead of down Signature Mitigation View held across an await Data silently changes mid-computation Never await inside an arena scope; assert in review View escapes into a result Downstream sees the next event's data Copy anything returned; NaN-fill on reset in staging Arena shared across tasks Non-deterministic corruption under concurrency One arena per worker, created with the worker Sized to the average event Frequent fallback; benefit disappears Size from the P99.9 high-water mark plus 50% Reset missing on the error path Offset grows until every request overflows Reset in a finally, at one call site Arena per request rather than per worker Allocation simply moved, not removed Bind the arena to the worker's lifetime
The failure mode table read across rather than down — each row is one axis of the decision, colour-keyed so a single trade-off can be followed across Signature, Mitigation.

The escaping-view failure is worth dwelling on because it is the only one that produces wrong answers rather than slow ones, and it is invisible in testing at low concurrency: with one event in flight at a time, the next reset happens after the consumer has read the data, so the bug does not manifest until production load. The NaN-fill trick makes it deterministic — set the arena’s contents to np.nan in reset() under a debug flag, and any escaped view produces obviously wrong numbers immediately rather than plausible ones eventually.

Finally, note the interaction with the offload boundary from async Python execution patterns for spatial math. Work sent to a process pool cannot share the arena — the buffers do not cross the process boundary — so a pipeline that offloads must either keep the arena inside the worker process, giving each pool worker its own, or accept ordinary allocation for the offloaded portion. Thread pools share the arena’s process but not its safety: a thread-pool task holding a view while the event loop resets the arena is the same bug as awaiting inside the scope. Give each thread its own arena, keyed by thread id, or copy at the boundary.