Benchmarking OSM Parser Memory and Throughput Jump to heading

Measure the peak resident memory and parse throughput of pyosmium and pyrosm on the same extract, so the decision between streaming and materializing rests on numbers from your data and your machine rather than a rule of thumb.

Prerequisites Jump to heading

Tick each box before running the harness; a skipped one is the usual reason two parsers appear to use identical memory or wildly different element counts.

Conceptual minimum Jump to heading

Two numbers decide a parser: how much memory it needs at its worst moment, and how fast it turns bytes into elements. The worst-moment figure is peak resident set size, and the operating system already tracks it for you — resource.getrusage(resource.RUSAGE_SELF).ru_maxrss returns a high-water mark that only ever climbs during a process’s life. That property is a gift and a trap: because it never decreases, running pyosmium and pyrosm in the same process would report the maximum of the two, hiding the very difference you are trying to see. The fix is to run each parser in its own fresh process and read the high-water mark there. Throughput is simpler — wrap the parse in time.perf_counter(), count the elements consumed, and divide. The one subtlety is fairness: the first parser warms the operating system’s page cache, so the second reads from RAM instead of disk and looks faster than it is. This page is the empirical companion to the decision guide in choosing an OSM parser, which explains why the two readers scale differently; here we quantify it.

How the harness captures peak RSS and throughput from one parser run Resident memory rises and plateaus over a run; a dashed line marks the ru_maxrss high-water peak. Vertical markers t0 and t1 bound the perf_counter elapsed time. Peak RSS is ru_maxrss; throughput is elements divided by elapsed time. Two numbers per run: the memory peak and the wall-clock rate RSS wall-clock time (perf_counter) ru_maxrss — high-water peak, never falls t0 t1 Captured per run peak RSS = ru_maxrss throughput = elements ÷ elapsed

Runnable solution Jump to heading

The harness runs each parser in a spawned child process, times the parse, and reports peak RSS and elements per second side by side. It targets pyosmium and pyrosm on Python 3.10+.

python
from __future__ import annotations

import logging
import multiprocessing as mp
import platform
import resource
import sys
import time
from dataclasses import dataclass

import osmium

logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("osm.parser_benchmark")


def maxrss_bytes() -> int:
    """Peak RSS of *this* process, normalized to bytes.

    ru_maxrss is a high-water mark that only ever increases, so it is only
    meaningful when read in a process that ran exactly one parser.
    """
    raw = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    # Linux reports kibibytes; macOS and the BSDs report bytes.
    return raw * 1024 if platform.system() == "Linux" else raw


@dataclass
class Result:
    parser: str
    elements: int
    seconds: float
    peak_rss_bytes: int

    @property
    def throughput(self) -> float:
        return self.elements / self.seconds if self.seconds else 0.0


class _ElementCounter(osmium.SimpleHandler):
    """Count every primitive so both parsers report comparable work."""

    def __init__(self) -> None:
        super().__init__()
        self.n: int = 0

    def node(self, _n: osmium.osm.Node) -> None:
        self.n += 1

    def way(self, _w: osmium.osm.Way) -> None:
        self.n += 1

    def relation(self, _r: osmium.osm.Relation) -> None:
        self.n += 1


def _run_pyosmium(path: str, q: mp.Queue) -> None:
    handler = _ElementCounter()
    t0 = time.perf_counter()
    handler.apply_file(path)
    elapsed = time.perf_counter() - t0
    q.put(Result("pyosmium", handler.n, elapsed, maxrss_bytes()))


def _run_pyrosm(path: str, q: mp.Queue) -> None:
    from pyrosm import OSM  # imported in the child so spawn stays cheap

    t0 = time.perf_counter()
    osm = OSM(path)
    nodes, edges = osm.get_network(nodes=True, network_type="all")
    count = len(nodes) + len(edges)
    elapsed = time.perf_counter() - t0
    q.put(Result("pyrosm", count, elapsed, maxrss_bytes()))


def benchmark(path: str) -> list[Result]:
    """Run each parser in a fresh process so ru_maxrss reflects one parser."""
    ctx = mp.get_context("spawn")  # a clean interpreter → clean high-water mark
    results: list[Result] = []
    for target in (_run_pyosmium, _run_pyrosm):
        q: mp.Queue = ctx.Queue()
        proc = ctx.Process(target=target, args=(path, q))
        proc.start()
        results.append(q.get())
        proc.join()
    return results


def print_table(results: list[Result]) -> None:
    logger.info("%-10s %13s %9s %14s %14s", "parser", "elements", "sec",
                "peak RSS (MB)", "elem/sec")
    for r in results:
        logger.info("%-10s %13d %9.2f %14.1f %14.0f", r.parser, r.elements,
                    r.seconds, r.peak_rss_bytes / 1e6, r.throughput)


if __name__ == "__main__":
    print_table(benchmark(sys.argv[1]))

