Tuning Pyrosm Worker Count for PBF Parsing Jump to heading

Pick the number of processes for a parallel pyrosm or pyosmium parse of a tiled OSM extract so throughput climbs with your cores but total resident memory never crosses the RAM budget and triggers an OOM kill — and back the choice with a measured sweep rather than a guess.

Prerequisites Jump to heading

Work through this list before you trust any worker-count number; the right value is a property of your hardware and your tiles, not a universal constant.

Conceptual minimum Jump to heading

Parallel PBF parsing is embarrassingly parallel at the tile granularity: each worker process opens one extract, parses it independently, and returns a compact result. Adding workers raises throughput — but only until one of two ceilings is hit. The first is the core ceiling: once every physical core is busy on CPU-bound parse work, another worker just time-slices an already-saturated CPU and buys nothing. The second, and the one that actually bites in OSM pipelines, is the memory ceiling. Each pyrosm worker materializes a full GeoDataFrame, so its peak resident-set size (RSS) can run to hundreds of megabytes on a dense tile. Multiply that by too many workers and total RSS crosses the RAM budget; the kernel’s OOM killer reaps a worker, the pool raises BrokenProcessPool, and the whole job dies — usually hours in.

Throughput against worker count on an eight-core machine A bar chart of parsed objects per second against worker count on an eight-core, sixteen-thread machine. One worker gives 0.42 million per second. Two gives 0.81. Four gives 1.56. Eight gives 2.71, the peak. Sixteen gives 2.44, below the peak because hyperthreads contend for the same decode units. Thirty-two gives 1.98 as memory pressure and scheduling overhead dominate. Throughput peaks at physical cores and then declines objects decoded per second, 8 physical cores / 16 threads, 1.2 GB PBF 1 worker 0.42 M/s · baseline 2 workers 0.81 M/s · 1.93× 4 workers 1.56 M/s · 3.7× 8 workers 2.71 M/s · 6.5× — peak 16 workers 2.44 M/s · hyperthread contention 32 workers 1.98 M/s · memory + scheduling Set the default to the physical core count, not os.cpu_count(), which reports logical threads and lands you on the wrong side of the peak.
The curve peaks at physical cores, not logical threads, because protobuf decode saturates the execution units that hyperthreads share. Past the peak, each extra worker costs memory and returns nothing.

So the correct worker count is the smaller of the two ceilings. If a worker’s peak RSS is peak_RSS_per_worker and you are willing to spend RAM_budget bytes on parsing, then memory admits at most floor(RAM_budget / peak_RSS_per_worker) workers, and cores admit at most cores. The safe pool size is:

workers=min ⁣(cores,  RAM_budgetpeak_RSS_per_worker)\text{workers} = \min\!\left(\text{cores},\; \left\lfloor \frac{\text{RAM\_budget}}{\text{peak\_RSS\_per\_worker}} \right\rfloor \right)

The two inputs behave differently. cores is fixed and free to read; peak_RSS_per_worker must be measured, because it depends on tile density, whether you request geometry, and which feature classes you pull. The measurement matters more than the formula: guess the RSS low and you reintroduce the OOM you were avoiding. Whether cores or memory wins also depends on the nature of the work — a distinction worth making explicit before benchmarking.

Throughput versus worker count with a memory ceiling Throughput in tiles per second rises with worker count, bending into diminishing returns near the core count. A vertical dashed line marks the memory ceiling where total resident set equals the RAM budget; the region to its right is the out-of-memory risk zone. The safe worker count is the smaller of the core knee and the memory ceiling. Worker count Throughput (tiles/s) OOM-risk zone total RSS > budget memory ceiling core count safe operating point = min(knee, ceiling) diminishing returns near cores

Whether the core knee or the memory ceiling comes first is set by the parse profile. CPU-bound parses — pulling networks and reconstructing geometry — saturate cores, so throughput bends over near cores and you rarely want more workers than physical cores. I/O-bound parses — reading large tiles off a slow disk while doing little per-record work — leave cores idle, so a few extra workers overlap disk latency, but each still costs a full worker’s RSS, so the memory ceiling is unforgiving regardless. When memory is the true constraint, widening the pool is the wrong move; fall back to the streaming approach in Memory-Efficient Chunk Processing instead.

