Skip to content
v0.1.1latest release34 skills2 categories4 sub-categories79 tested scripts157 reference docsContact us

Troubleshooting: Oversubscription and Contention

Reference for python-gil-bottleneck-analyzer · 242 lines

Programming / Pythonv1.0.0Updated OCT 21 20267 references2 scripts

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

  1. Symptom -> cause -> fix
  2. Counting usable cores (containers, affinity, cgroups)
  3. Native thread pools and their knobs
  4. Oversubscription arithmetic
  5. Capping threads correctly
  6. GIL contention and the convoy effect
  7. Lock contention in your code
  8. Memory-bandwidth-bound work
  9. Serial bottlenecks around a parallel stage
  10. Diagnostic command cheat sheet

1. Symptom -> cause -> fix

SymptomLikely causeCheckFix
More workers = slower; load average ≫ coresoversubscription (workers × native threads)ps -o nlwp PID per worker; perf stat involuntary context switchesone level of parallelism; OMP_NUM_THREADS=1 per worker
Process pool at 100% CPU everywhere, throughput worse than 4 workersBLAS/torch threads in each workerthreadpoolctl.threadpool_info() inside a workerthreadpool_limits(1) / env vars before import
Pool sized by os.cpu_count() is slow in Docker/KubernetesCPU quota smaller than host core count/sys/fs/cgroup/cpu.maxsize from the quota
Threads: one core 100%, others idleGIL-held Python or Cgil_probe.py, py-spy --gilprocesses / native / free-threaded
Threads: all cores busy but no speedupspinning, lock contention, false sharing, bandwidthperf stat IPC, py-spy --nativeshard state, fewer threads
Async server p99 explodes when a background CPU thread runsGIL convoy: I/O thread waits to re-acquirepy-spy --native --idle shows take_gilmove CPU to a process
sys time high, many context switchesthread churn, lock thrash, tiny taskspidstat -w, /usr/bin/time -vbatch tasks, fewer threads
Speedup flat at ~2-4x with processes toomemory bandwidth or serial parent stageperf stat -e cache-misses, profile the parentfloat32, fuse passes, parallelise the parent's work
Free-threaded build: threads still serialGIL re-enabled by an extension, or contention on one shared objectsys._is_gil_enabled(), per-object hot spotsupgrade/replace extension; per-thread accumulators
tokenizers warning about fork, then slowrayon disabled itself after forklog messageTOKENIZERS_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.

python
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())
APIRespects affinityRespects cgroup quota
os.cpu_count()nono
len(os.sched_getaffinity(0))yes (Linux)no
os.process_cpu_count() (3.13+)yesno
PYTHON_CPU_COUNT=N / -X cpu_count=N (3.13+)overrides both functions aboven/a
reading cpu.max (cgroup v2) or cpu.cfs_quota_us (v1)n/ayes

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.

LibraryEnv var(s)Runtime API
OpenMP-based (numpy with OpenBLAS-OpenMP, scikit-learn, numba's omp layer, many C extensions)OMP_NUM_THREADSthreadpoolctl.threadpool_limits(n)
OpenBLASOPENBLAS_NUM_THREADSthreadpoolctl
Intel MKLMKL_NUM_THREADSthreadpoolctl, mkl.set_num_threads
Apple AccelerateVECLIB_MAXIMUM_THREADS-
numexpr (pandas eval)NUMEXPR_NUM_THREADS, NUMEXPR_MAX_THREADSnumexpr.set_num_threads
PyTorch intra-opOMP_NUM_THREADS / MKL_NUM_THREADS at starttorch.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-
polarsPOLARS_MAX_THREADSpl.thread_pool_size() (read-only)
numbaNUMBA_NUM_THREADSnumba.set_num_threads(n)
TensorFlow-tf.config.threading.set_intra_op_parallelism_threads

Inspect what a process actually loaded:

python
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

text
runnable threads ≈ processes × (CPU-busy Python threads + native threads per process)
Setup on 16 coresRunnableResult
16 workers × BLAS default 16 threads256thrashing: slower than 1 worker
16 workers × OMP_NUM_THREADS=116good for many small matrices
1 process × BLAS 16 threads16good for a few large matrices
4 workers × 4 BLAS threads16good middle ground for medium matrices
uvicorn 4 workers × process pool 832 CPU-boundoversubscribed: pool 4 or workers 2
DataLoader 8 workers × torch 16 intra-op threads128set 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.

python
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.

bash
py-spy record --native --idle --threads --format raw -o stacks.txt --pid 4242 --duration 20
python scripts/profile_classify.py stacks.txt

Fixes, in order:

  1. Move CPU-bound work out of the process (process pool, job queue). I/O threads then get the GIL immediately.
  2. Make CPU work release the GIL (numpy, numba nogil=True, Cython nogil, PyO3 allow_threads/detach).
  3. Reduce the number of CPU-bound threads to one.
  4. Stop-gap: sys.setswitchinterval(0.001) to shorten the waits (costs some throughput).
  5. 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.

PatternFix
one global lock around a cache used on every requestfunctools.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 itemper-thread counters merged at the end, or a queue.Queue to one aggregator
logging handler lock with very chatty DEBUG logsQueueHandler + QueueListener; lower the level in hot loops
one queue.Queue feeding many fast consumers with tiny itemsbatch items per put
SQLite single writerone 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.

python
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

bash
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 5
More in Python

python-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.