Measuring RSS Growth in Long-Running Spatial Indexes
A geofence worker that holds a flat P99 for the first hour and then OOM-kills at hour nine is the single most common production pathology in a long-running spatial index. The logical index size — the count of active polygons — is constant, dashboards are green, and yet the resident set climbs by tens of megabytes an hour until the orchestrator reaps the process mid-shift. This page expands the churn model introduced in Memory Footprint of Streaming Polygon Indexes and the broader spatial index lookup architecture, narrowing to one measurement problem: how do you attribute that climb precisely enough to know whether you are chasing a real leak, a fragmentation artifact, or an unclosed cursor — before you spend a week on the wrong one?
Concept and specification
The first discipline is to stop conflating two numbers. RSS (resident set size) is what the kernel reports the process occupies in physical memory; it is what triggers cgroup OOM kills. Heap — the sum of live Python objects tracked by tracemalloc, plus the allocator’s own bookkeeping — is a subset of RSS. The gap between them is memory the allocator has requested from the kernel but not returned, and under CPython that gap is dominated by the glibc malloc arena behavior.
Define the growth signal as a linear slope over a warm window. If is RSS in MB at elapsed time $t$ in hours, fit
and treat $m$ (MB/hr) as the health metric. A bounded index should converge to after warm-up. Anything with a persistent positive slope over a multi-hour window is a defect. The subtlety is that $m$ can be positive while the Python heap slope is flat — that divergence is the fingerprint of fragmentation rather than a leak.
| Signal | What it measures | Bounded steady state | Diagnostic meaning if rising |
|---|---|---|---|
| RSS slope $m$ | Kernel-resident MB/hr | OOM risk; cause still ambiguous | |
| tracemalloc heap slope | Live Python allocations MB/hr | true leak: objects retained | |
| RSS − heap gap | Allocator + fragmentation | small, flat | arena retention / fragmentation |
| Open cursor count | Live rtree query handles |
flat | leaked C-side cursors/streams |
The parameter you tune in the harness is $N$, the number of evaluations between snapshots. Too small and snapshot overhead perturbs the very latency you protect; too large and short bursts of allocation average out. For a node doing 25k evaluations/sec, sampling every evaluations (~200 s) balances resolution against the ~15–40 ms cost of a full tracemalloc snapshot.
Step-by-step implementation
Prerequisites: Python 3.11+, rtree>=1.1, psutil>=5.9, and the stdlib tracemalloc / gc. The harness wraps a running index and samples on an evaluation counter, so it costs nothing between snapshots.
1. Capture RSS and heap on the same tick. Reading them apart lets drift creep in; sample both under one lock so the gap is meaningful.
import gc
import time
import tracemalloc
from dataclasses import dataclass, field
import psutil
@dataclass(slots=True)
class MemSample:
t_hr: float # elapsed hours since warm-up
rss_mb: float # kernel resident set
heap_mb: float # tracemalloc traced total
cursors: int # live rtree query handles
@dataclass(slots=True)
class RSSMonitor:
proc: psutil.Process = field(default_factory=psutil.Process)
t0: float = field(default_factory=time.monotonic)
samples: list[MemSample] = field(default_factory=list)
def snapshot(self, live_cursors: int) -> MemSample:
gc.collect() # settle pending frees first
traced, _peak = tracemalloc.get_traced_memory()
s = MemSample(
t_hr=(time.monotonic() - self.t0) / 3600.0,
rss_mb=self.proc.memory_info().rss / 2**20,
heap_mb=traced / 2**20,
cursors=live_cursors,
)
self.samples.append(s)
return s
2. Diff two tracemalloc snapshots every N evaluations. The diff, not the absolute total, localizes the growth to a source line.
N: int = 5_000_000 # ~200s at 25k eval/s; tune to keep snapshot cost < 0.1% CPU
def diff_top(prev: tracemalloc.Snapshot, cur: tracemalloc.Snapshot, k: int = 8):
"""Return the k source lines with the largest byte growth between snapshots."""
stats = cur.compare_to(prev, "lineno") # positive size_diff == growth
return [(str(s.traceback[0]), s.size_diff) for s in stats[:k]]
Gotcha:
tracemalloconly sees allocations made aftertracemalloc.start(). Start it before you build the index or the baseline arena is invisible and every diff looks clean while RSS still climbs.
3. Fit the slope and classify. A rising heap slope is a leak; a flat heap slope with a rising RSS is fragmentation.
def slope_mb_per_hr(xs: list[float], ys: list[float]) -> float:
n = len(xs)
if n < 3:
return 0.0
mx = sum(xs) / n
my = sum(ys) / n
num = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
den = sum((x - mx) ** 2 for x in xs) or 1e-9
return num / den # MB per hour
def classify(m: "RSSMonitor") -> str:
xs = [s.t_hr for s in m.samples]
rss_m = slope_mb_per_hr(xs, [s.rss_mb for s in m.samples])
heap_m = slope_mb_per_hr(xs, [s.heap_mb for s in m.samples])
if rss_m < 2.0:
return "bounded"
if heap_m > 1.0:
return "leak" # objects retained
if m.samples[-1].cursors > m.samples[0].cursors + 4:
return "leaked-cursors"
return "fragmentation" # arena retention
4. Close rtree cursors deterministically. The most common false “leak” is a query iterator that is never exhausted, leaving a C-side stream open. Wrap intersection queries so the handle is released even on an early break.
from contextlib import contextmanager
from rtree.index import Index
@contextmanager
def bounded_query(idx: Index, bbox: tuple[float, float, float, float]):
"""Yield matching ids; guarantees the underlying stream is closed."""
it = idx.intersection(bbox, objects=False) # generator over int ids
try:
yield it
finally:
it.close() if hasattr(it, "close") else None # drop the C stream
Benchmark and verification
Run the harness against a node holding ~5k polygons at 25k evaluations/sec for a 12-hour soak, then apply the fixes: bound the snapshot history, close cursors, and set MALLOC_ARENA_MAX=2 with a periodic ctypes call to malloc_trim(0).
import ctypes
_libc = ctypes.CDLL("libc.so.6")
def trim_arenas() -> int:
"""Return freed pages to the kernel; call after a churn burst, off the hot path."""
return _libc.malloc_trim(0) # 1 if memory was released
Representative 12-hour soak, same workload before and after:
| Metric | Before | After |
|---|---|---|
| RSS slope $m$ | 42 MB/hr | 2.6 MB/hr |
| tracemalloc heap slope | 1.4 MB/hr | 0.1 MB/hr |
| RSS − heap gap at hour 12 | 610 MB | 74 MB |
| Open cursors at hour 12 | 190 | 3 |
| Eval P99 | 9.1 ms | 7.8 ms |
| OOM kills / 24h | 1.3 | 0 |
The heap-slope collapse confirms the object retention (unbounded snapshot list, leaked cursors) was real and is fixed; the residual RSS−heap gap is arena fragmentation, tamed by MALLOC_ARENA_MAX and periodic trimming rather than by chasing phantom Python objects.
Failure modes and edge cases
- glibc arena retention. Multi-threaded CPython spins up per-thread malloc arenas that grow to the process high-water mark and never shrink. RSS looks like a leak while
tracemallocis flat. Cap arenas withMALLOC_ARENA_MAX=2, callmalloc_trim(0)after churn bursts, or switchPYTHONMALLOCto jemalloc for a decaying return policy — the reclamation levers detailed in Memory Footprint of Streaming Polygon Indexes. - False positives from the harness itself. The
sampleslist and retainedtracemalloc.Snapshotobjects are themselves allocations. Keep a bounded ring of the last M snapshots and diff adjacent pairs; never hold the full history, or your monitor becomes the leak. - Unclosed rtree cursors. An
intersectiongenerator abandoned mid-iteration keeps a libspatialindex stream alive. Always drive queries through a context manager and assert live-cursor count stays flat across a soak. - gc.collect() masking the signal. Forcing a collection before each sample hides genuine gen-2 retention. Sample raw first, then collect, and compare — divergence between pre- and post-collection RSS is itself a fragmentation indicator.
- Warm-up contamination. Fitting the slope across the first few minutes captures one-time index construction and import allocations, inflating $m$. Discard the warm-up window and fit only the steady-state tail.
Related
- Memory Footprint of Streaming Polygon Indexes — parent overview of heap behavior, the object tax, and pool reclamation this measurement feeds.
- Object Pooling for Polygon Vertex Buffers — the allocation-rate fix once the monitor confirms churn is the cause.
- Memory-Constrained Spatial Processing — the upstream latency budget that RSS pressure ultimately threatens.