Parallelism — multiprocessing, joblib, and Dask
Pick a parallel primitive by workload shape, and understand pickling, IPC, and shared-memory costs.
Why this matters
Once a single core isn't enough, you need real parallelism. CPU-bound work scales with processes (or free-threaded Python); embarrassingly parallel data work with Dask / Ray; distributed work with Spark / Ray cluster. Knowing which to reach for prevents over-engineering (don't deploy Spark for 4 GB) and under-engineering (don't try to fit 200 GB on one machine).
Learning objectives
- Use
multiprocessingandProcessPoolExecutorcleanly. - Use shared memory and
mp.Managercorrectly. - Use Dask for out-of-core / multi-machine arrays and dataframes.
- Use Ray for distributed actors and parallel tasks.
- Know when MPI / Spark / Slurm is right.
1. The decision rule (revisit)
| Data size + work | Tool |
|---|---|
| Fits in RAM; CPU-bound | multiprocessing / ProcessPoolExecutor / free-threaded 3.13t |
| Out of RAM, single machine | Dask / Polars streaming / DuckDB |
| Multi-machine, dataframe-shaped | Spark, Dask distributed, Ray Data |
| Stateful actor / RL training | Ray Core |
| Tightly-coupled HPC | MPI (mpi4py) + Slurm |
Most "I need parallelism" problems stop at ProcessPoolExecutor or Dask. Reach further only when you've measured.
2. multiprocessing recap
(See Lesson 4.3.) Quick reference:
from concurrent.futures import ProcessPoolExecutor
def heavy(x): ...
if __name__ == "__main__": # required on Windows / macOS
with ProcessPoolExecutor(max_workers=8) as ex:
results = list(ex.map(heavy, items, chunksize=100))- Each worker has its own memory; no shared state by default.
- Arguments + return values are pickled between processes.
chunksizematters — too small = pickling dominates.
Shared memory
For huge read-only data (e.g., a NumPy array workers all read):
from multiprocessing import shared_memory
import numpy as np
a = np.ones(10_000_000, dtype=np.float32)
shm = shared_memory.SharedMemory(create=True, size=a.nbytes)
shared_a = np.ndarray(a.shape, dtype=a.dtype, buffer=shm.buf)
shared_a[:] = a
# Worker process:
existing = shared_memory.SharedMemory(name=shm.name)
view = np.ndarray(a.shape, dtype=a.dtype, buffer=existing.buf)
# read freely; do NOT mutate without coordinationAvoids re-pickling GB of data per task. Available since 3.8.
mp.Manager for shared mutable objects
from multiprocessing import Manager
with Manager() as m:
counter = m.Value("i", 0)
d = m.dict()
lock = m.Lock()
# pass `counter`, `d`, `lock` to workersManager proxies are convenient but slow (every access is an RPC). Use only for low-throughput sharing.
3. mpire — multiprocessing without footguns
uv add mpirefrom mpire import WorkerPool
with WorkerPool(n_jobs=8) as pool:
results = pool.map(heavy, items, progress_bar=True)Wraps multiprocessing with:
- Built-in progress bars.
- Better error reporting.
- Shared objects.
imap_unorderedfor streaming.
Drop-in for many tasks where the stdlib is fiddly.
4. Dask — pandas/NumPy at scale
uv add "dask[complete]"Dask is two things:
- Bigger-than-RAM versions of NumPy / pandas APIs.
- A task scheduler that runs anywhere from one machine to thousands.
Dask DataFrame (pandas-like)
import dask.dataframe as dd
df = dd.read_parquet("s3://bucket/data/*.parquet")
result = (df[df.amount > 100]
.groupby("region")["amount"].sum()
.compute()) # actually runsAPI mirrors pandas. Operations are lazy until .compute().
Dask Array (NumPy-like)
import dask.array as da
a = da.from_array(huge_numpy_array, chunks=(1000, 1000))
result = (a.mean(axis=0) + a.std(axis=0)).compute()Computes in chunks; never materialises the whole array.
Dask Delayed (general)
from dask import delayed, compute
@delayed
def step(x): return slow(x)
tasks = [step(x) for x in inputs]
results = compute(*tasks) # parallel executionWraps any function call as a node in a task graph.
Cluster mode
from dask.distributed import Client
client = Client(n_workers=4, threads_per_worker=2)
# or attach to a remote clusterDashboard at http://localhost:8787 shows live tasks, memory, network — incredibly useful.
When use Dask
- Single-machine datasets bigger than RAM.
- Multi-machine clusters managed yourself or via dask-kubernetes / dask-gateway.
- Familiar pandas/NumPy API.
Polars / DuckDB now handle a lot of single-machine "out of core" work more efficiently. Dask wins when you need pandas semantics or genuine multi-machine compute.
5. Ray — distributed tasks + actors
uv add "ray[default]"import ray
ray.init() # single node; or address="auto" for cluster
@ray.remote
def heavy(x):
return slow(x)
futures = [heavy.remote(x) for x in items]
results = ray.get(futures)Resources
@ray.remote(num_cpus=2, num_gpus=0.5)
def train_chunk(data): ...Schedule by resource — Ray's selling point for ML workloads.
Actors — stateful workers
@ray.remote
class Counter:
def __init__(self): self.n = 0
def inc(self): self.n += 1; return self.n
c = Counter.remote()
ray.get(c.inc.remote()) # 1
ray.get(c.inc.remote()) # 2Actors keep state across calls. Perfect for caches, parameter servers, RL environments.
Ray Data
A modern alternative to Spark for data preprocessing in ML pipelines:
import ray.data as rd
ds = rd.read_parquet("s3://bucket/*.parquet").map_batches(preprocess).write_parquet("out/")Streaming, lazy, scales to clusters. Used heavily by OpenAI / Anthropic for data prep.
When use Ray
- ML training / inference at scale.
- Stateful distributed workloads (RL, parameter servers, agent simulations).
- Need to scale up and down on demand.
6. Apache Spark via PySpark
Spark dominates enterprise big-data jobs. Use when:
- Working with petabytes.
- Existing Spark cluster at your company.
- SQL or DataFrame transformations on huge data.
from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder.appName("etl").getOrCreate()
df = spark.read.parquet("s3://bucket/data/")
result = (df.filter(df.amount > 100)
.groupBy("region").agg(F.sum("amount")))
result.write.parquet("out/")Spark is heavy; for under ~100 GB single-machine work, Polars / DuckDB / Dask usually win.
7. MPI for HPC
uv add mpi4pyfrom mpi4py import MPI
comm = MPI.COMM_WORLD
rank = comm.Get_rank()
size = comm.Get_size()
if rank == 0:
data = compute()
comm.send(data, dest=1, tag=42)
elif rank == 1:
data = comm.recv(source=0, tag=42)Run with:
mpiexec -n 4 python script.pyFor tightly-coupled HPC (climate sims, molecular dynamics). Rare in mainstream data / AI.
8. Patterns
Map-reduce on a pool
with ProcessPoolExecutor() as ex:
counts = list(ex.map(count_in_chunk, file_chunks))
total = sum(counts)Streaming + bounded buffer
from concurrent.futures import ProcessPoolExecutor
import asyncio
async def main():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as ex:
async for chunk in stream_chunks():
result = await loop.run_in_executor(ex, heavy, chunk)
yield resultGPU/accelerator parallelism
For neural-net training, use PyTorch DDP / FSDP / DeepSpeed / JAX pmap — these are the right abstractions, not Dask/Ray for the GPU layer.
9. Cost / complexity trade-offs
| multiprocessing | Dask | Ray | Spark | |
|---|---|---|---|---|
| Setup | trivial | easy | easy | medium |
| Cluster | none / hand-rolled | dask-kubernetes etc. | autoscaling on K8s, AWS | YARN, K8s |
| API | low-level | pandas / NumPy / delayed | tasks + actors | DataFrame / SQL |
| Dashboard | none | superb | superb | basic |
| Right for | one-machine CPU | data + multi-machine, pandas | ML + actors | huge SQL |
Pick the smallest tool that fits.
10. Worked example: parallel feature engineering
A 30 GB CSV needs per-row transforms then aggregation.
Option A: chunked single-machine
import pandas as pd
def process_chunk(chunk):
chunk["x2"] = chunk["x"] ** 2
return chunk.groupby("region")["x2"].sum()
partials = []
for chunk in pd.read_csv("big.csv", chunksize=200_000):
partials.append(process_chunk(chunk))
result = pd.concat(partials).groupby(level=0).sum()Works. Single core. Slow.
Option B: Polars streaming
import polars as pl
(pl.scan_csv("big.csv")
.with_columns((pl.col("x") ** 2).alias("x2"))
.group_by("region").agg(pl.col("x2").sum())
.collect(streaming=True))Faster, multicore, less code. Often the right tool in 2026.
Option C: Dask
import dask.dataframe as dd
df = dd.read_csv("big.csv", blocksize="64MB")
df["x2"] = df["x"] ** 2
df.groupby("region")["x2"].sum().compute()Scales beyond one machine if needed.
Option D: Ray Data
import ray.data as rd
def transform(batch):
batch["x2"] = batch["x"] ** 2
return batch
ds = rd.read_csv("big.csv").map_batches(transform)
ds.groupby("region").sum("x2").show()For ML pipelines, Ray Data integrates with PyTorch / TF datasets.
Hands-on lab (2 hours)
- Write a CPU-bound function; parallelise with
ProcessPoolExecutor; measure speedup vs cores. - Use
shared_memoryto share a 100 MB NumPy array across 4 workers (read-only). - Install Dask; convert a chunked pandas loop to
dd.read_csv(...).compute(). - Open the Dask dashboard while a job runs; observe task graph.
- Install Ray; convert one parallel function to
@ray.remote. - Build a Ray actor for a stateful counter; verify state persists across calls.
- Bonus: spin up a local Dask Distributed cluster (
Client(n_workers=4)); compare to the default scheduler.
Common pitfalls
multiprocessingwithout the__main__guard → recursive spawn on Windows.- Pickling lambdas / closures.
- Re-pickling huge arrays per task; switch to shared memory or Dask.
- Using Spark for 10 GB of data — overhead dominates.
- Forgetting to
ray.init()(orClient()). mp.Manager.dictin the hot path — slow proxy calls.- Reaching for Dask/Ray when Polars / DuckDB on one machine would do.
Self-check
- When use Dask vs Ray?
- Why does
shared_memoryhelp with multiprocessing? - What's a Ray actor?
- When is Spark the right tool?
- Why not Spark for 5 GB of data?
References
- Python
multiprocessingdocs. - Dask docs: https://docs.dask.org/.
- Ray docs: https://docs.ray.io/.
- Polars streaming: https://docs.pola.rs/user-guide/concepts/streaming/.
- Scaling Python with Dask, Holden Karau.
- "Bigger Data, Faster" — Wes McKinney blog.
Sign in to save your progress and earn badges.