Python Central HubSign in

Python Programming / Python MultiProcessing

Mini Project (Parallel Number Processing)

Build a small multiprocessing project that parallelizes CPU-heavy work and measures the speedup.

Goal

Create a CPU-bound job and speed it up using a process pool.

Example job:

  • compute something expensive for many items

Step 1: Define an expensive function

Python
def expensive(x: int) -> int:
    # intentionally slow operation
    total = 0
    for i in range(1, 200_000):
        total += (x * i) % 97
    return total

Step 2: Run sequential

Python
import time
from expensive import expensive
 
items = list(range(20))
 
start = time.time()
results = [expensive(x) for x in items]
print("sequential seconds:", round(time.time() - start, 2))
print(results[:5])

Step 3: Run in parallel (Pool)

Python
import time
from multiprocessing import Pool
from expensive import expensive
 
items = list(range(20))
 
if __name__ == "__main__":
    start = time.time()
 
    with Pool() as pool:
        results = pool.map(expensive, items)
 
    print("parallel seconds:", round(time.time() - start, 2))
    print(results[:5])

Deliverable

  • Compare sequential vs parallel time.
  • Try different pool sizes.
  • Explain when multiprocessing helps (CPU-bound) vs not (I/O-bound).

The shape of every parallel batch job

Split the input, hand each piece to a worker, combine what comes back. The interesting decisions are all in the first box:

diagram Diagram mermaid

The correctness check comes first and is exact — however the work is split, the answer must not move:

Python
with mp.Pool(4) as pool:
    parts = pool.map(count_primes, chunks(2, 3_000_000, 4))
assert sum(parts) == 216_816        # holds for every split tried below

Splitting badly is the whole difficulty

Two splits of the same range across four workers, counted rather than timed, so the figures do not depend on the machine:

splitprimes found by each workerspread
four contiguous blocks60,238 / 53,917 / 51,926 / 50,7351.2×
every 4th number (stride 4)1 / 108,532 / 0 / 108,283108,532×

The stride split looks fair and is a catastrophe. With a stride of 4, two workers receive only even numbers, and every even number above 2 is rejected immediately. Those workers finish almost instantly having done nothing, while the other two do the entire job.

Counting the trial divisions actually performed (for n below 60,000, so the numbers stay readable) shows the same thing as a cost rather than a count:

splitdivisions per workerspread
contiguous106,939 / 164,962 / 200,344 / 226,6982.1×
stride 415,000 / 333,980 / 14,999 / 334,94922×

Note that even the "good" split is not level: the last block does 2.1× the work of the first, because testing a larger n requires trying divisors up to n\sqrt{n}. Equal width is not equal work whenever the cost per item depends on the item.

See it move

Switch between the splits and watch how the work piles up. The bar that matters is the tallest one.

sketch Equal width is not equal work p5.js
Contiguous blocks give a mild imbalance because larger numbers cost more to test. A stride of 4 gives two workers only even numbers, so they do almost nothing.

Let the pool balance it for you

Rather than hand-designing a fair split, cut the work into more pieces than workers and let Pool hand out the next piece whenever a worker goes idle:

Python
import multiprocessing as mp
 
if __name__ == "__main__":
    pieces = chunks(2, 3_000_000, 64)        # 64 pieces, 4 workers
    with mp.Pool(4) as pool:
        parts = pool.map(count_primes, pieces)
    print(sum(parts))                         # 216816

A worker that draws a cheap piece simply comes back for another. The finer the pieces, the better the balance — until the per-task overhead takes over. Measured on the smaller range, 64 pieces beat 4, and 2,000 pieces were slower than 4, because each task carries pickling and dispatch cost of its own.

The deliverable

A complete, correct version to compare yours against:

Python
import multiprocessing as mp
import math, time
 
def is_prime(n):
    if n < 2: return False
    if n % 2 == 0: return n == 2
    for i in range(3, math.isqrt(n) + 1, 2):
        if n % i == 0: return False
    return True
 
def count_primes(rng):
    lo, hi = rng
    return sum(1 for n in range(lo, hi) if is_prime(n))
 
def chunks(lo, hi, k):
    step = (hi - lo) // k
    return [(lo + i * step, lo + (i + 1) * step if i < k - 1 else hi)
            for i in range(k)]
 
if __name__ == "__main__":                     # required: spawn re-imports this file
    LO, HI = 2, 3_000_000
    t = time.perf_counter()
    with mp.Pool(4) as pool:
        parts = pool.map(count_primes, chunks(LO, HI, 64))
    print(f"{sum(parts):,} primes in {time.perf_counter() - t:.2f}s")
    assert sum(parts) == 216_816               # the answer must not depend on the split

Check yourself

quizCheck yourself
  1. Splitting 2..3,000,000 across four workers by stride of 4 gives prime counts of 1, 108532, 0 and 108283. Why?

    Show answer

    B — two workers receive only even numbers, which are rejected immediately, so they do almost no work — With stride 4, workers take n where n % 4 is 0 or 2 — all even. Every even number above 2 fails the first test, so those two workers idle while the other two do the whole job.

  2. Even the contiguous split does 2.1x more divisions in the last block than the first. What causes that?

    Show answer

    B — testing a larger n needs trial divisors up to its square root, so cost grows with n — The last block actually holds the FEWEST primes (50,735 against 60,238). It is slower because each candidate is larger and requires more divisors to be tried. Equal width is not equal work.

  3. For the smaller range where the sequential run took 0.42 s, every Pool configuration was slower. Why?

    Show answer

    B — pool startup is roughly 0.5-1 s, which alone exceeds the whole sequential run — Creating worker processes and pickling arguments costs about a second here. Work measured in fractions of a second cannot repay that, so parallelism is a net loss below some threshold.

  4. Why does cutting the range into 64 pieces for 4 workers usually beat cutting it into exactly 4?

    Show answer

    B — a worker that finishes a cheap piece immediately takes another, so idle time is absorbed — More pieces than workers lets Pool balance dynamically. The limit is per-task overhead: 2,000 pieces measured slower than 4, because dispatch and pickling began to dominate.

🧪 Try It Yourself

Exercise 1 – Start a Process

exerciseYour turn

Tab indents. Press Escape, then Tab, to leave the editor. Control plus Enter runs the code; Control plus Shift plus Enter submits it.

Output
Press Run to see what your code prints.

Exercise 2 – Process Pool map()

exerciseYour turn

Tab indents. Press Escape, then Tab, to leave the editor. Control plus Enter runs the code; Control plus Shift plus Enter submits it.

Output
Press Run to see what your code prints.

Exercise 3 – Multiprocessing Queue

exerciseYour turn

Tab indents. Press Escape, then Tab, to leave the editor. Control plus Enter runs the code; Control plus Shift plus Enter submits it.

Output
Press Run to see what your code prints.

Was this page helpful?

Let us know how we did