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.
“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 growth curves from the same instrumentation, and what each one indicates before any profiler is attached.
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
defwindow_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.
defleak_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.
defwhy_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 _ inrange(depth):
nxt =[]for node, path in frontier:for ref in gc.get_referrers(node):ifid(ref)in seen:continue
seen.add(id(ref))
trail = path +[f"{type(ref).__name__}"]ifisinstance(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.
from functools import lru_cache
@lru_cache(maxsize=64)# bounded, and small enough to matterdeftransformer_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.
From the two instrumented series to a cause: which measurement separates which pair of candidates.
deftest_rss_is_flat_across_windows(generate, windows, tol_mb=48):
stats =[]for i, w inenumerate(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")deftest_gc_object_count_is_flat(stats, tol=2000):
steady =[s["gc_objects"]for s in stats[20:]]assertmax(steady)-min(steady)< tol,"object count grows per window"deftest_caches_are_bounded(module):for name, fn invars(module).items():
info =getattr(fn,"cache_info",None)if info:assert info().maxsize isnotNone,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.
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.
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.
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.