Step-by-step walkthrough Jump to heading

  1. Normalize the RSS unitmaxrss_bytes() multiplies by 1024 on Linux, where ru_maxrss is kibibytes, and leaves it alone on macOS/BSD, where it is already bytes. Skip this and your peaks are off by a factor of 1024.
  2. Count comparable work_ElementCounter tallies nodes, ways, and relations, so “elements” means the same thing for both parsers and the throughput divisor is fair.
  3. Time only the parsetime.perf_counter() brackets the apply_file call (and pyrosm’s get_network), excluding import and process spin-up so the rate reflects parsing, not startup.
  4. Isolate each parser in its own processmp.get_context("spawn") starts a fresh interpreter per run, so the high-water mark read by maxrss_bytes() belongs to exactly one parser. This is the single most important step: measure both in one process and you get the max of the two, not each.
  5. Import pyrosm lazily — the from pyrosm import OSM lives inside _run_pyrosm, so the spawned pyosmium child never pays for pyrosm’s heavy import and vice versa.
  6. Report both metrics togetherprint_table lays peak RSS and elements-per-second side by side, which is the comparison the parser choice actually turns on.

For a live memory trace rather than only the final peak, sample psutil.Process().memory_info().rss on a timer thread inside each child; the getrusage peak is enough to rank the parsers, but a trace shows where pyrosm’s footprint spikes during geometry reconstruction — useful context for the windowed approach in memory-efficient chunk processing.

Verification Jump to heading

Confirm the harness measured what you think before trusting the ranking:

  • Peaks must differ. pyosmium’s peak RSS should be a small fraction of pyrosm’s on the same extract; if they are near-identical, you are almost certainly measuring in one process — check that spawn is in effect.
  • Element counts should be the same order of magnitude. pyrosm’s network_type="all" count will not match pyosmium’s total-primitive count exactly (pyrosm filters to the network), but a 1000× gap means the read pulled the wrong feature set.
  • Sanity-check the units. A city extract reporting a peak of tens of kilobytes means the Linux KiB-to-bytes conversion was skipped; a realistic pyrosm peak on a city file is hundreds of megabytes to a few gigabytes.
  • Repeat and look for stability. Run three times; throughput should cluster within roughly 10–20%. Wild variance points at other load on the machine or cold-versus-warm cache effects.

Common errors and fixes Jump to heading

Symptom Root cause One-line fix
Both parsers report the same peak RSS Measured in one process; ru_maxrss is a high-water mark Run each parser in its own spawned process
Peak RSS off by ~1024× ru_maxrss unit mismatch (Linux KiB vs macOS bytes) Normalize per platform as in maxrss_bytes()
Second parser looks unfairly fast Warm page cache left by the first run Alternate order across runs, or drop caches between them
ImportError: pyrosm in the child Heavy import at module top under spawn Import pyrosm inside the child target function
pyrosm counts far fewer elements get_network filters to a network type Use network_type="all", or compare against a full read
Throughput varies run to run Startup jitter, cache state, competing load Run 3+ times, report the median, quiesce the machine
pyrosm run OOM-killed Extract too large to materialize Benchmark on a smaller clip; that OOM is itself the answer

Specification reference Jump to heading

pyosmium processes a file by invoking handler callbacks as it streams elements, which is what keeps its resident memory bounded regardless of file size; see the pyosmium documentation for SimpleHandler and apply_file semantics. The peak-memory metric comes from getrusage(RUSAGE_SELF), whose ru_maxrss field is defined as the maximum resident set size used, in kilobytes on Linux — consult the Python resource documentation for the field list and platform notes.

Frequently Asked Questions Jump to heading

Why run each parser in a separate process?

Because ru_maxrss is a high-water mark that only ever increases within a process. If pyosmium runs first and pyrosm second in the same interpreter, the value read after pyrosm reflects the larger of the two peaks, so pyosmium’s much smaller footprint is invisible. Spawning a fresh process per parser gives each an independent high-water mark, which is the whole point of the comparison.

Is ru_maxrss the same as the memory my parser allocated?

Not exactly. ru_maxrss is the peak resident set size — the most physical RAM the process ever had mapped at once — which is what determines whether you OOM. Allocated memory can be larger (some is swapped or never faulted in) or the resident peak can include shared library pages. For ranking parsers by real-world memory pressure, resident peak is the number that matters.

How do I make the cold-versus-warm cache comparison fair?

The first parser to touch the file warms the operating system’s page cache, so the second reads from RAM and looks faster. Either run each parser on a cold cache (drop caches or reboot between them), or alternate the order across several runs and compare medians so the cache advantage averages out. Reporting whether the numbers are cold or warm is part of an honest benchmark.

Which number matters more, peak RSS or throughput?

It depends on the binding constraint. If jobs must fit a fixed memory ceiling — a shared runner or a container limit — peak RSS decides feasibility and throughput is secondary. If memory is ample and turnaround time dominates, throughput wins. The value of measuring both is that the parser that is fastest is often not the one that is leanest, and the trade-off is only visible with the two numbers side by side.

Up one level: pyosmium vs pyrosm vs osmium-tool: Choosing the Right Parser.