Diagnosing Memory Growth in a Windowed Generator

The generator processes one window at a time and holds one window’s worth of data, so resident memory should be flat. It rises steadily instead, and the run that was meant to take four hours is killed at hour three with an out-of-memory error.

Part of Memory-Optimized Large Grid Generation: windowing is the technique that makes a grid larger than memory tractable, and this is what goes wrong with it in practice — not the window logic, but everything attached to it.

Root Cause: Four Different Growth Curves, Four Different Bugs

“Memory grows” is not a diagnosis. There are four distinct causes, they need different fixes, and the shape of the growth curve distinguishes them before any profiler is attached.

Linear growth, one increment per window. Something is being retained per window — a result appended to a list, a window object kept in a log, an exception traceback holding a frame that holds an array. This is the most common cause and the easiest to fix once located.

Growth that flattens at a ceiling. A cache with a bound. This is usually correct behaviour being mistaken for a leak, and the fix, if any, is to lower the bound rather than to remove the cache.

Growth that flattens and then steps up again. A cache keyed on something with more distinct values than expected — often a window bounding box, or a CRS object that does not compare equal to itself across constructions. The plateau is the cache filling for one key population; the step is a new population arriving.

Sawtooth growth with a rising floor. Fragmentation. Each window’s arrays are freed correctly, but the allocator cannot return the pages to the operating system because a small long-lived object sits in the middle of an otherwise free region. Python-level tools show nothing, because at the Python level nothing is retained.

Four memory growth signatures from a windowed generator The horizontal axis is the number of windows processed, from zero to eighty. The vertical axis is resident memory in megabytes. Four curves are drawn on the same axes. A straight rising line means something is retained once per window — a result appended to a list, a window kept in a log, an exception holding a frame — and the work is to find the reference. A curve that rises and flattens to a ceiling is a bounded cache, which is usually correct behaviour mistaken for a leak. A curve that flattens, holds, and then steps up to a second plateau is a cache keyed on something with more distinct values than expected, so a new key population arrives partway through the run. A sawtooth whose peaks and troughs both drift upward is allocator fragmentation: every window's arrays are freed correctly, but pages cannot be returned to the operating system, and nothing at the Python level is retained at all. The note underneath gives the measurement that separates the last case from the first three without a profiler. The shape names the cause before a profiler is attached 0 20 40 60 80 400 550 700 850 windows processed resident memory (MB) find the reference not a leak check the key below Python linear — one retention per window bounded — a cache with a limit stepped — cache keyed too finely sawtooth, rising floor — fragmentation One measurement separates the fourth case from the first three: record traced Python memory beside resident memory. If both rise together the retention is at the Python level and a differential snapshot will name it. If resident rises while traced memory stays flat, nothing in Python holds it — the search moves to the allocator, to a native buffer, or to a forked worker's copy-on-write pages.
Four growth curves from the same instrumentation, and what each one indicates before any profiler is attached.

Prerequisite Check: Instrument Before Profiling

A profiler run on a job that takes three hours to fail is an expensive way to learn something a five-line counter would have told you. Record three numbers per window from the start.

python
import gc
import resource
import tracemalloc


def window_stats(index: int) -> dict:
    """Cheap per-window memory record — safe to leave on in production."""
    return {
        "window": index,
        "rss_mb": resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024,
        "python_mb": tracemalloc.get_traced_memory()[0] / 1e6,
        "gc_objects": len(gc.get_objects()),
    }

The gap between rss_mb and python_mb is the diagnostic. If both rise together, something is retained at the Python level and tracemalloc will name it. If rss_mb rises while python_mb stays flat, the retention is below Python — fragmentation, or a native buffer held by a library — and no amount of Python-level profiling will find it.

gc_objects distinguishes “many small objects retained” from “few large arrays retained”, which narrows the search considerably before any snapshot is taken.

Fix: Locate It With a Differential Snapshot, Then Cut the Reference

1 — Snapshot the difference between two steady-state windows

