๐Ÿ”ฅ Pattern 10: CPU Parallelism

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.

The Payoff: One Core โ†’ All Cores

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
Key Insight: the swap from sequential list comprehension to 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).

What the Numbers Look Like

Approximate results for the benchmark above on an 8-physical-core machine. Absolute times don't matter; the shape does.

WorkersTime (approx)SpeedupEfficiency
1 (sequential)8.0 s1.0ร—โœ… 100%
24.1 s2.0ร—โœ… ~98%
42.2 s3.6ร—โœ… ~90%
81.3 s6.2ร—โŒ ~77%
16 (hyperthreads)1.2 s6.7ร—โŒ ~42%
Two rules the table shows:
  • Diminishing returns. Each doubling buys less than a doubling. Overhead and the serial fraction eat the margin.
  • Speedup โ‰ค physical cores. Past your real core count, hyperthreads share execution units โ€” CPU-bound work barely benefits. os.cpu_count() counts logical (hyperthreaded) CPUs, so it often overshoots for pure-CPU jobs.

Amdahl's Law: The Serial Fraction Is Your Ceiling

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.

Amdahl's Law:
# 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.

The Overhead That Eats Your Gains

Scenario: you parallelize a function that does a tiny bit of work per call โ€” say squaring a number โ€” across a pool, and it comes out slower than the sequential loop. What happened? You paid full process overhead for near-zero compute.

Every task dispatched to a worker carries two hidden costs:

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.

Three rules to keep overhead from winning:
  • Make tasks coarse. Fewer, bigger tasks amortize dispatch cost.
  • Tune chunksize so each worker gets a batch, not a trickle (Pattern 5).
  • Don't ship big data. Pass file paths, or use shared_memory, instead of pickling gigabytes into every worker.

Granularity: Batch the Tiny Stuff

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
Same answer, minutes vs. seconds. The only variable is task granularity. A single chunksize argument can turn a parallel version that's slower than sequential into one that's several times faster.

When Not to Reach for Processes

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.

C extensions release the GIL. NumPy, pandas, PyTorch, Pillow, scikit-learn, compression and crypto libraries โ€” their inner loops run in C and drop the GIL while doing so. That means plain threads can parallelize them, with no pickling and no process startup โ€” cheaper than processes. Try a ThreadPoolExecutor first for NumPy-heavy work.
Watch for oversubscription: native math libraries (BLAS/LAPACK behind NumPy) often already use every core internally. Launch 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.

Sequential vs. Parallel, Drawn Out

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.

gantt title Sequential (1 core) vs Parallel (4 cores) dateFormat X axisFormat %s section Sequential (1 core) Task 1 :0, 1 Task 2 :1, 2 Task 3 :2, 3 Task 4 :3, 4 section Parallel (4 cores) Overhead (pickle + startup) :crit, 0, 1 Task 1 :done, 1, 2 Task 2 :done, 1, 2 Task 3 :done, 1, 2 Task 4 :done, 1, 2

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.

The Golden Rule: Measure

Real-world: the right worker count and 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.
The one-paragraph recap: processes deliver near-linear CPU speedup because each has its own GIL โ€” the thing threads can't do. But the ceiling is set by Amdahl's serial fraction, the floor is raised by startup + pickling overhead (so make tasks coarse and tune 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.