Parallelizing Coordinate Transforms with multiprocessing
Turning a single-core pyproj batch into a multi-core one is the concrete speed win that Concurrent pyproj Transformation Pipelines in Python frames in general terms, and it belongs to the wider automation practice in Batch Transformation & Automation for Cadastral Coordinate Pipelines; this page is the self-contained script that does it correctly, using concurrent.futures.ProcessPoolExecutor with an initializer that builds exactly one pyproj.Transformer per worker process.
Figure — split by index, transform per-worker, write back by offset.
Why the initializer is the whole trick
A pyproj.Transformer is not a plain value you can hand to a subprocess. It wraps a PROJ context — a C structure holding file handles and a grid cache — that is bound to the process that created it. On Linux the default process-start method is fork(), and a forked child inherits a copy of the pointer to that context without inheriting a valid, independent context. Transform through it and the child races the parent’s memory: the visible outcome is either a crashed worker or coordinates that are quietly wrong. The remedy is to never let a transformer cross the boundary. ProcessPoolExecutor accepts an initializer callable that runs once inside each worker before it handles any task; building the transformer there means the context is native to the child. We keep it in a module-global and read it back from the task function. The parallel efficiency you can expect follows Amdahl’s law: if a fraction
Figure — build the Transformer once per worker, not once per task.
so the serial remainder — pickling chunks, reassembly — caps the gain and is exactly why chunk sizing matters. The same context-isolation rule, applied to threads rather than processes, is what drives the design in thread-safe pyproj transformer caching.
Complete Runnable Implementation
The script is self-contained: it defines the per-worker initializer, a chunk transform task, a splitter, and a driver that preserves order and can time itself against a single-process baseline.
from __future__ import annotations
import time
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass
import numpy as np
from pyproj import Transformer
# One Transformer per worker, created by the initializer, read by the task.
_TF: Transformer | None = None
def _init(source_epsg: str, target_epsg: str) -> None:
"""Run once per worker: build the Transformer INSIDE the child so its PROJ
context is owned locally and never inherited across fork()."""
global _TF
_TF = Transformer.from_crs(source_epsg, target_epsg, always_xy=True)
@dataclass(frozen=True)
class _Chunk:
start: int # first row of this chunk in the source array
coords: np.ndarray # (m, 2) float64 slice
def _work(chunk: _Chunk) -> tuple[int, np.ndarray]:
"""Transform one chunk with the worker-local Transformer. Vectorized:
each column is handed to PROJ in a single call, not looped point by point."""
if _TF is None:
raise RuntimeError("worker Transformer not initialized")
x, y = _TF.transform(chunk.coords[:, 0], chunk.coords[:, 1])
return chunk.start, np.column_stack([x, y]).astype(np.float64, copy=False)
def _split(coords: np.ndarray, n_chunks: int) -> list[_Chunk]:
"""Split an (N,2) array into `n_chunks` contiguous, offset-tagged pieces."""
if coords.ndim != 2 or coords.shape[1] != 2:
raise ValueError(f"expected (N, 2), got {coords.shape}")
bounds = np.array_split(np.arange(coords.shape[0]), max(1, n_chunks))
return [_Chunk(int(b[0]), np.ascontiguousarray(coords[b[0]:b[-1] + 1]))
for b in bounds if b.size]
def parallel_transform(coords: np.ndarray, source_epsg: str, target_epsg: str,
n_workers: int = 4) -> np.ndarray:
"""Transform an (N,2) array across `n_workers` processes, preserving order.
Output row i is the transform of input row i, regardless of finish order."""
coords = np.ascontiguousarray(coords, dtype=np.float64)
out = np.empty_like(coords)
chunks = _split(coords, n_workers * 4) # a few chunks per worker
with ProcessPoolExecutor(max_workers=n_workers, initializer=_init,
initargs=(source_epsg, target_epsg)) as pool:
for start, block in pool.map(_work, chunks): # map preserves submit order
out[start:start + block.shape[0]] = block # but we place by offset anyway
return out
def timed_speedup(coords: np.ndarray, source_epsg: str, target_epsg: str,
n_workers: int = 4) -> dict[str, float]:
"""Measure wall-clock speedup of the parallel run over a single-process run."""
t0 = time.perf_counter()
_init(source_epsg, target_epsg) # build once for the baseline
serial = np.column_stack(_TF.transform(coords[:, 0], coords[:, 1]))
t_serial = time.perf_counter() - t0
t1 = time.perf_counter()
par = parallel_transform(coords, source_epsg, target_epsg, n_workers)
t_par = time.perf_counter() - t1
assert np.allclose(serial, par, atol=1e-6), "parallel result diverged from serial"
return {"serial_s": round(t_serial, 4), "parallel_s": round(t_par, 4),
"speedup": round(t_serial / t_par, 2) if t_par else float("inf")}
Parameter Reference
| Name | Type | Units | Valid range | Notes |
|---|---|---|---|---|
coords |
np.ndarray |
degrees / metres | (N, 2) float64 |
row order is the join key; must be preserved |
source_epsg |
str |
— | e.g. "EPSG:4326" |
source CRS handed to every worker’s Transformer |
target_epsg |
str |
— | e.g. "EPSG:32633" |
target CRS; wrong UTM zone offsets easting grossly |
n_workers |
int |
processes | 1 … CPU count | begin at physical core count |
_Chunk.start |
int |
row offset | 0 … N−1 | the offset each transformed block is written back to |
| return | np.ndarray |
metres | (N, 2) float64 |
aligned row-for-row with coords |
Minimal Worked Example
Transform four points from EPSG:4326 (WGS 84 longitude/latitude) to EPSG:32633 (WGS 84 / UTM zone 33N) and confirm the parallel path matches the serial one.
import numpy as np
pts = np.array([
[13.4050, 52.5200], # Berlin
[14.4378, 50.0755], # Prague
[11.5820, 48.1351], # Munich
[16.3738, 48.2082], # Vienna
], dtype=np.float64) # (longitude, latitude)
out = parallel_transform(pts, "EPSG:4326", "EPSG:32633", n_workers=2)
print(np.round(out[0], 2))
# [ 392788.16 5819943.4 ] Berlin easting/northing in metres
Validation Check
Gate the parallel output against the single-process result before trusting it — this catches a mis-built worker context and a reassembly bug at once.
Figure — chunk size against total time: too small pays dispatch, too large loses balance.
import numpy as np
serial = np.column_stack(
Transformer.from_crs("EPSG:4326", "EPSG:32633", always_xy=True)
.transform(pts[:, 0], pts[:, 1])
)
assert np.allclose(serial, out, atol=1e-6), "parallel diverged from serial baseline"
assert out.shape == pts.shape, "row count or order changed"
print("ok — parallel matches serial within 1e-6 m")
Common Mistakes
Forking a Transformer built in the parent process
Transformer.from_crs(...) once at module top level and relying on fork() to share it gives each child an invalid PROJ context — a segfault or silently garbage coordinates. Build the transformer inside the initializer so every worker owns its own context, and never pass a transformer as a task argument.Losing input↔output order
pool.map, which yields results in submit order, or write each block back by its start offset into a preallocated array. Do both here for defence in depth.Chunks so small the overhead dominates
Frequently Asked Questions
Why can’t I just pass a Transformer to each worker?
Because it wraps a native PROJ context that does not survive being pickled and sent across a process boundary. Depending on the platform and start method you will get a pickling error, or — worse — an object that appears to arrive and then misbehaves. Building it inside each worker sidesteps the question entirely and is also faster, because the construction cost is paid once per worker rather than once per task.
How large should each chunk be?
Large enough that dispatch overhead disappears and small enough that every worker gets several chunks. For coordinate transforms, tens of thousands of points per chunk is usually the flat part of the curve. Two chunks across eight workers is the failure mode at the other end: six workers idle while two do all the work.
Does multiprocessing change the numerical result?
It must not, and if it does you have a bug. The arithmetic is per point and independent, so worker count cannot affect any individual coordinate. What does change results is rounding or aggregation performed per chunk — round after the transform, on the assembled output, and the result becomes independent of how the work was divided.
Related
- Concurrent pyproj Transformation Pipelines in Python — the parent reference on fan-out/fan-in design and residual gating.
- Thread-Safe pyproj Transformer Caching — the same isolation rule applied to threads for I/O-bound reuse.
- Fallback routing strategies for missing grid files — pin one operation so every worker resolves an identical pipeline.
- Batch Transformation & Automation for Cadastral Coordinate Pipelines — the parent domain on scaling and auditing coordinate batches.