๐Ÿงฎ CPU-Bound Scaling with Multiprocessing

The only way to use all your cores in pure-Python. When the CPU is the bottleneck, threads and async can't help you โ€” separate processes can.

Why Threads and Async Can't Speed Up CPU Work

The GIL: CPython's Global Interpreter Lock lets exactly one thread execute Python bytecode at a time. Add 8 threads to a number-crunching loop and they take turns on one core โ€” you get the same throughput plus lock-handoff overhead. Async is worse for CPU: a single event loop on a single thread, so a heavy computation just blocks everything (see Async at Scale).
# โŒ Threads do NOT parallelize CPU-bound Python โ€” the GIL serializes them
from concurrent.futures import ThreadPoolExecutor
import time

def crunch(n):                       # pure-Python CPU work, holds the GIL
    total = 0
    for i in range(n):
        total += i * i
    return total

work = [10_000_000] * 8
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=8) as pool:
    list(pool.map(crunch, work))
print(f"threads: {time.perf_counter() - start:.2f}s")   # ~ same as running serially
Processes have no shared GIL. Each process is a separate CPython interpreter with its own GIL, its own memory, its own core. Spawn 8 processes and you get 8 cores of real parallel bytecode execution. This is the only route to CPU parallelism in stock CPython.

The Model: Split โ†’ Fan Out โ†’ Combine

