The whole reason the library exists: take a CPU-bound job that pins one core and spread it across all of them for a near-linear speedup. Then meet the three hard limits โ Amdahl, overhead, and the GIL-releasing libraries that make processes unnecessary.
A pure-Python CPU loop runs on exactly one core because the GIL
lets only one thread execute bytecode at a time. Threads give you essentially zero
speedup on this kind of work โ the GIL serializes them. Processes each carry their own GIL, so
N of them genuinely run on N cores at once. That's the payoff, and it's
dramatic.
Here is a self-contained benchmark: count primes across a range, sequentially and then in parallel.
import os
import time
from concurrent.futures import ProcessPoolExecutor
def count_primes(limit):
# pure-Python CPU work: the GIL is held the whole time
count = 0
for n in range(2, limit):
is_prime = True
for d in range(2, int(n ** 0.5) + 1):
if n % d == 0:
is_prime = False
break
if is_prime:
count += 1
return count
# split one big job into coarse chunks โ each is real work
RANGES = [200_000, 200_000, 200_000, 200_000, 200_000, 200_000, 200_000, 200_000]
if __name__ == "__main__":
start = time.perf_counter()
seq = [count_primes(r) for r in RANGES] # sequential baseline
print(f"sequential: {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
with ProcessPoolExecutor(max_workers=os.cpu_count()) as pool:
par = list(pool.map(count_primes, RANGES)) # spread across cores
print(f"parallel: {time.perf_counter() - start:.2f}s")
assert seq == par # same answers, less time
pool.map is
trivial, but the effect is qualitative โ you go from using 1/8 of your machine to using all of it.
Reach for threads on this exact code and you'd measure ~no improvement, because the GIL was never the
thing you could parallelize away (threading guides).
Approximate results for the benchmark above on an 8-physical-core machine. Absolute times don't matter; the shape does.
| Workers | Time (approx) | Speedup | Efficiency |
|---|---|---|---|
| 1 (sequential) | 8.0 s | 1.0ร | โ 100% |
| 2 | 4.1 s | 2.0ร | โ ~98% |
| 4 | 2.2 s | 3.6ร | โ ~90% |
| 8 | 1.3 s | 6.2ร | โ ~77% |
| 16 (hyperthreads) | 1.2 s | 6.7ร | โ ~42% |
os.cpu_count() counts logical
(hyperthreaded) CPUs, so it often overshoots for pure-CPU jobs.
No program is 100% parallelizable. Reading input, splitting work, collecting results, writing output โ
some fraction s is inherently serial, and that fraction caps your total
speedup no matter how many cores you throw at it.
# s = serial fraction (0..1)
# N = number of workers
# speedup = 1 / (s + (1 - s) / N)
# As N -> infinity, the (1-s)/N term vanishes:
# max_speedup = 1 / s
# If 10% of the work is serial (s = 0.10):
# N = 4 -> 1 / (0.10 + 0.90/4) = 3.08x
# N = 8 -> 1 / (0.10 + 0.90/8) = 4.71x
# N = 32 -> 1 / (0.10 + 0.90/32) = 8.42x
# N = inf -> 1 / 0.10 = 10x <- hard ceiling, forever
A merely 10%-serial workload can never exceed 10ร โ buying 64 cores to chase it is money lit on fire. The lever that actually moves the ceiling is shrinking the serial fraction (parallelize the setup/reduce steps too), not adding cores.
Every task dispatched to a worker carries two hidden costs:
spawn).pickled, copied across an OS boundary, and rebuilt on the other side
(Pattern 8).Processes only win when each task's CPU work vastly outweighs the cost of shipping its data. Squaring a million integers one-per-task loses badly: the pickling dwarfs the multiply.
chunksize so each worker gets a batch, not a trickle
(Pattern 5).shared_memory, instead of pickling
gigabytes into every worker.
The fix for "too many tiny tasks" is to make each dispatch do real work โ either by batching items
yourself, or by letting chunksize group them. Same computation, wildly different runtime.
import time
from concurrent.futures import ProcessPoolExecutor
def is_prime(n):
if n < 2:
return False
for d in range(2, int(n ** 0.5) + 1):
if n % d == 0:
return False
return True
def count_primes_in_batch(batch):
# one task now does REAL work: a whole batch, not a single number
return sum(1 for n in batch if is_prime(n))
if __name__ == "__main__":
numbers = range(2, 1_000_000)
# โ SLOW: one tiny task per number -> overhead dominates.
# (chunksize=1 default means a pickle round-trip per integer)
with ProcessPoolExecutor() as pool:
start = time.perf_counter()
slow = sum(pool.map(is_prime, numbers)) # crawls
print(f"one-item tasks: {time.perf_counter() - start:.2f}s")
# โ
FAST (option A): let chunksize batch the dispatch for you.
with ProcessPoolExecutor() as pool:
start = time.perf_counter()
fast = sum(pool.map(is_prime, numbers, chunksize=10_000))
print(f"chunksize=10k: {time.perf_counter() - start:.2f}s")
# โ
FAST (option B): hand-batch so each task is coarse-grained.
batches = [range(i, min(i + 10_000, 1_000_000)) for i in range(2, 1_000_000, 10_000)]
with ProcessPoolExecutor() as pool:
start = time.perf_counter()
fast2 = sum(pool.map(count_primes_in_batch, batches))
print(f"hand-batched: {time.perf_counter() - start:.2f}s")
assert slow == fast == fast2
chunksize argument can turn a parallel version that's slower than sequential into
one that's several times faster.
The reflex "CPU-bound โ processes" has a big exception: it assumes your hot loop is pure Python. If the heavy lifting is already inside a C extension, that changes the math.
ThreadPoolExecutor first for NumPy-heavy work.
N worker processes that each spin up
M BLAS threads and you get N ร M threads fighting over the same cores โ
cache thrash and a slowdown. Pin it with OMP_NUM_THREADS=1 (or
threadpoolctl) in the workers so processes and BLAS don't both try to parallelize.
For heavy numeric pipelines, purpose-built tools like joblib and Dask handle the worker/backend/memory decisions for you โ see the ecosystem tour in Common Libraries, and the full decision framework in Processes vs Alternatives.
Four equal CPU tasks. Sequentially they queue on one core; in parallel they run at once โ minus a thin sliver of dispatch/pickle overhead that never fully disappears.
Sequential finishes at t=4; parallel finishes at ~t=2 โ the compute collapses to a single task's duration, but the overhead sliver at the front is the tax you always pay. When tasks are large, that sliver is invisible; when tasks are tiny, the sliver is the runtime.
chunksize are empirical,
not derivable from theory. Speedup depends on your task granularity, data size, cache behavior, the
serial fraction, and whether your libraries already use the GIL/BLAS. Always time the sequential
baseline first with time.perf_counter(), then time each parallel configuration and compare.
max_workers=os.cpu_count() is a reasonable starting point โ but it counts
hyperthreads, so for pure-CPU jobs try physical-core count too. Profile before and after; a
"parallel" version that's slower is a common and quiet failure.
chunksize), the speedup caps at your physical core count,
and if your compute already lives in C extensions that release the GIL, cheaper
threads may beat processes entirely. Decide by measuring, not by reflex.