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.
# โ 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
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)
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)
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.
# โ 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))
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
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.
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
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)
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.
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()
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.
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 ...
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 source | Mitigation |
|---|---|
| Per-task pickle + pipe round-trips (many small items) | Raise chunksize to batch items per transfer |
| Large arguments pickled every call | Load once via initializer + module global |
| Large return values piped back | Reduce/summarize inside the worker; return small values |
| Big numeric arrays copied to every worker | SharedMemory + NumPy, or mmap / np.memmap |
| Slow worker startup under spawn | Keep imports lean; reuse one pool for many tasks |
| More processes than cores | Size to os.cpu_count(), not higher |
| Serial fraction of the job (Amdahl's Law) | Parallelize the split/combine too; kill serial locks |
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.