ProcessPoolExecutor vs Numba JIT for Spatial Math
Point-in-polygon evaluation is CPU-bound, and under the GIL a single Python thread serializes it onto one core no matter how many the box has. There are two production-grade escapes, and they occupy opposite ends of the overhead spectrum: ProcessPoolExecutor spreads work across separate interpreter processes that sidestep the GIL by not sharing one, at the cost of inter-process communication; and Numba’s @njit(nogil=True) compiles the kernel to machine code that releases the GIL in-process, so ordinary threads run it in parallel with near-zero per-call overhead. This page expands the offload-boundary problem from Async Python Execution Patterns for Spatial Math under the wider Core Architecture & Latency Constraints model, and pairs with the runnable measurements in benchmarking spatial containment in async Python. The question it answers by name: for a hot containment kernel, does multiprocessing or a nogil JIT give more throughput per core once you account for the overhead each imposes?
Concept and specification
Both approaches turn one saturated core into $P$ busy cores, but they pay for it differently.
ProcessPoolExecutor forks or spawns $P$ worker processes, each a full Python interpreter with its own GIL. A task’s arguments are pickled, pushed through a pipe, unpickled in the worker, and the result travels back the same way. That round trip is the fixed cost — roughly 50 µs per task once you eliminate array copies by putting the coordinate buffer in multiprocessing.shared_memory and passing only the block name. The model wins when each task carries enough compute to bury the IPC cost.
Numba @njit(nogil=True) compiles the kernel to native code via LLVM on first call. Because the compiled function holds no Python objects, it can drop the GIL for its entire duration, so a ThreadPoolExecutor of ordinary threads executes it truly in parallel inside one process. There is no serialization and no process boundary: per-call overhead is the ~0.2 µs of a function dispatch after the one-time compile.
Model the effective throughput of a batch of $B$ points per task across $P$ workers, with kernel cost per point and IPC cost per task:
The process pool only approaches as ; the fraction wasted on IPC is . With and , a task must carry points before the overhead falls below 20%, and roughly before it drops under 5%. Numba pays no such tax at any batch size — but it pays a fixed compile cost up front.
| Metric | ProcessPoolExecutor + shared_memory | Numba @njit(nogil=True) + threads |
|---|---|---|
| Warm-up / compile | pool spawn ~200 ms (fork) | first-call JIT ~0.8 s (disk-cached) |
| Per-call / per-task overhead | ~50 µs IPC/task | ~0.2 µs/call |
| Break-even task size | needs ~5k points/task | any size |
| Throughput @ 8 cores | ~7.2× (IPC-limited) | ~7.8× (near-linear) |
| Memory | +RSS per worker interpreter | single process, shared arrays |
| Deployment complexity | shared_memory lifecycle, spawn semantics | LLVM at runtime, cache invalidation |
Step-by-step implementation
Prerequisites: Python 3.11+, numpy>=1.26, numba>=0.59. The kernel is a ray-casting point-in-polygon test; input is a (N, 2) float64 coordinate array and a polygon’s separated vertex arrays.
1. Write the Numba kernel with the GIL released. Pass only raw NumPy scalars and arrays — no Python objects — so the compiler can drop the GIL.
import numpy as np
from numba import njit
@njit(nogil=True, cache=True, fastmath=True)
def _point_in_polygon(px: float, py: float, vx: np.ndarray, vy: np.ndarray) -> bool:
"""Ray-cast containment; nogil so threads run it truly in parallel."""
inside = False
n = vx.shape[0]
j = n - 1
for i in range(n):
# Edge straddles the horizontal ray and the crossing is to the right of px.
if ((vy[i] > py) != (vy[j] > py)) and (
px < (vx[j] - vx[i]) * (py - vy[i]) / (vy[j] - vy[i]) + vx[i]
):
inside = not inside
j = i
return inside
@njit(nogil=True, cache=True)
def contains_batch(
pxs: np.ndarray, pys: np.ndarray, vx: np.ndarray, vy: np.ndarray, out: np.ndarray
) -> None:
for k in range(pxs.shape[0]):
out[k] = _point_in_polygon(pxs[k], pys[k], vx, vy)
2. Pre-warm the JIT at startup so the first request never pays the compile. Call the kernel once on dummy data during boot; cache=True persists the compiled artifact to __pycache__ across restarts.
def warm_up() -> None:
dummy = np.zeros(1, dtype=np.float64)
out = np.zeros(1, dtype=np.bool_)
contains_batch(dummy, dummy, dummy, dummy, out) # forces the ~0.8 s compile once
3. Run it in parallel with a thread pool from asyncio. Because the kernel is nogil, threads — not processes — scale it without IPC.
import asyncio
from concurrent.futures import ThreadPoolExecutor
async def numba_route(
pxs: np.ndarray, pys: np.ndarray, vx: np.ndarray, vy: np.ndarray,
pool: ThreadPoolExecutor,
) -> np.ndarray:
out = np.empty(pxs.shape[0], dtype=np.bool_)
loop = asyncio.get_running_loop()
# No pickling, no copy: threads share the arrays and release the GIL in the kernel.
await loop.run_in_executor(pool, contains_batch, pxs, pys, vx, vy, out)
return out
Gotcha: use a
ThreadPoolExecutorwith Numba, not aProcessPoolExecutor. Wrapping a nogil kernel in a process pool reintroduces the exact IPC cost you compiled it to avoid.
4. The ProcessPoolExecutor alternative: share the buffer, pass only its name. Pickling the coordinate array per task is the trap; put it in shared_memory and hand workers the block name and a slice range.
from concurrent.futures import ProcessPoolExecutor
from multiprocessing import shared_memory
def _worker(
shm_name: str, shape: tuple[int, int], lo: int, hi: int,
vx: np.ndarray, vy: np.ndarray,
) -> np.ndarray:
shm = shared_memory.SharedMemory(name=shm_name) # attach, no copy
coords = np.ndarray(shape, dtype=np.float64, buffer=shm.buf)
res = np.empty(hi - lo, dtype=np.bool_)
for k in range(lo, hi):
res[k - lo] = _pip(coords[k, 0], coords[k, 1], vx, vy) # pure-Python fallback kernel
shm.close() # detach; do NOT unlink here
return res
Benchmark and verification
Single 8-core host, 1M points against a 64-vertex polygon, batched into tasks. Numba uses a ThreadPoolExecutor(8); the process pool uses ProcessPoolExecutor(8) with a shared_memory coordinate block.
| Metric | ProcessPool (pure-Python kernel) | ProcessPool (Numba kernel) | Numba threads (nogil) |
|---|---|---|---|
| First-request stall | ~200 ms spawn | ~1.0 s (spawn + JIT) | ~0.8 s JIT |
| Per-task overhead | ~50 µs | ~50 µs | ~0.2 µs |
| Throughput @ 8 cores (5k/task) | ~2.1 M pts/s | ~34 M pts/s | ~41 M pts/s |
| Throughput @ 8 cores (100/task) | ~0.9 M pts/s | ~6 M pts/s | ~40 M pts/s |
| Peak RSS | ~1.3 GB (8 interpreters) | ~1.3 GB | ~340 MB |
The small-task row is the decisive one: at 100 points per task the process pool loses most of its cores to IPC while Numba’s threads are unaffected, because Numba’s overhead is independent of batch size. The process pool only becomes competitive at large batches, and even then it carries eight interpreter copies in RSS. A minimal harness to reproduce the throughput figures:
import time
def bench_throughput(route, coords, runs: int = 20) -> float:
best = 0.0
for _ in range(runs):
t0 = time.perf_counter()
route(coords) # closure over the executor + polygon
rate = len(coords) / (time.perf_counter() - t0)
best = max(best, rate) # points/sec at peak
return best
Verify correctness against a reference before trusting either path: run the same batch through a plain Shapely contains and require bit-identical boolean output. Enable NUMBA_DEBUGINFO=1 and check contains_batch.inspect_types() to confirm the kernel compiled in nopython mode — a silent fallback to object mode erases the entire speedup. Watch the event loop with py-spy under load to confirm neither offload is blocking the reactor thread.
Failure modes and edge cases
- Pickling large arrays into the process pool. Passing a NumPy array directly as a task argument pickles and copies it per task, which at 50k tasks/sec dwarfs the kernel cost and can OOM the box. Always route bulk data through
shared_memoryand pass only the block name and a slice; pickle just the tiny per-task metadata. - Numba unsupported constructs.
@njitrejects Python lists of Shapely geometries, dicts of ragged arrays, and most object types; feed it only scalars, NumPy arrays, and typed containers. A construct it cannot lower triggers a fallback to slow object mode (or, withnopython, a compile error) — assert oninspect_types()in CI. - Cold-start compile stalls. The first call pays ~0.8 s of JIT. Without
warm_up()at boot, the first production request stalls and trips latency alerts; and any change to the kernel source or Numba version invalidates thecache=Trueartifact, so a deploy silently re-incurs the compile on the first hit of each new pod. - Fork vs spawn semantics. On Linux
ProcessPoolExecutorforks by default and inherits open sockets and a warm interpreter; on macOS and Windows it spawns, re-importing your module and re-triggering module-level side effects. Use aforkservercontext and guard startup code underif __name__ == "__main__":to avoid re-running warm-up per worker. - shared_memory leaks. A
SharedMemoryblock that is neverunlink-ed survives the process and leaks until reboot, and Python’sresource_trackeremits warnings about it. Create andunlinkthe block from the parent only, have workersclose()(neverunlink), and wrap the lifecycle in atry/finally. - GC pauses under either model. Sustained allocation of result arrays feeds generation-2 sweeps that inject tail latency; pool the output buffers and call
gc.freeze()after warm-up, as covered for the surrounding pipeline in the parent async execution patterns.
Related
- Async Python Execution Patterns for Spatial Math — the parent overview of offload boundaries and event-loop safety
- Benchmarking Spatial Containment in Async Python — the sibling with the full measurement harness these figures come from
- Core Architecture & Latency Constraints — the end-to-end latency budget these kernels fit inside
- Point-in-Polygon Algorithm Benchmarks — the ray-cast vs winding-number kernel being parallelized here