Choose Dask or multiprocessing for Parallel Schema Transforms Jump to heading

Parallelizing a geospatial schema transform looks trivial until a single malformed partition either crashes the whole run or silently vanishes. The two default answers on the Python stack — the standard-library multiprocessing.Pool and Dask — optimize for different failure and scale regimes, and picking the wrong one costs you either a weekend of cluster plumbing you did not need or a memory wall you cannot climb. This comparison sits inside Batch Schema Processing Pipelines: that parent workflow owns the manifest contract and streaming execution model, and this page owns the concrete engine choice underneath it. It assumes the transform itself is already deterministic and picklable — if it is not, no scheduler will save you.

The short version: single-node multiprocessing wins on simplicity, startup latency, and debuggability for datasets that fit in one machine’s RAM; Dask wins when the working set exceeds memory (out-of-core), when you need a task graph with fan-in/fan-out, or when you will eventually move to a multi-node cluster. Everything below is a reproducible way to prove which regime you are actually in, rather than guessing.

Engine selection decision flow A left-to-right decision path. First gate: does the working set fit in single-node RAM? If yes and the work is embarrassingly parallel, choose multiprocessing. If the set exceeds RAM, or a fan-in task graph is required, or a multi-node cluster is on the roadmap, choose Dask for out-of-core and distributed execution. Partition list N schema transforms Fits in single-node RAM? multiprocessing.Pool simple · low latency · in-memory Dask out-of-core · task graph · cluster yes no / need graph / cluster

Decision matrix Jump to heading

Dimension multiprocessing.Pool Dask
Setup cost Zero — standard library Extra dependency + scheduler concept
Working set Must fit in aggregate RAM Out-of-core; spills to disk
Scale ceiling One machine’s cores Single node → multi-node cluster, same API
Task graph Flat map only Arbitrary DAG (fan-in, joins, reductions)
Startup latency ~50–150 ms (fork) ~1–3 s (LocalCluster spin-up)
Error propagation First exception re-raised at get() Per-key exceptions; graph continues or short-circuits
Determinism Ordered map, insertion order preserved Order depends on compute collection type
Observability Print/log per worker Dashboard, task stream, per-key memory
Debuggability Straightforward pdb-per-worker Harder; needs dask.config synchronous mode

Prerequisites checklist Jump to heading

Step 1: Configure a shared transform contract Jump to heading

Both engines must run byte-for-byte identical work, or the benchmark is meaningless. Wrap the schema transform in one pure function that takes a partition path and returns a small, serializable result (a row count plus a content hash), not the full GeoDataFrame — returning large frames across the process boundary measures pickle bandwidth, not your transform.

python
# geopandas >=0.14, pyproj >=3.6, pyarrow >=14 — Python 3.10+
import hashlib
from dataclasses import dataclass
from pathlib import Path

import geopandas as gpd


@dataclass(frozen=True, slots=True)
class PartitionResult:
    path: str
    rows: int
    digest: str          # stable hash of the normalized attribute table
    ok: bool
    error: str | None = None


def transform_partition(path: str) -> PartitionResult:
    """Pure, picklable unit of work run identically by both engines."""
    p = Path(path)
    try:
        gdf = gpd.read_file(p)
        gdf = gdf.rename(columns=str.lower)          # deterministic schema step
        gdf = gdf.to_crs(4326)                        # deterministic transform
        payload = gdf.drop(columns="geometry").to_csv(index=False).encode()
        digest = hashlib.sha256(payload).hexdigest()
        return PartitionResult(str(p), len(gdf), digest, ok=True)
    except Exception as exc:  # noqa: BLE001 — capture, never crash the pool
        return PartitionResult(str(p), 0, "", ok=False, error=repr(exc))

Returning a captured error instead of raising is deliberate: it makes error propagation symmetric across engines so the comparison in Step 4 is fair.

Step 2: Execute both engines under matched partitions Jump to heading

Run the identical partition list through a fixed-size process pool and a Dask bag with the same worker count. Hold everything else constant — same input order, same max_workers, same machine, back to back.

python
# dask[distributed] >=2024.1 — Python 3.10+
import multiprocessing as mp
import time

import dask.bag as db
from dask.distributed import Client, LocalCluster


def run_multiprocessing(paths: list[str], workers: int) -> tuple[list, float]:
    t0 = time.perf_counter()
    with mp.get_context("spawn").Pool(processes=workers) as pool:
        # imap preserves input order and streams results as they finish.
        results = list(pool.imap(transform_partition, paths, chunksize=4))
    return results, time.perf_counter() - t0


def run_dask(paths: list[str], workers: int) -> tuple[list, float]:
    t0 = time.perf_counter()
    with LocalCluster(n_workers=workers, threads_per_worker=1,
                      memory_limit="2GiB") as cluster, Client(cluster):
        bag = db.from_sequence(paths, npartitions=workers * 4)
        results = bag.map(transform_partition).compute()
    return results, time.perf_counter() - t0

Use the spawn start method explicitly: fork (the Linux default) copies the parent’s memory and interacts badly with GDAL/PROJ global state, producing the intermittent segfaults teams blame on Dask when the real culprit is a forked pool. The Dask arm caps memory_limit so you can observe spill-to-disk rather than an OOM kill — the behavior that justifies Dask in the first place.

