Profiling Peak Memory of an OSM Parser Jump to heading

Every OSM pipeline is eventually killed by the operating system for using too much memory, and the debugging that follows is usually guesswork because nobody measured anything before it happened.

Prerequisites Jump to heading

Conceptual minimum Jump to heading

Three different numbers get called “memory usage” and they answer different questions.

Resident set size is what the operating system counts and what gets a process killed. It includes memory the allocator has freed but not returned, and pages of memory-mapped files that happen to be resident. It is the number that matters operationally and the least useful for finding a cause.

Allocated bytes, as tracked by the language runtime, attribute memory to the code that requested it. That makes it the number for diagnosis, and it deliberately excludes anything the runtime did not allocate — which includes memory-mapped files and native library allocations.

Peak versus current is the distinction that catches people. A pipeline sitting at two gigabytes may have peaked at twelve during a merge, and only the peak explains why it was killed.

The diagnostic question is almost always does it grow with input size. A structure whose size tracks the file is unbounded and will fail on a larger one; a cache that plateaus is fine however large it looks. Two runs on differently-sized inputs answer that question definitively, and nothing else does.

Three memory numbers, what each includes, and what each is for A grid of three measurements against what each includes and the question it answers. Resident set size includes freed-but-unreturned memory and resident pages of mapped files, and answers whether the process will be killed. Runtime-allocated bytes include only what the language runtime requested, exclude mapped files and native allocations, and answer which code is responsible. Peak values of either answer why a process died, where current values answer only how it is behaving now. Three numbers, three different questions Includes Answers Resident set size freed, mapped pages will it be killed? Runtime allocations only runtime requests which code? Peak, either one the maximum reached why it died Current, either one this instant how it is now Reporting only current allocated bytes, which is the easiest number to obtain, answers neither operational question.
A pipeline killed for memory needs the first row to confirm it and the second to explain it.

Runnable solution Jump to heading

python
from __future__ import annotations

import logging
import resource
import threading
import time
import tracemalloc
from collections.abc import Callable
from contextlib import contextmanager
from dataclasses import dataclass, field

import psutil

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.profile.memory")

SAMPLE_INTERVAL = 0.25


@dataclass
class MemoryProfile:
    peak_rss_mb: float = 0.0
    final_rss_mb: float = 0.0
    peak_allocated_mb: float = 0.0
    samples: list[float] = field(default_factory=list)
    top_allocators: list[str] = field(default_factory=list)
    seconds: float = 0.0


class Sampler(threading.Thread):
    """Resident memory is sampled, not queried: the peak is a maximum over time."""

    def __init__(self, interval: float = SAMPLE_INTERVAL) -> None:
        super().__init__(daemon=True)
        self.interval = interval
        self.samples: list[float] = []
        self._stop = threading.Event()
        self._process = psutil.Process()

    def run(self) -> None:
        while not self._stop.is_set():
            self.samples.append(self._process.memory_info().rss / 1024 ** 2)
            self._stop.wait(self.interval)

    def stop(self) -> None:
        self._stop.set()
        self.join(timeout=2.0)


@contextmanager
def profile(top: int = 8):
    """Measure resident peak, allocation peak and the top allocating lines."""
    tracemalloc.start(25)
    sampler = Sampler()
    sampler.start()
    started = time.perf_counter()
    result = MemoryProfile()
    try:
        yield result
    finally:
        result.seconds = time.perf_counter() - started
        snapshot = tracemalloc.take_snapshot()
        _current, peak = tracemalloc.get_traced_memory()
        tracemalloc.stop()
        sampler.stop()

        result.samples = sampler.samples
        result.peak_rss_mb = max(sampler.samples) if sampler.samples else 0.0
        result.final_rss_mb = sampler.samples[-1] if sampler.samples else 0.0
        result.peak_allocated_mb = peak / 1024 ** 2
        # The kernel's own high-water mark, as a cross-check on the sampling.
        kernel_peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
        result.top_allocators = [
            f"{stat.size / 1024 ** 2:7.1f} MB  {stat.traceback.format()[-1].strip()}"
            for stat in snapshot.statistics("lineno")[:top]]

        logger.info("peak rss %.0f MB (kernel says %.0f), final %.0f MB, "
                    "peak allocated %.0f MB, %.1fs",
                    result.peak_rss_mb, kernel_peak, result.final_rss_mb,
                    result.peak_allocated_mb, result.seconds)
        for line in result.top_allocators:
            logger.info("  %s", line)


def growth_test(run: Callable[[str], None], small: str, large: str,
                size_ratio: float) -> str:
    """Does memory grow with input size? This is the only question that matters.

    A structure whose footprint tracks the input is unbounded and will fail on
    a larger file; one that plateaus is a cache and is fine however large.
    """
    with profile() as a:
        run(small)
    with profile() as b:
        run(large)

    growth = b.peak_rss_mb / max(a.peak_rss_mb, 1.0)
    logger.info("input grew %.1fx, peak memory grew %.2fx", size_ratio, growth)

    if growth > size_ratio * 0.7:
        return ("UNBOUNDED: memory tracks input size; a structure is "
                "accumulating per element")
    if growth > 1.3:
        return "PARTIAL: something grows with input, but sub-linearly"
    return "BOUNDED: memory is flat; the footprint is a cache or a buffer"


if __name__ == "__main__":
    logger.info("run the growth test before optimising anything")

