When parallel Python is slow even though the GIL is not (only) the problem: too many threads for the cores, native thread pools nested inside process pools, locks and the GIL being fought over, memory bandwidth, and containers that lie about CPU count. Start from the symptom table, then use the section it points to.
Contents
- Symptom -> cause -> fix
- Counting usable cores (containers, affinity, cgroups)
- Native thread pools and their knobs
- Oversubscription arithmetic
- Capping threads correctly
- GIL contention and the convoy effect
- Lock contention in your code
- Memory-bandwidth-bound work
- Serial bottlenecks around a parallel stage
- Diagnostic command cheat sheet
1. Symptom -> cause -> fix
| Symptom | Likely cause | Check | Fix |
|---|---|---|---|
| More workers = slower; load average ≫ cores | oversubscription (workers × native threads) | ps -o nlwp PID per worker; perf stat involuntary context switches | one level of parallelism; OMP_NUM_THREADS=1 per worker |
| Process pool at 100% CPU everywhere, throughput worse than 4 workers | BLAS/torch threads in each worker | threadpoolctl.threadpool_info() inside a worker | threadpool_limits(1) / env vars before import |
Pool sized by os.cpu_count() is slow in Docker/Kubernetes | CPU quota smaller than host core count | /sys/fs/cgroup/cpu.max | size from the quota |
| Threads: one core 100%, others idle | GIL-held Python or C | gil_probe.py, py-spy --gil | processes / native / free-threaded |
| Threads: all cores busy but no speedup | spinning, lock contention, false sharing, bandwidth | perf stat IPC, py-spy --native | shard state, fewer threads |
| Async server p99 explodes when a background CPU thread runs | GIL convoy: I/O thread waits to re-acquire | py-spy --native --idle shows take_gil | move CPU to a process |
sys time high, many context switches | thread churn, lock thrash, tiny tasks | pidstat -w, /usr/bin/time -v | batch tasks, fewer threads |
| Speedup flat at ~2-4x with processes too | memory bandwidth or serial parent stage | perf stat -e cache-misses, profile the parent | float32, fuse passes, parallelise the parent's work |
| Free-threaded build: threads still serial | GIL re-enabled by an extension, or contention on one shared object | sys._is_gil_enabled(), per-object hot spots | upgrade/replace extension; per-thread accumulators |
| tokenizers warning about fork, then slow | rayon disabled itself after fork | log message | TOKENIZERS_PARALLELISM=false + processes, or one process using rayon |
2. Counting usable cores (containers, affinity, cgroups)
os.cpu_count() returns the machine's logical CPUs, not what this process may use.
import os
def usable_cpus() -> float:
"""Best estimate of CPUs this process can use: affinity, then cgroup v2 quota."""
try:
n = len(os.sched_getaffinity(0)) # Linux: respects taskset / cpusets
except AttributeError:
n = os.cpu_count() or 1
try:
quota, period = open("/sys/fs/cgroup/cpu.max").read().split()
if quota != "max":
n = min(n, int(quota) / int(period)) # e.g. "200000 100000" = 2 CPUs
except (OSError, ValueError):
pass
return n
print(os.cpu_count(), usable_cpus())| API | Respects affinity | Respects cgroup quota |
|---|---|---|
os.cpu_count() | no | no |
len(os.sched_getaffinity(0)) | yes (Linux) | no |
os.process_cpu_count() (3.13+) | yes | no |
PYTHON_CPU_COUNT=N / -X cpu_count=N (3.13+) | overrides both functions above | n/a |
reading cpu.max (cgroup v2) or cpu.cfs_quota_us (v1) | n/a | yes |
In 3.13+ ProcessPoolExecutor and ThreadPoolExecutor default sizes use os.process_cpu_count(); in
containers set PYTHON_CPU_COUNT to the quota so defaults (and libraries that call os.cpu_count()) see it.
Hyper-threads count as CPUs: CPU-bound numeric code often peaks at the physical core count.
3. Native thread pools and their knobs
Set environment variables before the library is imported (they are read at load time); later changes need the runtime APIs.
| Library | Env var(s) | Runtime API |
|---|---|---|
| OpenMP-based (numpy with OpenBLAS-OpenMP, scikit-learn, numba's omp layer, many C extensions) | OMP_NUM_THREADS | threadpoolctl.threadpool_limits(n) |
| OpenBLAS | OPENBLAS_NUM_THREADS | threadpoolctl |
| Intel MKL | MKL_NUM_THREADS | threadpoolctl, mkl.set_num_threads |
| Apple Accelerate | VECLIB_MAXIMUM_THREADS | - |
| numexpr (pandas eval) | NUMEXPR_NUM_THREADS, NUMEXPR_MAX_THREADS | numexpr.set_num_threads |
| PyTorch intra-op | OMP_NUM_THREADS / MKL_NUM_THREADS at start | torch.set_num_threads(n) |
| PyTorch inter-op | - | torch.set_num_interop_threads(n) (once, before parallel work) |
| onnxruntime | - | SessionOptions.intra_op_num_threads, inter_op_num_threads |
| HF tokenizers (rayon) | TOKENIZERS_PARALLELISM, RAYON_RS_NUM_CPUS | - |
| polars | POLARS_MAX_THREADS | pl.thread_pool_size() (read-only) |
| numba | NUMBA_NUM_THREADS | numba.set_num_threads(n) |
| TensorFlow | - | tf.config.threading.set_intra_op_parallelism_threads |
Inspect what a process actually loaded:
from threadpoolctl import threadpool_info, threadpool_limits
import numpy as np
for pool in threadpool_info():
print(pool["internal_api"], pool["num_threads"], pool.get("filepath", "")[-40:])
with threadpool_limits(limits=1): # temporarily single-threaded BLAS/OpenMP
np.linalg.svd(np.random.rand(500, 500))4. Oversubscription arithmetic
runnable threads ≈ processes × (CPU-busy Python threads + native threads per process)| Setup on 16 cores | Runnable | Result |
|---|---|---|
| 16 workers × BLAS default 16 threads | 256 | thrashing: slower than 1 worker |
16 workers × OMP_NUM_THREADS=1 | 16 | good for many small matrices |
| 1 process × BLAS 16 threads | 16 | good for a few large matrices |
| 4 workers × 4 BLAS threads | 16 | good middle ground for medium matrices |
| uvicorn 4 workers × process pool 8 | 32 CPU-bound | oversubscribed: pool 4 or workers 2 |
| DataLoader 8 workers × torch 16 intra-op threads | 128 | set torch.set_num_threads(1) in worker_init_fn |
The fix is never "more workers": pick where the parallelism lives and give that level all the cores.
5. Capping threads correctly
Process pools: cap in the initializer (spawn/forkserver workers re-import modules, so the env must be set
before the first import numpy in the worker) or in the parent's environment before creating the pool.
import os
from concurrent.futures import ProcessPoolExecutor
THREAD_VARS = ("OMP_NUM_THREADS", "MKL_NUM_THREADS", "OPENBLAS_NUM_THREADS", "NUMEXPR_NUM_THREADS",
"VECLIB_MAXIMUM_THREADS")
def init_worker(threads_per_worker: int) -> None:
for var in THREAD_VARS:
os.environ[var] = str(threads_per_worker)
os.environ["TOKENIZERS_PARALLELISM"] = "false"
try:
import torch
torch.set_num_threads(threads_per_worker)
except ImportError:
pass
def work(seed: int) -> int:
return sum(i * seed for i in range(10_000))
if __name__ == "__main__":
for var in THREAD_VARS: # children inherit the parent's environment
os.environ.setdefault(var, "1")
with ProcessPoolExecutor(max_workers=4, initializer=init_worker, initargs=(1,)) as ex:
print(sum(ex.map(work, range(8))))Caveat: if numpy was already imported in the parent and the start method is fork, children inherit the
already-initialised BLAS pool; env vars set in the initializer then come too late. Use threadpoolctl at runtime
or a spawn/forkserver context (forkserver is the 3.14 default on Linux). The
pool designer skill lints for this (MP021).
6. GIL contention and the convoy effect
Evidence: py-spy with --native --idle shows stacks ending in take_gil, PyEval_RestoreThread or
PyCOND_TIMEDWAIT under Python frames; profile_classify.py reports a gil-wait share and the verdict
GIL-CONTENDED. Each waiting thread was ready to run but did not hold the GIL.
py-spy record --native --idle --threads --format raw -o stacks.txt --pid 4242 --duration 20
python scripts/profile_classify.py stacks.txtFixes, in order:
- Move CPU-bound work out of the process (process pool, job queue). I/O threads then get the GIL immediately.
- Make CPU work release the GIL (numpy, numba
nogil=True, Cythonnogil, PyO3allow_threads/detach). - Reduce the number of CPU-bound threads to one.
- Stop-gap:
sys.setswitchinterval(0.001)to shorten the waits (costs some throughput). - Test the free-threaded build if all dependencies support it.
7. Lock contention in your code
A threading.Lock around a hot section serialises threads exactly like the GIL, on any build.
| Pattern | Fix |
|---|---|
| one global lock around a cache used on every request | functools.lru_cache for pure functions, or shard: locks[hash(key) % 64] |
lock held during I/O (with lock: client.post(...)) | do I/O outside the lock; lock only the shared update |
| shared counter / metrics dict updated per item | per-thread counters merged at the end, or a queue.Queue to one aggregator |
| logging handler lock with very chatty DEBUG logs | QueueHandler + QueueListener; lower the level in hot loops |
one queue.Queue feeding many fast consumers with tiny items | batch items per put |
| SQLite single writer | one writer thread, batch inserts in transactions |
Measure lock wait: py-spy --idle shows acquire frames; a quick in-code check is to time lock.acquire().
8. Memory-bandwidth-bound work
Big numpy/torch element-wise passes (normalising, adding, casting) read and write memory faster than cores can compute; adding cores stops helping once the memory bus is saturated (often at 2-6 cores per socket).
Signs: speedup flattens early for threads and processes; perf stat shows low instructions per cycle and
many cache misses; arrays far larger than the L3 cache.
Fixes: use float32 (or float16/int8 where acceptable) to halve traffic; fuse passes (one expression with
numexpr, or numba, instead of five temporaries); use in-place operations (np.divide(x, n, out=x));
process in cache-sized chunks; avoid .copy() and Python ↔ numpy conversions; on NUMA machines pin processes
to sockets.
9. Serial bottlenecks around a parallel stage
Amdahl's law: if 20% of the run is serial, the maximum speedup is 5x however many workers you add. Usual
serial parts in AI pipelines: reading one big file in the parent, pickling arguments in the parent, a single
writer to the vector DB, a json.dumps of the whole result, progress bars updated per item.
def amdahl(parallel_fraction: float, workers: int) -> float:
return 1 / ((1 - parallel_fraction) + parallel_fraction / workers)
for p in (0.5, 0.8, 0.95):
print(p, [round(amdahl(p, n), 1) for n in (2, 4, 8, 16)])Profile the parent process during a parallel run (py-spy record --pid PARENT); move reading/parsing into
workers (send paths), write from workers (or batch writes), and update progress per chunk.
10. Diagnostic command cheat sheet
nproc; cat /sys/fs/cgroup/cpu.max # cores visible vs quota
ps -o pid,nlwp,pcpu,rss,cmd -p $(pgrep -d, -f ingest) # threads per process (nlwp)
top -H -p PID # per-thread CPU
pidstat -t -p PID 1 # per-thread CPU over time
pidstat -w -p PID 1 # voluntary / involuntary context switches
perf stat -e task-clock,context-switches,cpu-migrations,cache-misses,instructions,cycles -p PID -- sleep 10
py-spy dump --pid PID --native # where every thread is right now
py-spy record --native --idle --threads --format raw -o stacks.txt --pid PID --duration 20
python scripts/profile_classify.py stacks.txt
python scripts/gil_probe.py --target mymod:hot_fn --workers 8 --repeat 5python-async-deadlock-debugger
Diagnose and fix hangs, deadlocks, livelocks and starvation in asyncio, threaded and multiprocessing Python: classify CPU spin vs blocked IO vs deadlock vs lost wakeup vs event loop blocked by sync code, collect evidence with faulthandler, py-spy dump, gdb py-bt, asyncio.all_tasks, python -m asyncio ps/pstree, debug mode and sys._current_frames, then fix lock-order cycles, non-reentrant asyncio.Lock re-acquisition, run_coroutine_threadsafe().result() on the loop thread, Queue.join without task_done, Condition lost wakeups, semaphore leaks, futures nobody resolves, nested event loops, thread pool exhaustion, multiprocessing Queue/fork deadlocks and shutdown hangs. Ships a static hang scanner and a watchdog that dumps every thread and asyncio task.
python-asyncio-pattern-advisor
Recommend, implement and review Python asyncio patterns: async vs threads vs processes (IO vs CPU-bound), coroutines vs tasks vs futures, TaskGroup, gather and return_exceptions, wait and as_completed, Semaphore and Queue worker pools with backpressure, asyncio.timeout vs wait_for, cancellation, shield and cleanup, token-bucket RPM/TPM rate limits for LLM APIs, retries with backoff and jitter, async generators and SSE token streaming, to_thread/run_in_executor, background task references, eager tasks, asyncio.run/Runner, uvloop, Jupyter, FastAPI async vs def routes, httpx.AsyncClient lifespan, async DB/vector-store clients, debug mode and pytest-asyncio.
python-beginner-concept-tutor
Teach Python concepts to beginners heading into AI engineering (data scientists, analysts, students, career switchers): plain-language definitions, analogies, minimal examples, line-by-line walkthroughs, common mistakes, check-your-understanding questions and mini exercises; Socratic hints before solutions; explaining tracebacks; mutable vs immutable, references and aliasing, is vs ==, integer division, floating point, slicing and off-by-one, indentation errors; a staged learning path from variables to calling an LLM API, embeddings and a tiny RAG. Ships a script that lists the concepts a snippet uses in learning order and traces execution step by step like Python Tutor.
python-comprehension-refactorer
Refactor Python loops into list, dict and set comprehensions and generator expressions, and unroll comprehensions that are too clever: filtering, mapping, nested-loop flattening, conditional expressions, walrus in comprehensions, dict inversion, grouping with defaultdict/Counter, lazy generators for sum/any/all/max, streaming large files, itertools (chain, islice, groupby, batched, pairwise, accumulate, product), scoping rules, side-effect anti-patterns and performance (when map/filter or numpy/pandas vectorization win). AI-engineering focus: building prompt lists, batching texts for embeddings, filtering retrieved chunks by score, id->embedding dicts, flattening tool-call results, lazy dataset streaming and token counting.
python-context-manager-writer
Write correct Python context managers and find resource-handling bugs — __enter__/__exit__ semantics and exception suppression, contextlib (contextmanager, asynccontextmanager, ExitStack, AsyncExitStack, closing, aclosing, suppress, redirect_stdout, nullcontext, chdir), async with, re-entrancy, leaked files/connections/clients/pools/locks, and AI-engineering recipes: torch.no_grad/inference_mode/autocast, eval()/train() switches, GPU cache cleanup, token and latency meters, tracing spans, temp env vars, rate-limit semaphores, httpx/aiohttp client lifecycle, FastAPI lifespan, DB/vector-store transactions, atomic checkpoint writes, seeds and agent sandboxes.
python-dataclass-converter
Convert boilerplate Python classes, dicts, tuples and JSON payloads into dataclasses, or into pydantic v2 BaseModel, TypedDict, NamedTuple, attrs or msgspec when that fits better. Covers every @dataclass option (frozen, slots, kw_only, order, unsafe_hash, match_args, weakref_slot), field() (default_factory, init=False, repr, compare, hash, metadata), __post_init__, InitVar, KW_ONLY, inheritance and default ordering, replace(), asdict deep-copy pitfalls, hashing/equality, serialization and validation. AI focus: LLM request/response and chat Message types, RAG chunks with embeddings (numpy eq gotcha), eval records, configs, typed OpenAI/Anthropic JSON.