9 min read 5 sections

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
ProcessPoolExecutor IPC boundary versus Numba nogil in-process threads Left: an async loop pickles arguments across a process boundary to separate worker interpreters, each with its own GIL, all reading a shared_memory array; IPC costs about 50 microseconds per task. Right: a single process where a thread pool runs the same njit nogil kernel in parallel because the compiled code releases the GIL, sharing one in-process array; per-call overhead is about 0.2 microseconds after a one-time JIT compile. ProcessPoolExecutor + shared_memory separate interpreters · IPC boundary async dispatch loop pickle args → pipe process boundary worker 1 GIL ray-cast worker 2 GIL ray-cast worker 3 GIL ray-cast shared_memory: coordinate array zero-copy read ≈ 50 µs IPC overhead / task pool spawn ≈ 200 ms · +RSS per worker Numba @njit(nogil=True) + threads one interpreter · GIL released in kernel single process async dispatch loop thread 1 · njit kernel (nogil) thread 2 · njit kernel (nogil) thread 3 · njit kernel (nogil) in-process array — no copy ≈ 0.2 µs / call after JIT first call ≈ 0.8 s JIT (disk-cached) 8-core host: ProcessPool ≈ 7.2× (IPC-limited) · Numba threads ≈ 7.8× (near-linear)
The process pool trades a per-task IPC tax for GIL isolation across interpreters; Numba trades a one-time compile for near-zero per-call overhead and shared memory in one process.

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.

python
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.

python
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.

python
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 ThreadPoolExecutor with Numba, not a ProcessPoolExecutor. 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.

python
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:

python
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_memory and pass only the block name and a slice; pickle just the tiny per-task metadata.
  • Numba unsupported constructs. @njit rejects 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, with nopython, a compile error) — assert on inspect_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 the cache=True artifact, so a deploy silently re-incurs the compile on the first hit of each new pod.
  • Fork vs spawn semantics. On Linux ProcessPoolExecutor forks 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 a forkserver context and guard startup code under if __name__ == "__main__": to avoid re-running warm-up per worker.
  • shared_memory leaks. A SharedMemory block that is never unlink-ed survives the process and leaks until reboot, and Python’s resource_tracker emits warnings about it. Create and unlink the block from the parent only, have workers close() (never unlink), and wrap the lifecycle in a try/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.