Step-by-step walkthrough Jump to heading

  1. Sample resident memory rather than querying it. The peak is a maximum over time, and a single reading after the run has finished misses it entirely — often by a factor of five.
  2. Cross-check against the kernel’s high-water mark. The operating system tracks a peak of its own, and a large disagreement with the sampled maximum means the sampling interval is too coarse for a short spike.
  3. Track allocations separately. Runtime allocation tracking attributes memory to lines of code, which is what turns “it used twelve gigabytes” into “this dictionary used twelve gigabytes”.
  4. Take the snapshot before stopping the tracker. Stopping first discards everything, which is an easy mistake to make and produces an empty report.
  5. Report peak and final separately. A large gap between them means a transient spike — a merge, a sort, a batch — rather than steady growth, and the two have completely different fixes.
  6. Run the growth test first. Comparing peak memory across two input sizes distinguishes an unbounded structure from a large but constant cache, and that distinction determines whether there is a problem at all.
  7. Compare growth against the size ratio. Memory growing roughly in proportion to input is the signature of accumulation; growing far less is a cache warming up.
Three memory profiles and what each shape means Three panels describing shapes seen when resident memory is plotted over a run. A flat profile that rises quickly and then plateaus indicates a bounded cache or buffer, and is healthy however large the plateau. A linearly rising profile indicates a structure accumulating per element, which will fail on a larger input and is the only shape that is genuinely a bug. A sawtooth profile with periodic spikes indicates a batch or merge operation, where the peak rather than the average is what must fit in memory. Three shapes, and only one is a bug Rises then plateaus A bounded cache Healthy at any size Plateau is configurable No action needed Rises linearly Accumulating per element Fails on a larger file The only real bug Find it with allocation stats Sawtooth spikes A batch or a merge Peak must fit, not the mean Reduce the batch size Or the spike is the limit Plotting the samples costs nothing and identifies which of the three you have in seconds, before any code is read.
Most memory investigations begin by reading code and should begin by looking at the shape of the curve.
How the growth test reads for four different structures Four structures measured across a fourfold increase in input size, with the factor by which peak memory grew. A fixed-size read buffer does not grow at all. A bounded cache grows slightly as it fills more completely on the larger input. A per-element accumulation grows in proportion to the input, which is the unbounded signature. An unbounded queue between producer and consumer grows fastest of all, because it absorbs the difference in rate as well as the volume. Peak memory growth against a fourfold input increase Fixed read buffer no growth Bounded cache about 1.2x Per-element accumulation about 3.8x Unbounded queue about 5.1x A growth factor approaching the input ratio is the unbounded signature; anything near one is a structure whose size you chose.
The bottom two rows are bugs and the top two are configuration, and the test separates them without reading any code.

Verification Jump to heading

  • Peak exceeds final. If they are equal, the sampler probably started too late or the interval is too coarse.
  • The kernel agrees. The sampled peak and the kernel’s high-water mark should be close; a large gap means a missed spike.
  • The growth test is decisive. Two inputs differing by a factor of four should produce an unambiguous verdict.
  • Top allocators are plausible. The largest entries should be structures you can name; an unfamiliar line is where to look.
  • The profile is reproducible. Two runs on identical input should report peaks within a few percent.

Common errors and fixes Jump to heading

Symptom Root cause One-line fix
Reported memory far below reality Measured after the run finished Sample throughout and take the maximum
Allocation report is empty Snapshot taken after stopping the tracker Snapshot first, then stop
Peak missed entirely Sampling interval too coarse for a spike Shorten the interval and cross-check the kernel peak
Mapped files not accounted for Only runtime allocations measured Measure resident memory as well
Optimising the wrong thing Growth test skipped Establish bounded or unbounded before touching code
Profiles differ between runs Input or environment varying Fix the input and the interval before comparing
Native allocations invisible Runtime tracking used alone Use resident memory for anything outside the runtime

Specification reference Jump to heading

Resident set size measures the portion of a process’s memory held in physical RAM, including pages of memory-mapped files and memory freed by the program but not returned to the operating system. Runtime allocation tracking attributes only allocations made through the language runtime and reports both current and peak traced memory. See the Python tracemalloc documentation for the snapshot and peak interfaces.

Frequently Asked Questions Jump to heading

Why is peak memory so much higher than what I measured?

Because you almost certainly measured after the run, and by then the allocator has released the transient structures that caused the peak. Memory usage is a curve, and the number that gets a process killed is its maximum. Sampling throughout the run and taking the maximum is the only way to capture it, and the difference between that and a single final reading is routinely a factor of several.

Which should I measure, resident memory or allocations?

Both, because they answer different questions. Resident memory is what the operating system counts and what determines whether the process survives, but it includes mapped files and unreturned free memory, so it rarely points at a cause. Allocation tracking attributes bytes to lines of code, which is what you need to fix anything, but it cannot see memory-mapped files or native library allocations.

How do I tell a leak from a cache?

Run the same code on two inputs of different sizes. A cache plateaus, so its peak is similar for both; an accumulating structure grows roughly in proportion to the input, so its peak tracks the size ratio. That single comparison answers the question definitively and takes two runs, where reading code to find the culprit can take an afternoon and still leave doubt.

What does a sawtooth profile mean?

A batch operation — a sort, a merge, a flush — that accumulates and then releases. The average is irrelevant and the peak is what must fit, so the fix is to reduce the batch size rather than to look for a leak. It is also the profile most likely to be missed by coarse sampling, because the spikes can be shorter than the interval.

Up one level: Memory-Efficient Chunk Processing.