Runnable solution Jump to heading

The script sweeps worker counts from 1 up to the core count, measuring wall-clock throughput and peak per-worker RSS at each step, then applies the sizing formula to recommend a pool size that fits the RAM budget. It targets Python 3.10+, pyrosm>=0.6, and psutil>=5.9.

python
from __future__ import annotations

import logging
import os
import time
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path

import psutil
from pyrosm import OSM

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

# RAM you are willing to spend on parsing (bytes): leave OS + parent headroom.
RAM_BUDGET = 12 * 1024**3


def parse_tile(tile_path: str) -> tuple[int, int]:
    """Parse one tile; return (row_count, peak_rss_bytes) for this worker."""
    reader = OSM(tile_path)
    gdf = reader.get_network(network_type="driving")
    rows = 0 if gdf is None or gdf.empty else len(gdf)
    # RSS after the GeoDataFrame is materialized is this worker's peak.
    peak_rss = psutil.Process(os.getpid()).memory_info().rss
    return rows, peak_rss


def sweep(tiles: list[Path], max_workers: int) -> None:
    """Benchmark throughput and per-worker RSS across worker counts."""
    cores = os.cpu_count() or 1
    upper = min(max_workers, cores)
    best_peak_rss = 0

    for n in range(1, upper + 1):
        start = time.perf_counter()
        with ProcessPoolExecutor(max_workers=n) as pool:
            results = list(pool.map(parse_tile, [str(t) for t in tiles]))
        elapsed = time.perf_counter() - start

        peak_rss = max(rss for _, rss in results)
        best_peak_rss = max(best_peak_rss, peak_rss)
        throughput = len(tiles) / elapsed
        logger.info(
            "workers=%d  throughput=%.2f tiles/s  peak_rss/worker=%.0f MiB",
            n, throughput, peak_rss / 1024**2,
        )

    recommend(cores, best_peak_rss)


def recommend(cores: int, peak_rss_per_worker: int) -> int:
    """workers = min(cores, floor(RAM_BUDGET / peak_RSS_per_worker))."""
    if peak_rss_per_worker <= 0:
        logger.warning("no RSS measurement; defaulting to 1 worker")
        return 1
    mem_ceiling = RAM_BUDGET // peak_rss_per_worker
    workers = max(1, min(cores, mem_ceiling))
    logger.info(
        "cores=%d  mem_ceiling=%d (budget %.0f GiB / %.0f MiB per worker)  -> workers=%d",
        cores, mem_ceiling, RAM_BUDGET / 1024**3,
        peak_rss_per_worker / 1024**2, workers,
    )
    return workers


if __name__ == "__main__":
    tiles = sorted(Path("tiles").glob("*.osm.pbf"))
    sweep(tiles, max_workers=os.cpu_count() or 1)

Step-by-step walkthrough Jump to heading

  1. Measure RSS inside the worker. psutil.Process(os.getpid()).memory_info().rss, read after the GeoDataFrame is built, captures that worker’s true peak — the number that drives the memory ceiling. Measuring from the parent would miss the per-process footprint entirely.
  2. Cap the sweep at core count. min(max_workers, cores) avoids benchmarking pool sizes that only oversubscribe the CPU; past the core knee, CPU-bound throughput flattens and extra workers just add RSS.
  3. Take the worst-case worker. max(rss for _, rss in results) uses the densest tile’s footprint, not the average — sizing against the average is how a single urban tile OOM-kills a pool that “looked fine” on rural data.
  4. Apply the formula. recommend computes floor(RAM_BUDGET / peak_rss_per_worker) for the memory ceiling and takes the min with cores, clamped to at least one worker so a huge tile never yields a zero-worker recommendation.
  5. Read throughput, not just success. The per-n throughput line reveals diminishing returns directly: when going from n to n+1 adds little tiles-per-second, you have passed the knee and the extra RSS is buying nothing.
  6. Re-run per dataset. Peak RSS is dataset-specific. A continental sweep and a metro sweep produce different recommendations, so tune against tiles that represent the job you will actually run.

Verification Jump to heading

Confirm the recommended count is both fast and safe:

Deciding whether a parse is limited by cores, memory or the disk A left-to-right decision chain. If throughput stops rising as workers are added while CPU utilisation is near 100 percent, the parse is CPU-bound and the peak has been found. If CPU is well below 100 percent and disk read throughput is at the device limit, it is I/O-bound and more workers will not help. If resident memory approaches the container limit, it is memory-bound and the fix is fewer workers or a smaller chunk. If none of these hold, the bottleneck is contention on a shared structure such as the node cache. Four bottlenecks, four different fixes — measure before tuning CPU near 100%? throughput flat CPU-bound: at the peak disk at device limit? CPU idle I/O-bound: faster storage memory near the cap? RSS climbing fewer workers, smaller chunks none of these all three low contention on a shared structure Add all three counters to the parse log permanently. The next person to tune this should not have to instrument it again.
Measuring which of the four it is takes one run with three counters, and it changes the fix completely — more workers, a faster disk, a smaller chunk, or a lock to remove.
  • Total RSS stays under budget. During a full run at the recommended worker count, sum resident memory across workers (ps or a psutil monitor); the peak must sit below RAM_BUDGET with headroom to spare. Crossing it means peak_RSS_per_worker was underestimated — re-measure on a denser tile.
  • Throughput is near the knee. Plot the sweep’s tiles-per-second against n; the recommended count should land at or just before the point where the curve flattens, not deep into the flat region.
  • No BrokenProcessPool. A clean full run with zero OOM-killed workers is the acceptance test. Any such kill means the pool is too wide for the budget.
  • Formula agrees with observation. The recommend log line’s mem_ceiling should match where total RSS would actually hit the budget in the sweep; a large discrepancy signals RSS was sampled at the wrong moment.

Common errors and fixes Jump to heading

Symptom Root cause One-line fix
BrokenProcessPool mid-run Worker OOM-killed; pool too wide Lower workers to the recommend output; re-measure peak RSS.
Throughput flat past N workers Passed the CPU core knee Cap workers at physical cores; extra processes only add RSS.
Recommendation is 0 workers Single tile’s RSS exceeds the budget Re-tile smaller (finer H3 resolution) or raise RAM_BUDGET.
RSS reads far too low Sampled before GeoDataFrame built Measure after get_network returns, inside the worker.
Cores idle, disk pegged I/O-bound parse under-using CPU Add a few workers to overlap I/O, but watch the memory ceiling.
Memory climbs across the sweep Executors not closed between runs Use the with ProcessPoolExecutor(...) context so each pool joins.

Specification reference Jump to heading

concurrent.futures.ProcessPoolExecutor(max_workers=None) “uses os.cpu_count() on the number of worker processes” by default and runs each submission in a separate process, sidestepping the GIL for CPU-bound work — see the concurrent.futures documentation. For the underlying process model, start method, and why each worker carries its own memory image, consult the multiprocessing documentation; the per-process RSS it describes is precisely the peak_RSS_per_worker term in the sizing formula.

Frequently Asked Questions Jump to heading

Should worker count ever exceed the number of physical cores?

For CPU-bound geometry reconstruction, no — past the core count you only time-slice a saturated CPU and pay another worker’s full RSS for near-zero extra throughput. A modest excess can help a purely I/O-bound parse by overlapping disk latency, but the memory ceiling still applies to every worker, so measure before oversubscribing.

Why measure peak RSS per worker instead of using a fixed rule of thumb?

Peak RSS depends on tile density, whether you request geometry, and which feature classes you pull, so it varies by an order of magnitude between a rural and an urban tile. A fixed rule that works on one dataset OOM-kills on another; measuring the worst-case tile’s footprint is the only way the sizing formula stays honest across jobs.

My pool dies with BrokenProcessPool — is that a code bug?

Usually not. It most often means the kernel’s OOM killer reaped a worker because total resident memory crossed the budget, and the pool surfaces the dead worker as BrokenProcessPool. Reduce the worker count to the formula’s output, or re-tile into smaller extracts so each worker’s peak RSS drops.

Does the choice of parser change the right worker count?

Yes. A streaming pyosmium handler holds far less per-process memory than a pyrosm GeoDataFrame, so it admits more workers under the same budget, while pyrosm trades memory for a convenient dataframe. Weigh that trade when picking a library; the comparison in the parser-selection guide covers the memory profiles directly.

Up one level: Async PBF Parsing with Pyrosm.