python
def leak_candidates(generate, windows, warmup: int = 5, gap: int = 20):
    """Compare allocations after warm-up against allocations much later."""
    tracemalloc.start(25)
    for w in windows[:warmup]:
        generate(w)
    gc.collect()
    early = tracemalloc.take_snapshot()
    for w in windows[warmup:warmup + gap]:
        generate(w)
    gc.collect()
    late = tracemalloc.take_snapshot()
    return late.compare_to(early, "traceback")[:12]

The warm-up matters. Comparing against the very first window attributes every one-time allocation — import-time caches, thread pools, the first CRS lookup — to the leak, and buries the real signal under a page of noise.

The gc.collect() calls matter too: without them the difference includes objects that are already unreachable and simply have not been collected, which is not a leak and will not grow without bound.

2 — Find what still refers to the retained object

python
def why_is_this_alive(obj, depth: int = 3):
    """Walk referrers outward until a module-level or frame-level holder appears."""
    seen, frontier, chains = {id(obj)}, [(obj, [])], []
    for _ in range(depth):
        nxt = []
        for node, path in frontier:
            for ref in gc.get_referrers(node):
                if id(ref) in seen:
                    continue
                seen.add(id(ref))
                trail = path + [f"{type(ref).__name__}"]
                if isinstance(ref, (dict, list)) and _is_module_level(ref):
                    chains.append(trail)
                else:
                    nxt.append((ref, trail))
        frontier = nxt
    return chains

In practice the chain terminates at one of four places: a module-level list, an lru_cache dict, an exception object stored for later reporting, or a closure captured by a callback registered once per window and never removed.

3 — Bound every cache, and key it on something small

python
from functools import lru_cache


@lru_cache(maxsize=64)                    # bounded, and small enough to matter
def transformer_for(src_epsg: int, dst_epsg: int):
    """Key on integers, not on CRS objects — two CRS objects for the same code may
    not compare equal, which turns a cache into an unbounded dictionary."""
    return build_transformer(src_epsg, dst_epsg)

The comment is the bug most often found by this exercise. An unbounded cache keyed on something that compares unequal every time is indistinguishable from a leak, grows linearly with window count, and looks entirely reasonable in review.

Which memory measurement separates which pair of causes A table with four measurements as rows. Comparing traced Python memory against resident memory separates Python-level retention from everything below Python: if both rise the next step is a differential snapshot, and if only resident rises the next step is the allocator or a native buffer. The garbage-collected object count separates many small retained objects from a few large retained arrays: a rising count points at accumulating records, a flat count with rising memory points at arrays. Cache introspection separates a bounded cache from an unbounded one and immediately settles whether the growth has a ceiling. A differential snapshot taken between two steady-state windows, rather than against the first window, names the allocation site directly, and taking it after warm-up is what keeps one-time import-and-startup allocations from burying the signal. The note underneath states the ordering: these are cheap and go in this order, and a profiler on a three-hour job is the last resort rather than the first. Cheapest measurement first — the profiler is the last resort measurement separates next action traced vs resident MB Python-level vs below the fundamental split snapshot, or drop to the allocator gc object count many small vs few large records vs arrays narrows the snapshot filter cache introspection bounded vs unbounded leak vs correct behaviour assert every maxsize is set differential snapshot names the allocation site after warm-up only walk referrers from there Run them in this order. The first three cost microseconds per window and can be left on permanently; the fourth costs a warm-up plus a comparison window. A profiler attached to a three-hour job is an expensive way to learn what a five-line counter reports in the first minute.
From the two instrumented series to a cause: which measurement separates which pair of candidates.

Verification Step: Assert Flatness Over a Long Enough Run

python
def test_rss_is_flat_across_windows(generate, windows, tol_mb=48):
    stats = []
    for i, w in enumerate(windows[:120]):
        generate(w)
        stats.append(window_stats(i))
    steady = [s["rss_mb"] for s in stats[20:]]      # discard warm-up
    slope = linear_slope(range(len(steady)), steady)
    assert slope * len(windows) < tol_mb, (
        f"projected growth over the full run is {slope * len(windows):.0f} MB"
    )


def test_gc_object_count_is_flat(stats, tol=2000):
    steady = [s["gc_objects"] for s in stats[20:]]
    assert max(steady) - min(steady) < tol, "object count grows per window"