flowchart TB M["Main process
(big CPU job)"] --> S["Split into chunks"] S --> W1["Worker 1
own interpreter + GIL"] S --> W2["Worker 2
own interpreter + GIL"] S --> W3["Worker 3
own interpreter + GIL"] S --> WN["Worker N
own interpreter + GIL"] W1 --> C["Combine results"] W2 --> C W3 --> C WN --> C

Every arrow crossing a process boundary is not free โ€” data is serialized (pickled), pushed through a pipe, and deserialized on the other side. That cost is the whole story of tuning multiprocessing, and the rest of this page is about minimizing it.

ProcessPoolExecutor โ€” same API as threads

concurrent.futures.ProcessPoolExecutor shares its entire API with ThreadPoolExecutor. Swap one class for the other and your CPU-bound job actually scales.

# โœ… Real parallelism โ€” same code as the thread example, one class changed
from concurrent.futures import ProcessPoolExecutor
import os, time

def crunch(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

if __name__ == "__main__":              # REQUIRED โ€” see fork vs spawn below
    work = [10_000_000] * os.cpu_count()
    start = time.perf_counter()
    with ProcessPoolExecutor() as pool:            # defaults to os.cpu_count() workers
        results = list(pool.map(crunch, work))
    print(f"processes: {time.perf_counter() - start:.2f}s")   # ~Nx faster on N cores

The lower-level multiprocessing.Pool gives you the same idea with more knobs (chunksize, initializer, imap):

from multiprocessing import Pool
import os

if __name__ == "__main__":
    with Pool(processes=os.cpu_count()) as pool:
        results = pool.map(crunch, [10_000_000] * 8)

Sizing: processes = os.cpu_count()

For CPU-bound work, more processes than cores hurts โ€” the OS time-slices them and you pay context-switch cost for zero extra throughput. This is the opposite of I/O-bound tuning, where oversubscription is fine because workers spend most of their time waiting.

import os

cpu_workers = os.cpu_count()          # CPU-bound: match physical parallelism
# io_workers = os.cpu_count() * 5     # I/O-bound: oversubscribe, workers mostly wait

# Leave a core free if the main process also does real work:
workers = max(1, os.cpu_count() - 1)
Rule of thumb: CPU-bound โ†’ processes = os.cpu_count() (or one less). Never "just add more workers" for CPU work โ€” Amdahl's Law and context switching cap you well before that.

The Big Cost: Pickling Across the Boundary

Every argument and every return value is pickled. The flow is: serialize the object โ†’ push bytes through an OS pipe โ†’ deserialize in the worker โ†’ (do the work) โ†’ serialize the result โ†’ pipe it back โ†’ deserialize in the parent. If your args or results are large, this IPC cost can completely erase the parallelism win โ€” you end up slower than single-process.
# โŒ Passing a 200MB list to each worker: the pickle+pipe dwarfs the compute
def process(big_list):                 # big_list gets pickled on every call
    return sum(big_list)

with ProcessPoolExecutor() as pool:
    # each task ships 200MB across the boundary โ€” IPC-bound, not CPU-bound
    results = list(pool.map(process, [huge_list] * 8))
Diagnosis: if adding processes doesn't speed things up (or makes it slower), you are almost always IPC-bound, not CPU-bound. Measure the size of what crosses the boundary before blaming the pool.

Optimization 1 โ€” chunksize to amortize IPC

By default pool.map ships items to workers one (or a few) at a time. For many small items, the per-task pipe round-trips dominate. A larger chunksize batches many items into one transfer, amortizing the overhead.

from multiprocessing import Pool
import time

def light(x):
    return x * x          # tiny compute โ€” IPC overhead dominates per task

if __name__ == "__main__":
    data = range(2_000_000)

    for cs in (1, 1000, 50_000):
        start = time.perf_counter()
        with Pool() as pool:
            pool.map(light, data, chunksize=cs)
        print(f"chunksize={cs:>6}: {time.perf_counter() - start:.2f}s")
    # chunksize=1 is dramatically slower โ€” millions of tiny IPC round-trips
Heuristic: chunksize โ‰ˆ len(data) / (workers ร— 4). Big enough to amortize IPC, small enough to keep every worker fed until the end. ProcessPoolExecutor.map takes the same chunksize argument.

Optimization 2 โ€” Don't Return Huge Objects

A result is pickled and piped back too. If each worker returns a giant structure, the parent becomes a serialization bottleneck and a memory hog. Return a reduced value โ€” a count, a sum, a small summary โ€” and do the reduction in the worker.

# โŒ Returns a 50MB transformed list per task โ†’ parent drowns deserializing
def transform_all(rows):
    return [expensive(r) for r in rows]      # huge return, pickled back

# โœ… Reduce inside the worker; return something small
def summarize(rows):
    return sum(expensive(r) for r in rows)   # one float crosses the boundary

Optimization 3 โ€” initializer for Big Read-Only Data

Passing a large read-only object (a model, a lookup table, a config) as a per-task argument re-pickles it on every call. Instead, load it once per process in an initializer and stash it in a module global โ€” the same pattern used for per-process connection pools.

from multiprocessing import Pool

_lookup = None                          # module global, one per worker process

def init_worker(shared_table):
    global _lookup
    _lookup = shared_table              # loaded ONCE when the worker starts

def score(key):
    return _lookup[key] * 2             # read the big table for free, no per-task pickle

if __name__ == "__main__":
    big_table = {i: i ** 2 for i in range(1_000_000)}
    with Pool(initializer=init_worker, initargs=(big_table,)) as pool:
        results = pool.map(score, range(1_000_000), chunksize=10_000)
Under fork (Linux default) the initializer's data is inherited copy-on-write and shipped once. Under spawn the initargs are pickled once per worker at startup โ€” still far cheaper than once per task.

Optimization 4 โ€” Shared Memory for Large Arrays

For big numeric arrays, don't pass copies at all. multiprocessing.shared_memory.SharedMemory exposes one block of RAM to every process; back a NumPy array with it and workers read/write the same bytes โ€” zero pickling of the payload.

import numpy as np
from multiprocessing import Pool
from multiprocessing.shared_memory import SharedMemory

SHM_NAME, SHAPE, DTYPE = "arr_shm", (10_000_000,), np.float64

def worker(bounds):
    lo, hi = bounds
    shm = SharedMemory(name=SHM_NAME)                 # attach, no copy
    arr = np.ndarray(SHAPE, dtype=DTYPE, buffer=shm.buf)
    result = float(np.sum(arr[lo:hi] ** 2))           # operate in place
    shm.close()
    return result

if __name__ == "__main__":
    shm = SharedMemory(name=SHM_NAME, create=True, size=int(np.prod(SHAPE)) * 8)
    arr = np.ndarray(SHAPE, dtype=DTYPE, buffer=shm.buf)
    arr[:] = np.arange(SHAPE[0], dtype=DTYPE)         # fill the shared block once
    slices = [(i, i + 2_000_000) for i in range(0, 10_000_000, 2_000_000)]
    try:
        with Pool(processes=5) as pool:
            print(sum(pool.map(worker, slices)))
    finally:
        shm.close()
        shm.unlink()                                  # free the block โ€” always clean up

For a fixed-size list of Python scalars there's also shared_memory.ShareableList:

from multiprocessing.shared_memory import ShareableList

sl = ShareableList([1, 2, 3, "ready", 4.5])   # small, fixed layout, shared across procs
# attach elsewhere by name:
other = ShareableList(name=sl.shm.name)
sl.shm.close(); sl.shm.unlink()
Memory-mapped files (mmap / np.memmap) are the on-disk cousin: map a huge file into each process's address space and let the OS page it in on demand. Same win โ€” no copy per worker โ€” for datasets too big to hold fully in RAM.

Start Methods: fork vs spawn

How a worker is created matters for both cost and correctness.

import multiprocessing as mp

if __name__ == "__main__":            # WITHOUT this, spawn re-imports the module,
    mp.set_start_method("spawn")      # re-runs Pool creation โ†’ infinite process fork bomb
    # ... build your pool here ...
Keep worker imports lean. Under spawn, every heavy top-level import in your module runs again in each worker at startup. Move slow imports into the functions that need them, or into the initializer, and always guard entrypoint code with if __name__ == "__main__":.

Overhead Sources โ†’ Mitigations

Overhead sourceMitigation
Per-task pickle + pipe round-trips (many small items)Raise chunksize to batch items per transfer
Large arguments pickled every callLoad once via initializer + module global
Large return values piped backReduce/summarize inside the worker; return small values
Big numeric arrays copied to every workerSharedMemory + NumPy, or mmap / np.memmap
Slow worker startup under spawnKeep imports lean; reuse one pool for many tasks
More processes than coresSize to os.cpu_count(), not higher
Serial fraction of the job (Amdahl's Law)Parallelize the split/combine too; kill serial locks
Checklist: โ‘  processes = cores โ‘ก batch with chunksize โ‘ข big read-only data via initializer โ‘ฃ return small, reduce in-worker โ‘ค shared memory / mmap for arrays โ‘ฅ guard with if __name__ == "__main__": and keep imports lean. If adding processes doesn't help, you're IPC-bound โ€” shrink what crosses the boundary.