7 min read 5 sections

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.

Attributing RSS growth: leak vs fragmentation vs leaked cursor A timeline where RSS rises while the Python heap stays flat, the gap attributed to arena fragmentation, then a decision tree branching to real leak, glibc arena retention, or unclosed rtree cursors, each with a remediation. RSS vs heap over time RSS heap gap = allocator + fragmentation Attribution Is the heap slope also rising? yes → real leak retained snapshots, unbounded cache no → glibc arena retention fragmentation, not a leak cursor count rising → leaked handles unclosed rtree query streams Remediation bound caches · drop old snapshots malloc_trim(0) · MALLOC_ARENA_MAX jemalloc / PYTHONMALLOC close cursors · context managers
A rising RSS with a flat Python heap is fragmentation, not a leak — the attribution branch decides the fix.

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.

python
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.

python
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: tracemalloc only sees allocations made after tracemalloc.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.

python
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.

python
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).

python
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 tracemalloc is flat. Cap arenas with MALLOC_ARENA_MAX=2, call malloc_trim(0) after churn bursts, or switch PYTHONMALLOC to jemalloc for a decaying return policy — the reclamation levers detailed in Memory Footprint of Streaming Polygon Indexes.
  • False positives from the harness itself. The samples list and retained tracemalloc.Snapshot objects 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 intersection generator 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.