def test_caches_are_bounded(module):
    for name, fn in vars(module).items():
        info = getattr(fn, "cache_info", None)
        if info:
            assert info().maxsize is not None, f"{name} has an unbounded cache"

Projecting the slope over the full run, rather than asserting on an absolute figure, is what makes this test useful at a size that fits in CI. A hundred and twenty windows take a minute; the projection tells you whether ninety thousand of them will survive.

Per-window memory by component across five window sizes Five window edge lengths run along the horizontal axis, from two hundred and fifty-six cells up to four thousand and ninety-six. The vertical axis is memory in megabytes, with each bar broken into four stacked components: the working array itself, the overlap halo carried around it for kernel support, the output encoder's buffers, and the fixed runtime cost of interpreter and libraries. At the smallest window the working array is a small fraction of the total and the halo is larger than the array it surrounds, because a sixty-four cell halo on a two-hundred-and-fifty-six cell window more than doubles the area. As the window grows the halo's share falls away and the working array comes to dominate. Between the two extremes the total passes through a minimum. The note underneath spells out the consequence that catches people: shrinking windows to reduce memory increases it below that minimum, and the fixed runtime term means the smallest window is never the cheapest per unit of work. Shrinking the window does not always shrink the memory 0 100 200 300 per-window memory (MB) 207 256² 208 512² 211 1024² 225 2048² 277 4096² working array overlap halo output encoder fixed runtime 64-cell halo, 4-byte cells, one encoder. Below the minimum, shrinking the window increases total memory — the halo area grows as the square of the perimeter-to-area ratio while the fixed terms do not shrink at all. That is counter-intuitive enough that it is almost always found by measurement rather than by reasoning about it.
Where a window's memory actually goes: the working array is rarely the largest term, and the overlap halo is the term that scales worst.

Edge Cases & Gotchas

The overlap halo is not free. A window with a halo wide enough for a kernel’s support holds more than the window. At small window sizes the halo dominates, so shrinking windows to reduce memory can increase it — which is counter-intuitive enough that it is usually discovered by measurement rather than by reasoning.

Copy-on-write in forked workers. A parent process that builds a large lookup before forking shares it with every worker until something touches it, at which point each worker gets its own copy. Memory then grows with worker count for reasons invisible in single-process profiling. This interacts badly with reference-counting: merely reading a Python object writes to its refcount and triggers the copy.

ru_maxrss is a high-water mark, not current usage. It never goes down, so a flat ru_maxrss proves flatness but a rising one does not prove growth is ongoing. For a genuine current reading, read /proc/self/statm on Linux instead.

Compression buffers on the write path. Streaming output to a compressed format holds encoder state whose size depends on the tile layout rather than on the window. A window-shaped memory model does not predict it and it is easy to attribute to the generator.

Designing the Window So the Question Rarely Comes Up

Most of the diagnostic work above is avoidable, and the avoidance is structural rather than disciplined.

Make the per-window function pure. A function that takes a window and returns an array, holding nothing across calls, cannot leak per window — there is nowhere for the retention to live. Every leak found by the procedure above is ultimately something that survived a call that should not have, and the cheapest way to prevent that class of bug is to give it no surface.

Push accumulation to the caller and bound it there. Results have to go somewhere, and the right somewhere is the write path rather than a list. A generator that yields tiles to a consumer which writes and discards them has a memory profile that does not depend on the tile count at all; one that returns a list of tiles has a profile that is linear in it by design, and no amount of profiling will make that go away.

Fix the window size once and record it. A window size derived at runtime from available memory makes every run’s memory profile different, which means a growth curve from one run cannot be compared to another and the first diagnostic step is unavailable. Choose it, record it in the run manifest, and let a run fail loudly on a smaller machine rather than quietly reshape itself.

Leave the three counters on. They cost microseconds per window and they turn “it died at hour three” into “it grew three megabytes per window from window twelve”, which is most of a diagnosis. The instrumentation is only expensive if it has to be added after the failure, which is exactly when the run that would have collected it is no longer available.