Step 3: Validate memory profile and determinism Jump to heading

Throughput alone is a trap. Sample peak resident memory while each engine runs, and assert the two engines produce the same content. If the digests diverge, the transform is not actually deterministic and no throughput number matters.

python
# psutil >=5.9 — Python 3.10+
import psutil


def sample_peak_rss(pid: int, children: bool = True) -> int:
    """Peak RSS in bytes for a process tree; call repeatedly while running."""
    proc = psutil.Process(pid)
    total = proc.memory_info().rss
    if children:
        for child in proc.children(recursive=True):
            try:
                total += child.memory_info().rss
            except psutil.NoSuchProcess:
                continue
    return total


def assert_equivalent(mp_results: list, dask_results: list) -> None:
    """Both engines must agree on content, independent of completion order."""
    mp_map = {r.path: r.digest for r in mp_results if r.ok}
    dk_map = {r.path: r.digest for r in dask_results if r.ok}
    assert mp_map == dk_map, "engines disagree — transform is non-deterministic"

Note the ordering caveat: pool.imap yields in input order, but dask.bag.compute() does not guarantee element order across partitions. Compare by keyed dictionary, never by list position, or you will report a false determinism failure.

Step 4: Log the decision and error propagation behavior Jump to heading

Record throughput, peak memory, and — critically — how each engine handled the partition that failed. A multiprocessing.Pool re-raises the first worker exception at result-collection time and can leave the pool in an undefined state; Dask isolates the failing key and lets independent branches complete. Because Step 1 captures errors into the return value, both engines here degrade gracefully, and failed partitions become records you can route to a dead-letter queue.

python
# Python 3.10+
import json
import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
log = logging.getLogger("engine-bench")


def log_decision(engine: str, results: list, seconds: float, peak_bytes: int) -> dict:
    failed = [r.path for r in results if not r.ok]
    record = {
        "engine": engine,
        "partitions": len(results),
        "failed": failed,
        "throughput_per_s": round(len(results) / seconds, 2),
        "wall_seconds": round(seconds, 2),
        "peak_rss_mb": round(peak_bytes / 1e6, 1),
    }
    log.info("%s", json.dumps(record))
    return record

Route every path in failed to the structured queue described in building a dead-letter queue for failed feature transforms, and emit the whole record to your append-only lineage manifests so the engine choice and its failure surface are auditable, not tribal knowledge.

Benchmark-style numbers (with caveats) Jump to heading

Indicative figures for ~10,000 mid-sized shapefiles (each 5–50 MB) on a 16-core / 64 GB node, transform as in Step 1. These are illustrative, not guarantees — they move with disk I/O, GDAL build, partition size, and PROJ grid caching. Measure your own workload with the harness above.

Metric multiprocessing (16 workers) Dask LocalCluster (16 workers)
Throughput (partitions/s) ~118 ~101
Peak RSS (whole tree) ~41 GB ~19 GB (spills to disk)
Startup overhead ~0.1 s ~2.4 s
Behavior at 4× data OOM kill near 64 GB Completes, slower, spills
Recovery from 1 bad partition Pool intact (errors captured) Key isolated, graph completes

The pattern is consistent: multiprocessing is ~10–20% faster while the working set fits, and Dask’s flatter, disk-backed memory profile is what lets it finish the run that kills the pool. Below the memory wall, pay for simplicity; above it, pay for Dask.

Verification Jump to heading

Confirm the harness is fair and the decision is grounded before you commit to an engine.

python
mp_res, mp_t = run_multiprocessing(paths, workers=8)
dk_res, dk_t = run_dask(paths, workers=8)

# 1. Same work, same result — non-negotiable.
assert_equivalent(mp_res, dk_res)

# 2. Both engines processed every partition (success or captured failure).
assert len(mp_res) == len(dk_res) == len(paths), "lost partitions"

# 3. The decision is data-driven, not assumed.
mp_rec = log_decision("multiprocessing", mp_res, mp_t, peak_bytes=mp_peak)
dk_rec = log_decision("dask", dk_res, dk_t, peak_bytes=dk_peak)
assert mp_rec["failed"] == dk_rec["failed"], "engines diverged on failures"

A healthy run logs two JSON records with identical failed lists and partitions counts. If multiprocessing peak RSS approaches physical RAM, that alone is the signal to move to Dask regardless of the throughput delta.

Troubleshooting Jump to heading

Symptom Likely cause Fix
Intermittent segfaults in the pool fork start method copying GDAL/PROJ global state Use mp.get_context("spawn"); reinitialize GDAL per worker.
Dask “reports” non-determinism Comparing results by list index, not by key Compare {path: digest} maps; dask.bag does not preserve order.
multiprocessing run OOM-killed Aggregate working set exceeds RAM; no spill Switch to Dask with a memory_limit, or shrink partitions.
Whole pool dies on one bad file An uncaught exception re-raised at get() Capture errors into the return value (Step 1); route to the dead-letter queue.
Dask slower than expected on small data LocalCluster startup + scheduling overhead dominates For in-memory data under a few GB, prefer multiprocessing.